Compare commits
64 Commits
84683d5efa
...
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 |
@@ -35,7 +35,15 @@ jobs:
|
|||||||
go env GOMODCACHE
|
go env GOMODCACHE
|
||||||
|
|
||||||
- name: Install build tools
|
- 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
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|||||||
@@ -110,6 +110,22 @@ jobs:
|
|||||||
-e APP_GRPC_ENABLED=true \
|
-e APP_GRPC_ENABLED=true \
|
||||||
-e APP_GRPC_PORT=50051 \
|
-e APP_GRPC_PORT=50051 \
|
||||||
-e APP_SERVER_MODE=release \
|
-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 }}" \
|
-e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \
|
||||||
${IMAGE}
|
${IMAGE}
|
||||||
|
|
||||||
@@ -130,3 +146,4 @@ jobs:
|
|||||||
sudo docker logs ${CONTAINER} --tail 20
|
sudo docker logs ${CONTAINER} --tail 20
|
||||||
|
|
||||||
echo "=== Deployment completed ==="
|
echo "=== Deployment completed ==="
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/server
|
/server
|
||||||
|
*.exe
|
||||||
*.tar
|
*.tar
|
||||||
|
|
||||||
# Runtime files
|
# Runtime files
|
||||||
|
|||||||
281
.kilo/package-lock.json
generated
281
.kilo/package-lock.json
generated
@@ -5,21 +5,22 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/plugin": "7.2.10"
|
"@kilocode/plugin": "7.2.31"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kilocode/plugin": {
|
"node_modules/@kilocode/plugin": {
|
||||||
"version": "7.2.10",
|
"version": "7.2.31",
|
||||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz",
|
||||||
"integrity": "sha512-VJPhJC+E5WWu7XgEJzrVOxKJlwJ+OATwxEzgjqEPj8KN5N38YxUPBY/rzUTjv90x7nkzyk1rFGfCVqXdA/Koug==",
|
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/sdk": "7.2.10",
|
"@kilocode/sdk": "7.2.31",
|
||||||
|
"effect": "4.0.0-beta.48",
|
||||||
"zod": "4.1.8"
|
"zod": "4.1.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.97",
|
"@opentui/core": ">=0.1.100",
|
||||||
"@opentui/solid": ">=0.1.97"
|
"@opentui/solid": ">=0.1.100"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
@@ -31,14 +32,98 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kilocode/sdk": {
|
"node_modules/@kilocode/sdk": {
|
||||||
"version": "7.2.10",
|
"version": "7.2.31",
|
||||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz",
|
||||||
"integrity": "sha512-H6jGXYAhN/yjOGX3MRZ0OxyEAuRGY3VOwDbLTh4O6ljpgutFHaLvomDZ82qNVy7gl7AjJgi3SAQAt9UQpeGl/w==",
|
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "7.0.6"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -53,12 +138,135 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -68,6 +276,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -89,6 +313,28 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -104,6 +350,21 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ COPY server ./carrot_bbs
|
|||||||
# 复制配置文件
|
# 复制配置文件
|
||||||
COPY configs ./configs
|
COPY configs ./configs
|
||||||
|
|
||||||
# 给可执行文件添加执行权限
|
# 给可执行文件添加执行权限,并创建运行时所需目录
|
||||||
RUN chmod +x ./carrot_bbs
|
RUN chmod +x ./carrot_bbs \
|
||||||
|
&& mkdir -p logs data
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"with_you/internal/config"
|
"with_you/internal/config"
|
||||||
"with_you/internal/grpc/runner"
|
"with_you/internal/grpc/runner"
|
||||||
|
"with_you/internal/pkg/ws"
|
||||||
"with_you/internal/router"
|
"with_you/internal/router"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
@@ -21,9 +23,14 @@ type App struct {
|
|||||||
Router *router.Router
|
Router *router.Router
|
||||||
PushService service.PushService
|
PushService service.PushService
|
||||||
HotRankWorker *service.HotRankWorker
|
HotRankWorker *service.HotRankWorker
|
||||||
|
FileCleanupWorker *service.FileCleanupWorker
|
||||||
|
DeviceCleanupWorker *service.DeviceCleanupWorker
|
||||||
GRPCServer *runner.Server
|
GRPCServer *runner.Server
|
||||||
Server *http.Server
|
Server *http.Server
|
||||||
|
Publisher ws.MessagePublisher
|
||||||
|
TaskDispatcher runner.TaskDispatcher
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
|
PushWorker *service.PushWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 创建应用程序
|
// NewApp 创建应用程序
|
||||||
@@ -33,8 +40,13 @@ func NewApp(
|
|||||||
r *router.Router,
|
r *router.Router,
|
||||||
pushService service.PushService,
|
pushService service.PushService,
|
||||||
hotRankWorker *service.HotRankWorker,
|
hotRankWorker *service.HotRankWorker,
|
||||||
|
fileCleanupWorker *service.FileCleanupWorker,
|
||||||
|
deviceCleanupWorker *service.DeviceCleanupWorker,
|
||||||
grpcServer *runner.Server,
|
grpcServer *runner.Server,
|
||||||
|
publisher ws.MessagePublisher,
|
||||||
|
taskDispatcher runner.TaskDispatcher,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
|
pushWorker *service.PushWorker,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -42,8 +54,13 @@ func NewApp(
|
|||||||
Router: r,
|
Router: r,
|
||||||
PushService: pushService,
|
PushService: pushService,
|
||||||
HotRankWorker: hotRankWorker,
|
HotRankWorker: hotRankWorker,
|
||||||
|
FileCleanupWorker: fileCleanupWorker,
|
||||||
|
DeviceCleanupWorker: deviceCleanupWorker,
|
||||||
GRPCServer: grpcServer,
|
GRPCServer: grpcServer,
|
||||||
|
Publisher: publisher,
|
||||||
|
TaskDispatcher: taskDispatcher,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
PushWorker: pushWorker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +82,21 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
a.HotRankWorker.Start(ctx)
|
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 服务器
|
// 3. 创建 HTTP 服务器
|
||||||
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
|
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
|
||||||
a.Logger.Info("Starting HTTP server",
|
a.Logger.Info("Starting HTTP server",
|
||||||
@@ -74,6 +106,10 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
a.Server = &http.Server{
|
a.Server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: a.Router.Engine(),
|
Handler: a.Router.Engine(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 启动 HTTP 服务器
|
// 4. 启动 HTTP 服务器
|
||||||
@@ -92,7 +128,18 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
func (a *App) Stop(ctx context.Context) error {
|
func (a *App) Stop(ctx context.Context) error {
|
||||||
a.Logger.Info("Shutting down server")
|
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 {
|
if a.Server != nil {
|
||||||
a.Logger.Info("Stopping HTTP server")
|
a.Logger.Info("Stopping HTTP server")
|
||||||
if err := a.Server.Shutdown(ctx); err != nil {
|
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 {
|
if a.GRPCServer != nil {
|
||||||
a.Logger.Info("Stopping gRPC server")
|
a.Logger.Info("Stopping gRPC server")
|
||||||
a.GRPCServer.Stop()
|
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.Logger.Info("Stopping push worker")
|
||||||
a.PushService.StopPushWorker()
|
a.PushService.StopPushWorker()
|
||||||
|
|
||||||
@@ -117,7 +172,23 @@ func (a *App) Stop(ctx context.Context) error {
|
|||||||
a.HotRankWorker.Stop()
|
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")
|
a.Logger.Info("Closing database connection")
|
||||||
sqlDB, err := a.DB.DB()
|
sqlDB, err := a.DB.DB()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -14,9 +15,8 @@ func main() {
|
|||||||
// 初始化应用程序(Wire 自动生成)
|
// 初始化应用程序(Wire 自动生成)
|
||||||
app, err := InitializeApp()
|
app, err := InitializeApp()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zap.L().Fatal("failed to initialize app",
|
fmt.Fprintf(os.Stderr, "failed to initialize app: %v\n", err)
|
||||||
zap.Error(err),
|
os.Exit(1)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动应用程序
|
// 启动应用程序
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
//go:build wireinject
|
//go:build wireinject
|
||||||
// +build wireinject
|
// +build wireinject
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/google/wire"
|
||||||
|
|
||||||
|
"with_you/internal/cache"
|
||||||
"with_you/internal/handler"
|
"with_you/internal/handler"
|
||||||
|
"with_you/internal/middleware"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/repository"
|
||||||
"with_you/internal/router"
|
"with_you/internal/router"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
appwire "with_you/internal/wire"
|
appwire "with_you/internal/wire"
|
||||||
|
|
||||||
"github.com/google/wire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitializeApp 初始化应用程序
|
// InitializeApp 初始化应用程序
|
||||||
@@ -32,7 +34,10 @@ func ProvideRouter(
|
|||||||
messageHandler *handler.MessageHandler,
|
messageHandler *handler.MessageHandler,
|
||||||
notificationHandler *handler.NotificationHandler,
|
notificationHandler *handler.NotificationHandler,
|
||||||
uploadHandler *handler.UploadHandler,
|
uploadHandler *handler.UploadHandler,
|
||||||
jwtService *service.JWTService,
|
jwtService service.JWTService,
|
||||||
|
sessionService service.SessionService,
|
||||||
|
identityProvider middleware.IdentityProvider,
|
||||||
|
cache cache.Cache,
|
||||||
pushHandler *handler.PushHandler,
|
pushHandler *handler.PushHandler,
|
||||||
systemMessageHandler *handler.SystemMessageHandler,
|
systemMessageHandler *handler.SystemMessageHandler,
|
||||||
groupHandler *handler.GroupHandler,
|
groupHandler *handler.GroupHandler,
|
||||||
@@ -40,6 +45,9 @@ func ProvideRouter(
|
|||||||
voteHandler *handler.VoteHandler,
|
voteHandler *handler.VoteHandler,
|
||||||
channelHandler *handler.ChannelHandler,
|
channelHandler *handler.ChannelHandler,
|
||||||
scheduleHandler *handler.ScheduleHandler,
|
scheduleHandler *handler.ScheduleHandler,
|
||||||
|
gradeHandler *handler.GradeHandler,
|
||||||
|
examHandler *handler.ExamHandler,
|
||||||
|
emptyClassroomHandler *handler.EmptyClassroomHandler,
|
||||||
roleHandler *handler.RoleHandler,
|
roleHandler *handler.RoleHandler,
|
||||||
adminUserHandler *handler.AdminUserHandler,
|
adminUserHandler *handler.AdminUserHandler,
|
||||||
adminPostHandler *handler.AdminPostHandler,
|
adminPostHandler *handler.AdminPostHandler,
|
||||||
@@ -56,46 +64,58 @@ func ProvideRouter(
|
|||||||
adminVerificationHandler *handler.AdminVerificationHandler,
|
adminVerificationHandler *handler.AdminVerificationHandler,
|
||||||
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
|
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
|
||||||
setupHandler *handler.SetupHandler,
|
setupHandler *handler.SetupHandler,
|
||||||
|
tradeHandler *handler.TradeHandler,
|
||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
|
userService service.UserService,
|
||||||
wsHandler *handler.WSHandler,
|
wsHandler *handler.WSHandler,
|
||||||
|
liveKitHandler *handler.LiveKitHandler,
|
||||||
) *router.Router {
|
) *router.Router {
|
||||||
return router.New(
|
return router.New(router.RouterDeps{
|
||||||
userRepo,
|
UserRepo: userRepo,
|
||||||
userHandler,
|
UserHandler: userHandler,
|
||||||
postHandler,
|
PostHandler: postHandler,
|
||||||
commentHandler,
|
CommentHandler: commentHandler,
|
||||||
messageHandler,
|
MessageHandler: messageHandler,
|
||||||
notificationHandler,
|
NotificationHandler: notificationHandler,
|
||||||
uploadHandler,
|
UploadHandler: uploadHandler,
|
||||||
jwtService,
|
JWTService: jwtService,
|
||||||
pushHandler,
|
SessionService: sessionService,
|
||||||
systemMessageHandler,
|
IdentityProvider: identityProvider,
|
||||||
groupHandler,
|
Cache: cache,
|
||||||
stickerHandler,
|
PushHandler: pushHandler,
|
||||||
voteHandler,
|
SystemMessageHandler: systemMessageHandler,
|
||||||
channelHandler,
|
GroupHandler: groupHandler,
|
||||||
scheduleHandler,
|
StickerHandler: stickerHandler,
|
||||||
roleHandler,
|
VoteHandler: voteHandler,
|
||||||
adminUserHandler,
|
ChannelHandler: channelHandler,
|
||||||
adminPostHandler,
|
ScheduleHandler: scheduleHandler,
|
||||||
adminCommentHandler,
|
GradeHandler: gradeHandler,
|
||||||
adminGroupHandler,
|
ExamHandler: examHandler,
|
||||||
adminDashboardHandler,
|
EmptyClassroomHandler: emptyClassroomHandler,
|
||||||
adminLogHandler,
|
RoleHandler: roleHandler,
|
||||||
qrcodeHandler,
|
AdminUserHandler: adminUserHandler,
|
||||||
materialHandler,
|
AdminPostHandler: adminPostHandler,
|
||||||
callHandler,
|
AdminCommentHandler: adminCommentHandler,
|
||||||
reportHandler,
|
AdminGroupHandler: adminGroupHandler,
|
||||||
adminReportHandler,
|
AdminDashboardHandler: adminDashboardHandler,
|
||||||
verificationHandler,
|
AdminLogHandler: adminLogHandler,
|
||||||
adminVerificationHandler,
|
QRCodeHandler: qrcodeHandler,
|
||||||
adminProfileAuditHandler,
|
MaterialHandler: materialHandler,
|
||||||
setupHandler,
|
CallHandler: callHandler,
|
||||||
logService,
|
LiveKitHandler: liveKitHandler,
|
||||||
activityService,
|
ReportHandler: reportHandler,
|
||||||
casbinService,
|
AdminReportHandler: adminReportHandler,
|
||||||
wsHandler,
|
VerificationHandler: verificationHandler,
|
||||||
)
|
AdminVerificationHandler: adminVerificationHandler,
|
||||||
|
AdminProfileAuditHandler: adminProfileAuditHandler,
|
||||||
|
SetupHandler: setupHandler,
|
||||||
|
TradeHandler: tradeHandler,
|
||||||
|
LogService: logService,
|
||||||
|
ActivityService: activityService,
|
||||||
|
CasbinService: casbinService,
|
||||||
|
UserService: userService,
|
||||||
|
WSHandler: wsHandler,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"with_you/internal/cache"
|
||||||
"with_you/internal/handler"
|
"with_you/internal/handler"
|
||||||
|
"with_you/internal/middleware"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/repository"
|
||||||
"with_you/internal/router"
|
"with_you/internal/router"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
@@ -31,10 +33,12 @@ func InitializeApp() (*App, error) {
|
|||||||
pushRecordRepository := repository.NewPushRecordRepository(db)
|
pushRecordRepository := repository.NewPushRecordRepository(db)
|
||||||
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
||||||
messageRepository := repository.NewMessageRepository(db)
|
messageRepository := repository.NewMessageRepository(db)
|
||||||
hub := wire.ProvideWSHub()
|
|
||||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub)
|
|
||||||
postRepository := repository.NewPostRepository(db)
|
|
||||||
client := wire.ProvideRedisClient(config)
|
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)
|
cache := wire.ProvideCache(config, client)
|
||||||
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
||||||
emailClient := wire.ProvideEmailClient(config)
|
emailClient := wire.ProvideEmailClient(config)
|
||||||
@@ -42,13 +46,15 @@ func InitializeApp() (*App, error) {
|
|||||||
operationLogRepository := repository.NewOperationLogRepository(db)
|
operationLogRepository := repository.NewOperationLogRepository(db)
|
||||||
loginLogRepository := repository.NewLoginLogRepository(db)
|
loginLogRepository := repository.NewLoginLogRepository(db)
|
||||||
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
||||||
logger := wire.ProvideLogger()
|
|
||||||
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
|
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
|
||||||
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
|
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
|
||||||
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
||||||
dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository)
|
dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository)
|
||||||
logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService)
|
logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService)
|
||||||
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache, 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)
|
userActivityRepository := wire.ProvideUserActivityRepository(db, cache)
|
||||||
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
|
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
|
||||||
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
|
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
|
||||||
@@ -65,10 +71,10 @@ func InitializeApp() (*App, error) {
|
|||||||
transactionManager := wire.ProvideTransactionManager(db)
|
transactionManager := wire.ProvideTransactionManager(db)
|
||||||
builtinHooks := wire.ProvideBuiltinHooks(manager)
|
builtinHooks := wire.ProvideBuiltinHooks(manager)
|
||||||
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
|
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
|
||||||
channelRepository := repository.NewChannelRepository(db)
|
|
||||||
channelService := wire.ProvideChannelService(channelRepository, cache)
|
|
||||||
postRefRepository := repository.NewPostRefRepository(db)
|
postRefRepository := repository.NewPostRefRepository(db)
|
||||||
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
|
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
|
||||||
|
channelRepository := repository.NewChannelRepository(db)
|
||||||
|
channelService := wire.ProvideChannelService(channelRepository, cache)
|
||||||
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
|
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
|
||||||
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
|
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
|
||||||
commentHandler := handler.NewCommentHandler(commentService)
|
commentHandler := handler.NewCommentHandler(commentService)
|
||||||
@@ -76,18 +82,25 @@ func InitializeApp() (*App, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
uploadedFileRepository := repository.NewUploadedFileRepository(db)
|
||||||
chatService := wire.ProvideChatService(messageRepository, userRepository, hub, cache, uploadService)
|
uploadService := wire.ProvideUploadService(s3Client, userService, uploadedFileRepository)
|
||||||
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
|
||||||
|
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
|
||||||
|
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
|
||||||
groupRepository := repository.NewGroupRepository(db)
|
groupRepository := repository.NewGroupRepository(db)
|
||||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||||
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, hub, cache)
|
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
|
||||||
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub)
|
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)
|
notificationRepository := repository.NewNotificationRepository(db)
|
||||||
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
||||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||||
uploadHandler := handler.NewUploadHandler(uploadService)
|
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)
|
pushHandler := handler.NewPushHandler(pushService)
|
||||||
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService)
|
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService)
|
||||||
groupHandler := wire.ProvideGroupHandler(groupService, userService)
|
groupHandler := wire.ProvideGroupHandler(groupService, userService)
|
||||||
@@ -100,34 +113,40 @@ func InitializeApp() (*App, error) {
|
|||||||
channelHandler := handler.NewChannelHandler(channelService)
|
channelHandler := handler.NewChannelHandler(channelService)
|
||||||
scheduleRepository := repository.NewScheduleRepository(db)
|
scheduleRepository := repository.NewScheduleRepository(db)
|
||||||
scheduleService := wire.ProvideScheduleService(scheduleRepository)
|
scheduleService := wire.ProvideScheduleService(scheduleRepository)
|
||||||
server := wire.ProvideGRPCServer(config, logger)
|
runnerHub := wire.ProvideRunnerHub(logger)
|
||||||
taskManager := wire.ProvideTaskManager(server)
|
taskManager := wire.ProvideTaskManager(runnerHub, logger)
|
||||||
scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger)
|
scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger)
|
||||||
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
|
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
|
||||||
roleRepository := wire.ProvideRoleRepository(db)
|
gradeRepository := repository.NewGradeRepository(db)
|
||||||
enforcer := wire.ProvideCasbinEnforcer(config, db)
|
gradeSyncService := wire.ProvideGradeSyncService(taskManager, gradeRepository, logger)
|
||||||
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
|
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)
|
roleService := wire.ProvideRoleService(roleRepository, casbinService)
|
||||||
roleHandler := handler.NewRoleHandler(roleService)
|
roleHandler := handler.NewRoleHandler(roleService)
|
||||||
adminUserService := wire.ProvideAdminUserService(userRepository)
|
adminUserService := wire.ProvideAdminUserService(userRepository, sessionService, cache)
|
||||||
adminUserHandler := handler.NewAdminUserHandler(adminUserService)
|
adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService)
|
||||||
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
|
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
|
||||||
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
|
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
|
||||||
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository)
|
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository, cache)
|
||||||
adminCommentHandler := handler.NewAdminCommentHandler(adminCommentService)
|
adminCommentHandler := handler.NewAdminCommentHandler(adminCommentService)
|
||||||
adminGroupService := wire.ProvideAdminGroupService(groupRepository, userRepository)
|
adminGroupService := wire.ProvideAdminGroupService(groupRepository, userRepository)
|
||||||
adminGroupHandler := handler.NewAdminGroupHandler(adminGroupService)
|
adminGroupHandler := handler.NewAdminGroupHandler(adminGroupService)
|
||||||
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
||||||
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
||||||
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
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)
|
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
|
||||||
materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
|
materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
|
||||||
materialFileRepository := repository.NewMaterialFileRepository(db)
|
materialFileRepository := repository.NewMaterialFileRepository(db)
|
||||||
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
||||||
materialHandler := handler.NewMaterialHandler(materialService)
|
materialHandler := handler.NewMaterialHandler(materialService)
|
||||||
callRepository := repository.NewCallRepository(db)
|
callRepository := repository.NewCallRepository(db)
|
||||||
callService := wire.ProvideCallService(callRepository, hub, config, db)
|
callService := wire.ProvideCallService(callRepository, messagePublisher, config, db, client, groupService)
|
||||||
callHandler := handler.NewCallHandler(callService)
|
callHandler := handler.NewCallHandler(callService)
|
||||||
reportRepository := repository.NewReportRepository(db)
|
reportRepository := repository.NewReportRepository(db)
|
||||||
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
|
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
|
||||||
@@ -138,17 +157,24 @@ func InitializeApp() (*App, error) {
|
|||||||
verificationService := wire.ProvideVerificationService(verificationRepository, userRepository)
|
verificationService := wire.ProvideVerificationService(verificationRepository, userRepository)
|
||||||
verificationHandler := handler.NewVerificationHandler(verificationService)
|
verificationHandler := handler.NewVerificationHandler(verificationService)
|
||||||
adminVerificationService := wire.ProvideAdminVerificationService(verificationRepository, userRepository)
|
adminVerificationService := wire.ProvideAdminVerificationService(verificationRepository, userRepository)
|
||||||
adminVerificationHandler := wire.ProvideAdminVerificationHandler(adminVerificationService, userRepository)
|
adminVerificationHandler := wire.ProvideAdminVerificationHandler(adminVerificationService, userService)
|
||||||
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
|
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
|
||||||
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
|
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
|
||||||
setupHandler := wire.ProvideSetupHandler(setupService)
|
setupHandler := wire.ProvideSetupHandler(setupService)
|
||||||
wsHandler := wire.ProvideWSHandler(hub, chatService, groupService, jwtService, callService, userRepository)
|
|
||||||
tradeRepository := repository.NewTradeRepository(db)
|
tradeRepository := repository.NewTradeRepository(db)
|
||||||
tradeService := wire.ProvideTradeService(tradeRepository)
|
tradeService := wire.ProvideTradeService(tradeRepository)
|
||||||
tradeHandler := handler.NewTradeHandler(tradeService)
|
tradeHandler := handler.NewTradeHandler(tradeService)
|
||||||
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, wsHandler)
|
wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService)
|
||||||
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache)
|
liveKitService := wire.ProvideLiveKitService(config, logger)
|
||||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, 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
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +189,10 @@ func ProvideRouter(
|
|||||||
messageHandler *handler.MessageHandler,
|
messageHandler *handler.MessageHandler,
|
||||||
notificationHandler *handler.NotificationHandler,
|
notificationHandler *handler.NotificationHandler,
|
||||||
uploadHandler *handler.UploadHandler,
|
uploadHandler *handler.UploadHandler,
|
||||||
jwtService *service.JWTService,
|
jwtService service.JWTService,
|
||||||
|
sessionService service.SessionService,
|
||||||
|
identityProvider middleware.IdentityProvider, cache2 cache.Cache,
|
||||||
|
|
||||||
pushHandler *handler.PushHandler,
|
pushHandler *handler.PushHandler,
|
||||||
systemMessageHandler *handler.SystemMessageHandler,
|
systemMessageHandler *handler.SystemMessageHandler,
|
||||||
groupHandler *handler.GroupHandler,
|
groupHandler *handler.GroupHandler,
|
||||||
@@ -171,6 +200,9 @@ func ProvideRouter(
|
|||||||
voteHandler *handler.VoteHandler,
|
voteHandler *handler.VoteHandler,
|
||||||
channelHandler *handler.ChannelHandler,
|
channelHandler *handler.ChannelHandler,
|
||||||
scheduleHandler *handler.ScheduleHandler,
|
scheduleHandler *handler.ScheduleHandler,
|
||||||
|
gradeHandler *handler.GradeHandler,
|
||||||
|
examHandler *handler.ExamHandler,
|
||||||
|
emptyClassroomHandler *handler.EmptyClassroomHandler,
|
||||||
roleHandler *handler.RoleHandler,
|
roleHandler *handler.RoleHandler,
|
||||||
adminUserHandler *handler.AdminUserHandler,
|
adminUserHandler *handler.AdminUserHandler,
|
||||||
adminPostHandler *handler.AdminPostHandler,
|
adminPostHandler *handler.AdminPostHandler,
|
||||||
@@ -191,44 +223,54 @@ func ProvideRouter(
|
|||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
|
userService service.UserService,
|
||||||
wsHandler *handler.WSHandler,
|
wsHandler *handler.WSHandler,
|
||||||
|
liveKitHandler *handler.LiveKitHandler,
|
||||||
) *router.Router {
|
) *router.Router {
|
||||||
return router.New(
|
return router.New(router.RouterDeps{
|
||||||
userRepo,
|
UserRepo: userRepo,
|
||||||
userHandler,
|
UserHandler: userHandler,
|
||||||
postHandler,
|
PostHandler: postHandler,
|
||||||
commentHandler,
|
CommentHandler: commentHandler,
|
||||||
messageHandler,
|
MessageHandler: messageHandler,
|
||||||
notificationHandler,
|
NotificationHandler: notificationHandler,
|
||||||
uploadHandler,
|
UploadHandler: uploadHandler,
|
||||||
jwtService,
|
JWTService: jwtService,
|
||||||
pushHandler,
|
SessionService: sessionService,
|
||||||
systemMessageHandler,
|
IdentityProvider: identityProvider,
|
||||||
groupHandler,
|
Cache: cache2,
|
||||||
stickerHandler,
|
PushHandler: pushHandler,
|
||||||
voteHandler,
|
SystemMessageHandler: systemMessageHandler,
|
||||||
channelHandler,
|
GroupHandler: groupHandler,
|
||||||
scheduleHandler,
|
StickerHandler: stickerHandler,
|
||||||
roleHandler,
|
VoteHandler: voteHandler,
|
||||||
adminUserHandler,
|
ChannelHandler: channelHandler,
|
||||||
adminPostHandler,
|
ScheduleHandler: scheduleHandler,
|
||||||
adminCommentHandler,
|
GradeHandler: gradeHandler,
|
||||||
adminGroupHandler,
|
ExamHandler: examHandler,
|
||||||
adminDashboardHandler,
|
EmptyClassroomHandler: emptyClassroomHandler,
|
||||||
adminLogHandler,
|
RoleHandler: roleHandler,
|
||||||
qrcodeHandler,
|
AdminUserHandler: adminUserHandler,
|
||||||
materialHandler,
|
AdminPostHandler: adminPostHandler,
|
||||||
callHandler,
|
AdminCommentHandler: adminCommentHandler,
|
||||||
reportHandler,
|
AdminGroupHandler: adminGroupHandler,
|
||||||
adminReportHandler,
|
AdminDashboardHandler: adminDashboardHandler,
|
||||||
verificationHandler,
|
AdminLogHandler: adminLogHandler,
|
||||||
adminVerificationHandler,
|
QRCodeHandler: qrcodeHandler,
|
||||||
adminProfileAuditHandler,
|
MaterialHandler: materialHandler,
|
||||||
setupHandler,
|
CallHandler: callHandler,
|
||||||
tradeHandler,
|
LiveKitHandler: liveKitHandler,
|
||||||
logService,
|
ReportHandler: reportHandler,
|
||||||
activityService,
|
AdminReportHandler: adminReportHandler,
|
||||||
casbinService,
|
VerificationHandler: verificationHandler,
|
||||||
wsHandler,
|
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]
|
[policy_effect]
|
||||||
e = some(where (p.eft == allow))
|
e = some(where (p.eft == allow))
|
||||||
|
|
||||||
|
# 真源策略:user_roles 表为用户-角色唯一真源;Casbin 仅存 p, role, resource, action。
|
||||||
|
# EnforceForUser 由 CasbinService 先查 DB 角色再对每个角色调 Enforce(role, obj, act),
|
||||||
|
# 因此 matcher 直接匹配 r.sub == p.sub(角色直接匹配),不再依赖 g 分组。
|
||||||
|
#
|
||||||
|
# matcher 使用 globMatch(doublestar 语义):
|
||||||
|
# - '* 匹配单层资源段(不跨 '/');
|
||||||
|
# - '**' 跨多层(如 admin/** 命中 admin/users/roles/write)。
|
||||||
|
# 资源命名采用路径式 admin/<domain>[/<sub>],避免 keyMatch2 把 '*' 当作正则通配
|
||||||
|
# 跨 '.' 而误匹配;也避免 globMatch 在点号分隔下 '*' 仍跨 '.' 的过宽问题。
|
||||||
[matchers]
|
[matchers]
|
||||||
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ database:
|
|||||||
password: postgres
|
password: postgres
|
||||||
dbname: with_you
|
dbname: with_you
|
||||||
sslmode: disable
|
sslmode: disable
|
||||||
|
# 读写分离配置(仅 postgres 模式生效,不配置则所有请求走主库)
|
||||||
|
# 注意:replica 字段已在 config.go 中显式 BindEnv,环境变量优先于 YAML。
|
||||||
|
# 单副本配置(推荐用环境变量覆盖,无需取消下方注释):
|
||||||
|
# APP_DATABASE_REPLICA_HOST, APP_DATABASE_REPLICA_PORT, APP_DATABASE_REPLICA_USER,
|
||||||
|
# APP_DATABASE_REPLICA_PASSWORD, APP_DATABASE_REPLICA_DBNAME, APP_DATABASE_REPLICA_SSLMODE
|
||||||
|
# APP_DATABASE_REPLICA_MAX_IDLE_CONNS, APP_DATABASE_REPLICA_MAX_OPEN_CONNS(可选,缺省回退主库连接池)
|
||||||
|
# replica:
|
||||||
|
# host: replica1.internal
|
||||||
|
# port: 5432
|
||||||
|
# user: readonly
|
||||||
|
# password: readonly_pass
|
||||||
|
# dbname: with_you
|
||||||
|
# sslmode: disable
|
||||||
|
# 多副本配置(仅 YAML,环境变量不支持切片结构体):
|
||||||
|
# replicas:
|
||||||
|
# - host: replica1.internal
|
||||||
|
# port: 5432
|
||||||
|
# user: readonly
|
||||||
|
# password: readonly_pass
|
||||||
|
# dbname: with_you
|
||||||
|
# sslmode: disable
|
||||||
|
# replica_policy: random # random
|
||||||
|
# replica_max_idle_conns: 10
|
||||||
|
# replica_max_open_conns: 100
|
||||||
max_idle_conns: 10
|
max_idle_conns: 10
|
||||||
max_open_conns: 100
|
max_open_conns: 100
|
||||||
log_level: warn
|
log_level: warn
|
||||||
@@ -119,7 +143,7 @@ log:
|
|||||||
- bank_card
|
- bank_card
|
||||||
rotation:
|
rotation:
|
||||||
max_size: 100
|
max_size: 100
|
||||||
max_age: 30
|
max_age: 180
|
||||||
max_backups: 10
|
max_backups: 10
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
@@ -135,6 +159,14 @@ upload:
|
|||||||
- image/gif
|
- image/gif
|
||||||
- image/webp
|
- image/webp
|
||||||
|
|
||||||
|
# 设备 registration_id 清理配置
|
||||||
|
# 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||||
|
# 环境变量: APP_DEVICE_CLEANUP_ENABLED, APP_DEVICE_CLEANUP_RETENTION_DAYS, APP_DEVICE_CLEANUP_INTERVAL_MINUTES
|
||||||
|
device_cleanup:
|
||||||
|
enabled: true # 是否启用清理 worker
|
||||||
|
retention_days: 3 # 保留天数,3 天不活跃即清理(按 last_used_at 判断)
|
||||||
|
interval_minutes: 360 # 扫描间隔(分钟),默认 6 小时
|
||||||
|
|
||||||
# 敏感词过滤配置
|
# 敏感词过滤配置
|
||||||
sensitive:
|
sensitive:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -263,8 +295,118 @@ casbin:
|
|||||||
report:
|
report:
|
||||||
auto_hide_threshold: 3 # 自动隐藏阈值,举报次数达到此数值时自动隐藏内容
|
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
|
# 环境变量: APP_SETUP_SECRET
|
||||||
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
||||||
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
|
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
|
||||||
setup_secret: ""
|
setup_secret: ""
|
||||||
|
|
||||||
|
# WebSocket 配置
|
||||||
|
# 环境变量:
|
||||||
|
# APP_WEBSOCKET_MODE, APP_WEBSOCKET_CLUSTER_INSTANCE_ID
|
||||||
|
# APP_WEBSOCKET_CLUSTER_MSG_CHANNEL, APP_WEBSOCKET_CLUSTER_ONLINE_TTL
|
||||||
|
# APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL
|
||||||
|
websocket:
|
||||||
|
mode: standalone # standalone 或 cluster
|
||||||
|
cluster:
|
||||||
|
instance_id: "" # 留空自动生成 UUID
|
||||||
|
msg_channel: "ws:msg" # Redis Pub/Sub 频道
|
||||||
|
online_ttl: 60 # 在线状态 TTL(秒)
|
||||||
|
heartbeat_interval: 20 # 心跳间隔(秒)
|
||||||
|
|
||||||
|
# Seq 预分配配置
|
||||||
|
# 环境变量:
|
||||||
|
# APP_SEQ_BUFFER_ENABLED, APP_SEQ_BUFFER_PRIVATE_BUFFER_SIZE
|
||||||
|
# APP_SEQ_BUFFER_GROUP_BUFFER_SIZE, APP_SEQ_BUFFER_LOCK_TIMEOUT_MS
|
||||||
|
# APP_SEQ_BUFFER_MAX_RETRIES
|
||||||
|
seq_buffer:
|
||||||
|
enabled: false # 启用后使用 Lua 预分配代替 INCR,关闭则回退旧逻辑
|
||||||
|
private_buffer_size: 50 # 私聊每次向 Redis 预分配步长
|
||||||
|
group_buffer_size: 200 # 群聊每次向 Redis 预分配步长(群聊消息更频繁)
|
||||||
|
lock_timeout_ms: 5000 # 分布式锁超时(毫秒)
|
||||||
|
max_retries: 3 # 冷启动获取锁失败最大重试次数
|
||||||
|
|
||||||
|
# 版本日志增量同步配置
|
||||||
|
# 环境变量:
|
||||||
|
# APP_VERSION_LOG_ENABLED, APP_VERSION_LOG_SYNC_LIMIT
|
||||||
|
# APP_VERSION_LOG_MAX_SYNC_GAP
|
||||||
|
version_log:
|
||||||
|
enabled: false # 启用后客户端可使用 /conversations/sync 增量同步
|
||||||
|
sync_limit: 100 # 单次同步最大返回变更条数
|
||||||
|
max_sync_gap: 1000 # 版本差距超过此值建议全量同步
|
||||||
|
|
||||||
|
# LiveKit SFU 配置(语音/视频通话)
|
||||||
|
# 环境变量:
|
||||||
|
# APP_LIVEKIT_ENABLED, APP_LIVEKIT_URL, APP_LIVEKIT_API_KEY, APP_LIVEKIT_API_SECRET
|
||||||
|
# APP_LIVEKIT_TOKEN_TTL, APP_LIVEKIT_WEBHOOK_SECRET
|
||||||
|
livekit:
|
||||||
|
enabled: false
|
||||||
|
url: "http://localhost:7880"
|
||||||
|
api_key: "devkey"
|
||||||
|
api_secret: "u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA"
|
||||||
|
token_ttl: 600 # token 有效期(秒)
|
||||||
|
webhook_secret: "" # webhook 签名密钥(与 LiveKit 服务端配置一致)
|
||||||
|
|||||||
32
configs/livekit.yaml
Normal file
32
configs/livekit.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# LiveKit Server Configuration
|
||||||
|
# See https://docs.livekit.io/realtime/server/config/ for all options
|
||||||
|
|
||||||
|
port: 7880
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
udp_port: 7882
|
||||||
|
use_external_ip: false
|
||||||
|
# Enable TURN/TLS relay for restrictive networks
|
||||||
|
port_range_start: 7881
|
||||||
|
port_range_end: 7882
|
||||||
|
node_ip: 127.0.0.1
|
||||||
|
|
||||||
|
keys:
|
||||||
|
devkey: u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
# Redis for distributed state (required)
|
||||||
|
redis:
|
||||||
|
address: livekit-redis:6379
|
||||||
|
|
||||||
|
# PostgreSQL for persistent storage (rooms, participants, egress)
|
||||||
|
db:
|
||||||
|
address: postgres://postgres:postgres@livekit-postgres:5432/livekit?sslmode=disable
|
||||||
|
|
||||||
|
# Webhook for call history persistence
|
||||||
|
# webhook:
|
||||||
|
# urls:
|
||||||
|
# - http://app:8080/api/v1/calls/webhook
|
||||||
|
# api_key: devkey
|
||||||
@@ -55,6 +55,58 @@ services:
|
|||||||
timeout: 20s
|
timeout: 20s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# LiveKit SFU Server (voice/video calling)
|
||||||
|
livekit:
|
||||||
|
image: livekit/livekit-server:v1.9.5
|
||||||
|
container_name: livekit
|
||||||
|
ports:
|
||||||
|
- "7880:7880" # HTTP (token, webhooks, dashboard)
|
||||||
|
- "7881:7881" # TCP/TURN fallback
|
||||||
|
- "7882:7882" # UDP media
|
||||||
|
volumes:
|
||||||
|
- ./configs/livekit.yaml:/etc/livekit.yaml
|
||||||
|
command: --config /etc/livekit.yaml --node-ip=127.0.0.1
|
||||||
|
depends_on:
|
||||||
|
livekit-redis:
|
||||||
|
condition: service_started
|
||||||
|
livekit-postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# LiveKit Redis (required for distributed coordination)
|
||||||
|
livekit-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: livekit_redis
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
volumes:
|
||||||
|
- livekit_redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# LiveKit PostgreSQL (required for room/participant state)
|
||||||
|
livekit-postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: livekit_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: livekit
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- livekit_pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d livekit"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -76,3 +128,5 @@ volumes:
|
|||||||
# postgres_data:
|
# postgres_data:
|
||||||
# redis_data:
|
# redis_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
livekit_redis_data:
|
||||||
|
livekit_pg_data:
|
||||||
|
|||||||
48
go.mod
48
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/google/wire v0.7.0
|
github.com/google/wire v0.7.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
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/minio/minio-go/v7 v7.0.99
|
||||||
github.com/redis/go-redis/v9 v9.18.0
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
@@ -25,24 +26,36 @@ require (
|
|||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
gorm.io/plugin/dbresolver v1.6.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
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
|
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/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/gammazero/deque v1.1.0 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||||
github.com/go-ini/ini v1.67.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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
@@ -52,6 +65,7 @@ require (
|
|||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
|
github.com/google/cel-go v0.26.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
@@ -59,10 +73,14 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/jxskiss/base62 v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
|
||||||
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
|
||||||
|
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||||
github.com/microsoft/go-mssqldb v1.9.8 // indirect
|
github.com/microsoft/go-mssqldb v1.9.8 // indirect
|
||||||
@@ -70,10 +88,31 @@ require (
|
|||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/nats-io/nats.go v1.43.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/runc v1.1.14 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
|
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.40 // indirect
|
||||||
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.15 // indirect
|
||||||
|
github.com/pion/rtp v1.8.21 // indirect
|
||||||
|
github.com/pion/sctp v1.8.39 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.15 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.6 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
|
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||||
|
github.com/pion/webrtc/v4 v4.1.3 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -84,28 +123,35 @@ require (
|
|||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // 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/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tinylib/msgp v1.6.3 // indirect
|
github.com/tinylib/msgp v1.6.3 // indirect
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.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/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.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric 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/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr 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
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.25.0 // 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/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/mysql v1.6.0 // indirect
|
gorm.io/driver/mysql v1.6.0 // indirect
|
||||||
gorm.io/driver/sqlserver v1.6.3 // indirect
|
gorm.io/driver/sqlserver v1.6.3 // indirect
|
||||||
gorm.io/plugin/dbresolver v1.6.2 // indirect
|
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
136
go.sum
136
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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
@@ -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.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 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
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/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 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||||
|
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||||
|
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
@@ -46,28 +66,46 @@ github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrH
|
|||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
|
github.com/docker/cli v26.1.4+incompatible h1:I8PHdc0MtxEADqYJZvhBrW9bo8gawKwwenxRM7/rLu8=
|
||||||
|
github.com/docker/cli v26.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
|
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
||||||
|
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
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 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gammazero/deque v1.1.0 h1:OyiyReBbnEG2PP0Bnv1AASLIYvyKqIFN5xfl1t8oGLo=
|
||||||
|
github.com/gammazero/deque v1.1.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg=
|
||||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
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 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
@@ -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/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 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/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-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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -150,6 +197,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/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 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -170,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/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
@@ -183,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/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 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
||||||
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -190,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/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug=
|
||||||
|
github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||||
|
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/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 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||||
|
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||||
|
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||||
|
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||||
|
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||||
|
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||||
|
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||||
|
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||||
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
|
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||||
|
github.com/pion/rtp v1.8.21 h1:3yrOwmZFyUpcIosNcWRpQaU+UXIJ6yxLuJ8Bx0mw37Y=
|
||||||
|
github.com/pion/rtp v1.8.21/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||||
|
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||||
|
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||||
|
github.com/pion/sdp/v3 v3.0.15 h1:F0I1zds+K/+37ZrzdADmx2Q44OFDOPRLhPnNTaUX9hk=
|
||||||
|
github.com/pion/sdp/v3 v3.0.15/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||||
|
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||||
|
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
||||||
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
|
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||||
|
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||||
|
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
||||||
|
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
@@ -204,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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
@@ -220,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/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 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
|
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||||
|
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
@@ -230,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/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 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -249,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/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 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
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/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
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/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 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
@@ -282,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/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 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||||
@@ -300,6 +431,8 @@ 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.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
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/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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
@@ -401,6 +534,8 @@ golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
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 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
@@ -417,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.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
8
internal/cache/cache.go
vendored
8
internal/cache/cache.go
vendored
@@ -32,6 +32,8 @@ type Cache interface {
|
|||||||
Increment(key string) int64
|
Increment(key string) int64
|
||||||
// IncrementBy 增加指定值
|
// IncrementBy 增加指定值
|
||||||
IncrementBy(key string, value int64) int64
|
IncrementBy(key string, value int64) int64
|
||||||
|
// DeleteBatch 批量删除多个 key(使用 Pipeline 减少网络往返)
|
||||||
|
DeleteBatch(keys []string)
|
||||||
|
|
||||||
// ==================== Hash 操作 ====================
|
// ==================== Hash 操作 ====================
|
||||||
// HSet 设置 Hash 字段
|
// HSet 设置 Hash 字段
|
||||||
@@ -46,6 +48,8 @@ type Cache interface {
|
|||||||
HGetAll(ctx context.Context, key string) (map[string]string, error)
|
HGetAll(ctx context.Context, key string) (map[string]string, error)
|
||||||
// HDel 删除 Hash 字段
|
// HDel 删除 Hash 字段
|
||||||
HDel(ctx context.Context, key string, fields ...string) error
|
HDel(ctx context.Context, key string, fields ...string) error
|
||||||
|
// HIncrBy 原子递增 Hash 字段的值
|
||||||
|
HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error)
|
||||||
|
|
||||||
// ==================== Sorted Set 操作 ====================
|
// ==================== Sorted Set 操作 ====================
|
||||||
// ZAdd 添加 Sorted Set 成员
|
// ZAdd 添加 Sorted Set 成员
|
||||||
@@ -66,6 +70,8 @@ type Cache interface {
|
|||||||
// ==================== 计数器操作 ====================
|
// ==================== 计数器操作 ====================
|
||||||
// Incr 原子递增(返回新值)
|
// Incr 原子递增(返回新值)
|
||||||
Incr(ctx context.Context, key string) (int64, error)
|
Incr(ctx context.Context, key string) (int64, error)
|
||||||
|
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||||
|
IncrBySeq(ctx context.Context, key string, delta int64) (int64, error)
|
||||||
// Expire 设置过期时间
|
// Expire 设置过期时间
|
||||||
Expire(ctx context.Context, key string, ttl time.Duration) error
|
Expire(ctx context.Context, key string, ttl time.Duration) error
|
||||||
}
|
}
|
||||||
@@ -93,7 +99,7 @@ var settings = Settings{
|
|||||||
NullTTL: 5 * time.Second,
|
NullTTL: 5 * time.Second,
|
||||||
JitterRatio: 0.1,
|
JitterRatio: 0.1,
|
||||||
PostListTTL: 30 * time.Second,
|
PostListTTL: 30 * time.Second,
|
||||||
ConversationTTL: 60 * time.Second,
|
ConversationTTL: 120 * time.Second,
|
||||||
UnreadCountTTL: 30 * time.Second,
|
UnreadCountTTL: 30 * time.Second,
|
||||||
GroupMembersTTL: 120 * time.Second,
|
GroupMembersTTL: 120 * time.Second,
|
||||||
DisableFlushDB: true,
|
DisableFlushDB: true,
|
||||||
|
|||||||
366
internal/cache/conversation_cache.go
vendored
366
internal/cache/conversation_cache.go
vendored
@@ -6,11 +6,40 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
redisPkg "with_you/internal/pkg/redis"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nextSeqLua 原子递增 seq:key 存在时 INCR 返回新值,不存在时返回 0 触发冷启动
|
||||||
|
var nextSeqLua = redis.NewScript(`
|
||||||
|
local seqKey = KEYS[1]
|
||||||
|
if redis.call('EXISTS', seqKey) == 1 then
|
||||||
|
return redis.call('INCR', seqKey)
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`)
|
||||||
|
|
||||||
|
// initSeqLua 冷启动原子初始化:SETNX 防止覆盖 + INCR 递增,返回第一个可用 seq
|
||||||
|
var initSeqLua = redis.NewScript(`
|
||||||
|
local seqKey = KEYS[1]
|
||||||
|
local initVal = tonumber(ARGV[1])
|
||||||
|
redis.call('SETNX', seqKey, initVal)
|
||||||
|
return redis.call('INCR', seqKey)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// syncSeqLua 写透缓存:确保 Redis seq >= DB seq,防止冷启动后读到过期值
|
||||||
|
var syncSeqLua = redis.NewScript(`
|
||||||
|
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||||
|
if current == nil or current < tonumber(ARGV[1]) then
|
||||||
|
redis.call('SET', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
`)
|
||||||
|
|
||||||
// CachedConversation 带缓存元数据的会话
|
// CachedConversation 带缓存元数据的会话
|
||||||
type CachedConversation struct {
|
type CachedConversation struct {
|
||||||
Data *model.Conversation // 实际数据
|
Data *model.Conversation // 实际数据
|
||||||
@@ -72,19 +101,23 @@ type ConversationCacheSettings struct {
|
|||||||
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
|
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
|
||||||
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
|
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
|
||||||
MessageCountTTL time.Duration // 消息计数缓存 (30min)
|
MessageCountTTL time.Duration // 消息计数缓存 (30min)
|
||||||
|
|
||||||
|
// Seq 缓存配置
|
||||||
|
SeqTTL time.Duration // Seq 计数器缓存 (24h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConversationCacheSettings 返回默认配置
|
// DefaultConversationCacheSettings 返回默认配置
|
||||||
func DefaultConversationCacheSettings() *ConversationCacheSettings {
|
func DefaultConversationCacheSettings() *ConversationCacheSettings {
|
||||||
return &ConversationCacheSettings{
|
return &ConversationCacheSettings{
|
||||||
DetailTTL: 5 * time.Minute,
|
DetailTTL: 15 * time.Minute,
|
||||||
ListTTL: 60 * time.Second,
|
ListTTL: 120 * time.Second,
|
||||||
ParticipantTTL: 5 * time.Minute,
|
ParticipantTTL: 15 * time.Minute,
|
||||||
UnreadTTL: 30 * time.Second,
|
UnreadTTL: 30 * time.Second,
|
||||||
MessageDetailTTL: 30 * time.Minute,
|
MessageDetailTTL: 30 * time.Minute,
|
||||||
MessageListTTL: 5 * time.Minute,
|
MessageListTTL: 5 * time.Minute,
|
||||||
MessageIndexTTL: 30 * time.Minute,
|
MessageIndexTTL: 30 * time.Minute,
|
||||||
MessageCountTTL: 30 * time.Minute,
|
MessageCountTTL: 30 * time.Minute,
|
||||||
|
SeqTTL: 24 * time.Hour,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +248,7 @@ type MessageRepository interface {
|
|||||||
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
||||||
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
||||||
GetMessagesBeforeSeq(convID string, beforeSeq int64, limit int) ([]*model.Message, error)
|
GetMessagesBeforeSeq(convID string, beforeSeq int64, limit int) ([]*model.Message, error)
|
||||||
|
GetNextSeq(convID string) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -227,10 +261,11 @@ type ConversationCache struct {
|
|||||||
settings *ConversationCacheSettings // 配置
|
settings *ConversationCacheSettings // 配置
|
||||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||||
|
seqBufferMgr *SeqBufferManager // seq 预分配管理器(nil 则使用旧 INCR 逻辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConversationCache 创建会话缓存管理器
|
// NewConversationCache 创建会话缓存管理器
|
||||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings) *ConversationCache {
|
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings, seqBufferMgr *SeqBufferManager) *ConversationCache {
|
||||||
if settings == nil {
|
if settings == nil {
|
||||||
settings = DefaultConversationCacheSettings()
|
settings = DefaultConversationCacheSettings()
|
||||||
}
|
}
|
||||||
@@ -239,6 +274,7 @@ func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo Mess
|
|||||||
settings: settings,
|
settings: settings,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
msgRepo: msgRepo,
|
msgRepo: msgRepo,
|
||||||
|
seqBufferMgr: seqBufferMgr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +514,289 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
|
|||||||
c.cache.Delete(UnreadDetailKey(userID, convID))
|
c.cache.Delete(UnreadDetailKey(userID, convID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 已读位置缓存(OpenIM 风格 hasReadSeq)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// readSeqExpire 已读位置缓存 TTL
|
||||||
|
const readSeqExpire = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// SetUserReadSeq 设置用户在某会话的已读位置(防回退:仅当新值大于当前值时更新)
|
||||||
|
func (c *ConversationCache) SetUserReadSeq(ctx context.Context, convID, userID string, seq int64) error {
|
||||||
|
key := UserReadSeqKey(convID, userID)
|
||||||
|
|
||||||
|
redisCache, ok := c.cache.(*RedisCache)
|
||||||
|
if !ok {
|
||||||
|
// 非 Redis 模式,直接设置
|
||||||
|
c.cache.Set(key, seq, readSeqExpire)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rdb := redisCache.GetRedisClient().GetClient()
|
||||||
|
nKey := normalizeKey(key)
|
||||||
|
// Lua 脚本:仅当新 seq > 当前值时才更新,防回退
|
||||||
|
script := redis.NewScript(`
|
||||||
|
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||||
|
if current and current >= tonumber(ARGV[1]) then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
redis.call('SET', KEYS[1], ARGV[1])
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||||
|
return 1
|
||||||
|
`)
|
||||||
|
_, err := script.Run(ctx, rdb, []string{nKey}, seq, int64(readSeqExpire.Seconds())).Result()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserReadSeq 获取用户在某会话的已读位置
|
||||||
|
func (c *ConversationCache) GetUserReadSeq(ctx context.Context, convID, userID string) (int64, error) {
|
||||||
|
key := UserReadSeqKey(convID, userID)
|
||||||
|
|
||||||
|
redisCache, ok := c.cache.(*RedisCache)
|
||||||
|
if !ok {
|
||||||
|
raw, ok := c.cache.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return 0, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case int64:
|
||||||
|
return v, nil
|
||||||
|
case string:
|
||||||
|
var n int64
|
||||||
|
fmt.Sscanf(v, "%d", &n)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
return 0, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
rdb := redisCache.GetRedisClient().GetClient()
|
||||||
|
nKey := normalizeKey(key)
|
||||||
|
val, err := rdb.Get(ctx, nKey).Int64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserReadSeqs 批量获取用户在多个会话的已读位置
|
||||||
|
func (c *ConversationCache) GetUserReadSeqs(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||||
|
if len(convIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
redisCache, ok := c.cache.(*RedisCache)
|
||||||
|
if !ok {
|
||||||
|
result := make(map[string]int64, len(convIDs))
|
||||||
|
for _, convID := range convIDs {
|
||||||
|
if seq, err := c.GetUserReadSeq(ctx, convID, userID); err == nil {
|
||||||
|
result[convID] = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rdb := redisCache.GetRedisClient().GetClient()
|
||||||
|
keys := make([]string, len(convIDs))
|
||||||
|
for i, convID := range convIDs {
|
||||||
|
keys[i] = normalizeKey(UserReadSeqKey(convID, userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
vals, err := rdb.MGet(ctx, keys...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]int64, len(convIDs))
|
||||||
|
for i, val := range vals {
|
||||||
|
if val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var seq int64
|
||||||
|
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
|
||||||
|
result[convIDs[i]] = seq
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConvMaxSeq 获取会话的最大 seq(复用 msg_seq 计数器,读取当前值)
|
||||||
|
func (c *ConversationCache) GetConvMaxSeq(ctx context.Context, convID string) (int64, error) {
|
||||||
|
seqKey := MessageSeqKey(convID)
|
||||||
|
|
||||||
|
redisCache, ok := c.cache.(*RedisCache)
|
||||||
|
if !ok {
|
||||||
|
return 0, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
rdb := redisCache.GetRedisClient().GetClient()
|
||||||
|
nKey := normalizeKey(seqKey)
|
||||||
|
val, err := rdb.Get(ctx, nKey).Int64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConvMaxSeqBatch 批量获取多个会话的 maxSeq(MGet 减少网络往返)
|
||||||
|
func (c *ConversationCache) GetConvMaxSeqBatch(ctx context.Context, convIDs []string) (map[string]int64, error) {
|
||||||
|
if len(convIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
redisCache, ok := c.cache.(*RedisCache)
|
||||||
|
if !ok {
|
||||||
|
result := make(map[string]int64, len(convIDs))
|
||||||
|
for _, convID := range convIDs {
|
||||||
|
if seq, err := c.GetConvMaxSeq(ctx, convID); err == nil {
|
||||||
|
result[convID] = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rdb := redisCache.GetRedisClient().GetClient()
|
||||||
|
keys := make([]string, len(convIDs))
|
||||||
|
for i, convID := range convIDs {
|
||||||
|
keys[i] = normalizeKey(MessageSeqKey(convID))
|
||||||
|
}
|
||||||
|
|
||||||
|
vals, err := rdb.MGet(ctx, keys...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]int64, len(convIDs))
|
||||||
|
for i, val := range vals {
|
||||||
|
if val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var seq int64
|
||||||
|
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
|
||||||
|
if seq > 0 {
|
||||||
|
result[convIDs[i]] = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeUnreadCount 计算单个会话未读数:maxSeq - hasReadSeq
|
||||||
|
func (c *ConversationCache) ComputeUnreadCount(ctx context.Context, convID, userID string) (int64, error) {
|
||||||
|
maxSeq, err := c.GetConvMaxSeq(ctx, convID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
readSeq, err := c.GetUserReadSeq(ctx, convID, userID)
|
||||||
|
if err != nil {
|
||||||
|
// 未读到已读位置,视为 0,返回 maxSeq
|
||||||
|
return maxSeq, nil
|
||||||
|
}
|
||||||
|
unread := maxSeq - readSeq
|
||||||
|
if unread < 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return unread, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeAllUnreadCount 计算所有会话未读总数:sum(maxSeq - hasReadSeq)
|
||||||
|
func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID string, convIDs []string, maxSeqs map[string]int64) (int64, error) {
|
||||||
|
readSeqs, err := c.GetUserReadSeqs(ctx, userID, convIDs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
for _, convID := range convIDs {
|
||||||
|
maxSeq, ok := maxSeqs[convID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
readSeq, hasRead := readSeqs[convID]
|
||||||
|
if !hasRead {
|
||||||
|
total += maxSeq
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unread := maxSeq - readSeq
|
||||||
|
if unread > 0 {
|
||||||
|
total += unread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextSeq 获取会话的下一个 seq 值(单次 Redis INCR,原子无竞态)
|
||||||
|
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
||||||
|
// 优先使用 SeqBufferManager(已简化为单次 INCR)
|
||||||
|
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||||
|
return c.seqBufferMgr.GetNextSeq(ctx, convID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
seqKey := MessageSeqKey(convID)
|
||||||
|
|
||||||
|
// 尝试 Redis INCR(key 存在时递增,不存在时返回 0 触发冷启动)
|
||||||
|
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||||
|
rdb := lc.GetRedisClient()
|
||||||
|
if rdb != nil {
|
||||||
|
result, err := nextSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}).Int64()
|
||||||
|
if err == nil {
|
||||||
|
if result > 0 {
|
||||||
|
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
// 冷启动:从 DB 加载 last_seq,SETNX+INCR 原子初始化
|
||||||
|
return c.initSeqFromDB(ctx, convID, seqKey, rdb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis 不可用,降级到 DB(SELECT FOR UPDATE 保证原子性)
|
||||||
|
if c.msgRepo != nil {
|
||||||
|
if dbSeq, dbErr := c.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||||
|
return dbSeq, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("seq allocation failed for %s: no redis or db available", convID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSeqFromDB 冷启动:从 DB 加载 last_seq,用 SETNX+INCR 原子初始化
|
||||||
|
func (c *ConversationCache) initSeqFromDB(ctx context.Context, convID, seqKey string, rdb *redisPkg.Client) (int64, error) {
|
||||||
|
if c.repo == nil {
|
||||||
|
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||||
|
}
|
||||||
|
conv, dbErr := c.repo.GetConversationByID(convID)
|
||||||
|
if dbErr != nil {
|
||||||
|
return 0, fmt.Errorf("load conversation for seq init: %w", dbErr)
|
||||||
|
}
|
||||||
|
result, initErr := initSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, conv.LastSeq).Int64()
|
||||||
|
if initErr != nil {
|
||||||
|
return 0, fmt.Errorf("initSeqLua failed: %w", initErr)
|
||||||
|
}
|
||||||
|
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextSeqWithGroup 获取会话的下一个 seq 值(isGroup 参数保留兼容,简化后不区分步长)
|
||||||
|
func (c *ConversationCache) GetNextSeqWithGroup(ctx context.Context, convID string, isGroup bool) (int64, error) {
|
||||||
|
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||||
|
return c.seqBufferMgr.GetNextSeq(ctx, convID, isGroup)
|
||||||
|
}
|
||||||
|
return c.GetNextSeq(ctx, convID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncConvSeq 写透缓存:将 DB 刚分配的 seq 同步到 Redis,确保 Redis seq >= DB seq
|
||||||
|
func (c *ConversationCache) SyncConvSeq(ctx context.Context, convID string, seq int64) {
|
||||||
|
seqKey := MessageSeqKey(convID)
|
||||||
|
|
||||||
|
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||||
|
rdb := lc.GetRedisClient()
|
||||||
|
if rdb != nil {
|
||||||
|
_ = syncSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, seq).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.seqBufferMgr != nil && c.seqBufferMgr.rdb != nil {
|
||||||
|
_ = syncSeqLua.Run(ctx, c.seqBufferMgr.rdb, []string{seqKey}, seq).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 消息缓存方法
|
// 消息缓存方法
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -618,11 +937,11 @@ func (c *ConversationCache) GetMessagesBeforeSeq(ctx context.Context, convID str
|
|||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheMessage 缓存单条消息(立即写入缓存)
|
// CacheMessage 缓存单条消息(使用 Redis Pipeline 减少网络往返)
|
||||||
// 写入 Hash、Sorted Set、更新计数
|
|
||||||
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
|
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
|
||||||
hashKey := MessageHashKey(convID)
|
hashKey := MessageHashKey(convID)
|
||||||
indexKey := MessageIndexKey(convID)
|
indexKey := MessageIndexKey(convID)
|
||||||
|
countKey := MessageCountKey(convID)
|
||||||
|
|
||||||
msgData := MessageCacheDataFromModel(msg)
|
msgData := MessageCacheDataFromModel(msg)
|
||||||
data, err := json.Marshal(msgData)
|
data, err := json.Marshal(msgData)
|
||||||
@@ -630,23 +949,34 @@ func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg
|
|||||||
return fmt.Errorf("failed to marshal message: %w", err)
|
return fmt.Errorf("failed to marshal message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HSET 消息详情
|
hashKey = ResolveKey(hashKey)
|
||||||
if err := c.cache.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
|
indexKey = ResolveKey(indexKey)
|
||||||
|
countKey = ResolveKey(countKey)
|
||||||
|
|
||||||
|
redisCache, ok := c.cache.(*RedisCache)
|
||||||
|
if ok {
|
||||||
|
rdb := redisCache.GetRedisClient().GetClient()
|
||||||
|
pipe := rdb.Pipeline()
|
||||||
|
pipe.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data))
|
||||||
|
pipe.ZAdd(ctx, indexKey, redis.Z{Score: float64(msg.Seq), Member: fmt.Sprintf("%d", msg.Seq)})
|
||||||
|
pipe.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
|
||||||
|
pipe.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
|
||||||
|
pipe.Incr(ctx, countKey)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to pipeline cache message: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.cache.HSet(ctx, MessageHashKey(convID), fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
|
||||||
return fmt.Errorf("failed to set hash: %w", err)
|
return fmt.Errorf("failed to set hash: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.cache.ZAdd(ctx, MessageIndexKey(convID), float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
||||||
// ZADD 消息索引
|
|
||||||
if err := c.cache.ZAdd(ctx, indexKey, float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
|
||||||
return fmt.Errorf("failed to add to sorted set: %w", err)
|
return fmt.Errorf("failed to add to sorted set: %w", err)
|
||||||
}
|
}
|
||||||
|
c.cache.Expire(ctx, MessageHashKey(convID), c.settings.MessageDetailTTL)
|
||||||
// 设置 TTL
|
c.cache.Expire(ctx, MessageIndexKey(convID), c.settings.MessageIndexTTL)
|
||||||
c.cache.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
|
|
||||||
c.cache.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
|
|
||||||
|
|
||||||
// INCR 消息计数
|
|
||||||
c.cache.Incr(ctx, MessageCountKey(convID))
|
c.cache.Incr(ctx, MessageCountKey(convID))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
58
internal/cache/keys.go
vendored
58
internal/cache/keys.go
vendored
@@ -27,6 +27,9 @@ const (
|
|||||||
PrefixUnreadConversation = "unread:conversation"
|
PrefixUnreadConversation = "unread:conversation"
|
||||||
PrefixUnreadDetail = "unread:detail"
|
PrefixUnreadDetail = "unread:detail"
|
||||||
|
|
||||||
|
// 已读位置相关(OpenIM 风格:缓存 hasReadSeq,用于 O(1) 未读数计算)
|
||||||
|
PrefixUserReadSeq = "user_read_seq"
|
||||||
|
|
||||||
// 用户相关
|
// 用户相关
|
||||||
PrefixUserInfo = "users:info"
|
PrefixUserInfo = "users:info"
|
||||||
PrefixUserMe = "users:me"
|
PrefixUserMe = "users:me"
|
||||||
@@ -40,6 +43,10 @@ const (
|
|||||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||||
|
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
||||||
|
|
||||||
|
// 帖子创建幂等键前缀(clientRequestID -> 已创建的 postID)
|
||||||
|
keyPrefixPostIdempotent = "post_idem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostListKey 生成帖子列表缓存键
|
// PostListKey 生成帖子列表缓存键
|
||||||
@@ -82,25 +89,13 @@ func ConversationListKey(userID string, page, pageSize int) string {
|
|||||||
return fmt.Sprintf("%s:%s:%d:%d", PrefixConversationList, userID, page, pageSize)
|
return fmt.Sprintf("%s:%s:%d:%d", PrefixConversationList, userID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConversationDetailKey 生成会话详情缓存键
|
|
||||||
func ConversationDetailKey(conversationID, userID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s:%s", PrefixConversationDetail, conversationID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupMembersKey 生成群组成员缓存键
|
// GroupMembersKey 生成群组成员缓存键
|
||||||
func GroupMembersKey(groupID string, page, pageSize int) string {
|
func GroupMembersKey(groupID string, page, pageSize int) string {
|
||||||
return fmt.Sprintf("%s:%s:page:%d:size:%d", PrefixGroupMembers, groupID, page, pageSize)
|
return fmt.Sprintf("%s:%s:page:%d:size:%d", PrefixGroupMembers, groupID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupMembersAllKey 生成群组全量成员ID列表缓存键
|
|
||||||
func GroupMembersAllKey(groupID string) string {
|
|
||||||
return fmt.Sprintf("%s:all:%s", PrefixGroupMembers, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupInfoKey 生成群组信息缓存键
|
|
||||||
func GroupInfoKey(groupID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", PrefixGroupInfo, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnreadSystemKey 生成系统消息未读数缓存键
|
// UnreadSystemKey 生成系统消息未读数缓存键
|
||||||
func UnreadSystemKey(userID string) string {
|
func UnreadSystemKey(userID string) string {
|
||||||
@@ -117,15 +112,6 @@ func UnreadDetailKey(userID, conversationID string) string {
|
|||||||
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfoKey 生成用户信息缓存键
|
|
||||||
func UserInfoKey(userID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", PrefixUserInfo, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserMeKey 生成当前用户信息缓存键
|
|
||||||
func UserMeKey(userID string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", PrefixUserMe, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel)
|
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel)
|
||||||
func ChannelsAllListKey() string {
|
func ChannelsAllListKey() string {
|
||||||
@@ -152,20 +138,12 @@ func InvalidateConversationList(cache Cache, userID string) {
|
|||||||
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateConversationDetail 失效会话详情缓存
|
|
||||||
func InvalidateConversationDetail(cache Cache, conversationID, userID string) {
|
|
||||||
cache.Delete(ConversationDetailKey(conversationID, userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateGroupMembers 失效群组成员缓存
|
// InvalidateGroupMembers 失效群组成员缓存
|
||||||
func InvalidateGroupMembers(cache Cache, groupID string) {
|
func InvalidateGroupMembers(cache Cache, groupID string) {
|
||||||
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateGroupInfo 失效群组信息缓存
|
|
||||||
func InvalidateGroupInfo(cache Cache, groupID string) {
|
|
||||||
cache.Delete(GroupInfoKey(groupID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
||||||
func InvalidateUnreadSystem(cache Cache, userID string) {
|
func InvalidateUnreadSystem(cache Cache, userID string) {
|
||||||
@@ -182,11 +160,6 @@ func InvalidateUnreadDetail(cache Cache, userID, conversationID string) {
|
|||||||
cache.Delete(UnreadDetailKey(userID, conversationID))
|
cache.Delete(UnreadDetailKey(userID, conversationID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateUserInfo 失效用户信息缓存
|
|
||||||
func InvalidateUserInfo(cache Cache, userID string) {
|
|
||||||
cache.Delete(UserInfoKey(userID))
|
|
||||||
cache.Delete(UserMeKey(userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 消息缓存 Key 生成函数
|
// 消息缓存 Key 生成函数
|
||||||
@@ -221,3 +194,20 @@ func MessagePageKey(convID string, page, pageSize int) string {
|
|||||||
func InvalidateMessagePages(cache Cache, conversationID string) {
|
func InvalidateMessagePages(cache Cache, conversationID string) {
|
||||||
cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID))
|
cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MessageIdempotentKey 消息幂等键 (clientMsgID -> server MsgID)
|
||||||
|
func MessageIdempotentKey(senderID, clientMsgID string) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostCreateIdempotentKey 帖子创建幂等键 (userID + clientRequestID -> postID)
|
||||||
|
// 用于在 SetNX 原子抢占期间识别同一客户端请求的重试,避免重复发帖。
|
||||||
|
func PostCreateIdempotentKey(userID, clientRequestID string) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s", keyPrefixPostIdempotent, userID, clientRequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
||||||
|
func UserReadSeqKey(convID, userID string) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
111
internal/cache/layered_cache.go
vendored
111
internal/cache/layered_cache.go
vendored
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
redislib "github.com/redis/go-redis/v9"
|
redislib "github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
redisPkg "with_you/internal/pkg/redis"
|
redisPkg "with_you/internal/pkg/redis"
|
||||||
)
|
)
|
||||||
@@ -111,8 +109,12 @@ func (c *LayeredCache) Get(key string) (any, bool) {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if ttl := c.getLocalTTL(); ttl > 0 {
|
if ttl := c.getLocalTTL(); ttl > 0 {
|
||||||
|
if str, ok := val.(string); ok {
|
||||||
|
c.local.SetRaw(key, []byte(str), ttl)
|
||||||
|
} else {
|
||||||
c.local.Set(key, val, ttl)
|
c.local.Set(key, val, ttl)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return val, true
|
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() {
|
func (c *LayeredCache) Clear() {
|
||||||
c.local.Clear()
|
c.local.Clear()
|
||||||
if c.redis != nil {
|
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...)
|
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 {
|
func (c *LayeredCache) ZAdd(ctx context.Context, key string, score float64, member string) error {
|
||||||
if !c.enabled || c.redis == nil {
|
if !c.enabled || c.redis == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -325,14 +352,27 @@ func (c *LayeredCache) ZCard(ctx context.Context, key string) (int64, error) {
|
|||||||
return c.redis.ZCard(ctx, key)
|
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) {
|
func (c *LayeredCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||||
if !c.enabled || c.redis == nil {
|
if !c.IsEnabled() {
|
||||||
return 0, ErrKeyNotFound
|
return 0, ErrKeyNotFound
|
||||||
}
|
}
|
||||||
c.local.Delete(key)
|
c.local.Delete(key)
|
||||||
return c.redis.Incr(ctx, 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 {
|
func (c *LayeredCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||||
if !c.enabled || c.redis == nil {
|
if !c.enabled || c.redis == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -375,59 +415,6 @@ func (c *LayeredCache) GetRedisClient() *redisPkg.Client {
|
|||||||
return c.redis.GetRedisClient()
|
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) {
|
func (c *LayeredCache) InvalidateUserCache(userID uint) {
|
||||||
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
|
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
|
||||||
@@ -472,15 +459,3 @@ func buildGroupMembersKey(groupID uint) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ Cache = (*LayeredCache)(nil)
|
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) {
|
func (l *LRU) Get(key string) (any, bool) {
|
||||||
val, err := l.getItem(key)
|
val, err := l.getItem(key)
|
||||||
if err != nil {
|
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 {
|
func (l *LRUStriped) DeleteMulti(keys []string) error {
|
||||||
var err error
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
l.Delete(key)
|
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 {
|
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 全局缓存指标实例
|
// metrics 全局缓存指标实例
|
||||||
var metrics cacheMetrics
|
var metrics cacheMetrics
|
||||||
|
|
||||||
// MetricsSnapshot 指标快照
|
|
||||||
type MetricsSnapshot struct {
|
|
||||||
Hit int64
|
|
||||||
Miss int64
|
|
||||||
DecodeError int64
|
|
||||||
SetError int64
|
|
||||||
Invalidate int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMetricsSnapshot 获取当前指标快照
|
|
||||||
func GetMetricsSnapshot() MetricsSnapshot {
|
|
||||||
return MetricsSnapshot{
|
|
||||||
Hit: metrics.hit.Load(),
|
|
||||||
Miss: metrics.miss.Load(),
|
|
||||||
DecodeError: metrics.decodeError.Load(),
|
|
||||||
SetError: metrics.setError.Load(),
|
|
||||||
Invalidate: metrics.invalidate.Load(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordHit 记录缓存命中
|
// recordHit 记录缓存命中
|
||||||
func recordHit() {
|
func recordHit() {
|
||||||
|
|||||||
38
internal/cache/redis_cache.go
vendored
38
internal/cache/redis_cache.go
vendored
@@ -82,7 +82,6 @@ func (c *RedisCache) Delete(key string) {
|
|||||||
// DeleteByPrefix 根据前缀删除缓存
|
// DeleteByPrefix 根据前缀删除缓存
|
||||||
func (c *RedisCache) DeleteByPrefix(prefix string) {
|
func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||||
prefix = normalizePrefix(prefix)
|
prefix = normalizePrefix(prefix)
|
||||||
// 使用原生客户端执行SCAN命令
|
|
||||||
rdb := c.client.GetClient()
|
rdb := c.client.GetClient()
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
for {
|
for {
|
||||||
@@ -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 清空所有缓存
|
// Clear 清空所有缓存
|
||||||
func (c *RedisCache) Clear() {
|
func (c *RedisCache) Clear() {
|
||||||
if settings.DisableFlushDB {
|
if settings.DisableFlushDB {
|
||||||
@@ -199,6 +222,12 @@ func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) err
|
|||||||
return c.client.HDel(ctx, key, fields...)
|
return c.client.HDel(ctx, key, fields...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HIncrBy 原子递增 Hash 字段的值
|
||||||
|
func (c *RedisCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
|
||||||
|
key = normalizeKey(key)
|
||||||
|
return c.client.HIncrBy(ctx, key, field, incr)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== RedisCache Sorted Set 操作 ====================
|
// ==================== RedisCache Sorted Set 操作 ====================
|
||||||
|
|
||||||
// ZAdd 添加 Sorted Set 成员
|
// ZAdd 添加 Sorted Set 成员
|
||||||
@@ -258,6 +287,13 @@ func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) {
|
|||||||
return c.client.Incr(ctx, key)
|
return c.client.Incr(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||||
|
func (c *RedisCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
|
||||||
|
key = normalizeKey(key)
|
||||||
|
rdb := c.client.GetClient()
|
||||||
|
return rdb.IncrBy(ctx, key, delta).Result()
|
||||||
|
}
|
||||||
|
|
||||||
// Expire 设置过期时间
|
// Expire 设置过期时间
|
||||||
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||||
key = normalizeKey(key)
|
key = normalizeKey(key)
|
||||||
|
|||||||
5
internal/cache/repository_adapter.go
vendored
5
internal/cache/repository_adapter.go
vendored
@@ -74,3 +74,8 @@ func (a *MessageRepositoryAdapter) CreateMessage(msg *model.Message) error {
|
|||||||
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
|
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
|
||||||
return a.repo.UpdateConversationLastSeq(convID, seq)
|
return a.repo.UpdateConversationLastSeq(convID, seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNextSeq 实现 MessageRepository 接口
|
||||||
|
func (a *MessageRepositoryAdapter) GetNextSeq(convID string) (int64, error) {
|
||||||
|
return a.repo.GetNextSeq(convID)
|
||||||
|
}
|
||||||
|
|||||||
105
internal/cache/seq_buffer.go
vendored
Normal file
105
internal/cache/seq_buffer.go
vendored
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
redisPkg "with_you/internal/pkg/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
const seqKeyTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
// SeqBufferConfig seq 预分配配置(保留结构以兼容配置文件,实际不再使用缓冲)
|
||||||
|
type SeqBufferConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
PrivateBufferSize int `mapstructure:"private_buffer_size"`
|
||||||
|
GroupBufferSize int `mapstructure:"group_buffer_size"`
|
||||||
|
LockTimeoutMs int `mapstructure:"lock_timeout_ms"`
|
||||||
|
MaxRetries int `mapstructure:"max_retries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeqBufferManager seq 分配管理器(简化版:每条消息一个 Redis INCR,无本地缓冲)
|
||||||
|
type SeqBufferManager struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
config SeqBufferConfig
|
||||||
|
repo ConversationRepository
|
||||||
|
msgRepo MessageRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeqBufferManager 创建 seq 分配管理器
|
||||||
|
func NewSeqBufferManager(rdb *redis.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||||
|
return &SeqBufferManager{
|
||||||
|
rdb: rdb,
|
||||||
|
config: cfg,
|
||||||
|
repo: repo,
|
||||||
|
msgRepo: msgRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextSeq 获取下一个 seq(单次 Redis INCR,原子且无竞态)
|
||||||
|
func (m *SeqBufferManager) GetNextSeq(ctx context.Context, convID string, _ bool) (int64, error) {
|
||||||
|
seqKey := MessageSeqKey(convID)
|
||||||
|
|
||||||
|
// 1. 尝试 INCR(key 存在时直接递增,原子操作)
|
||||||
|
result, err := nextSeqLua.Run(ctx, m.rdb, []string{seqKey}).Int64()
|
||||||
|
if err == nil {
|
||||||
|
if result > 0 {
|
||||||
|
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
// result == 0: 冷启动,需从 DB 初始化
|
||||||
|
return m.initAndIncr(ctx, convID, seqKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis 不可用,降级到 DB
|
||||||
|
zap.L().Warn("seq INCR failed, falling back to DB", zap.String("convID", convID), zap.Error(err))
|
||||||
|
if m.msgRepo != nil {
|
||||||
|
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||||
|
return dbSeq, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("seq allocation failed for %s: %w", convID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initAndIncr 冷启动:从 DB 加载 last_seq,然后用 SETNX+INCR 原子初始化
|
||||||
|
func (m *SeqBufferManager) initAndIncr(ctx context.Context, convID, seqKey string) (int64, error) {
|
||||||
|
if m.repo == nil {
|
||||||
|
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||||
|
}
|
||||||
|
|
||||||
|
conv, err := m.repo.GetConversationByID(convID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("load conversation for seq init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initVal := conv.LastSeq
|
||||||
|
result, err := initSeqLua.Run(ctx, m.rdb, []string{seqKey}, initVal).Int64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("initSeqLua failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateBuffer no-op(保留接口兼容)
|
||||||
|
func (m *SeqBufferManager) InvalidateBuffer(convID string) {}
|
||||||
|
|
||||||
|
// IsEnabled 是否启用
|
||||||
|
func (m *SeqBufferManager) IsEnabled() bool {
|
||||||
|
return m.config.Enabled && m.rdb != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeqBufferManagerFromRedisPkg 从 pkg/redis.Client 创建 SeqBufferManager
|
||||||
|
func NewSeqBufferManagerFromRedisPkg(redisPkgClient *redisPkg.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||||
|
if redisPkgClient == nil {
|
||||||
|
zap.L().Warn("SeqBuffer: redis client is nil, seq pre-allocation disabled")
|
||||||
|
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
|
||||||
|
}
|
||||||
|
rdb := redisPkgClient.GetClient()
|
||||||
|
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,6 +19,8 @@ type Config struct {
|
|||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Upload UploadConfig `mapstructure:"upload"`
|
Upload UploadConfig `mapstructure:"upload"`
|
||||||
|
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||||
|
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
|
||||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
@@ -30,10 +29,16 @@ type Config struct {
|
|||||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
Casbin CasbinConfig `mapstructure:"casbin"`
|
||||||
Encryption EncryptionConfig `mapstructure:"encryption"`
|
Encryption EncryptionConfig `mapstructure:"encryption"`
|
||||||
HotRank HotRankConfig `mapstructure:"hot_rank"`
|
HotRank HotRankConfig `mapstructure:"hot_rank"`
|
||||||
WebRTC WebRTCConfig `mapstructure:"webrtc"`
|
LiveKit LiveKitConfig `mapstructure:"livekit"`
|
||||||
Report ReportConfig `mapstructure:"report"`
|
Report ReportConfig `mapstructure:"report"`
|
||||||
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
||||||
|
JPush JPushConfig `mapstructure:"jpush"`
|
||||||
SetupSecret string `mapstructure:"setup_secret"`
|
SetupSecret string `mapstructure:"setup_secret"`
|
||||||
|
WebSocket WSConfig `mapstructure:"websocket"`
|
||||||
|
Runner RunnerConfig `mapstructure:"runner"`
|
||||||
|
SeqBuffer SeqBufferConfig `mapstructure:"seq_buffer"`
|
||||||
|
VersionLog VersionLogConfig `mapstructure:"version_log"`
|
||||||
|
PushWorker PushWorkerConfig `mapstructure:"push_worker"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 加载配置文件
|
// Load 加载配置文件
|
||||||
@@ -59,6 +64,21 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("database.slow_threshold_ms", 200)
|
viper.SetDefault("database.slow_threshold_ms", 200)
|
||||||
viper.SetDefault("database.ignore_record_not_found", true)
|
viper.SetDefault("database.ignore_record_not_found", true)
|
||||||
viper.SetDefault("database.parameterized_queries", true)
|
viper.SetDefault("database.parameterized_queries", true)
|
||||||
|
viper.SetDefault("database.replica_policy", "random")
|
||||||
|
|
||||||
|
// 读写分离:单副本字段显式 BindEnv。
|
||||||
|
// AutomaticEnv 对 YAML 中未出现的嵌套 key(replica 段在 config.yaml 中默认被注释)
|
||||||
|
// 不会反向推导,必须逐字段 BindEnv 才能让 APP_DATABASE_REPLICA_HOST 等环境变量生效。
|
||||||
|
// 与 jpush.channel.* 处理方式一致(见下方 BindEnv 块)。
|
||||||
|
viper.BindEnv("database.replica.host", "APP_DATABASE_REPLICA_HOST")
|
||||||
|
viper.BindEnv("database.replica.port", "APP_DATABASE_REPLICA_PORT")
|
||||||
|
viper.BindEnv("database.replica.user", "APP_DATABASE_REPLICA_USER")
|
||||||
|
viper.BindEnv("database.replica.password", "APP_DATABASE_REPLICA_PASSWORD")
|
||||||
|
viper.BindEnv("database.replica.dbname", "APP_DATABASE_REPLICA_DBNAME")
|
||||||
|
viper.BindEnv("database.replica.sslmode", "APP_DATABASE_REPLICA_SSLMODE")
|
||||||
|
// 副本连接池参数(可选,留空回退到主库连接池配置,见 database.go)
|
||||||
|
viper.BindEnv("database.replica_max_idle_conns", "APP_DATABASE_REPLICA_MAX_IDLE_CONNS")
|
||||||
|
viper.BindEnv("database.replica_max_open_conns", "APP_DATABASE_REPLICA_MAX_OPEN_CONNS")
|
||||||
viper.SetDefault("redis.type", "miniredis")
|
viper.SetDefault("redis.type", "miniredis")
|
||||||
viper.SetDefault("redis.redis.host", "localhost")
|
viper.SetDefault("redis.redis.host", "localhost")
|
||||||
viper.SetDefault("redis.redis.port", 6379)
|
viper.SetDefault("redis.redis.port", 6379)
|
||||||
@@ -91,6 +111,15 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
||||||
viper.SetDefault("upload.max_file_size", 10485760)
|
viper.SetDefault("upload.max_file_size", 10485760)
|
||||||
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
||||||
|
// 文件过期清理默认值
|
||||||
|
viper.SetDefault("file_cleanup.enabled", true)
|
||||||
|
viper.SetDefault("file_cleanup.retention_days", 7)
|
||||||
|
viper.SetDefault("file_cleanup.interval_minutes", 360)
|
||||||
|
viper.SetDefault("file_cleanup.batch_size", 100)
|
||||||
|
// 设备 registration_id 清理默认值(3 天不活跃即清理)
|
||||||
|
viper.SetDefault("device_cleanup.enabled", true)
|
||||||
|
viper.SetDefault("device_cleanup.retention_days", 3)
|
||||||
|
viper.SetDefault("device_cleanup.interval_minutes", 360)
|
||||||
viper.SetDefault("s3.endpoint", "")
|
viper.SetDefault("s3.endpoint", "")
|
||||||
viper.SetDefault("s3.access_key", "")
|
viper.SetDefault("s3.access_key", "")
|
||||||
viper.SetDefault("s3.secret_key", "")
|
viper.SetDefault("s3.secret_key", "")
|
||||||
@@ -148,7 +177,6 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("grpc.tls_enabled", false)
|
viper.SetDefault("grpc.tls_enabled", false)
|
||||||
viper.SetDefault("grpc.tls_cert_file", "")
|
viper.SetDefault("grpc.tls_cert_file", "")
|
||||||
viper.SetDefault("grpc.tls_key_file", "")
|
viper.SetDefault("grpc.tls_key_file", "")
|
||||||
// Casbin 默认值
|
|
||||||
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
|
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
|
||||||
viper.SetDefault("casbin.auto_save", true)
|
viper.SetDefault("casbin.auto_save", true)
|
||||||
viper.SetDefault("casbin.auto_load", true)
|
viper.SetDefault("casbin.auto_load", true)
|
||||||
@@ -165,13 +193,136 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("hot_rank.recent_window_hours", 168)
|
viper.SetDefault("hot_rank.recent_window_hours", 168)
|
||||||
viper.SetDefault("hot_rank.recent_fetch_limit", 500)
|
viper.SetDefault("hot_rank.recent_fetch_limit", 500)
|
||||||
viper.SetDefault("hot_rank.candidate_cap", 800)
|
viper.SetDefault("hot_rank.candidate_cap", 800)
|
||||||
// WebRTC 默认值(Google 公共 STUN 服务器)
|
|
||||||
viper.SetDefault("webrtc.ice_servers", []map[string]any{
|
|
||||||
{"urls": []string{"stun:stun.l.google.com:19302"}},
|
|
||||||
})
|
|
||||||
// Report 默认值
|
// Report 默认值
|
||||||
viper.SetDefault("report.auto_hide_threshold", 3)
|
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", "")
|
viper.SetDefault("setup_secret", "")
|
||||||
|
// WebSocket 默认值
|
||||||
|
viper.SetDefault("websocket.mode", "standalone")
|
||||||
|
viper.SetDefault("websocket.cluster.instance_id", "")
|
||||||
|
viper.SetDefault("websocket.cluster.msg_channel", "ws:msg")
|
||||||
|
viper.SetDefault("websocket.cluster.online_ttl", 60)
|
||||||
|
viper.SetDefault("websocket.cluster.heartbeat_interval", 20)
|
||||||
|
// Runner 集群默认值(mode 和 instance_id 空则跟随 websocket)
|
||||||
|
viper.SetDefault("runner.mode", "")
|
||||||
|
viper.SetDefault("runner.cluster.instance_id", "")
|
||||||
|
viper.SetDefault("runner.cluster.task_channel", "runner:task")
|
||||||
|
viper.SetDefault("runner.cluster.result_channel", "runner:result")
|
||||||
|
viper.SetDefault("runner.cluster.registry_prefix", "runner:reg:")
|
||||||
|
viper.SetDefault("runner.cluster.cap_prefix", "runner:cap:")
|
||||||
|
viper.SetDefault("runner.cluster.registry_ttl", 90)
|
||||||
|
viper.SetDefault("runner.cluster.heartbeat_interval", 30)
|
||||||
|
// SeqBuffer 默认值
|
||||||
|
viper.SetDefault("seq_buffer.enabled", false)
|
||||||
|
viper.SetDefault("seq_buffer.private_buffer_size", 50)
|
||||||
|
viper.SetDefault("seq_buffer.group_buffer_size", 200)
|
||||||
|
viper.SetDefault("seq_buffer.lock_timeout_ms", 5000)
|
||||||
|
viper.SetDefault("seq_buffer.max_retries", 3)
|
||||||
|
// VersionLog 默认值
|
||||||
|
viper.SetDefault("version_log.enabled", false)
|
||||||
|
viper.SetDefault("version_log.sync_limit", 100)
|
||||||
|
viper.SetDefault("version_log.max_sync_gap", 1000)
|
||||||
|
// PushWorker 默认值
|
||||||
|
viper.SetDefault("push_worker.enabled", false)
|
||||||
|
viper.SetDefault("push_worker.stream", "msg_push")
|
||||||
|
viper.SetDefault("push_worker.group", "push_worker")
|
||||||
|
viper.SetDefault("push_worker.batch_size", 50)
|
||||||
|
viper.SetDefault("push_worker.poll_timeout_ms", 2000)
|
||||||
|
viper.SetDefault("push_worker.max_retries", 3)
|
||||||
|
viper.SetDefault("push_worker.max_stream_len", 100000)
|
||||||
|
viper.SetDefault("push_worker.idle_timeout_ms", 30000)
|
||||||
|
// LiveKit 默认值
|
||||||
|
viper.SetDefault("livekit.enabled", false)
|
||||||
|
viper.SetDefault("livekit.url", "http://localhost:7880")
|
||||||
|
viper.SetDefault("livekit.api_key", "devkey")
|
||||||
|
viper.SetDefault("livekit.api_secret", "")
|
||||||
|
viper.SetDefault("livekit.token_ttl", 600)
|
||||||
|
viper.SetDefault("livekit.webhook_secret", "")
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||||
@@ -182,96 +333,14 @@ func Load(configPath string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runner mode 和 instance_id 空则跟随 WebSocket 配置
|
||||||
|
cfg.Runner.ApplyWSDefaults(cfg.WebSocket)
|
||||||
|
|
||||||
// Convert seconds to duration
|
// Convert seconds to duration
|
||||||
cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second
|
cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second
|
||||||
cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second
|
cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second
|
||||||
|
|
||||||
// 环境变量覆盖(显式处理敏感配置)
|
// Viper AutomaticEnv 已自动处理所有 APP_ 前缀的环境变量
|
||||||
cfg.JWT.Secret = getEnvOrDefault("APP_JWT_SECRET", cfg.JWT.Secret)
|
|
||||||
cfg.Database.SQLite.Path = getEnvOrDefault("APP_DATABASE_SQLITE_PATH", cfg.Database.SQLite.Path)
|
|
||||||
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)))
|
|
||||||
// TencentTMS 环境变量覆盖
|
|
||||||
cfg.TencentTMS.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_TENCENT_TMS_ENABLED", fmt.Sprintf("%t", cfg.TencentTMS.Enabled)))
|
|
||||||
cfg.TencentTMS.SecretID = getEnvOrDefault("APP_TENCENT_TMS_SECRET_ID", cfg.TencentTMS.SecretID)
|
|
||||||
cfg.TencentTMS.SecretKey = getEnvOrDefault("APP_TENCENT_TMS_SECRET_KEY", cfg.TencentTMS.SecretKey)
|
|
||||||
cfg.TencentTMS.Region = getEnvOrDefault("APP_TENCENT_TMS_REGION", cfg.TencentTMS.Region)
|
|
||||||
cfg.TencentTMS.BizType = getEnvOrDefault("APP_TENCENT_TMS_BIZ_TYPE", cfg.TencentTMS.BizType)
|
|
||||||
cfg.TencentTMS.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_TENCENT_TMS_TIMEOUT", fmt.Sprintf("%d", cfg.TencentTMS.Timeout)))
|
|
||||||
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)))
|
|
||||||
// Sensitive 环境变量覆盖
|
|
||||||
cfg.Sensitive.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_ENABLED", fmt.Sprintf("%t", cfg.Sensitive.Enabled)))
|
|
||||||
cfg.Sensitive.ReplaceStr = getEnvOrDefault("APP_SENSITIVE_REPLACE_STR", cfg.Sensitive.ReplaceStr)
|
|
||||||
cfg.Sensitive.MinMatchLen, _ = strconv.Atoi(getEnvOrDefault("APP_SENSITIVE_MIN_MATCH_LEN", fmt.Sprintf("%d", cfg.Sensitive.MinMatchLen)))
|
|
||||||
cfg.Sensitive.LoadFromDB, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_DB", fmt.Sprintf("%t", cfg.Sensitive.LoadFromDB)))
|
|
||||||
cfg.Sensitive.LoadFromRedis, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_REDIS", fmt.Sprintf("%t", cfg.Sensitive.LoadFromRedis)))
|
|
||||||
cfg.Sensitive.RedisKeyPrefix = getEnvOrDefault("APP_SENSITIVE_REDIS_KEY_PREFIX", cfg.Sensitive.RedisKeyPrefix)
|
|
||||||
|
|
||||||
cfg.SetupSecret = getEnvOrDefault("APP_SETUP_SECRET", cfg.SetupSecret)
|
|
||||||
|
|
||||||
// 安全检查:生产模式必须设置JWT密钥
|
// 安全检查:生产模式必须设置JWT密钥
|
||||||
if cfg.Server.Mode != "debug" {
|
if cfg.Server.Mode != "debug" {
|
||||||
@@ -286,7 +355,4 @@ func Load(configPath string) (*Config, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEnvOrDefault 获取环境变量值,如果未设置则返回默认值
|
|
||||||
func getEnvOrDefault(key, defaultValue string) string {
|
|
||||||
return cmp.Or(os.Getenv(key), defaultValue)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ type DatabaseConfig struct {
|
|||||||
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
||||||
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
||||||
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
||||||
|
// 读写分离:单副本配置(支持环境变量,如 APP_DATABASE_REPLICA_HOST)
|
||||||
|
Replica PostgresConfig `mapstructure:"replica"`
|
||||||
|
// 读写分离:多副本配置(仅 YAML 支持,环境变量无法覆盖切片结构体)
|
||||||
|
Replicas []PostgresConfig `mapstructure:"replicas"`
|
||||||
|
ReplicaPolicy string `mapstructure:"replica_policy"`
|
||||||
|
ReplicaMaxIdle int `mapstructure:"replica_max_idle_conns"`
|
||||||
|
ReplicaMaxOpen int `mapstructure:"replica_max_open_conns"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLiteConfig SQLite 配置
|
// SQLiteConfig SQLite 配置
|
||||||
@@ -20,7 +27,7 @@ type SQLiteConfig struct {
|
|||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgresConfig PostgreSQL 配置
|
// PostgresConfig PostgreSQL 配置(主库和副本共用)
|
||||||
type PostgresConfig struct {
|
type PostgresConfig struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
@@ -37,3 +44,18 @@ func (d PostgresConfig) DSN() string {
|
|||||||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasReplica 返回是否配置了读副本(单副本或多副本)
|
||||||
|
func (d DatabaseConfig) HasReplica() bool {
|
||||||
|
return d.Replica.Host != "" || len(d.Replicas) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllReplicas 返回所有读副本配置(合并单副本 + 多副本)
|
||||||
|
func (d DatabaseConfig) AllReplicas() []PostgresConfig {
|
||||||
|
var result []PostgresConfig
|
||||||
|
if d.Replica.Host != "" {
|
||||||
|
result = append(result, d.Replica)
|
||||||
|
}
|
||||||
|
result = append(result, d.Replicas...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
83
internal/config/jpush.go
Normal file
83
internal/config/jpush.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type JPushConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
AppKey string `mapstructure:"app_key"`
|
||||||
|
MasterSecret string `mapstructure:"master_secret"`
|
||||||
|
Production bool `mapstructure:"production"`
|
||||||
|
// Channel 厂商通道 channel_id 配置
|
||||||
|
// 通过 options.third_party_channel 下发到各厂商(小米/华为/OPPO 等)
|
||||||
|
Channel ChannelConfig `mapstructure:"channel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelConfig 厂商通道 channel_id 配置
|
||||||
|
// 区分「系统消息」与「私聊/群聊消息」两类 channel
|
||||||
|
type ChannelConfig struct {
|
||||||
|
// System 系统消息/通知默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||||
|
System string `mapstructure:"system"`
|
||||||
|
// Chat 私聊/群聊消息默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||||
|
Chat string `mapstructure:"chat"`
|
||||||
|
// Vendor 各厂商 channel_id 覆盖配置(留空则回退到 System/Chat)
|
||||||
|
Vendor VendorChannel `mapstructure:"vendor"`
|
||||||
|
// iOS APNs 通知场景区分配置(按 thread-id 分组)
|
||||||
|
IOS IOSChannelConfig `mapstructure:"ios"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IOSChannelConfig iOS APNs 通知场景区分配置
|
||||||
|
// iOS 走 APNs 无 channel_id 概念,通过 thread-id 实现通知分组
|
||||||
|
type IOSChannelConfig struct {
|
||||||
|
// ChatThreadID 私聊/群聊消息通知分组 thread-id
|
||||||
|
ChatThreadID string `mapstructure:"chat_thread_id"`
|
||||||
|
// SystemThreadID 系统消息/通知分组 thread-id
|
||||||
|
SystemThreadID string `mapstructure:"system_thread_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorChannel 各厂商通道 channel_id 覆盖
|
||||||
|
// 每个厂商区分 System(系统消息)与 Chat(私聊消息)两个 channel_id
|
||||||
|
type VendorChannel struct {
|
||||||
|
Xiaomi VendorChannelID `mapstructure:"xiaomi"`
|
||||||
|
Huawei VendorChannelID `mapstructure:"huawei"`
|
||||||
|
OPPO VendorChannelID `mapstructure:"oppo"`
|
||||||
|
VIVO VendorChannelID `mapstructure:"vivo"`
|
||||||
|
Meizu VendorChannelID `mapstructure:"meizu"`
|
||||||
|
Honor VendorChannelID `mapstructure:"honor"`
|
||||||
|
FCM VendorChannelID `mapstructure:"fcm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorChannelID 单个厂商的两类 channel 配置
|
||||||
|
// 每个 channel 可携带厂商私有模板配置(如小米 mi_template_id、OPPO private_msg_template_id)
|
||||||
|
type VendorChannelID struct {
|
||||||
|
// channel_id
|
||||||
|
System string `mapstructure:"system"`
|
||||||
|
Chat string `mapstructure:"chat"`
|
||||||
|
|
||||||
|
// 小米消息模板(可选,配置后私信消息下发时携带 channel_id 及 mi_template_id)
|
||||||
|
// 见:小米关于消息模板推送新规的更新通知
|
||||||
|
MiSystemTemplateID string `mapstructure:"mi_system_template_id"` // 系统消息模板 id
|
||||||
|
MiChatTemplateID string `mapstructure:"mi_chat_template_id"` // 私聊消息模板 id
|
||||||
|
|
||||||
|
// OPPO 私信模板(可选,仅 OPPO 厂商,配置后下发私信时携带)
|
||||||
|
// 见:OPUSH 私信模版校验能力接入说明
|
||||||
|
OppoSystemPrivateTemplateID string `mapstructure:"oppo_system_private_template_id"` // 系统消息私信模板 id
|
||||||
|
OppoChatPrivateTemplateID string `mapstructure:"oppo_chat_private_template_id"` // 私聊消息私信模板 id
|
||||||
|
|
||||||
|
// OPPO 消息分类(可选,2024.11.20 新规)
|
||||||
|
// category 消息场景标识,使用 notify_level 时必传;notify_level 提醒等级
|
||||||
|
// 取值:category 如 IM/ACCOUNT;notify_level 1=通知栏, 2=通知栏+锁屏, 16=通知栏+锁屏+横幅+震动+铃声
|
||||||
|
OppoSystemCategory string `mapstructure:"oppo_system_category"` // 系统消息 category(如 ACCOUNT)
|
||||||
|
OppoChatCategory string `mapstructure:"oppo_chat_category"` // 私聊消息 category(如 IM)
|
||||||
|
OppoSystemNotifyLevel int `mapstructure:"oppo_system_notify_level"` // 系统消息提醒等级(如 2)
|
||||||
|
OppoChatNotifyLevel int `mapstructure:"oppo_chat_notify_level"` // 私聊消息提醒等级(如 16)
|
||||||
|
|
||||||
|
// 荣耀通知栏消息智能分类(可选,importance 字段)
|
||||||
|
// 取值:"NORMAL"=服务与通讯,"LOW"=资讯营销
|
||||||
|
// 注:classification 优先级更高,会覆盖 importance 设置的值
|
||||||
|
HonorSystemImportance string `mapstructure:"honor_system_importance"` // 系统消息 importance(如 LOW)
|
||||||
|
HonorChatImportance string `mapstructure:"honor_chat_importance"` // 私聊消息 importance(如 NORMAL)
|
||||||
|
|
||||||
|
// vivo 厂商消息场景标识(可选,category 字段)
|
||||||
|
// 用于标识消息类型,确定提醒方式;classification=1 时 category 必须为系统消息类
|
||||||
|
// 不携带 category 会默认按运营消息下发,受频控限制
|
||||||
|
VivoSystemCategory string `mapstructure:"vivo_system_category"` // 系统消息 category(如 ACCOUNT 账号动态)
|
||||||
|
VivoChatCategory string `mapstructure:"vivo_chat_category"` // 私聊消息 category(如 IM 即时通讯)
|
||||||
|
}
|
||||||
82
internal/config/jpush_default_test.go
Normal file
82
internal/config/jpush_default_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestXiaomiDefaultValuesFromViper 验证:config.yaml 中小米字段留空时,
|
||||||
|
// viper.SetDefault 设置的默认值(153608/153609 + M10289/P10761)能正确填充到 Config 结构体。
|
||||||
|
// 这证明:不配置环境变量/yaml 值时,代码内置默认值生效。
|
||||||
|
func TestXiaomiDefaultValuesFromViper(t *testing.T) {
|
||||||
|
// 加载仓库内的 config.yaml(小米字段已留空,依赖代码默认值)
|
||||||
|
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||||
|
cfg, err := Load(yamlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load config failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
x := cfg.JPush.Channel.Vendor.Xiaomi
|
||||||
|
if x.System != "153609" {
|
||||||
|
t.Errorf("xiaomi.system default = %q, want 153609", x.System)
|
||||||
|
}
|
||||||
|
if x.Chat != "153608" {
|
||||||
|
t.Errorf("xiaomi.chat default = %q, want 153608", x.Chat)
|
||||||
|
}
|
||||||
|
if x.MiSystemTemplateID != "P10761" {
|
||||||
|
t.Errorf("xiaomi.mi_system_template_id default = %q, want P10761", x.MiSystemTemplateID)
|
||||||
|
}
|
||||||
|
if x.MiChatTemplateID != "M10289" {
|
||||||
|
t.Errorf("xiaomi.mi_chat_template_id default = %q, want M10289", x.MiChatTemplateID)
|
||||||
|
}
|
||||||
|
t.Logf("小米默认值生效: system=%s chat=%s mi_sys=%s mi_chat=%s",
|
||||||
|
x.System, x.Chat, x.MiSystemTemplateID, x.MiChatTemplateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVendorDefaultValuesFromViper 验证:OPPO/vivo/荣耀的分类字段在 yaml 留空时,
|
||||||
|
// viper.SetDefault 设置的默认值能正确填充到 Config 结构体。
|
||||||
|
// channel_id 类字段需厂商后台注册,不在默认值范围(留空)。
|
||||||
|
func TestVendorDefaultValuesFromViper(t *testing.T) {
|
||||||
|
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||||
|
cfg, err := Load(yamlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load config failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPPO: chat=IM/16, system=ACCOUNT/2
|
||||||
|
oppo := cfg.JPush.Channel.Vendor.OPPO
|
||||||
|
if oppo.OppoChatCategory != "IM" {
|
||||||
|
t.Errorf("oppo.oppo_chat_category default = %q, want IM", oppo.OppoChatCategory)
|
||||||
|
}
|
||||||
|
if oppo.OppoSystemCategory != "ACCOUNT" {
|
||||||
|
t.Errorf("oppo.oppo_system_category default = %q, want ACCOUNT", oppo.OppoSystemCategory)
|
||||||
|
}
|
||||||
|
if oppo.OppoChatNotifyLevel != 16 {
|
||||||
|
t.Errorf("oppo.oppo_chat_notify_level default = %d, want 16", oppo.OppoChatNotifyLevel)
|
||||||
|
}
|
||||||
|
if oppo.OppoSystemNotifyLevel != 2 {
|
||||||
|
t.Errorf("oppo.oppo_system_notify_level default = %d, want 2", oppo.OppoSystemNotifyLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vivo: chat=IM, system=ACCOUNT
|
||||||
|
vivo := cfg.JPush.Channel.Vendor.VIVO
|
||||||
|
if vivo.VivoChatCategory != "IM" {
|
||||||
|
t.Errorf("vivo.vivo_chat_category default = %q, want IM", vivo.VivoChatCategory)
|
||||||
|
}
|
||||||
|
if vivo.VivoSystemCategory != "ACCOUNT" {
|
||||||
|
t.Errorf("vivo.vivo_system_category default = %q, want ACCOUNT", vivo.VivoSystemCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 荣耀: chat=NORMAL, system=LOW
|
||||||
|
honor := cfg.JPush.Channel.Vendor.Honor
|
||||||
|
if honor.HonorChatImportance != "NORMAL" {
|
||||||
|
t.Errorf("honor.honor_chat_importance default = %q, want NORMAL", honor.HonorChatImportance)
|
||||||
|
}
|
||||||
|
if honor.HonorSystemImportance != "LOW" {
|
||||||
|
t.Errorf("honor.honor_system_importance default = %q, want LOW", honor.HonorSystemImportance)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("OPPO: chat=%s/%d system=%s/%d", oppo.OppoChatCategory, oppo.OppoChatNotifyLevel, oppo.OppoSystemCategory, oppo.OppoSystemNotifyLevel)
|
||||||
|
t.Logf("vivo: chat=%s system=%s", vivo.VivoChatCategory, vivo.VivoSystemCategory)
|
||||||
|
t.Logf("荣耀: chat=%s system=%s", honor.HonorChatImportance, honor.HonorSystemImportance)
|
||||||
|
}
|
||||||
11
internal/config/livekit.go
Normal file
11
internal/config/livekit.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// LiveKitConfig LiveKit SFU 配置
|
||||||
|
type LiveKitConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
URL string `mapstructure:"url"`
|
||||||
|
APIKey string `mapstructure:"api_key"`
|
||||||
|
APISecret string `mapstructure:"api_secret"`
|
||||||
|
TokenTTL int `mapstructure:"token_ttl"`
|
||||||
|
WebhookSecret string `mapstructure:"webhook_secret"`
|
||||||
|
}
|
||||||
13
internal/config/push_worker.go
Normal file
13
internal/config/push_worker.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// PushWorkerConfig 推送 Worker 配置(Redis Stream 异步推送)
|
||||||
|
type PushWorkerConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
Stream string `mapstructure:"stream"` // Redis Stream 名称,默认 "msg_push"
|
||||||
|
Group string `mapstructure:"group"` // Consumer Group 名称,默认 "push_worker"
|
||||||
|
BatchSize int `mapstructure:"batch_size"` // XREADGROUP 每次读取条数,默认 50
|
||||||
|
PollTimeoutMs int `mapstructure:"poll_timeout_ms"` // XREADGROUP BLOCK 超时 ms,默认 2000
|
||||||
|
MaxRetries int `mapstructure:"max_retries"` // 消息最大重试次数,默认 3
|
||||||
|
MaxStreamLen int64 `mapstructure:"max_stream_len"` // Stream MAXLEN,默认 100000
|
||||||
|
IdleTimeoutMs int64 `mapstructure:"idle_timeout_ms"` // XPENDING 空闲超时 ms,默认 30000
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RedisConfig Redis 配置
|
// RedisConfig Redis 配置
|
||||||
@@ -62,19 +59,3 @@ type MiniredisConfig struct {
|
|||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRedis 创建 Redis 客户端(真实 Redis)
|
|
||||||
func NewRedis(cfg *RedisConfig) (*redis.Client, error) {
|
|
||||||
client := redis.NewClient(&redis.Options{
|
|
||||||
Addr: cfg.Redis.Addr(),
|
|
||||||
Password: cfg.Redis.Password,
|
|
||||||
DB: cfg.Redis.DB,
|
|
||||||
PoolSize: cfg.PoolSize,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := client.Ping(ctx).Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to redis: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|||||||
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/seq_buffer.go
Normal file
10
internal/config/seq_buffer.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// SeqBufferConfig seq 预分配配置
|
||||||
|
type SeqBufferConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
PrivateBufferSize int `mapstructure:"private_buffer_size"` // 私聊预分配步长(默认 50)
|
||||||
|
GroupBufferSize int `mapstructure:"group_buffer_size"` // 群聊预分配步长(默认 200)
|
||||||
|
LockTimeoutMs int `mapstructure:"lock_timeout_ms"` // 分布式锁超时毫秒(默认 5000)
|
||||||
|
MaxRetries int `mapstructure:"max_retries"` // 冷启动重试次数(默认 3)
|
||||||
|
}
|
||||||
@@ -61,3 +61,19 @@ type UploadConfig struct {
|
|||||||
MaxFileSize int64 `mapstructure:"max_file_size"`
|
MaxFileSize int64 `mapstructure:"max_file_size"`
|
||||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
AllowedTypes []string `mapstructure:"allowed_types"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileCleanupConfig 聊天文件 TTL 过期清理配置
|
||||||
|
type FileCleanupConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker
|
||||||
|
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 7
|
||||||
|
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||||
|
BatchSize int `mapstructure:"batch_size"` // 单次扫描处理上限,默认 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceCleanupConfig 设备 registration_id 清理配置
|
||||||
|
// 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||||
|
type DeviceCleanupConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker,默认 true
|
||||||
|
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 3(3 天不活跃即清理)
|
||||||
|
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/minio/minio-go/v7"
|
|
||||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
||||||
)
|
|
||||||
|
|
||||||
// S3Config S3 存储配置
|
// S3Config S3 存储配置
|
||||||
type S3Config struct {
|
type S3Config struct {
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint"`
|
||||||
@@ -19,30 +10,3 @@ type S3Config struct {
|
|||||||
Region string `mapstructure:"region"`
|
Region string `mapstructure:"region"`
|
||||||
Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin
|
Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewS3 创建 S3 客户端
|
|
||||||
func NewS3(cfg *S3Config) (*minio.Client, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
|
||||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
|
||||||
Secure: cfg.UseSSL,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create S3 client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
|
||||||
if err != nil {
|
|
||||||
// Access Denied 或网络不通时,跳过 bucket 检查,假定 bucket 已存在
|
|
||||||
} else 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"` // 超过此版本差距建议全量同步
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
// WebRTCConfig WebRTC ICE 服务器配置
|
|
||||||
type WebRTCConfig struct {
|
|
||||||
ICEServers []ICEServerConfig `mapstructure:"ice_servers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ICEServerConfig ICE 服务器配置
|
|
||||||
type ICEServerConfig struct {
|
|
||||||
URLs []string `mapstructure:"urls"`
|
|
||||||
Username string `mapstructure:"username"`
|
|
||||||
Credential string `mapstructure:"credential"`
|
|
||||||
}
|
|
||||||
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,191 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"with_you/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==================== Call Request DTOs ====================
|
|
||||||
|
|
||||||
// StartCallRequest 发起通话请求
|
|
||||||
type StartCallRequest struct {
|
|
||||||
ConversationID string `json:"conversation_id" binding:"required"` // 会话ID
|
|
||||||
CallType model.CallType `json:"call_type" binding:"required"` // 通话类型: private, group
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswerCallRequest 接听通话请求
|
|
||||||
type AnswerCallRequest struct {
|
|
||||||
SDP string `json:"sdp" binding:"required"` // SDP Answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendSDPRequest 发送SDP请求
|
|
||||||
type SendSDPRequest struct {
|
|
||||||
SDP string `json:"sdp" binding:"required"` // SDP Offer/Answer
|
|
||||||
Type string `json:"type" binding:"required"` // offer 或 answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendICECandidateRequest 发送ICE候选请求
|
|
||||||
type SendICECandidateRequest struct {
|
|
||||||
Candidate string `json:"candidate" binding:"required"` // candidate 字符串
|
|
||||||
SDPMid string `json:"sdp_mid"` // SDP mid
|
|
||||||
SDPMLineIndex int16 `json:"sdp_mline_index"` // SDP m-line index
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Call Response DTOs ====================
|
|
||||||
|
|
||||||
// CallSessionResponse 通话会话响应
|
|
||||||
type CallSessionResponse struct {
|
|
||||||
ID string `json:"id"` // 通话ID
|
|
||||||
ConversationID string `json:"conversation_id"` // 会话ID
|
|
||||||
GroupID string `json:"group_id,omitempty"`
|
|
||||||
CallerID string `json:"caller_id"` // 发起者ID
|
|
||||||
CallType model.CallType `json:"call_type"` // 通话类型
|
|
||||||
Status model.CallStatus `json:"status"` // 通话状态
|
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
||||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
|
||||||
Duration int64 `json:"duration"` // 通话时长(秒)
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
Participants []CallParticipantResponse `json:"participants"` // 参与者列表
|
|
||||||
Caller *UserResponse `json:"caller,omitempty"` // 发起者信息
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallParticipantResponse 通话参与者响应
|
|
||||||
type CallParticipantResponse struct {
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Status model.ParticipantStatus `json:"status"`
|
|
||||||
JoinedAt *time.Time `json:"joined_at,omitempty"`
|
|
||||||
LeftAt *time.Time `json:"left_at,omitempty"`
|
|
||||||
User *UserResponse `json:"user,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Call SSE Event DTOs ====================
|
|
||||||
|
|
||||||
// CallInviteEvent 来电邀请事件
|
|
||||||
type CallInviteEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
ConversationID string `json:"conversation_id"`
|
|
||||||
GroupID string `json:"group_id,omitempty"`
|
|
||||||
CallerID string `json:"caller_id"`
|
|
||||||
CallType model.CallType `json:"call_type"`
|
|
||||||
Caller *UserResponse `json:"caller,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallAnswerEvent 接听响应事件
|
|
||||||
type CallAnswerEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
SDP string `json:"sdp"` // SDP Answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallRejectEvent 拒绝通话事件
|
|
||||||
type CallRejectEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallEndEvent 结束通话事件
|
|
||||||
type CallEndEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Duration int64 `json:"duration"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallICECandidateEvent ICE候选事件
|
|
||||||
type CallICECandidateEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Candidate string `json:"candidate"`
|
|
||||||
SDPMid string `json:"sdp_mid"`
|
|
||||||
SDPMLineIndex int16 `json:"sdp_mline_index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallSDPOfferEvent SDP Offer事件
|
|
||||||
type CallSDPOfferEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
SDP string `json:"sdp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallSDPAnswerEvent SDP Answer事件
|
|
||||||
type CallSDPAnswerEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
SDP string `json:"sdp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallUserJoinedEvent 用户加入通话事件(群聊)
|
|
||||||
type CallUserJoinedEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
User *UserResponse `json:"user,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallUserLeftEvent 用户离开通话事件(群聊)
|
|
||||||
type CallUserLeftEvent struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== ICE Server Config ====================
|
|
||||||
|
|
||||||
// ICEServerConfig ICE服务器配置
|
|
||||||
type ICEServerConfig struct {
|
|
||||||
URLs []string `json:"urls"`
|
|
||||||
Username string `json:"username,omitempty"`
|
|
||||||
Credential string `json:"credential,omitempty"`
|
|
||||||
CredentialType string `json:"credential_type,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallConfigResponse 通话配置响应
|
|
||||||
type CallConfigResponse struct {
|
|
||||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Converter Functions ====================
|
|
||||||
|
|
||||||
// ConvertCallSessionToResponse 转换通话会话为响应格式
|
|
||||||
func ConvertCallSessionToResponse(call *model.CallSession, caller *model.User, participants []*model.User) *CallSessionResponse {
|
|
||||||
resp := &CallSessionResponse{
|
|
||||||
ID: call.ID,
|
|
||||||
ConversationID: call.ConversationID,
|
|
||||||
CallerID: call.CallerID,
|
|
||||||
CallType: call.CallType,
|
|
||||||
Status: call.Status,
|
|
||||||
StartedAt: call.StartedAt,
|
|
||||||
EndedAt: call.EndedAt,
|
|
||||||
Duration: call.Duration,
|
|
||||||
CreatedAt: call.CreatedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
if call.GroupID != nil {
|
|
||||||
resp.GroupID = *call.GroupID
|
|
||||||
}
|
|
||||||
|
|
||||||
if caller != nil {
|
|
||||||
resp.Caller = ConvertUserToResponse(caller)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换参与者
|
|
||||||
resp.Participants = make([]CallParticipantResponse, 0, len(call.Participants))
|
|
||||||
userMap := make(map[string]*model.User)
|
|
||||||
for _, u := range participants {
|
|
||||||
userMap[u.ID] = u
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range call.Participants {
|
|
||||||
pr := CallParticipantResponse{
|
|
||||||
UserID: p.UserID,
|
|
||||||
Status: p.Status,
|
|
||||||
JoinedAt: p.JoinedAt,
|
|
||||||
LeftAt: p.LeftAt,
|
|
||||||
}
|
|
||||||
if u, ok := userMap[p.UserID]; ok {
|
|
||||||
pr.User = ConvertUserToResponse(u)
|
|
||||||
}
|
|
||||||
resp.Participants = append(resp.Participants, pr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,9 @@ type UserResponse struct {
|
|||||||
IsFollowing bool `json:"is_following"`
|
IsFollowing bool `json:"is_following"`
|
||||||
IsFollowingMe bool `json:"is_following_me"`
|
IsFollowingMe bool `json:"is_following_me"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
// 认证信息
|
||||||
|
Identity model.UserIdentity `json:"identity,omitempty"`
|
||||||
|
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSelfResponse 用户自身信息响应(包含敏感信息,仅本人可见)
|
// UserSelfResponse 用户自身信息响应(包含敏感信息,仅本人可见)
|
||||||
@@ -46,6 +49,9 @@ type UserSelfResponse struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
// 隐私设置
|
// 隐私设置
|
||||||
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
|
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
|
||||||
|
// 认证信息
|
||||||
|
Identity model.UserIdentity `json:"identity,omitempty"`
|
||||||
|
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息)
|
// UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息)
|
||||||
@@ -70,6 +76,9 @@ type UserDetailResponse struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
// 隐私设置
|
// 隐私设置
|
||||||
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
|
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
|
||||||
|
// 认证信息
|
||||||
|
Identity model.UserIdentity `json:"identity,omitempty"`
|
||||||
|
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Admin User DTOs ====================
|
// ==================== Admin User DTOs ====================
|
||||||
@@ -122,79 +131,6 @@ type AdminUpdateUserStatusRequest struct {
|
|||||||
Status string `json:"status" binding:"required,oneof=active banned"`
|
Status string `json:"status" binding:"required,oneof=active banned"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Post DTOs ====================
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
Segments model.MessageSegments `json:"segments,omitempty"`
|
|
||||||
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"`
|
|
||||||
Channel *PostChannelBrief `json:"channel,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Comment DTOs ====================
|
// ==================== Comment DTOs ====================
|
||||||
|
|
||||||
// CommentImageResponse 评论图片响应
|
// CommentImageResponse 评论图片响应
|
||||||
@@ -447,6 +383,7 @@ type CreateConversationRequest struct {
|
|||||||
type SendMessageRequest struct {
|
type SendMessageRequest struct {
|
||||||
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
|
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
|
||||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
|
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
|
||||||
|
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkReadRequest 标记已读请求
|
// MarkReadRequest 标记已读请求
|
||||||
@@ -454,6 +391,23 @@ type MarkReadRequest struct {
|
|||||||
LastReadSeq int64 `json:"last_read_seq" binding:"required"` // 已读到的seq位置
|
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 设置会话置顶请求
|
// SetConversationPinnedRequest 设置会话置顶请求
|
||||||
type SetConversationPinnedRequest struct {
|
type SetConversationPinnedRequest struct {
|
||||||
ConversationID string `json:"conversation_id" binding:"required"`
|
ConversationID string `json:"conversation_id" binding:"required"`
|
||||||
@@ -498,6 +452,18 @@ type ConversationUnreadCountResponse struct {
|
|||||||
UnreadCount int64 `json:"unread_count"`
|
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 消息列表响应
|
// MessageListResponse 消息列表响应
|
||||||
type MessageListResponse struct {
|
type MessageListResponse struct {
|
||||||
Messages []*MessageResponse `json:"messages"`
|
Messages []*MessageResponse `json:"messages"`
|
||||||
@@ -533,6 +499,22 @@ type DeviceTokenResponse struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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 ====================
|
// ==================== 推送记录 DTOs ====================
|
||||||
|
|
||||||
// PushRecordResponse 推送记录响应
|
// PushRecordResponse 推送记录响应
|
||||||
@@ -607,122 +589,6 @@ func FormatTimePointer(t *time.Time) string {
|
|||||||
return FormatTime(*t)
|
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 ====================
|
// ==================== WebSocket Event DTOs ====================
|
||||||
|
|
||||||
// WSEventResponse WebSocket事件响应结构体
|
// WSEventResponse WebSocket事件响应结构体
|
||||||
@@ -733,7 +599,7 @@ type WSEventResponse struct {
|
|||||||
Type string `json:"type"` // 事件类型 (message, notification, system等)
|
Type string `json:"type"` // 事件类型 (message, notification, system等)
|
||||||
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
|
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
|
||||||
ConversationID string `json:"conversation_id"` // 会话ID
|
ConversationID string `json:"conversation_id"` // 会话ID
|
||||||
Seq string `json:"seq"` // 消息序列号
|
Seq int64 `json:"seq"` // 消息序列号
|
||||||
Segments model.MessageSegments `json:"segments"` // 消息段数组
|
Segments model.MessageSegments `json:"segments"` // 消息段数组
|
||||||
SenderID string `json:"sender_id"` // 发送者用户ID
|
SenderID string `json:"sender_id"` // 发送者用户ID
|
||||||
}
|
}
|
||||||
@@ -746,6 +612,7 @@ type SendMessageParams struct {
|
|||||||
ConversationID string `json:"conversation_id"` // 会话ID
|
ConversationID string `json:"conversation_id"` // 会话ID
|
||||||
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
|
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
|
||||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
|
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
|
||||||
|
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteMsgParams 撤回消息参数
|
// DeleteMsgParams 撤回消息参数
|
||||||
@@ -794,6 +661,12 @@ type SetGroupAvatarParams struct {
|
|||||||
Avatar string `json:"avatar"` // 头像URL
|
Avatar string `json:"avatar"` // 头像URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetGroupDescriptionParams 设置群描述参数
|
||||||
|
type SetGroupDescriptionParams struct {
|
||||||
|
GroupID string `json:"group_id"` // 群组ID
|
||||||
|
Description string `json:"description"` // 群描述
|
||||||
|
}
|
||||||
|
|
||||||
// SetGroupLeaveParams 退出群组参数
|
// SetGroupLeaveParams 退出群组参数
|
||||||
type SetGroupLeaveParams struct {
|
type SetGroupLeaveParams struct {
|
||||||
GroupID string `json:"group_id"` // 群组ID
|
GroupID string `json:"group_id"` // 群组ID
|
||||||
@@ -916,41 +789,6 @@ type GetMyMemberInfoParams struct {
|
|||||||
GroupID string `json:"group_id"` // 群组ID
|
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 ====================
|
// ==================== WebSocket Response DTOs ====================
|
||||||
|
|
||||||
// WSResponse WebSocket响应结构体
|
// WSResponse WebSocket响应结构体
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// LogFilter 日志过滤条件
|
|
||||||
type LogFilter struct {
|
|
||||||
UserID string
|
|
||||||
Operation string
|
|
||||||
TargetType string
|
|
||||||
TargetID string
|
|
||||||
Status string
|
|
||||||
IP string
|
|
||||||
StartTime string
|
|
||||||
EndTime string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginFilter 登录日志过滤条件
|
|
||||||
type LoginFilter struct {
|
|
||||||
UserID string
|
|
||||||
Event string
|
|
||||||
Result string
|
|
||||||
FailReason string
|
|
||||||
LoginType string
|
|
||||||
IP string
|
|
||||||
StartTime time.Time
|
|
||||||
EndTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataChangeFilter 数据变更日志过滤条件
|
|
||||||
type DataChangeFilter struct {
|
|
||||||
UserID string
|
|
||||||
OperatorID string
|
|
||||||
ChangeType string
|
|
||||||
TargetType string
|
|
||||||
OperatorType string
|
|
||||||
IP string
|
|
||||||
StartTime string
|
|
||||||
EndTime string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaterialFileQueryParams 学习资料查询参数
|
|
||||||
type MaterialFileQueryParams struct {
|
|
||||||
SubjectID string
|
|
||||||
FileType string
|
|
||||||
Status string
|
|
||||||
Keyword string
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
SortBy string
|
|
||||||
SortOrder string
|
|
||||||
}
|
|
||||||
@@ -50,26 +50,6 @@ func GroupMemberToResponse(member *model.GroupMember) *GroupMemberResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupMemberToResponseWithUser 将GroupMember转换为GroupMemberResponse(包含用户信息)
|
|
||||||
func GroupMemberToResponseWithUser(member *model.GroupMember, user *model.User) *GroupMemberResponse {
|
|
||||||
if member == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
resp := GroupMemberToResponse(member)
|
|
||||||
if user != nil {
|
|
||||||
resp.User = ConvertUserToResponse(user)
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupMembersToResponse 将GroupMember列表转换为GroupMemberResponse列表
|
|
||||||
func GroupMembersToResponse(members []model.GroupMember) []*GroupMemberResponse {
|
|
||||||
result := make([]*GroupMemberResponse, 0, len(members))
|
|
||||||
for i := range members {
|
|
||||||
result = append(result, GroupMemberToResponse(&members[i]))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse
|
// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse
|
||||||
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
||||||
|
|||||||
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"`
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ import (
|
|||||||
|
|
||||||
// ConvertMessageToResponse 将Message转换为MessageResponse
|
// ConvertMessageToResponse 将Message转换为MessageResponse
|
||||||
func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||||
|
return ConvertMessageToResponseWithExpiry(message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertMessageToResponseWithExpiry 将Message转换为MessageResponse,并对已过期的 file segment 注入 expired 标记。
|
||||||
|
// expiredURLSet 为已从 S3 清理的文件 URL 集合(可为 nil,表示不标记)。
|
||||||
|
// 注意:注入时对 file segment 的 Data 做深拷贝,避免污染消息缓存。
|
||||||
|
func ConvertMessageToResponseWithExpiry(message *model.Message, expiredURLSet map[string]struct{}) *MessageResponse {
|
||||||
if message == nil {
|
if message == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
|||||||
// 直接使用 segments,不需要解析
|
// 直接使用 segments,不需要解析
|
||||||
segments := make(model.MessageSegments, len(message.Segments))
|
segments := make(model.MessageSegments, len(message.Segments))
|
||||||
for i, seg := range message.Segments {
|
for i, seg := range message.Segments {
|
||||||
|
// 对 file 类型且 URL 已过期的 segment,深拷贝 Data 并注入 expired 标记
|
||||||
|
if seg.Type == string(model.ContentTypeFile) && len(expiredURLSet) > 0 {
|
||||||
|
url, _ := seg.Data["url"].(string)
|
||||||
|
if _, expired := expiredURLSet[url]; expired {
|
||||||
|
newData := make(map[string]any, len(seg.Data)+1)
|
||||||
|
for k, v := range seg.Data {
|
||||||
|
newData[k] = v
|
||||||
|
}
|
||||||
|
newData["expired"] = true
|
||||||
|
segments[i] = model.MessageSegment{Type: seg.Type, Data: newData}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
segments[i] = model.MessageSegment{
|
segments[i] = model.MessageSegment{
|
||||||
Type: seg.Type,
|
Type: seg.Type,
|
||||||
Data: seg.Data,
|
Data: seg.Data,
|
||||||
@@ -107,15 +127,16 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertConversationsToResponse 将Conversation列表转换为响应列表
|
// ConvertMessagesToResponseWithExpiry 将Message列表转换为响应列表,并标记过期文件
|
||||||
func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse {
|
func ConvertMessagesToResponseWithExpiry(messages []*model.Message, expiredURLSet map[string]struct{}) []*MessageResponse {
|
||||||
result := make([]*ConversationResponse, 0, len(convs))
|
result := make([]*MessageResponse, 0, len(messages))
|
||||||
for _, conv := range convs {
|
for _, msg := range messages {
|
||||||
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false, false))
|
result = append(result, ConvertMessageToResponseWithExpiry(msg, expiredURLSet))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==================== PushRecord 转换 ====================
|
// ==================== PushRecord 转换 ====================
|
||||||
|
|
||||||
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
||||||
@@ -178,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceTokenToAdminResponse 将DeviceToken转换为管理端响应(含 registration_id)
|
||||||
|
func DeviceTokenToAdminResponse(token *model.DeviceToken) *AdminDeviceTokenResponse {
|
||||||
|
if token == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp := &AdminDeviceTokenResponse{
|
||||||
|
ID: token.ID,
|
||||||
|
UserID: token.UserID,
|
||||||
|
DeviceID: token.DeviceID,
|
||||||
|
DeviceType: string(token.DeviceType),
|
||||||
|
PushToken: token.PushToken,
|
||||||
|
IsActive: token.IsActive,
|
||||||
|
DeviceName: token.DeviceName,
|
||||||
|
CreatedAt: token.CreatedAt,
|
||||||
|
UpdatedAt: token.UpdatedAt,
|
||||||
|
}
|
||||||
|
if token.LastUsedAt != nil {
|
||||||
|
resp.LastUsedAt = *token.LastUsedAt
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceTokensToAdminResponse 将DeviceToken列表转换为管理端响应列表
|
||||||
|
func DeviceTokensToAdminResponse(tokens []*model.DeviceToken) []*AdminDeviceTokenResponse {
|
||||||
|
result := make([]*AdminDeviceTokenResponse, 0, len(tokens))
|
||||||
|
for _, token := range tokens {
|
||||||
|
result = append(result, DeviceTokenToAdminResponse(token))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,49 +33,6 @@ func ConvertNotificationsToResponse(notifications []*model.Notification) []*Noti
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SystemMessage 转换 ====================
|
|
||||||
|
|
||||||
// SystemMessageToResponse 将Message转换为SystemMessageResponse
|
|
||||||
func SystemMessageToResponse(msg *model.Message) *SystemMessageResponse {
|
|
||||||
if msg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 segments 中提取文本内容
|
|
||||||
content := ExtractTextContentFromModel(msg.Segments)
|
|
||||||
|
|
||||||
resp := &SystemMessageResponse{
|
|
||||||
ID: msg.ID,
|
|
||||||
SenderID: msg.SenderID,
|
|
||||||
ReceiverID: "", // 系统消息的接收者需要从上下文获取
|
|
||||||
Content: content,
|
|
||||||
Category: string(msg.Category),
|
|
||||||
SystemType: string(msg.SystemType),
|
|
||||||
CreatedAt: msg.CreatedAt,
|
|
||||||
}
|
|
||||||
if msg.ExtraData != nil {
|
|
||||||
resp.ExtraData = map[string]any{
|
|
||||||
"actor_id": msg.ExtraData.ActorID,
|
|
||||||
"actor_name": msg.ExtraData.ActorName,
|
|
||||||
"avatar_url": msg.ExtraData.AvatarURL,
|
|
||||||
"target_id": msg.ExtraData.TargetID,
|
|
||||||
"target_type": msg.ExtraData.TargetType,
|
|
||||||
"action_url": msg.ExtraData.ActionURL,
|
|
||||||
"action_time": msg.ExtraData.ActionTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemMessagesToResponse 将Message列表转换为SystemMessageResponse列表
|
|
||||||
func SystemMessagesToResponse(messages []*model.Message) []*SystemMessageResponse {
|
|
||||||
result := make([]*SystemMessageResponse, 0, len(messages))
|
|
||||||
for _, msg := range messages {
|
|
||||||
result = append(result, SystemMessageToResponse(msg))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse
|
// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse
|
||||||
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
||||||
if n == nil {
|
if n == nil {
|
||||||
@@ -118,11 +75,3 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemNotificationsToResponse 将SystemNotification列表转换为SystemMessageResponse列表
|
|
||||||
func SystemNotificationsToResponse(notifications []*model.SystemNotification) []*SystemMessageResponse {
|
|
||||||
result := make([]*SystemMessageResponse, 0, len(notifications))
|
|
||||||
for _, n := range notifications {
|
|
||||||
result = append(result, SystemNotificationToResponse(n))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -124,50 +124,7 @@ func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Chann
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertPostToDetailResponse 将Post转换为PostDetailResponse;channelByID 可为 nil
|
// ConvertPostsToResponse 将Post列表转换为响应列表;channelByID 可为 nil
|
||||||
func ConvertPostToDetailResponse(post *model.Post, channelByID map[string]*model.Channel, 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,
|
|
||||||
Segments: SegmentsOrDefault(post.Segments, post.Content),
|
|
||||||
Images: images,
|
|
||||||
Status: string(post.Status),
|
|
||||||
LikesCount: post.LikesCount,
|
|
||||||
CommentsCount: post.CommentsCount,
|
|
||||||
FavoritesCount: post.FavoritesCount,
|
|
||||||
SharesCount: post.SharesCount,
|
|
||||||
ViewsCount: post.ViewsCount,
|
|
||||||
IsPinned: post.IsPinned,
|
|
||||||
IsLocked: post.IsLocked,
|
|
||||||
IsVote: post.IsVote,
|
|
||||||
CreatedAt: FormatTime(post.CreatedAt),
|
|
||||||
UpdatedAt: FormatTime(post.UpdatedAt),
|
|
||||||
ContentEditedAt: FormatTimePointer(post.ContentEditedAt),
|
|
||||||
Author: author,
|
|
||||||
IsLiked: isLiked,
|
|
||||||
IsFavorited: isFavorited,
|
|
||||||
Channel: postChannelBrief(post, channelByID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertPostsToResponse 将Post列表转换为响应列表(每个帖子独立检查点赞/收藏状态);channelByID 可为 nil
|
|
||||||
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||||
result := make([]*PostResponse, 0, len(posts))
|
result := make([]*PostResponse, 0, len(posts))
|
||||||
for _, post := range posts {
|
for _, post := range posts {
|
||||||
@@ -255,14 +212,6 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCommentsToResponse 将Comment列表转换为响应列表
|
|
||||||
func ConvertCommentsToResponse(comments []*model.Comment, isLiked bool) []*CommentResponse {
|
|
||||||
result := make([]*CommentResponse, 0, len(comments))
|
|
||||||
for _, comment := range comments {
|
|
||||||
result = append(result, ConvertCommentToResponse(comment, isLiked))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLikedChecker 点赞状态检查器接口
|
// IsLikedChecker 点赞状态检查器接口
|
||||||
type IsLikedChecker interface {
|
type IsLikedChecker interface {
|
||||||
|
|||||||
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"`
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -26,17 +26,6 @@ type ReportResponse struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminReportListQuery 管理端举报列表查询参数
|
|
||||||
type AdminReportListQuery struct {
|
|
||||||
Page int `form:"page" binding:"min=1"`
|
|
||||||
PageSize int `form:"page_size" binding:"min=1,max=100"`
|
|
||||||
TargetType string `form:"target_type" binding:"omitempty,oneof=post comment message"`
|
|
||||||
Status string `form:"status" binding:"omitempty,oneof=pending processing resolved rejected"`
|
|
||||||
StartDate string `form:"start_date" binding:"omitempty"`
|
|
||||||
EndDate string `form:"end_date" binding:"omitempty"`
|
|
||||||
Keyword string `form:"keyword" binding:"omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminReportListResponse 管理端举报列表响应
|
// AdminReportListResponse 管理端举报列表响应
|
||||||
type AdminReportListResponse struct {
|
type AdminReportListResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -122,6 +111,19 @@ func ConvertReportToResponse(report *model.Report) *ReportResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 转换为管理端列表响应
|
// ConvertReportToAdminListResponse 将 Report 转换为管理端列表响应
|
||||||
func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse {
|
func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse {
|
||||||
resp := &AdminReportListResponse{
|
resp := &AdminReportListResponse{
|
||||||
@@ -136,20 +138,7 @@ func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User
|
|||||||
Result: report.Result,
|
Result: report.Result,
|
||||||
CreatedAt: FormatTime(report.CreatedAt),
|
CreatedAt: FormatTime(report.CreatedAt),
|
||||||
}
|
}
|
||||||
|
fillAdminReportCommon(resp, report, reporter, handler)
|
||||||
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
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,19 +157,15 @@ func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.Us
|
|||||||
CreatedAt: FormatTime(report.CreatedAt),
|
CreatedAt: FormatTime(report.CreatedAt),
|
||||||
TargetContent: targetContent,
|
TargetContent: targetContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
if reporter != nil {
|
if reporter != nil {
|
||||||
resp.Reporter = ConvertUserToResponse(reporter)
|
resp.Reporter = ConvertUserToResponse(reporter)
|
||||||
}
|
}
|
||||||
|
|
||||||
if handler != nil {
|
if handler != nil {
|
||||||
resp.Handler = ConvertUserToResponse(handler)
|
resp.Handler = ConvertUserToResponse(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
if report.HandledAt != nil {
|
if report.HandledAt != nil {
|
||||||
handledAt := FormatTime(*report.HandledAt)
|
handledAt := FormatTime(*report.HandledAt)
|
||||||
resp.HandledAt = &handledAt
|
resp.HandledAt = &handledAt
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
@@ -8,15 +8,6 @@ import (
|
|||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseSegmentData 解析Segment数据到目标结构体
|
|
||||||
func ParseSegmentData(segment model.MessageSegment, target any) error {
|
|
||||||
dataBytes, err := json.Marshal(segment.Data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return json.Unmarshal(dataBytes, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTextSegment 创建文本Segment
|
// NewTextSegment 创建文本Segment
|
||||||
func NewTextSegment(content string) model.MessageSegment {
|
func NewTextSegment(content string) model.MessageSegment {
|
||||||
return model.MessageSegment{
|
return model.MessageSegment{
|
||||||
@@ -25,137 +16,6 @@ func NewTextSegment(content string) model.MessageSegment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewImageSegment 创建图片Segment
|
|
||||||
func NewImageSegment(url string, width, height int, thumbnailURL string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeImage),
|
|
||||||
Data: map[string]any{
|
|
||||||
"url": url,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"thumbnail_url": thumbnailURL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewImageSegmentWithSize 创建带文件大小的图片Segment
|
|
||||||
func NewImageSegmentWithSize(url string, width, height int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeImage),
|
|
||||||
Data: map[string]any{
|
|
||||||
"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]any{
|
|
||||||
"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]any{
|
|
||||||
"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]any{
|
|
||||||
"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]any{
|
|
||||||
"user_id": userID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAtAllSegment 创建@所有人Segment
|
|
||||||
func NewAtAllSegment() model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeAt),
|
|
||||||
Data: map[string]any{
|
|
||||||
"user_id": "all",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReplySegment 创建回复Segment
|
|
||||||
func NewReplySegment(messageID string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeReply),
|
|
||||||
Data: map[string]any{"id": messageID},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFaceSegment 创建表情Segment
|
|
||||||
func NewFaceSegment(id int, name, url string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypeFace),
|
|
||||||
Data: map[string]any{
|
|
||||||
"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]any{
|
|
||||||
"url": url,
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"image": image,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPostRefSegment 创建帖子内链Segment
|
|
||||||
func NewPostRefSegment(postID, title string) model.MessageSegment {
|
|
||||||
return model.MessageSegment{
|
|
||||||
Type: string(SegmentTypePostRef),
|
|
||||||
Data: map[string]any{
|
|
||||||
"post_id": postID,
|
|
||||||
"title": title,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
|
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
|
||||||
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
@@ -179,22 +39,6 @@ func HasVoteSegment(segments model.MessageSegments) bool {
|
|||||||
return HasSegmentType(segments, SegmentTypeVote)
|
return HasSegmentType(segments, SegmentTypeVote)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractTextContentFromJSON 从JSON格式的segments中提取纯文本内容
|
|
||||||
// 用于从数据库读取的 []byte 格式的 segments
|
|
||||||
// 已废弃:现在数据库直接存储 model.MessageSegments 类型
|
|
||||||
func ExtractTextContentFromJSON(segmentsJSON []byte) string {
|
|
||||||
if len(segmentsJSON) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var segments model.MessageSegments
|
|
||||||
if err := json.Unmarshal(segmentsJSON, &segments); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExtractTextContentFromModel(segments)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
||||||
func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
||||||
var result string
|
var result string
|
||||||
@@ -290,72 +134,6 @@ func ExtractReferencedPosts(segments model.MessageSegments) []string {
|
|||||||
return postIDs
|
return postIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPostRefSegment 检查消息链中是否包含帖子引用
|
|
||||||
func HasPostRefSegment(segments model.MessageSegments) bool {
|
|
||||||
return HasSegmentType(segments, SegmentTypePostRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
||||||
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
||||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||||
@@ -363,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"`
|
||||||
|
}
|
||||||
@@ -39,16 +39,14 @@ func ConvertTradeItemToResponse(item *model.TradeItem, isFavorited bool) *TradeI
|
|||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
Content: item.Content,
|
Content: item.Content,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
OriginalPrice: item.OriginalPrice,
|
|
||||||
Condition: item.Condition,
|
Condition: item.Condition,
|
||||||
Location: item.Location,
|
|
||||||
Status: string(item.Status),
|
Status: string(item.Status),
|
||||||
ViewsCount: item.ViewsCount,
|
ViewsCount: item.ViewsCount,
|
||||||
FavoritesCount: item.FavoritesCount,
|
FavoritesCount: item.FavoritesCount,
|
||||||
IsFavorited: isFavorited,
|
IsFavorited: isFavorited,
|
||||||
Images: ConvertTradeImagesToResponse(item.Images),
|
Images: ConvertTradeImagesToResponse(item.Images),
|
||||||
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: FormatTime(item.CreatedAt),
|
||||||
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
UpdatedAt: FormatTime(item.UpdatedAt),
|
||||||
}
|
}
|
||||||
if len(item.Segments) > 0 {
|
if len(item.Segments) > 0 {
|
||||||
resp.Segments = item.Segments
|
resp.Segments = item.Segments
|
||||||
@@ -68,34 +66,3 @@ func ConvertTradeItemsToResponse(items []*model.TradeItem, favoriteMap map[strin
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertTradeItemToDetailResponse(item *model.TradeItem, isFavorited bool) *TradeItemDetailResponse {
|
|
||||||
if item == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
resp := &TradeItemDetailResponse{
|
|
||||||
ID: item.ID,
|
|
||||||
UserID: item.UserID,
|
|
||||||
TradeType: string(item.TradeType),
|
|
||||||
Category: string(item.Category),
|
|
||||||
Title: item.Title,
|
|
||||||
Content: item.Content,
|
|
||||||
Price: item.Price,
|
|
||||||
OriginalPrice: item.OriginalPrice,
|
|
||||||
Condition: item.Condition,
|
|
||||||
Location: item.Location,
|
|
||||||
Status: string(item.Status),
|
|
||||||
ViewsCount: item.ViewsCount,
|
|
||||||
FavoritesCount: item.FavoritesCount,
|
|
||||||
IsFavorited: isFavorited,
|
|
||||||
Images: ConvertTradeImagesToResponse(item.Images),
|
|
||||||
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
if len(item.Segments) > 0 {
|
|
||||||
resp.Segments = item.Segments
|
|
||||||
}
|
|
||||||
if item.User != nil {
|
|
||||||
resp.Author = ConvertUserToResponse(item.User)
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
@@ -24,31 +24,7 @@ type TradeItemResponse struct {
|
|||||||
Segments model.MessageSegments `json:"segments,omitempty"`
|
Segments model.MessageSegments `json:"segments,omitempty"`
|
||||||
Images []TradeImageResponse `json:"images"`
|
Images []TradeImageResponse `json:"images"`
|
||||||
Price *float64 `json:"price,omitempty"`
|
Price *float64 `json:"price,omitempty"`
|
||||||
OriginalPrice *float64 `json:"original_price,omitempty"`
|
|
||||||
Condition string `json:"condition,omitempty"`
|
Condition string `json:"condition,omitempty"`
|
||||||
Location string `json:"location,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 TradeItemDetailResponse 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"`
|
|
||||||
OriginalPrice *float64 `json:"original_price,omitempty"`
|
|
||||||
Condition string `json:"condition,omitempty"`
|
|
||||||
Location string `json:"location,omitempty"`
|
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ViewsCount int `json:"views_count"`
|
ViewsCount int `json:"views_count"`
|
||||||
FavoritesCount int `json:"favorites_count"`
|
FavoritesCount int `json:"favorites_count"`
|
||||||
@@ -66,9 +42,7 @@ type CreateTradeItemRequest struct {
|
|||||||
Segments model.MessageSegments `json:"segments"`
|
Segments model.MessageSegments `json:"segments"`
|
||||||
Images []string `json:"images"`
|
Images []string `json:"images"`
|
||||||
Price *float64 `json:"price"`
|
Price *float64 `json:"price"`
|
||||||
OriginalPrice *float64 `json:"original_price"`
|
|
||||||
Condition string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
|
Condition string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
|
||||||
Location string `json:"location" binding:"omitempty,max=100"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateTradeItemRequest struct {
|
type UpdateTradeItemRequest struct {
|
||||||
@@ -79,9 +53,7 @@ type UpdateTradeItemRequest struct {
|
|||||||
Segments model.MessageSegments `json:"segments"`
|
Segments model.MessageSegments `json:"segments"`
|
||||||
Images []string `json:"images"`
|
Images []string `json:"images"`
|
||||||
Price *float64 `json:"price"`
|
Price *float64 `json:"price"`
|
||||||
OriginalPrice *float64 `json:"original_price"`
|
|
||||||
Condition *string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
|
Condition *string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
|
||||||
Location *string `json:"location" binding:"omitempty,max=100"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateTradeItemStatusRequest struct {
|
type UpdateTradeItemStatusRequest struct {
|
||||||
|
|||||||
@@ -5,18 +5,41 @@ import (
|
|||||||
"with_you/internal/pkg/utils"
|
"with_you/internal/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== User 转换 ====================
|
type UserResponseOption func(*UserResponse)
|
||||||
|
|
||||||
|
func WithFollowing(isFollowing, isFollowingMe bool) UserResponseOption {
|
||||||
|
return func(r *UserResponse) {
|
||||||
|
r.IsFollowing = isFollowing
|
||||||
|
r.IsFollowingMe = isFollowingMe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPostsCount(count int) UserResponseOption {
|
||||||
|
return func(r *UserResponse) {
|
||||||
|
r.PostsCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getAvatarOrDefault 获取头像URL,如果为空则返回在线头像生成服务的URL
|
// getAvatarOrDefault 获取头像URL,如果为空则返回在线头像生成服务的URL
|
||||||
func getAvatarOrDefault(user *model.User) string {
|
func getAvatarOrDefault(user *model.User) string {
|
||||||
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
|
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertUserToResponse 将User转换为UserResponse(公开信息,不包含敏感数据)
|
// publicVerificationFields 返回认证信息:
|
||||||
func ConvertUserToResponse(user *model.User) *UserResponse {
|
// - approved 时返回真实身份和状态
|
||||||
|
// - 其他状态统一返回 general + none,不暴露具体审核状态
|
||||||
|
func publicVerificationFields(user *model.User) (model.UserIdentity, model.VerificationStatus) {
|
||||||
|
if user.VerificationStatus == model.VerificationStatusApproved {
|
||||||
|
return user.Identity, user.VerificationStatus
|
||||||
|
}
|
||||||
|
return model.UserIdentityGeneral, model.VerificationStatusNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseUserResponse(user *model.User) *UserResponse {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
identity, vs := publicVerificationFields(user)
|
||||||
return &UserResponse{
|
return &UserResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
@@ -31,9 +54,23 @@ func ConvertUserToResponse(user *model.User) *UserResponse {
|
|||||||
FollowersCount: user.FollowersCount,
|
FollowersCount: user.FollowersCount,
|
||||||
FollowingCount: user.FollowingCount,
|
FollowingCount: user.FollowingCount,
|
||||||
CreatedAt: FormatTime(user.CreatedAt),
|
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(本人信息,包含敏感数据)
|
// ConvertUserToSelfResponse 将User转换为UserSelfResponse(本人信息,包含敏感数据)
|
||||||
func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse {
|
func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
@@ -57,137 +94,8 @@ func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfRespon
|
|||||||
IsVerified: user.IsVerified,
|
IsVerified: user.IsVerified,
|
||||||
CreatedAt: FormatTime(user.CreatedAt),
|
CreatedAt: FormatTime(user.CreatedAt),
|
||||||
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,34 +122,33 @@ func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int)
|
|||||||
IsVerified: user.IsVerified,
|
IsVerified: user.IsVerified,
|
||||||
CreatedAt: FormatTime(user.CreatedAt),
|
CreatedAt: FormatTime(user.CreatedAt),
|
||||||
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
||||||
|
Identity: user.Identity,
|
||||||
|
VerificationStatus: user.VerificationStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySettingsResponse {
|
||||||
|
if ps == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &PrivacySettingsResponse{
|
||||||
|
FollowersVisibility: string(ps.FollowersVisibility),
|
||||||
|
FollowingVisibility: string(ps.FollowingVisibility),
|
||||||
|
PostsVisibility: string(ps.PostsVisibility),
|
||||||
|
FavoritesVisibility: string(ps.FavoritesVisibility),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertUsersToResponse 将User列表转换为响应列表
|
// ConvertUsersToResponse 将User列表转换为响应列表
|
||||||
func ConvertUsersToResponse(users []*model.User) []*UserResponse {
|
func ConvertUsersToResponse(users []*model.User, opts ...UserResponseOption) []*UserResponse {
|
||||||
result := make([]*UserResponse, 0, len(users))
|
result := make([]*UserResponse, 0, len(users))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
result = append(result, ConvertUserToResponse(user))
|
result = append(result, ConvertUserToResponse(user, opts...))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表(包含双向关注状态)
|
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和帖子数量)
|
||||||
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
|
||||||
func ConvertUsersToResponseWithMutualFollow(users []*model.User, followingStatusMap map[string][2]bool) []*UserResponse {
|
|
||||||
result := make([]*UserResponse, 0, len(users))
|
|
||||||
for _, user := range users {
|
|
||||||
status, ok := followingStatusMap[user.ID]
|
|
||||||
if ok {
|
|
||||||
result = append(result, ConvertUserToResponseWithMutualFollow(user, status[0], status[1]))
|
|
||||||
} else {
|
|
||||||
result = append(result, ConvertUserToResponse(user))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和实时计算的帖子数量)
|
|
||||||
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
||||||
// postsCountMap: key是用户ID,value是帖子数量
|
// postsCountMap: key是用户ID,value是帖子数量
|
||||||
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
|
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
|
||||||
@@ -249,17 +156,15 @@ func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, fo
|
|||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
status, hasStatus := followingStatusMap[user.ID]
|
status, hasStatus := followingStatusMap[user.ID]
|
||||||
postsCount, hasPostsCount := postsCountMap[user.ID]
|
postsCount, hasPostsCount := postsCountMap[user.ID]
|
||||||
|
|
||||||
// 如果没有帖子数量,使用数据库中的值
|
|
||||||
if !hasPostsCount {
|
if !hasPostsCount {
|
||||||
postsCount = int64(user.PostsCount)
|
postsCount = int64(user.PostsCount)
|
||||||
}
|
}
|
||||||
|
var opts []UserResponseOption
|
||||||
if hasStatus {
|
if hasStatus {
|
||||||
result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount)))
|
opts = append(opts, WithFollowing(status[0], status[1]))
|
||||||
} else {
|
|
||||||
result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount)))
|
|
||||||
}
|
}
|
||||||
|
opts = append(opts, WithPostsCount(int(postsCount)))
|
||||||
|
result = append(result, ConvertUserToResponse(user, opts...))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,8 @@ var (
|
|||||||
ErrInvalidCallState = &AppError{Code: "INVALID_CALL_STATE", Message: "通话状态无效"}
|
ErrInvalidCallState = &AppError{Code: "INVALID_CALL_STATE", Message: "通话状态无效"}
|
||||||
ErrCalleeOffline = &AppError{Code: "CALLEE_OFFLINE", Message: "对方不在线"}
|
ErrCalleeOffline = &AppError{Code: "CALLEE_OFFLINE", Message: "对方不在线"}
|
||||||
ErrCallAlreadyAnswered = &AppError{Code: "CALL_ALREADY_ANSWERED", 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: "会话不存在或无权限"}
|
ErrConversationNotFound = &AppError{Code: "CONVERSATION_NOT_FOUND", Message: "会话不存在或无权限"}
|
||||||
@@ -128,6 +130,9 @@ var (
|
|||||||
// 学习资料相关错误
|
// 学习资料相关错误
|
||||||
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
||||||
|
|
||||||
|
// 帖子创建幂等性:同一 client_request_id 的请求仍在处理中(占位未回填真实结果)
|
||||||
|
ErrDuplicatePostRequest = &AppError{Code: "DUPLICATE_POST_REQUEST", Message: "正在发布中,请稍候"}
|
||||||
|
|
||||||
// 身份认证相关错误
|
// 身份认证相关错误
|
||||||
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
||||||
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
||||||
@@ -136,6 +141,13 @@ var (
|
|||||||
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"}
|
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"}
|
||||||
ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", 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: "超级管理员已初始化,无法重复设置"}
|
ErrSetupAlreadyCompleted = &AppError{Code: "SETUP_ALREADY_COMPLETED", Message: "超级管理员已初始化,无法重复设置"}
|
||||||
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}
|
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}
|
||||||
@@ -151,16 +163,6 @@ func Wrap(err error, appErr *AppError) *AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建新错误
|
|
||||||
func New(code, message string) *AppError {
|
|
||||||
return &AppError{Code: code, Message: message}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is 判断是否为特定错误
|
|
||||||
func Is(err, target error) bool {
|
|
||||||
return errors.Is(err, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// As 将错误转换为 AppError
|
// As 将错误转换为 AppError
|
||||||
func As(err error) (*AppError, bool) {
|
func As(err error) (*AppError, bool) {
|
||||||
var appErr *AppError
|
var appErr *AppError
|
||||||
|
|||||||
365
internal/grpc/runner/bus.go
Normal file
365
internal/grpc/runner/bus.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"with_you/proto/runner"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskBusMsg Redis 任务频道消息格式
|
||||||
|
type taskBusMsg struct {
|
||||||
|
OriginInstanceID string `json:"origin_instance_id"`
|
||||||
|
TargetRunnerID string `json:"target_runner_id"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
TaskType string `json:"task_type"`
|
||||||
|
TaskPayload json.RawMessage `json:"task_payload"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
TimeoutSeconds int32 `json:"timeout_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultBusMsg Redis 结果频道消息格式
|
||||||
|
type resultBusMsg struct {
|
||||||
|
OriginInstanceID string `json:"origin_instance_id"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CompletedAt int64 `json:"completed_at"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
ErrorMessage string `json:"error_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskBus 组合 RunnerHub + Redis Pub/Sub,实现跨实例任务分发和结果路由
|
||||||
|
type TaskBus struct {
|
||||||
|
hub *RunnerHub
|
||||||
|
registry *RunnerRegistry
|
||||||
|
rdb *redis.Client
|
||||||
|
instanceID string
|
||||||
|
taskChan string
|
||||||
|
resultChan string
|
||||||
|
logger *zap.Logger
|
||||||
|
|
||||||
|
remoteTasks sync.Map // taskID → originInstanceID(从其他实例接收的任务)
|
||||||
|
|
||||||
|
sub *redis.PubSub
|
||||||
|
cancel context.CancelFunc
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskBus 创建 TaskBus 实例
|
||||||
|
func NewTaskBus(hub *RunnerHub, rdb *redis.Client, registry *RunnerRegistry, instanceID, taskChan, resultChan string, logger *zap.Logger) *TaskBus {
|
||||||
|
return &TaskBus{
|
||||||
|
hub: hub,
|
||||||
|
registry: registry,
|
||||||
|
rdb: rdb,
|
||||||
|
instanceID: instanceID,
|
||||||
|
taskChan: taskChan,
|
||||||
|
resultChan: resultChan,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 启动 Redis 订阅
|
||||||
|
func (b *TaskBus) Start() error {
|
||||||
|
if b.rdb == nil {
|
||||||
|
b.logger.Info("TaskBus: no Redis client, running in standalone mode")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
b.cancel = cancel
|
||||||
|
|
||||||
|
sub := b.rdb.Subscribe(ctx, b.taskChan, b.resultChan)
|
||||||
|
b.sub = sub
|
||||||
|
|
||||||
|
if _, err := sub.Receive(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go b.subscribeLoop(ctx, sub)
|
||||||
|
|
||||||
|
b.logger.Info("TaskBus started",
|
||||||
|
zap.String("instance_id", b.instanceID),
|
||||||
|
zap.String("task_channel", b.taskChan),
|
||||||
|
zap.String("result_channel", b.resultChan),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 关闭 TaskBus
|
||||||
|
func (b *TaskBus) Stop() error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.closed = true
|
||||||
|
|
||||||
|
if b.cancel != nil {
|
||||||
|
b.cancel()
|
||||||
|
}
|
||||||
|
if b.sub != nil {
|
||||||
|
b.sub.Close()
|
||||||
|
}
|
||||||
|
if b.registry != nil {
|
||||||
|
b.registry.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
b.logger.Info("TaskBus stopped", zap.String("instance_id", b.instanceID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceID 返回实例 ID
|
||||||
|
func (b *TaskBus) InstanceID() string {
|
||||||
|
return b.instanceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchTask 实现 TaskDispatcher 接口
|
||||||
|
// 本地优先 → Redis 注册中心查找 → 远程发布
|
||||||
|
func (b *TaskBus) DispatchTask(task *runner.Task) error {
|
||||||
|
taskTypeStr := task.Type.String()
|
||||||
|
|
||||||
|
// 1. 尝试本地查找
|
||||||
|
conn, err := b.hub.GetAvailableRunner(task.Type)
|
||||||
|
if err == nil && conn != nil {
|
||||||
|
return b.hub.SendTask(conn.ID, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询 Redis 注册中心
|
||||||
|
if b.registry != nil {
|
||||||
|
runnerID, instanceID, err := b.registry.GetAvailableRunner(taskTypeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no available runner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 runner 在本实例(可能本地 map 还没更新),直接发送
|
||||||
|
if instanceID == b.instanceID {
|
||||||
|
return b.hub.SendTask(runnerID, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布到远程实例
|
||||||
|
return b.publishTask(task, runnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("no available runner for task type: %s", taskTypeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleResult 处理任务结果
|
||||||
|
// 如果任务来自远程实例,转发结果;否则交给 TaskManager
|
||||||
|
func (b *TaskBus) HandleResult(result *runner.TaskResult) {
|
||||||
|
originInstance, ok := b.remoteTasks.Load(result.TaskId)
|
||||||
|
if ok {
|
||||||
|
b.remoteTasks.Delete(result.TaskId)
|
||||||
|
|
||||||
|
// 任务来自远程实例,转发结果
|
||||||
|
if originStr, ok := originInstance.(string); ok && originStr != b.instanceID {
|
||||||
|
b.publishResult(result, originStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地任务,交给 TaskManager
|
||||||
|
if b.hub.taskManager != nil {
|
||||||
|
b.hub.taskManager.HandleResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishTask 发布任务到 Redis 频道
|
||||||
|
func (b *TaskBus) publishTask(task *runner.Task, targetRunnerID string) error {
|
||||||
|
msg := taskBusMsg{
|
||||||
|
OriginInstanceID: b.instanceID,
|
||||||
|
TargetRunnerID: targetRunnerID,
|
||||||
|
TaskID: task.TaskId,
|
||||||
|
TaskType: task.Type.String(),
|
||||||
|
TaskPayload: task.Payload,
|
||||||
|
CreatedAt: task.CreatedAt,
|
||||||
|
TimeoutSeconds: task.TimeoutSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal task message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.rdb.Publish(context.Background(), b.taskChan, data).Err(); err != nil {
|
||||||
|
return fmt.Errorf("failed to publish task to Redis: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishResult 发布结果到 Redis 频道
|
||||||
|
func (b *TaskBus) publishResult(result *runner.TaskResult, originInstanceID string) error {
|
||||||
|
var data json.RawMessage
|
||||||
|
if len(result.Data) > 0 {
|
||||||
|
data = result.Data
|
||||||
|
} else {
|
||||||
|
data = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := resultBusMsg{
|
||||||
|
OriginInstanceID: originInstanceID,
|
||||||
|
TaskID: result.TaskId,
|
||||||
|
Status: result.Status.String(),
|
||||||
|
CompletedAt: result.CompletedAt,
|
||||||
|
Data: data,
|
||||||
|
ErrorCode: result.ErrorCode,
|
||||||
|
ErrorMessage: result.ErrorMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Error("Failed to marshal result message", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.rdb.Publish(context.Background(), b.resultChan, raw).Err(); err != nil {
|
||||||
|
b.logger.Error("Failed to publish result to Redis", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeLoop 处理 Redis 订阅消息
|
||||||
|
func (b *TaskBus) subscribeLoop(ctx context.Context, sub *redis.PubSub) {
|
||||||
|
ch := sub.Channel()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch msg.Channel {
|
||||||
|
case b.taskChan:
|
||||||
|
b.handleRemoteTask(msg)
|
||||||
|
case b.resultChan:
|
||||||
|
b.handleRemoteResult(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoteTask 处理从其他实例发来的任务
|
||||||
|
func (b *TaskBus) handleRemoteTask(msg *redis.Message) {
|
||||||
|
var taskMsg taskBusMsg
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &taskMsg); err != nil {
|
||||||
|
b.logger.Error("TaskBus: failed to unmarshal task message", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤自己发送的消息
|
||||||
|
if taskMsg.OriginInstanceID == b.instanceID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查目标 Runner 是否在本实例
|
||||||
|
conn, err := b.hub.GetRunnerByID(taskMsg.TargetRunnerID)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Warn("TaskBus: target runner not found locally",
|
||||||
|
zap.String("runner_id", taskMsg.TargetRunnerID),
|
||||||
|
zap.String("task_id", taskMsg.TaskID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录任务的来源实例,用于结果回传
|
||||||
|
b.remoteTasks.Store(taskMsg.TaskID, taskMsg.OriginInstanceID)
|
||||||
|
|
||||||
|
// 构造 Task 并发送
|
||||||
|
task := &runner.Task{
|
||||||
|
TaskId: taskMsg.TaskID,
|
||||||
|
Type: parseTaskType(taskMsg.TaskType),
|
||||||
|
CreatedAt: taskMsg.CreatedAt,
|
||||||
|
TimeoutSeconds: taskMsg.TimeoutSeconds,
|
||||||
|
Payload: taskMsg.TaskPayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.hub.SendTask(conn.ID, task); err != nil {
|
||||||
|
b.logger.Error("TaskBus: failed to send remote task to runner",
|
||||||
|
zap.String("runner_id", conn.ID),
|
||||||
|
zap.String("task_id", taskMsg.TaskID),
|
||||||
|
zap.Error(err))
|
||||||
|
b.remoteTasks.Delete(taskMsg.TaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoteResult 处理从其他实例发来的任务结果
|
||||||
|
func (b *TaskBus) handleRemoteResult(msg *redis.Message) {
|
||||||
|
var resultMsg resultBusMsg
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &resultMsg); err != nil {
|
||||||
|
b.logger.Error("TaskBus: failed to unmarshal result message", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤自己发送的消息
|
||||||
|
if resultMsg.OriginInstanceID == b.instanceID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有 origin 匹配本实例才需要处理
|
||||||
|
// (originInstanceID 标识的是发起任务的实例)
|
||||||
|
// 收到其他实例转发的结果,且 origin 是我们 → 交给 TaskManager
|
||||||
|
// 但这里收到的是其他实例广播的结果,我们只是中继
|
||||||
|
// 实际上 result 是发到 runner:result channel 的,所有实例都会收到
|
||||||
|
// 我们只关心 origin 是本实例的结果
|
||||||
|
if resultMsg.OriginInstanceID != b.instanceID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交给 TaskManager 处理
|
||||||
|
result := &runner.TaskResult{
|
||||||
|
TaskId: resultMsg.TaskID,
|
||||||
|
Status: parseTaskStatus(resultMsg.Status),
|
||||||
|
CompletedAt: resultMsg.CompletedAt,
|
||||||
|
Data: resultMsg.Data,
|
||||||
|
ErrorCode: resultMsg.ErrorCode,
|
||||||
|
ErrorMessage: resultMsg.ErrorMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.hub.taskManager != nil {
|
||||||
|
b.hub.taskManager.HandleResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTaskType 将字符串转换为 runner.TaskType 枚举
|
||||||
|
func parseTaskType(s string) runner.TaskType {
|
||||||
|
switch s {
|
||||||
|
case "TASK_TYPE_LOGIN":
|
||||||
|
return runner.TaskType_TASK_TYPE_LOGIN
|
||||||
|
case "TASK_TYPE_GET_SCHEDULE":
|
||||||
|
return runner.TaskType_TASK_TYPE_GET_SCHEDULE
|
||||||
|
case "TASK_TYPE_GET_GRADES":
|
||||||
|
return runner.TaskType_TASK_TYPE_GET_GRADES
|
||||||
|
case "TASK_TYPE_GET_EXAMS":
|
||||||
|
return runner.TaskType_TASK_TYPE_GET_EXAMS
|
||||||
|
case "TASK_TYPE_GET_USER_INFO":
|
||||||
|
return runner.TaskType_TASK_TYPE_GET_USER_INFO
|
||||||
|
default:
|
||||||
|
return runner.TaskType_TASK_TYPE_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTaskStatus 将字符串转换为 runner.TaskStatus 枚举
|
||||||
|
func parseTaskStatus(s string) runner.TaskStatus {
|
||||||
|
switch s {
|
||||||
|
case "TASK_STATUS_SUCCESS":
|
||||||
|
return runner.TaskStatus_TASK_STATUS_SUCCESS
|
||||||
|
case "TASK_STATUS_FAILED":
|
||||||
|
return runner.TaskStatus_TASK_STATUS_FAILED
|
||||||
|
case "TASK_STATUS_TIMEOUT":
|
||||||
|
return runner.TaskStatus_TASK_STATUS_TIMEOUT
|
||||||
|
case "TASK_STATUS_CANCELLED":
|
||||||
|
return runner.TaskStatus_TASK_STATUS_CANCELLED
|
||||||
|
default:
|
||||||
|
return runner.TaskStatus_TASK_STATUS_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,8 @@ type RunnerHub struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
runners map[string]*RunnerConnection
|
runners map[string]*RunnerConnection
|
||||||
taskManager *TaskManager
|
taskManager *TaskManager
|
||||||
registerChan chan *RunnerConnection
|
bus *TaskBus
|
||||||
unregisterChan chan string
|
registry *RunnerRegistry
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +39,6 @@ func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub {
|
|||||||
return &RunnerHub{
|
return &RunnerHub{
|
||||||
runners: make(map[string]*RunnerConnection),
|
runners: make(map[string]*RunnerConnection),
|
||||||
taskManager: taskManager,
|
taskManager: taskManager,
|
||||||
registerChan: make(chan *RunnerConnection, 100),
|
|
||||||
unregisterChan: make(chan string, 100),
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +135,15 @@ func (h *RunnerHub) handleRegister(req *runner.RegisterRequest, stream runner.Ru
|
|||||||
h.runners[req.RunnerId] = conn
|
h.runners[req.RunnerId] = conn
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// 同步到 Redis 注册中心
|
||||||
|
if h.registry != nil {
|
||||||
|
if err := h.registry.Register(req.RunnerId, req.Version, req.Capabilities); err != nil {
|
||||||
|
h.logger.Error("Failed to register runner in registry",
|
||||||
|
zap.String("runner_id", req.RunnerId),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送注册响应
|
// 发送注册响应
|
||||||
if err := conn.send(&runner.StreamMessage{
|
if err := conn.send(&runner.StreamMessage{
|
||||||
Message: &runner.StreamMessage_RegisterResponse{
|
Message: &runner.StreamMessage_RegisterResponse{
|
||||||
@@ -161,6 +168,15 @@ func (h *RunnerHub) handleHeartbeat(conn *RunnerConnection, hb *runner.Heartbeat
|
|||||||
}
|
}
|
||||||
conn.LastSeen = time.Now()
|
conn.LastSeen = time.Now()
|
||||||
|
|
||||||
|
// 刷新 Redis 注册中心的 TTL
|
||||||
|
if h.registry != nil {
|
||||||
|
if err := h.registry.RefreshHeartbeat(conn.ID); err != nil {
|
||||||
|
h.logger.Debug("Failed to refresh runner heartbeat in registry",
|
||||||
|
zap.String("runner_id", conn.ID),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := conn.send(&runner.StreamMessage{
|
if err := conn.send(&runner.StreamMessage{
|
||||||
Message: &runner.StreamMessage_HeartbeatAck{
|
Message: &runner.StreamMessage_HeartbeatAck{
|
||||||
HeartbeatAck: &runner.HeartbeatAck{
|
HeartbeatAck: &runner.HeartbeatAck{
|
||||||
@@ -179,6 +195,12 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
|||||||
zap.String("task_id", result.TaskId),
|
zap.String("task_id", result.TaskId),
|
||||||
zap.String("status", result.Status.String()))
|
zap.String("status", result.Status.String()))
|
||||||
|
|
||||||
|
// 如果有 Bus,委托给 Bus 处理(可能需要转发到远程实例)
|
||||||
|
if h.bus != nil {
|
||||||
|
h.bus.HandleResult(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if h.taskManager != nil {
|
if h.taskManager != nil {
|
||||||
h.taskManager.HandleResult(result)
|
h.taskManager.HandleResult(result)
|
||||||
}
|
}
|
||||||
@@ -187,12 +209,19 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
|||||||
// unregisterRunner 注销 Runner
|
// unregisterRunner 注销 Runner
|
||||||
func (h *RunnerHub) unregisterRunner(runnerID string) {
|
func (h *RunnerHub) unregisterRunner(runnerID string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
var capabilities map[string]string
|
||||||
if conn, ok := h.runners[runnerID]; ok {
|
if conn, ok := h.runners[runnerID]; ok {
|
||||||
|
capabilities = conn.Capabilities
|
||||||
close(conn.sendChan)
|
close(conn.sendChan)
|
||||||
delete(h.runners, runnerID)
|
delete(h.runners, runnerID)
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// 从 Redis 注册中心移除
|
||||||
|
if h.registry != nil && capabilities != nil {
|
||||||
|
h.registry.Unregister(runnerID, capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
h.logger.Info("Runner unregistered", zap.String("runner_id", runnerID))
|
h.logger.Info("Runner unregistered", zap.String("runner_id", runnerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +286,37 @@ func (h *RunnerHub) SetTaskManager(tm *TaskManager) {
|
|||||||
h.taskManager = tm
|
h.taskManager = tm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetBus 设置 TaskBus(集群模式)
|
||||||
|
func (h *RunnerHub) SetBus(bus *TaskBus) {
|
||||||
|
h.bus = bus
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistry 设置 RunnerRegistry(集群模式)
|
||||||
|
func (h *RunnerHub) SetRegistry(registry *RunnerRegistry) {
|
||||||
|
h.registry = registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchTask 实现 TaskDispatcher 接口(standalone 模式)
|
||||||
|
func (h *RunnerHub) DispatchTask(task *runner.Task) error {
|
||||||
|
conn, err := h.GetAvailableRunner(task.Type)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h.SendTask(conn.ID, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRunnerByID 根据 ID 获取 Runner 连接
|
||||||
|
func (h *RunnerHub) GetRunnerByID(runnerID string) (*RunnerConnection, error) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
conn, ok := h.runners[runnerID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("runner not found: %s", runnerID)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
// send 向 RunnerConnection 发送消息
|
// send 向 RunnerConnection 发送消息
|
||||||
func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
||||||
select {
|
select {
|
||||||
@@ -270,8 +330,11 @@ func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
|||||||
// CheckStaleRunners 检查并清理超时的 Runner 连接
|
// CheckStaleRunners 检查并清理超时的 Runner 连接
|
||||||
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
|
var toRemove []struct {
|
||||||
|
id string
|
||||||
|
capabilities map[string]string
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for id, conn := range h.runners {
|
for id, conn := range h.runners {
|
||||||
if now.Sub(conn.LastSeen) > timeout {
|
if now.Sub(conn.LastSeen) > timeout {
|
||||||
@@ -280,6 +343,18 @@ func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
|||||||
zap.Time("last_seen", conn.LastSeen))
|
zap.Time("last_seen", conn.LastSeen))
|
||||||
close(conn.sendChan)
|
close(conn.sendChan)
|
||||||
delete(h.runners, id)
|
delete(h.runners, id)
|
||||||
|
toRemove = append(toRemove, struct {
|
||||||
|
id string
|
||||||
|
capabilities map[string]string
|
||||||
|
}{id: id, capabilities: conn.Capabilities})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// 从 Redis 注册中心批量移除
|
||||||
|
if h.registry != nil {
|
||||||
|
for _, r := range toRemove {
|
||||||
|
h.registry.Unregister(r.id, r.capabilities)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
305
internal/grpc/runner/registry.go
Normal file
305
internal/grpc/runner/registry.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunnerRegistry 分布式 Runner 注册中心
|
||||||
|
// 使用 Redis Hash 存储 Runner 信息,Redis Set 按能力索引
|
||||||
|
type RunnerRegistry struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
instanceID string
|
||||||
|
registryPrefix string
|
||||||
|
capPrefix string
|
||||||
|
onlineTTL time.Duration
|
||||||
|
heartbeatInterval time.Duration
|
||||||
|
localRunners sync.Map // runnerID → map[string]string (capabilities)
|
||||||
|
logger *zap.Logger
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnerInfo 存储在 Redis Hash 中的 Runner 信息
|
||||||
|
type runnerInfo struct {
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Capabilities string `json:"capabilities"` // JSON-encoded map
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunnerRegistry 创建 Runner 注册中心
|
||||||
|
func NewRunnerRegistry(rdb *redis.Client, instanceID, registryPrefix, capPrefix string, onlineTTL, heartbeatInterval time.Duration, logger *zap.Logger) *RunnerRegistry {
|
||||||
|
return &RunnerRegistry{
|
||||||
|
rdb: rdb,
|
||||||
|
instanceID: instanceID,
|
||||||
|
registryPrefix: registryPrefix,
|
||||||
|
capPrefix: capPrefix,
|
||||||
|
onlineTTL: onlineTTL,
|
||||||
|
heartbeatInterval: heartbeatInterval,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册 Runner 到分布式注册中心
|
||||||
|
func (r *RunnerRegistry) Register(runnerID, version string, capabilities map[string]string) error {
|
||||||
|
if r.rdb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
capJSON, _ := json.Marshal(capabilities)
|
||||||
|
key := r.registryPrefix + runnerID
|
||||||
|
|
||||||
|
info := runnerInfo{
|
||||||
|
InstanceID: r.instanceID,
|
||||||
|
Version: version,
|
||||||
|
Capabilities: string(capJSON),
|
||||||
|
}
|
||||||
|
infoJSON, _ := json.Marshal(info)
|
||||||
|
|
||||||
|
pipe := r.rdb.Pipeline()
|
||||||
|
pipe.Set(ctx, key, infoJSON, r.onlineTTL)
|
||||||
|
|
||||||
|
// 为每个能力创建索引
|
||||||
|
taskTypeStr := parseTaskTypes(capabilities)
|
||||||
|
for _, tt := range taskTypeStr {
|
||||||
|
capKey := r.capPrefix + tt
|
||||||
|
pipe.SAdd(ctx, capKey, runnerID)
|
||||||
|
pipe.Expire(ctx, capKey, r.onlineTTL*2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to register runner in redis: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.localRunners.Store(runnerID, capabilities)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister 从分布式注册中心移除 Runner
|
||||||
|
func (r *RunnerRegistry) Unregister(runnerID string, capabilities map[string]string) {
|
||||||
|
if r.rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
key := r.registryPrefix + runnerID
|
||||||
|
pipe := r.rdb.Pipeline()
|
||||||
|
pipe.Del(ctx, key)
|
||||||
|
|
||||||
|
taskTypeStr := parseTaskTypes(capabilities)
|
||||||
|
for _, tt := range taskTypeStr {
|
||||||
|
capKey := r.capPrefix + tt
|
||||||
|
pipe.SRem(ctx, capKey, runnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
r.logger.Error("Failed to unregister runner from redis",
|
||||||
|
zap.String("runner_id", runnerID),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
r.localRunners.Delete(runnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshHeartbeat 刷新 Runner 的 TTL
|
||||||
|
func (r *RunnerRegistry) RefreshHeartbeat(runnerID string) error {
|
||||||
|
if r.rdb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
key := r.registryPrefix + runnerID
|
||||||
|
return r.rdb.Expire(ctx, key, r.onlineTTL).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableRunner 查找支持指定任务类型的 Runner
|
||||||
|
// 返回 runnerID 和所在 instanceID
|
||||||
|
func (r *RunnerRegistry) GetAvailableRunner(taskType string) (runnerID, instanceID string, err error) {
|
||||||
|
if r.rdb == nil {
|
||||||
|
return "", "", fmt.Errorf("registry not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
capKey := r.capPrefix + taskType
|
||||||
|
|
||||||
|
members, err := r.rdb.SMembers(ctx, capKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to query runner capabilities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(members) == 0 {
|
||||||
|
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机选一个(伪随机,map遍历本身随机性)
|
||||||
|
for _, rid := range members {
|
||||||
|
key := r.registryPrefix + rid
|
||||||
|
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||||
|
if err != nil {
|
||||||
|
continue // 已过期,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
var info runnerInfo
|
||||||
|
if err := json.Unmarshal(val, &info); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return rid, info.InstanceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRunnerInstance 获取 Runner 所在的实例 ID
|
||||||
|
func (r *RunnerRegistry) GetRunnerInstance(runnerID string) (string, error) {
|
||||||
|
if r.rdb == nil {
|
||||||
|
return "", fmt.Errorf("registry not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
key := r.registryPrefix + runnerID
|
||||||
|
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("runner not found: %s", runnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info runnerInfo
|
||||||
|
if err := json.Unmarshal(val, &info); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse runner info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.InstanceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllRunners 获取所有注册的 Runner 信息
|
||||||
|
func (r *RunnerRegistry) GetAllRunners() ([]map[string]any, error) {
|
||||||
|
if r.rdb == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pattern := r.registryPrefix + "*"
|
||||||
|
keys, err := r.rdb.Keys(ctx, pattern).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]any, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var info runnerInfo
|
||||||
|
if err := json.Unmarshal(val, &info); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var caps map[string]string
|
||||||
|
json.Unmarshal([]byte(info.Capabilities), &caps)
|
||||||
|
|
||||||
|
runnerID := key[len(r.registryPrefix):]
|
||||||
|
result = append(result, map[string]any{
|
||||||
|
"id": runnerID,
|
||||||
|
"instance_id": info.InstanceID,
|
||||||
|
"version": info.Version,
|
||||||
|
"capabilities": caps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartHeartbeat 启动心跳续期
|
||||||
|
func (r *RunnerRegistry) StartHeartbeat() {
|
||||||
|
if r.rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
r.cancel = cancel
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(r.heartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.heartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RunnerRegistry) heartbeat() {
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := r.rdb.Pipeline()
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
r.localRunners.Range(func(key, _ any) bool {
|
||||||
|
runnerID := key.(string)
|
||||||
|
regKey := r.registryPrefix + runnerID
|
||||||
|
pipe.Expire(ctx, regKey, r.onlineTTL)
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
r.logger.Error("Failed to heartbeat runner registry", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止心跳并清理本地 Runner 的 Redis 注册
|
||||||
|
func (r *RunnerRegistry) Stop() {
|
||||||
|
if r.cancel != nil {
|
||||||
|
r.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.localRunners.Range(func(key, value any) bool {
|
||||||
|
runnerID := key.(string)
|
||||||
|
capabilities, _ := value.(map[string]string)
|
||||||
|
r.Unregister(runnerID, capabilities)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTaskTypes 从 capabilities map 中提取支持的任务类型列表
|
||||||
|
func parseTaskTypes(capabilities map[string]string) []string {
|
||||||
|
var types []string
|
||||||
|
for capKey, capValue := range capabilities {
|
||||||
|
// key = "TASK_TYPE_GET_SCHEDULE", value = "true"
|
||||||
|
if capValue == "true" && isTaskType(capKey) {
|
||||||
|
types = append(types, capKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// key = "schedule", value = "TASK_TYPE_GET_SCHEDULE"
|
||||||
|
if isTaskType(capValue) {
|
||||||
|
types = append(types, capValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTaskType 检查字符串是否为有效的 TaskType 枚举值
|
||||||
|
func isTaskType(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case "TASK_TYPE_LOGIN", "TASK_TYPE_GET_SCHEDULE", "TASK_TYPE_GET_GRADES",
|
||||||
|
"TASK_TYPE_GET_EXAMS", "TASK_TYPE_GET_USER_INFO":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server gRPC 服务器
|
// Server gRPC 服务器
|
||||||
@@ -23,13 +21,8 @@ type Server struct {
|
|||||||
grpcSrv *grpc.Server
|
grpcSrv *grpc.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer 创建新的 gRPC 服务器
|
// NewServerWithDeps 使用外部依赖创建 gRPC 服务器(Wire DI 使用)
|
||||||
func NewServer(cfg *config.GRPCConfig, logger *zap.Logger) *Server {
|
func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
|
||||||
// 创建 TaskManager 和 RunnerHub,解决循环依赖
|
|
||||||
taskManager := NewTaskManager(nil, 60*time.Second, logger)
|
|
||||||
hub := NewRunnerHub(taskManager, logger)
|
|
||||||
taskManager.SetHub(hub)
|
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -103,43 +96,3 @@ func (s *Server) Stop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHub 获取 RunnerHub
|
|
||||||
func (s *Server) GetHub() *RunnerHub {
|
|
||||||
return s.hub
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTaskManager 获取 TaskManager
|
|
||||||
func (s *Server) GetTaskManager() *TaskManager {
|
|
||||||
return s.hub.taskManager
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown 优雅关闭
|
|
||||||
func (s *Server) Shutdown(ctx context.Context) error {
|
|
||||||
if s.grpcSrv == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
s.grpcSrv.GracefulStop()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
s.logger.Info("gRPC server stopped gracefully")
|
|
||||||
return nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
s.logger.Warn("gRPC server shutdown timeout, forcing stop")
|
|
||||||
s.grpcSrv.Stop()
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClientCredentials 获取客户端凭证配置
|
|
||||||
func GetClientCredentials(cfg *config.GRPCConfig) (credentials.TransportCredentials, error) {
|
|
||||||
if cfg.TLSEnabled {
|
|
||||||
return credentials.NewClientTLSFromFile(cfg.TLSCertFile, "")
|
|
||||||
}
|
|
||||||
return insecure.NewCredentials(), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import (
|
|||||||
// TaskCallback 任务完成回调函数类型
|
// TaskCallback 任务完成回调函数类型
|
||||||
type TaskCallback func(result *runner.TaskResult)
|
type TaskCallback func(result *runner.TaskResult)
|
||||||
|
|
||||||
|
// TaskDispatcher 任务分发接口
|
||||||
|
type TaskDispatcher interface {
|
||||||
|
DispatchTask(task *runner.Task) error
|
||||||
|
}
|
||||||
|
|
||||||
// PendingTask 表示一个等待处理的任务
|
// PendingTask 表示一个等待处理的任务
|
||||||
type PendingTask struct {
|
type PendingTask struct {
|
||||||
Task *runner.Task
|
Task *runner.Task
|
||||||
@@ -27,16 +32,16 @@ type PendingTask struct {
|
|||||||
type TaskManager struct {
|
type TaskManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
pending map[string]*PendingTask
|
pending map[string]*PendingTask
|
||||||
hub *RunnerHub
|
dispatcher TaskDispatcher
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTaskManager 创建新的任务管理器
|
// NewTaskManager 创建新的任务管理器
|
||||||
func NewTaskManager(hub *RunnerHub, timeout time.Duration, logger *zap.Logger) *TaskManager {
|
func NewTaskManager(dispatcher TaskDispatcher, timeout time.Duration, logger *zap.Logger) *TaskManager {
|
||||||
return &TaskManager{
|
return &TaskManager{
|
||||||
pending: make(map[string]*PendingTask),
|
pending: make(map[string]*PendingTask),
|
||||||
hub: hub,
|
dispatcher: dispatcher,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
@@ -69,17 +74,10 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
|
|||||||
zap.String("task_id", task.TaskId),
|
zap.String("task_id", task.TaskId),
|
||||||
zap.String("type", taskType.String()))
|
zap.String("type", taskType.String()))
|
||||||
|
|
||||||
// 获取可用 Runner
|
// 通过 dispatcher 分发任务
|
||||||
conn, err := tm.hub.GetAvailableRunner(taskType)
|
if err := tm.dispatcher.DispatchTask(task); err != nil {
|
||||||
if err != nil {
|
|
||||||
tm.cleanupTask(task.TaskId)
|
tm.cleanupTask(task.TaskId)
|
||||||
return nil, fmt.Errorf("no available runner: %w", err)
|
return nil, fmt.Errorf("failed to dispatch task: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
// 发送任务
|
|
||||||
if err := tm.hub.SendTask(conn.ID, task); err != nil {
|
|
||||||
tm.cleanupTask(task.TaskId)
|
|
||||||
return nil, fmt.Errorf("failed to send task: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动超时检查
|
// 启动超时检查
|
||||||
@@ -203,9 +201,9 @@ func (tm *TaskManager) GetPendingTaskCount() int {
|
|||||||
return len(tm.pending)
|
return len(tm.pending)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHub 设置 RunnerHub(用于解决循环依赖)
|
// SetDispatcher 设置任务分发器(用于解决循环依赖)
|
||||||
func (tm *TaskManager) SetHub(hub *RunnerHub) {
|
func (tm *TaskManager) SetDispatcher(dispatcher TaskDispatcher) {
|
||||||
tm.hub = hub
|
tm.dispatcher = dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupStaleTasks 清理超时的任务
|
// CleanupStaleTasks 清理超时的任务
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"with_you/internal/dto"
|
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/query"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -37,7 +37,7 @@ func (h *AdminLogHandler) GetOperationLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := dto.LogFilter{
|
filters := query.LogFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
Operation: c.Query("operation"),
|
Operation: c.Query("operation"),
|
||||||
TargetType: c.Query("target_type"),
|
TargetType: c.Query("target_type"),
|
||||||
@@ -70,7 +70,7 @@ func (h *AdminLogHandler) GetLoginLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := dto.LoginFilter{
|
filters := query.LoginFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
Event: c.Query("event"),
|
Event: c.Query("event"),
|
||||||
Result: c.Query("result"),
|
Result: c.Query("result"),
|
||||||
@@ -113,7 +113,7 @@ func (h *AdminLogHandler) GetDataChangeLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := dto.DataChangeFilter{
|
filters := query.DataChangeFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
OperatorID: c.Query("operator_id"),
|
OperatorID: c.Query("operator_id"),
|
||||||
ChangeType: c.Query("change_type"),
|
ChangeType: c.Query("change_type"),
|
||||||
@@ -190,7 +190,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
|||||||
"logs": logs,
|
"logs": logs,
|
||||||
})
|
})
|
||||||
case "login":
|
case "login":
|
||||||
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), dto.LoginFilter{}, 1, 10000)
|
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), query.LoginFilter{}, 1, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to export login logs")
|
response.HandleError(c, err, "failed to export login logs")
|
||||||
return
|
return
|
||||||
@@ -200,7 +200,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
|||||||
"log": logs,
|
"log": logs,
|
||||||
})
|
})
|
||||||
case "data_change":
|
case "data_change":
|
||||||
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), dto.DataChangeFilter{}, 1, 10000)
|
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), query.DataChangeFilter{}, 1, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to export data change logs")
|
response.HandleError(c, err, "failed to export data change logs")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/query"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -25,7 +25,7 @@ func NewAdminReportHandler(adminReportService service.AdminReportService) *Admin
|
|||||||
// List 获取举报列表
|
// List 获取举报列表
|
||||||
func (h *AdminReportHandler) List(c *gin.Context) {
|
func (h *AdminReportHandler) List(c *gin.Context) {
|
||||||
// 解析查询参数
|
// 解析查询参数
|
||||||
var query dto.AdminReportListQuery
|
var query query.AdminReportListQuery
|
||||||
if err := c.ShouldBindQuery(&query); err != nil {
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
response.BadRequest(c, "参数错误: "+err.Error())
|
response.BadRequest(c, "参数错误: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -137,16 +137,3 @@ func (h *AdminReportHandler) BatchHandle(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePageParams 解析分页参数
|
|
||||||
func parsePageParams(c *gin.Context) (int, int) {
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize < 1 || pageSize > 100 {
|
|
||||||
pageSize = 20
|
|
||||||
}
|
|
||||||
return page, pageSize
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,12 +14,14 @@ import (
|
|||||||
// AdminUserHandler 管理端用户处理器
|
// AdminUserHandler 管理端用户处理器
|
||||||
type AdminUserHandler struct {
|
type AdminUserHandler struct {
|
||||||
adminUserService service.AdminUserService
|
adminUserService service.AdminUserService
|
||||||
|
pushService service.PushService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminUserHandler 创建管理端用户处理器
|
// NewAdminUserHandler 创建管理端用户处理器
|
||||||
func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler {
|
func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler {
|
||||||
return &AdminUserHandler{
|
return &AdminUserHandler{
|
||||||
adminUserService: adminUserService,
|
adminUserService: adminUserService,
|
||||||
|
pushService: pushService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, userDetail)
|
response.Success(c, userDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserDevices 获取用户设备列表(含 registration_id)
|
||||||
|
// GET /api/v1/admin/users/:id/devices
|
||||||
|
// 供后台查询目标用户各设备的 JPush RegistrationID(push_token),用于排查推送问题或手动推送测试。
|
||||||
|
func (h *AdminUserHandler) GetUserDevices(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
if userID == "" {
|
||||||
|
response.BadRequest(c, "user id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "failed to get user devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, dto.DeviceTokensToAdminResponse(devices))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/repository"
|
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,16 +14,16 @@ import (
|
|||||||
|
|
||||||
type AdminVerificationHandler struct {
|
type AdminVerificationHandler struct {
|
||||||
adminVerificationService service.AdminVerificationService
|
adminVerificationService service.AdminVerificationService
|
||||||
userRepo repository.UserRepository
|
userService service.UserService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminVerificationHandler(
|
func NewAdminVerificationHandler(
|
||||||
adminVerificationService service.AdminVerificationService,
|
adminVerificationService service.AdminVerificationService,
|
||||||
userRepo repository.UserRepository,
|
userService service.UserService,
|
||||||
) *AdminVerificationHandler {
|
) *AdminVerificationHandler {
|
||||||
return &AdminVerificationHandler{
|
return &AdminVerificationHandler{
|
||||||
adminVerificationService: adminVerificationService,
|
adminVerificationService: adminVerificationService,
|
||||||
userRepo: userRepo,
|
userService: userService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ func (h *AdminVerificationHandler) ListVerifications(c *gin.Context) {
|
|||||||
|
|
||||||
responses := make([]*dto.AdminVerificationListResponse, len(records))
|
responses := make([]*dto.AdminVerificationListResponse, len(records))
|
||||||
for i, record := range records {
|
for i, record := range records {
|
||||||
responses[i] = h.convertToAdminListResponse(record)
|
responses[i] = h.convertToAdminListResponse(c.Request.Context(), record)
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Paginated(c, responses, total, page, pageSize)
|
response.Paginated(c, responses, total, page, pageSize)
|
||||||
@@ -74,7 +74,7 @@ func (h *AdminVerificationHandler) GetVerificationDetail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, h.convertToAdminDetailResponse(record))
|
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
||||||
@@ -113,10 +113,10 @@ func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, h.convertToAdminDetailResponse(record))
|
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.VerificationRecord) *dto.AdminVerificationListResponse {
|
func (h *AdminVerificationHandler) convertToAdminListResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationListResponse {
|
||||||
resp := &dto.AdminVerificationListResponse{
|
resp := &dto.AdminVerificationListResponse{
|
||||||
ID: record.ID,
|
ID: record.ID,
|
||||||
UserID: record.UserID,
|
UserID: record.UserID,
|
||||||
@@ -129,7 +129,7 @@ func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.Veri
|
|||||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userRepo.GetByID(record.UserID)
|
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||||
if err == nil && user != nil {
|
if err == nil && user != nil {
|
||||||
resp.Username = user.Username
|
resp.Username = user.Username
|
||||||
resp.Nickname = user.Nickname
|
resp.Nickname = user.Nickname
|
||||||
@@ -139,7 +139,7 @@ func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.Veri
|
|||||||
if record.ReviewedAt != nil {
|
if record.ReviewedAt != nil {
|
||||||
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
||||||
if record.ReviewedBy != nil {
|
if record.ReviewedBy != nil {
|
||||||
reviewer, err := h.userRepo.GetByID(*record.ReviewedBy)
|
reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy)
|
||||||
if err == nil && reviewer != nil {
|
if err == nil && reviewer != nil {
|
||||||
resp.ReviewerName = reviewer.Nickname
|
resp.ReviewerName = reviewer.Nickname
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.Veri
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminVerificationHandler) convertToAdminDetailResponse(record *model.VerificationRecord) *dto.AdminVerificationDetailResponse {
|
func (h *AdminVerificationHandler) convertToAdminDetailResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationDetailResponse {
|
||||||
resp := &dto.AdminVerificationDetailResponse{
|
resp := &dto.AdminVerificationDetailResponse{
|
||||||
ID: record.ID,
|
ID: record.ID,
|
||||||
UserID: record.UserID,
|
UserID: record.UserID,
|
||||||
@@ -163,7 +163,7 @@ func (h *AdminVerificationHandler) convertToAdminDetailResponse(record *model.Ve
|
|||||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userRepo.GetByID(record.UserID)
|
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||||
if err == nil && user != nil {
|
if err == nil && user != nil {
|
||||||
resp.Username = user.Username
|
resp.Username = user.Username
|
||||||
resp.Nickname = user.Nickname
|
resp.Nickname = user.Nickname
|
||||||
@@ -175,7 +175,7 @@ func (h *AdminVerificationHandler) convertToAdminDetailResponse(record *model.Ve
|
|||||||
}
|
}
|
||||||
if record.ReviewedBy != nil {
|
if record.ReviewedBy != nil {
|
||||||
resp.ReviewedBy = *record.ReviewedBy
|
resp.ReviewedBy = *record.ReviewedBy
|
||||||
if reviewer, err := h.userRepo.GetByID(*record.ReviewedBy); err == nil && reviewer != nil {
|
if reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy); err == nil && reviewer != nil {
|
||||||
resp.ReviewerName = reviewer.Nickname
|
resp.ReviewerName = reviewer.Nickname
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -49,12 +49,3 @@ func (h *CallHandler) GetCallHistory(c *gin.Context) {
|
|||||||
"page": page,
|
"page": page,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetICEServers 获取 ICE 服务器配置
|
|
||||||
// GET /api/v1/calls/ice-servers
|
|
||||||
func (h *CallHandler) GetICEServers(c *gin.Context) {
|
|
||||||
servers := h.callService.GetICEServers()
|
|
||||||
response.Success(c, gin.H{
|
|
||||||
"ice_servers": servers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
@@ -120,4 +120,3 @@ func (h *ChannelHandler) AdminDelete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.Success(c, gin.H{"success": true})
|
response.Success(c, gin.H{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import (
|
|||||||
|
|
||||||
// CommentHandler 评论处理器
|
// CommentHandler 评论处理器
|
||||||
type CommentHandler struct {
|
type CommentHandler struct {
|
||||||
commentService *service.CommentService
|
commentService service.CommentService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommentHandler 创建评论处理器
|
// NewCommentHandler 创建评论处理器
|
||||||
func NewCommentHandler(commentService *service.CommentService) *CommentHandler {
|
func NewCommentHandler(commentService service.CommentService) *CommentHandler {
|
||||||
return &CommentHandler{
|
return &CommentHandler{
|
||||||
commentService: commentService,
|
commentService: commentService,
|
||||||
}
|
}
|
||||||
@@ -178,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
comment.Content = req.Content
|
comment.Content = req.Content
|
||||||
|
|
||||||
err = h.commentService.Update(c.Request.Context(), comment)
|
err = h.commentService.Update(c.Request.Context(), userID, comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to update comment")
|
response.HandleError(c, err, "failed to update comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.commentService.Delete(c.Request.Context(), id)
|
err = h.commentService.Delete(c.Request.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete comment")
|
response.HandleError(c, err, "failed to delete comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -838,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleGetAnnouncements 获取群公告列表
|
// HandleGetAnnouncements 获取群公告列表
|
||||||
// GET /api/v1/groups/:id/announcements
|
// GET /api/v1/groups/:id/announcements
|
||||||
|
//
|
||||||
|
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||||
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -851,6 +853,12 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成员校验:公告仅群成员可见。
|
||||||
|
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
@@ -1387,7 +1395,9 @@ func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
// 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) {
|
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1401,6 +1411,12 @@ func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成员校验
|
||||||
|
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 解析游标分页请求
|
// 解析游标分页请求
|
||||||
req := h.parseCursorRequest(c)
|
req := h.parseCursorRequest(c)
|
||||||
|
|
||||||
@@ -1764,6 +1780,50 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
|
|||||||
response.Success(c, dto.GroupToResponse(group))
|
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
|
||||||
|
}
|
||||||
|
response.InternalServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取更新后的群组信息
|
||||||
|
group, _ := h.groupService.GetGroupByID(groupID)
|
||||||
|
response.Success(c, dto.GroupToResponse(group))
|
||||||
|
}
|
||||||
|
|
||||||
// HandleSetGroupLeave 退出群组
|
// HandleSetGroupLeave 退出群组
|
||||||
// POST /api/v1/groups/:id/leave
|
// POST /api/v1/groups/:id/leave
|
||||||
func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) {
|
func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) {
|
||||||
@@ -1893,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleGetGroupInfo 获取群信息
|
// HandleGetGroupInfo 获取群信息
|
||||||
// GET /api/v1/groups/:id
|
// GET /api/v1/groups/:id
|
||||||
|
//
|
||||||
|
// 可见性策略:成员返回完整详情;非成员仅返回公开字段(name/avatar/description/member_count)。
|
||||||
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1923,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
|||||||
resp := dto.GroupToResponse(group)
|
resp := dto.GroupToResponse(group)
|
||||||
resp.MemberCount = memberCount
|
resp.MemberCount = memberCount
|
||||||
|
|
||||||
|
// 非成员仅返回公开字段:与成员分支同构(仍是 GroupResponse),但清空敏感字段。
|
||||||
|
// 与成员分支共用 GroupResponse 结构,依赖完整字段的客户端解析不会因字段缺失出错。
|
||||||
|
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||||
|
resp.OwnerID = ""
|
||||||
|
resp.MaxMembers = 0
|
||||||
|
resp.JoinType = 0
|
||||||
|
resp.MuteAll = false
|
||||||
|
resp.IsMember = false
|
||||||
|
response.Success(c, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.IsMember = true
|
||||||
response.Success(c, resp)
|
response.Success(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGetGroupMemberList 获取群成员列表
|
// HandleGetGroupMemberList 获取群成员列表
|
||||||
// GET /api/v1/groups/:id/members
|
// GET /api/v1/groups/:id/members
|
||||||
|
//
|
||||||
|
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||||
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1944,8 +2021,13 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||||
|
|
||||||
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
// 成员校验在 service 层完成。
|
||||||
|
members, total, err := h.groupService.GetMembersForUser(userID, groupID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrNotGroupMember) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, service.ErrGroupNotFound) {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
|
|||||||
166
internal/handler/livekit_handler.go
Normal file
166
internal/handler/livekit_handler.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/livekit/protocol/livekit"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"with_you/internal/config"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveKitHandler LiveKit 令牌和 Webhook 处理器
|
||||||
|
type LiveKitHandler struct {
|
||||||
|
liveKitService service.LiveKitService
|
||||||
|
callService service.CallService
|
||||||
|
config *config.LiveKitConfig
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLiveKitHandler 创建 LiveKit 处理器
|
||||||
|
func NewLiveKitHandler(
|
||||||
|
liveKitService service.LiveKitService,
|
||||||
|
callService service.CallService,
|
||||||
|
cfg *config.Config,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *LiveKitHandler {
|
||||||
|
return &LiveKitHandler{
|
||||||
|
liveKitService: liveKitService,
|
||||||
|
callService: callService,
|
||||||
|
config: &cfg.LiveKit,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetToken 生成 LiveKit 访问令牌
|
||||||
|
// GET /api/v1/calls/token?room=<callID>
|
||||||
|
func (h *LiveKitHandler) GetToken(c *gin.Context) {
|
||||||
|
if !h.config.Enabled {
|
||||||
|
response.Forbidden(c, "livekit is not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
response.Unauthorized(c, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room := c.Query("room")
|
||||||
|
if room == "" {
|
||||||
|
response.BadRequest(c, "room parameter is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户是该通话的参与者
|
||||||
|
activeCall := h.callService.GetActiveCall(c.Request.Context(), room)
|
||||||
|
if activeCall == nil {
|
||||||
|
response.NotFound(c, "call not found or already ended")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isParticipant := false
|
||||||
|
for _, p := range activeCall.Participants {
|
||||||
|
if p.UserID == userID.(string) {
|
||||||
|
isParticipant = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isParticipant && activeCall.CallerID != userID.(string) && activeCall.CalleeID != userID.(string) {
|
||||||
|
response.Forbidden(c, "not a participant of this call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canPublish := true
|
||||||
|
canSubscribe := true
|
||||||
|
if cp := c.Query("can_publish"); cp != "" {
|
||||||
|
canPublish, _ = strconv.ParseBool(cp)
|
||||||
|
}
|
||||||
|
if cs := c.Query("can_subscribe"); cs != "" {
|
||||||
|
canSubscribe, _ = strconv.ParseBool(cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.liveKitService.GenerateToken(room, userID.(string), canPublish, canSubscribe)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to generate livekit token", zap.Error(err))
|
||||||
|
response.InternalServerError(c, "failed to generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"token": token,
|
||||||
|
"url": h.config.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook 处理 LiveKit Webhook 回调
|
||||||
|
// POST /api/v1/calls/webhook
|
||||||
|
func (h *LiveKitHandler) HandleWebhook(c *gin.Context) {
|
||||||
|
if !h.config.Enabled {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "livekit is not enabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to read webhook body", zap.Error(err))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
event, err := h.liveKitService.ProcessWebhook(c.Request.Context(), body, authHeader)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to process webhook", zap.Error(err))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 webhook 事件类型处理
|
||||||
|
switch event.Event {
|
||||||
|
case "room_finished":
|
||||||
|
h.handleRoomFinished(c.Request.Context(), event)
|
||||||
|
case "participant_left":
|
||||||
|
identity := event.Participant.GetIdentity()
|
||||||
|
roomName := event.Room.GetName()
|
||||||
|
h.logger.Info("participant left room",
|
||||||
|
zap.String("room", roomName),
|
||||||
|
zap.String("identity", identity),
|
||||||
|
)
|
||||||
|
if identity != "" && roomName != "" {
|
||||||
|
if err := h.callService.ParticipantLeave(c.Request.Context(), roomName, identity, "left_livekit"); err != nil {
|
||||||
|
h.logger.Warn("failed to handle participant_left",
|
||||||
|
zap.String("room", roomName),
|
||||||
|
zap.String("identity", identity),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRoomFinished 处理 room_finished 事件,作为通话历史持久化的安全兜底
|
||||||
|
func (h *LiveKitHandler) handleRoomFinished(ctx context.Context, event *livekit.WebhookEvent) {
|
||||||
|
roomName := event.Room.GetName()
|
||||||
|
if roomName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试结束通话(如果尚未结束)
|
||||||
|
// callService.End 会检查调用是否仍然活跃,如果已经结束则返回 nil
|
||||||
|
_, err := h.callService.End(ctx, roomName, "", "room_finished")
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Debug("room_finished: call end returned error (may already be ended)",
|
||||||
|
zap.String("room", roomName),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"with_you/internal/dto"
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/query"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -148,7 +148,7 @@ func (h *MaterialHandler) ListMaterials(c *gin.Context) {
|
|||||||
fileType := c.Query("file_type")
|
fileType := c.Query("file_type")
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
params := dto.MaterialFileQueryParams{
|
params := query.MaterialFileQueryParams{
|
||||||
SubjectID: subjectID,
|
SubjectID: subjectID,
|
||||||
FileType: fileType,
|
FileType: fileType,
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
@@ -329,7 +329,7 @@ func (h *MaterialHandler) AdminListMaterials(c *gin.Context) {
|
|||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
params := dto.MaterialFileQueryParams{
|
params := query.MaterialFileQueryParams{
|
||||||
SubjectID: subjectID,
|
SubjectID: subjectID,
|
||||||
FileType: fileType,
|
FileType: fileType,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -14,26 +14,113 @@ import (
|
|||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// enrichConversations 批量填充会话列表响应数据(解决 N+1 问题)
|
||||||
|
func (h *MessageHandler) enrichConversations(ctx context.Context, convs []*model.Conversation, userID string) []*dto.ConversationResponse {
|
||||||
|
if len(convs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
convIDs := make([]string, len(convs))
|
||||||
|
for i, c := range convs {
|
||||||
|
convIDs[i] = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询:4-5 次查询替代 N*5 次
|
||||||
|
unreadCounts, _ := h.chatService.GetUnreadCountBatch(ctx, userID, convIDs)
|
||||||
|
lastMessages, _ := h.chatService.GetLastMessagesBatch(ctx, convIDs)
|
||||||
|
myParticipants, _ := h.messageService.GetMyParticipantsBatch(ctx, convIDs, userID)
|
||||||
|
allParticipants, _ := h.messageService.GetParticipantsBatch(ctx, convIDs)
|
||||||
|
|
||||||
|
// 收集需要额外查询的 ID
|
||||||
|
var groupIDs []string
|
||||||
|
var userIDs []string
|
||||||
|
for _, conv := range convs {
|
||||||
|
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||||
|
groupIDs = append(groupIDs, *conv.GroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, participants := range allParticipants {
|
||||||
|
for _, p := range participants {
|
||||||
|
if p.UserID != userID {
|
||||||
|
userIDs = append(userIDs, p.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberCounts, _ := h.groupService.GetMemberCountBatch(ctx, groupIDs)
|
||||||
|
usersMap, _ := h.userService.GetUsersByIDs(ctx, userIDs)
|
||||||
|
|
||||||
|
// 组装响应
|
||||||
|
result := make([]*dto.ConversationResponse, len(convs))
|
||||||
|
for i, conv := range convs {
|
||||||
|
unreadCount := unreadCounts[conv.ID]
|
||||||
|
lastMessage := lastMessages[conv.ID]
|
||||||
|
|
||||||
|
myP := myParticipants[conv.ID]
|
||||||
|
isPinned := myP != nil && myP.IsPinned
|
||||||
|
notificationMuted := myP != nil && myP.NotificationMuted
|
||||||
|
|
||||||
|
var resp *dto.ConversationResponse
|
||||||
|
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||||
|
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||||
|
if mc, ok := memberCounts[*conv.GroupID]; ok {
|
||||||
|
resp.MemberCount = int(mc)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
participants := allParticipants[conv.ID]
|
||||||
|
var users []*model.User
|
||||||
|
for _, p := range participants {
|
||||||
|
if p.UserID == userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, ok := usersMap[p.UserID]; ok {
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = dto.ConvertConversationToResponse(conv, users, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||||
|
}
|
||||||
|
result[i] = resp
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MessageHandler 消息处理器
|
// MessageHandler 消息处理器
|
||||||
type MessageHandler struct {
|
type MessageHandler struct {
|
||||||
chatService service.ChatService
|
chatService service.ChatService
|
||||||
messageService *service.MessageService
|
messageService service.MessageService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
wsHub *ws.Hub
|
uploadService service.UploadService
|
||||||
|
wsPublisher ws.MessagePublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageHandler 创建消息处理器
|
// NewMessageHandler 创建消息处理器
|
||||||
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, wsHub *ws.Hub) *MessageHandler {
|
func NewMessageHandler(chatService service.ChatService, messageService service.MessageService, userService service.UserService, groupService service.GroupService, uploadService service.UploadService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
||||||
return &MessageHandler{
|
return &MessageHandler{
|
||||||
chatService: chatService,
|
chatService: chatService,
|
||||||
messageService: messageService,
|
messageService: messageService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
wsHub: wsHub,
|
uploadService: uploadService,
|
||||||
|
wsPublisher: wsPublisher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(expiredSet) == 0 {
|
||||||
|
return dto.ConvertMessagesToResponse(messages)
|
||||||
|
}
|
||||||
|
return dto.ConvertMessagesToResponseWithExpiry(messages, expiredSet)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleTyping 输入状态上报
|
// HandleTyping 输入状态上报
|
||||||
// POST /api/v1/conversations/typing
|
// POST /api/v1/conversations/typing
|
||||||
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
||||||
@@ -80,37 +167,8 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 批量填充会话数据(解决 N+1 问题)
|
||||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||||
for i, conv := range filteredConvs {
|
|
||||||
// 获取未读数
|
|
||||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
|
||||||
|
|
||||||
// 获取最后一条消息
|
|
||||||
var lastMessage *model.Message
|
|
||||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
|
||||||
if len(messages) > 0 {
|
|
||||||
lastMessage = messages[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 群聊时返回member_count,私聊时返回participants
|
|
||||||
var resp *dto.ConversationResponse
|
|
||||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
|
||||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
|
||||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
|
||||||
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, notificationMuted)
|
|
||||||
resp.MemberCount = memberCount
|
|
||||||
} else {
|
|
||||||
// 私聊:获取参与者信息
|
|
||||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
|
||||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
|
||||||
}
|
|
||||||
result[i] = resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 total 为过滤后的数量
|
// 更新 total 为过滤后的数量
|
||||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||||
@@ -203,6 +261,12 @@ func (h *MessageHandler) GetConversationByID(c *gin.Context) {
|
|||||||
|
|
||||||
// 获取对方用户的已读位置
|
// 获取对方用户的已读位置
|
||||||
otherLastReadSeq := int64(0)
|
otherLastReadSeq := int64(0)
|
||||||
|
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))
|
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -268,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -288,12 +352,43 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览回填/跳转)
|
||||||
|
// GET /api/v1/messages/:id
|
||||||
|
func (h *MessageHandler) GetReplyMessage(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := c.Param("id")
|
||||||
|
if messageID == "" {
|
||||||
|
response.BadRequest(c, "message id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := h.chatService.GetReplyMessage(c.Request.Context(), messageID, userID)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应格式(标记已过期文件 / 撤回占位等,保持与列表接口一致)
|
||||||
|
result := h.convertMessagesWithExpiry(c.Request.Context(), []*model.Message{message})
|
||||||
|
if len(result) == 0 {
|
||||||
|
response.InternalServerError(c, "failed to convert message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result[0])
|
||||||
|
}
|
||||||
|
|
||||||
// SendMessage 发送消息
|
// SendMessage 发送消息
|
||||||
// POST /api/conversations/:id/messages
|
// POST /api/conversations/:id/messages
|
||||||
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||||
@@ -316,8 +411,7 @@ func (h *MessageHandler) SendMessage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接使用 segments
|
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
||||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -359,7 +453,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID)
|
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID, params.ClientMsgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -372,7 +466,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
|||||||
Type: "message",
|
Type: "message",
|
||||||
DetailType: params.DetailType,
|
DetailType: params.DetailType,
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
Seq: strconv.FormatInt(msg.Seq, 10),
|
Seq: msg.Seq,
|
||||||
Segments: params.Segments,
|
Segments: params.Segments,
|
||||||
SenderID: userID,
|
SenderID: userID,
|
||||||
}
|
}
|
||||||
@@ -438,37 +532,8 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 批量填充会话数据(解决 N+1 问题)
|
||||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||||
for i, conv := range filteredConvs {
|
|
||||||
// 获取未读数
|
|
||||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
|
||||||
|
|
||||||
// 获取最后一条消息
|
|
||||||
var lastMessage *model.Message
|
|
||||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
|
||||||
if len(messages) > 0 {
|
|
||||||
lastMessage = messages[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 群聊时返回member_count,私聊时返回participants
|
|
||||||
var resp *dto.ConversationResponse
|
|
||||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
|
||||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
|
||||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
|
||||||
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, notificationMuted)
|
|
||||||
resp.MemberCount = memberCount
|
|
||||||
} else {
|
|
||||||
// 私聊:获取参与者信息
|
|
||||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
|
||||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
|
||||||
}
|
|
||||||
result[i] = resp
|
|
||||||
}
|
|
||||||
|
|
||||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||||
}
|
}
|
||||||
@@ -548,6 +613,57 @@ func (h *MessageHandler) GetUnreadCount(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleGetSyncData 获取同步元数据(轻量级 seq + 时间)
|
||||||
|
// GET /api/v1/conversations/sync-data
|
||||||
|
func (h *MessageHandler) HandleGetSyncData(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := h.chatService.GetSyncData(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "failed to get sync data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, &dto.SyncDataResponse{
|
||||||
|
Conversations: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetSyncByVersion 增量同步:按版本号获取会话变更
|
||||||
|
// GET /api/v1/conversations/sync?version=0&limit=100
|
||||||
|
func (h *MessageHandler) HandleGetSyncByVersion(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionStr := c.DefaultQuery("version", "0")
|
||||||
|
sinceVersion, err := strconv.ParseInt(versionStr, 10, 64)
|
||||||
|
if err != nil || sinceVersion < 0 {
|
||||||
|
response.BadRequest(c, "invalid version parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := c.DefaultQuery("limit", "100")
|
||||||
|
limit, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit < 1 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.chatService.GetSyncByVersion(c.Request.Context(), userID, sinceVersion, limit)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
// GetConversationUnreadCount 获取单个会话的未读数
|
// GetConversationUnreadCount 获取单个会话的未读数
|
||||||
// GET /api/conversations/:id/unread/count
|
// GET /api/conversations/:id/unread/count
|
||||||
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
||||||
@@ -616,16 +732,6 @@ func (h *MessageHandler) DeleteMessage(c *gin.Context) {
|
|||||||
response.SuccessWithMessage(c, "message deleted", nil)
|
response.SuccessWithMessage(c, "message deleted", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助函数:验证内容类型
|
|
||||||
func isValidContentType(contentType model.ContentType) bool {
|
|
||||||
switch contentType {
|
|
||||||
case model.ContentTypeText, model.ContentTypeImage, model.ContentTypeVideo, model.ContentTypeAudio, model.ContentTypeFile:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数:获取会话参与者信息
|
// 辅助函数:获取会话参与者信息
|
||||||
func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) {
|
func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) {
|
||||||
// 从repository获取参与者列表
|
// 从repository获取参与者列表
|
||||||
@@ -741,7 +847,7 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
|
|||||||
// 获取参与者信息
|
// 获取参与者信息
|
||||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
||||||
|
|
||||||
// 获取当前用户的已读位置
|
// 获取当前用户的已读位置(优先从 Redis 缓存读取)
|
||||||
myLastReadSeq := int64(0)
|
myLastReadSeq := int64(0)
|
||||||
isPinned := false
|
isPinned := false
|
||||||
notificationMuted := false
|
notificationMuted := false
|
||||||
@@ -755,8 +861,14 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取对方用户的已读位置
|
// 获取对方用户的已读位置(优先从 Redis 缓存读取)
|
||||||
otherLastReadSeq := int64(0)
|
otherLastReadSeq := int64(0)
|
||||||
|
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))
|
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,8 +905,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -821,8 +933,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -841,8 +953,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
@@ -877,6 +989,33 @@ func (h *MessageHandler) HandleMarkRead(c *gin.Context) {
|
|||||||
response.SuccessWithMessage(c, "marked as read", nil)
|
response.SuccessWithMessage(c, "marked as read", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleMarkReadAll 批量标记所有会话已读
|
||||||
|
// POST /api/v1/conversations/read-all
|
||||||
|
func (h *MessageHandler) HandleMarkReadAll(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.BatchMarkReadRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount, err := h.chatService.MarkAsReadBatch(c.Request.Context(), userID, req.Conversations)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "failed to mark all as read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, &dto.BatchMarkReadResponse{
|
||||||
|
SuccessCount: successCount,
|
||||||
|
TotalCount: len(req.Conversations),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// HandleSetConversationPinned 设置会话置顶
|
// HandleSetConversationPinned 设置会话置顶
|
||||||
// PUT /api/v1/conversations/:id/pinned
|
// PUT /api/v1/conversations/:id/pinned
|
||||||
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
||||||
@@ -969,15 +1108,15 @@ func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
|||||||
// 解析游标分页参数
|
// 解析游标分页参数
|
||||||
pageReq := parseCursorPageRequest(c)
|
pageReq := parseCursorPageRequest(c)
|
||||||
|
|
||||||
// 调用服务层
|
// 调用服务层(内部会校验 currentUserID 是否是会话参与者,否则拒绝)
|
||||||
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, pageReq)
|
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, userID, pageReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get messages")
|
response.HandleError(c, err, "failed to get messages")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
items := dto.ConvertMessagesToResponse(result.Items)
|
items := h.convertMessagesWithExpiry(c.Request.Context(), result.Items)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageCursorPageResponse{
|
response.Success(c, &dto.MessageCursorPageResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
@@ -1018,37 +1157,8 @@ func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 批量填充会话数据(解决 N+1 问题)
|
||||||
items := make([]*dto.ConversationResponse, len(filteredItems))
|
items := h.enrichConversations(c.Request.Context(), filteredItems, userID)
|
||||||
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
|
|
||||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
|
||||||
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, notificationMuted)
|
|
||||||
resp.MemberCount = memberCount
|
|
||||||
} else {
|
|
||||||
// 私聊:获取参与者信息
|
|
||||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
|
||||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
|
||||||
}
|
|
||||||
items[i] = resp
|
|
||||||
}
|
|
||||||
|
|
||||||
response.Success(c, &dto.ConversationCursorPageResponse{
|
response.Success(c, &dto.ConversationCursorPageResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
NextCursor: result.NextCursor,
|
NextCursor: result.NextCursor,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,11 +13,11 @@ import (
|
|||||||
|
|
||||||
// NotificationHandler 通知处理器
|
// NotificationHandler 通知处理器
|
||||||
type NotificationHandler struct {
|
type NotificationHandler struct {
|
||||||
notificationService *service.NotificationService
|
notificationService service.NotificationService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNotificationHandler 创建通知处理器
|
// NewNotificationHandler 创建通知处理器
|
||||||
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
|
func NewNotificationHandler(notificationService service.NotificationService) *NotificationHandler {
|
||||||
return &NotificationHandler{
|
return &NotificationHandler{
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
|||||||
|
|
||||||
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to mark as read")
|
response.HandleError(c, err, "failed to mark as read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
|||||||
|
|
||||||
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete notification")
|
response.HandleError(c, err, "failed to delete notification")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -105,11 +106,11 @@ func (h *PostHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID)
|
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID, req.ClientRequestID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var moderationErr *service.PostModerationRejectedError
|
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||||
if errors.As(err, &moderationErr) {
|
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||||
response.BadRequest(c, moderationErr.UserMessage())
|
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.InternalServerError(c, "failed to create post")
|
response.InternalServerError(c, "failed to create post")
|
||||||
@@ -164,7 +165,7 @@ func (h *PostHandler) GetByID(c *gin.Context) {
|
|||||||
if currentUserID != "" {
|
if currentUserID != "" {
|
||||||
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe)
|
authorWithFollowStatus = dto.ConvertUserToResponse(post.User, dto.WithFollowing(isFollowing, isFollowingMe))
|
||||||
} else {
|
} else {
|
||||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
||||||
}
|
}
|
||||||
@@ -496,9 +497,9 @@ func (h *PostHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.postService.Delete(c.Request.Context(), id)
|
err = h.postService.Delete(c.Request.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete post")
|
response.HandleError(c, err, "failed to delete post")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
@@ -67,7 +67,7 @@ func (h *PushHandler) UnregisterDevice(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.pushService.UnregisterDevice(c.Request.Context(), deviceID)
|
err := h.pushService.UnregisterDevice(c.Request.Context(), userID, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to unregister device")
|
response.InternalServerError(c, "failed to unregister device")
|
||||||
return
|
return
|
||||||
@@ -85,12 +85,13 @@ func (h *PushHandler) GetMyDevices(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里需要从DeviceTokenRepository获取设备列表
|
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||||
// 由于PushService接口没有提供获取设备列表的方法,我们暂时返回空列表
|
if err != nil {
|
||||||
// TODO: 在PushService接口中添加GetUserDevices方法
|
response.InternalServerError(c, "failed to get devices")
|
||||||
_ = userID // 避免未使用变量警告
|
return
|
||||||
|
}
|
||||||
|
|
||||||
response.Success(c, []*dto.DeviceTokenResponse{})
|
response.Success(c, dto.DeviceTokensToResponse(devices))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPushRecords 获取推送记录
|
// GetPushRecords 获取推送记录
|
||||||
@@ -140,14 +141,14 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
PushToken string `json:"push_token" binding:"required"`
|
PushToken string `json:"push_token"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.pushService.UpdateDeviceToken(c.Request.Context(), deviceID, req.PushToken)
|
err := h.pushService.UpdateDeviceToken(c.Request.Context(), userID, deviceID, req.PushToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to update device token")
|
response.InternalServerError(c, "failed to update device token")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,11 +14,11 @@ import (
|
|||||||
|
|
||||||
// QRCodeHandler 二维码登录处理器
|
// QRCodeHandler 二维码登录处理器
|
||||||
type QRCodeHandler struct {
|
type QRCodeHandler struct {
|
||||||
qrcodeService *service.QRCodeLoginService
|
qrcodeService service.QRCodeLoginService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQRCodeHandler 创建二维码登录处理器
|
// NewQRCodeHandler 创建二维码登录处理器
|
||||||
func NewQRCodeHandler(qrcodeService *service.QRCodeLoginService) *QRCodeHandler {
|
func NewQRCodeHandler(qrcodeService service.QRCodeLoginService) *QRCodeHandler {
|
||||||
return &QRCodeHandler{
|
return &QRCodeHandler{
|
||||||
qrcodeService: qrcodeService,
|
qrcodeService: qrcodeService,
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func (h *QRCodeHandler) WSEvents(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ch, cancel, replay := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
|
ch, cancel := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
w := c.Writer
|
w := c.Writer
|
||||||
@@ -82,13 +82,6 @@ func (h *QRCodeHandler) WSEvents(c *gin.Context) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送历史事件
|
|
||||||
for _, ev := range replay {
|
|
||||||
if !writeEvent(ev) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 心跳
|
// 心跳
|
||||||
heartbeat := time.NewTicker(25 * time.Second)
|
heartbeat := time.NewTicker(25 * time.Second)
|
||||||
defer heartbeat.Stop()
|
defer heartbeat.Stop()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
|
|
||||||
// UploadHandler 上传处理器
|
// UploadHandler 上传处理器
|
||||||
type UploadHandler struct {
|
type UploadHandler struct {
|
||||||
uploadService *service.UploadService
|
uploadService service.UploadService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUploadHandler 创建上传处理器
|
// NewUploadHandler 创建上传处理器
|
||||||
func NewUploadHandler(uploadService *service.UploadService) *UploadHandler {
|
func NewUploadHandler(uploadService service.UploadService) *UploadHandler {
|
||||||
return &UploadHandler{
|
return &UploadHandler{
|
||||||
uploadService: uploadService,
|
uploadService: uploadService,
|
||||||
}
|
}
|
||||||
@@ -92,3 +92,43 @@ func (h *UploadHandler) UploadCover(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, gin.H{"url": url})
|
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,11 +6,17 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"with_you/internal/cache"
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
|
"with_you/internal/middleware"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
|
"with_you/internal/pkg/auth"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
@@ -35,7 +41,9 @@ type UserHandler struct {
|
|||||||
userService service.UserService
|
userService service.UserService
|
||||||
activityService service.UserActivityService
|
activityService service.UserActivityService
|
||||||
profileAuditService service.UserProfileAuditService
|
profileAuditService service.UserProfileAuditService
|
||||||
jwtService *service.JWTService
|
jwtService service.JWTService
|
||||||
|
sessionService service.SessionService
|
||||||
|
cache cache.Cache
|
||||||
logService *service.LogService
|
logService *service.LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +60,7 @@ func (h *UserHandler) SetProfileAuditService(profileAuditService service.UserPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetJWTService 设置JWT服务
|
// SetJWTService 设置JWT服务
|
||||||
func (h *UserHandler) SetJWTService(jwtService *service.JWTService) {
|
func (h *UserHandler) SetJWTService(jwtService service.JWTService) {
|
||||||
h.jwtService = jwtService
|
h.jwtService = jwtService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +69,16 @@ func (h *UserHandler) SetActivityService(activityService service.UserActivitySer
|
|||||||
h.activityService = activityService
|
h.activityService = activityService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// generateTokenID 生成Token ID
|
||||||
func generateTokenID(token string) string {
|
func generateTokenID(token string) string {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
@@ -91,12 +109,16 @@ func (h *UserHandler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成Token
|
// 签发带会话的令牌对:sid 写入 JWT,支持登出/封禁后撤销。
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
|
||||||
|
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
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 {
|
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)
|
user, err := h.userService.Login(c.Request.Context(), account, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
middleware.RecordLoginFailure(c.ClientIP())
|
||||||
response.HandleError(c, err, "failed to login")
|
response.HandleError(c, err, "failed to login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成Token
|
middleware.ResetLoginFailures(c.ClientIP())
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
|
||||||
|
|
||||||
|
// 签发带会话的令牌对。
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
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 {
|
if h.activityService != nil {
|
||||||
@@ -175,8 +203,6 @@ func (h *UserHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
// 更新登录日志(补充IP和UserAgent)
|
// 更新登录日志(补充IP和UserAgent)
|
||||||
if h.logService != nil {
|
if h.logService != nil {
|
||||||
ip := c.ClientIP()
|
|
||||||
userAgent := c.GetHeader("User-Agent")
|
|
||||||
go func() {
|
go func() {
|
||||||
h.logService.LoginLog.RecordLogin(&model.LoginLog{
|
h.logService.LoginLog.RecordLogin(&model.LoginLog{
|
||||||
UserID: user.ID,
|
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)
|
response.Success(c, userResponse)
|
||||||
}
|
}
|
||||||
@@ -459,6 +485,11 @@ func (h *UserHandler) VerifyEmail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新Token
|
// RefreshToken 刷新Token
|
||||||
|
//
|
||||||
|
// 安全语义:
|
||||||
|
// - 仅接受 refresh token(jwt.ParseRefreshToken 严格校验 typ=refresh)。
|
||||||
|
// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。
|
||||||
|
// - 不接受 access token,access token 不能在刷新端点换取新令牌。
|
||||||
func (h *UserHandler) RefreshToken(c *gin.Context) {
|
func (h *UserHandler) RefreshToken(c *gin.Context) {
|
||||||
type RefreshRequest struct {
|
type RefreshRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
@@ -470,26 +501,34 @@ func (h *UserHandler) RefreshToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 refresh token
|
// 获取用户信息用于填充新令牌的 username 声明。
|
||||||
claims, err := h.jwtService.ParseToken(req.RefreshToken)
|
// 先解析 refresh 拿 userID,再查 DB;不直接信任 JWT 中的 username。
|
||||||
|
claims, err := h.jwtService.ParseRefreshToken(req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Unauthorized(c, "invalid refresh token")
|
response.HandleError(c, apperrors.ErrInvalidToken, "invalid refresh token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户信息
|
|
||||||
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
|
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "user not found")
|
response.HandleError(c, apperrors.ErrUserNotFound, "user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成新 token
|
if user.Status != model.UserStatusActive && user.Status != model.UserStatusPendingDeletion {
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
response.HandleError(c, apperrors.ErrUserBanned, "user banned")
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
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 {
|
if h.activityService != nil {
|
||||||
@@ -660,8 +699,9 @@ func (h *UserHandler) GetFollowingList(c *gin.Context) {
|
|||||||
|
|
||||||
page := c.DefaultQuery("page", "1")
|
page := c.DefaultQuery("page", "1")
|
||||||
pageSize := c.DefaultQuery("page_size", "20")
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get following list")
|
response.InternalServerError(c, "failed to get following list")
|
||||||
return
|
return
|
||||||
@@ -706,8 +746,9 @@ func (h *UserHandler) GetFollowersList(c *gin.Context) {
|
|||||||
|
|
||||||
page := c.DefaultQuery("page", "1")
|
page := c.DefaultQuery("page", "1")
|
||||||
pageSize := c.DefaultQuery("page_size", "20")
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get followers list")
|
response.InternalServerError(c, "failed to get followers list")
|
||||||
return
|
return
|
||||||
@@ -795,9 +836,14 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Logout 用户登出
|
// Logout 用户登出
|
||||||
|
//
|
||||||
|
// 安全语义:撤销当前会话,使 refresh token 立即失效。
|
||||||
|
// access token 在剩余 TTL 内仍可用(无状态 JWT 的固有限制),但 refresh token 已撤销,
|
||||||
|
// 攻击者无法用 refresh token 续期,access token 也会在短 TTL 内自然过期。
|
||||||
|
// 如需 access token 即时失效,可后续引入 jti 黑名单。
|
||||||
func (h *UserHandler) Logout(c *gin.Context) {
|
func (h *UserHandler) Logout(c *gin.Context) {
|
||||||
currentUserID := c.GetString("user_id")
|
principal, ok := auth.GetPrincipal(c)
|
||||||
if currentUserID == "" {
|
if !ok || principal == nil {
|
||||||
response.Unauthorized(c, "not logged in")
|
response.Unauthorized(c, "not logged in")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -805,8 +851,23 @@ func (h *UserHandler) Logout(c *gin.Context) {
|
|||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "user not found")
|
response.InternalServerError(c, "user not found")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/pkg/sanitizer"
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -114,7 +115,7 @@ func convertVerificationRecordToResponse(record *model.VerificationRecord) *dto.
|
|||||||
UserID: record.UserID,
|
UserID: record.UserID,
|
||||||
Identity: string(record.Identity),
|
Identity: string(record.Identity),
|
||||||
Status: string(record.Status),
|
Status: string(record.Status),
|
||||||
RealName: record.RealName,
|
RealName: sanitizer.MaskValue("real_name", record.RealName),
|
||||||
StudentID: record.StudentID,
|
StudentID: record.StudentID,
|
||||||
EmployeeID: record.EmployeeID,
|
EmployeeID: record.EmployeeID,
|
||||||
Department: record.Department,
|
Department: record.Department,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,12 +13,12 @@ import (
|
|||||||
|
|
||||||
// VoteHandler 投票处理器
|
// VoteHandler 投票处理器
|
||||||
type VoteHandler struct {
|
type VoteHandler struct {
|
||||||
voteService *service.VoteService
|
voteService service.VoteService
|
||||||
postService service.PostService
|
postService service.PostService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVoteHandler 创建投票处理器
|
// NewVoteHandler 创建投票处理器
|
||||||
func NewVoteHandler(voteService *service.VoteService, postService service.PostService) *VoteHandler {
|
func NewVoteHandler(voteService service.VoteService, postService service.PostService) *VoteHandler {
|
||||||
return &VoteHandler{
|
return &VoteHandler{
|
||||||
voteService: voteService,
|
voteService: voteService,
|
||||||
postService: postService,
|
postService: postService,
|
||||||
@@ -42,9 +42,9 @@ func (h *VoteHandler) CreateVotePost(c *gin.Context) {
|
|||||||
|
|
||||||
post, err := h.voteService.CreateVotePost(c.Request.Context(), userID, &req)
|
post, err := h.voteService.CreateVotePost(c.Request.Context(), userID, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var moderationErr *service.PostModerationRejectedError
|
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||||
if errors.As(err, &moderationErr) {
|
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||||
response.BadRequest(c, moderationErr.UserMessage())
|
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Error(c, http.StatusBadRequest, err.Error())
|
response.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,7 +19,6 @@ import (
|
|||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"with_you/internal/pkg/ws"
|
"with_you/internal/pkg/ws"
|
||||||
"with_you/internal/repository"
|
|
||||||
"with_you/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,31 +65,43 @@ func isAllowedWebSocketOrigin(origin string) bool {
|
|||||||
|
|
||||||
// WSHandler WebSocket处理器
|
// WSHandler WebSocket处理器
|
||||||
type WSHandler struct {
|
type WSHandler struct {
|
||||||
wsHub *ws.Hub
|
publisher ws.MessagePublisher
|
||||||
chatService service.ChatService
|
chatService service.ChatService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
jwtService *service.JWTService
|
jwtService service.JWTService
|
||||||
callService service.CallService
|
callService service.CallService
|
||||||
userRepo repository.UserRepository
|
userService service.UserService
|
||||||
clientSeq uint64
|
clientSeq uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hub 获取底层 Hub(用于 Register/Unregister/AckMessage 等本地操作)
|
||||||
|
func (h *WSHandler) hub() *ws.Hub {
|
||||||
|
switch p := h.publisher.(type) {
|
||||||
|
case *ws.Bus:
|
||||||
|
return p.Hub()
|
||||||
|
case *ws.Hub:
|
||||||
|
return p
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewWSHandler 创建WebSocket处理器
|
// NewWSHandler 创建WebSocket处理器
|
||||||
func NewWSHandler(
|
func NewWSHandler(
|
||||||
wsHub *ws.Hub,
|
publisher ws.MessagePublisher,
|
||||||
chatService service.ChatService,
|
chatService service.ChatService,
|
||||||
groupService service.GroupService,
|
groupService service.GroupService,
|
||||||
jwtService *service.JWTService,
|
jwtService service.JWTService,
|
||||||
callService service.CallService,
|
callService service.CallService,
|
||||||
userRepo repository.UserRepository,
|
userService service.UserService,
|
||||||
) *WSHandler {
|
) *WSHandler {
|
||||||
return &WSHandler{
|
return &WSHandler{
|
||||||
wsHub: wsHub,
|
publisher: publisher,
|
||||||
chatService: chatService,
|
chatService: chatService,
|
||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
callService: callService,
|
callService: callService,
|
||||||
userRepo: userRepo,
|
userService: userService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +125,8 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := h.jwtService.ParseToken(token)
|
// 仅接受 access token,防止 refresh token 通过 WebSocket 入口绕过限制。
|
||||||
|
claims, err := h.jwtService.ParseAccessToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Unauthorized(c, "invalid token")
|
response.Unauthorized(c, "invalid token")
|
||||||
return
|
return
|
||||||
@@ -139,39 +150,47 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
|
|
||||||
// 3. 创建客户端
|
// 3. 创建客户端
|
||||||
clientID := atomic.AddUint64(&h.clientSeq, 1)
|
clientID := atomic.AddUint64(&h.clientSeq, 1)
|
||||||
|
compressEnabled := c.Query("compress") == "1"
|
||||||
client := &ws.Client{
|
client := &ws.Client{
|
||||||
ID: clientID,
|
ID: clientID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Send: make(chan []byte, defaultUserBufferSize),
|
Send: make(chan []byte, defaultUserBufferSize),
|
||||||
Quit: make(chan struct{}),
|
Quit: make(chan struct{}),
|
||||||
|
PendingAcks: make(map[string]time.Time),
|
||||||
|
CompressEnabled: compressEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册客户端
|
// 4. 注册客户端
|
||||||
replayEvents := h.wsHub.Register(client)
|
regErr := h.hub().Register(client)
|
||||||
defer h.wsHub.Unregister(client)
|
if regErr != nil {
|
||||||
|
zap.L().Warn("WebSocket registration rejected",
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.Error(regErr),
|
||||||
|
)
|
||||||
|
conn.WriteMessage(websocket.CloseMessage,
|
||||||
|
[]byte(`{"type":"error","payload":{"code":"connection_limit","message":"too many connections"}}`))
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer h.hub().Unregister(client)
|
||||||
|
|
||||||
zap.L().Info("WebSocket client connected",
|
zap.L().Info("WebSocket client connected",
|
||||||
zap.String("user_id", userID),
|
zap.String("user_id", userID),
|
||||||
zap.Uint64("client_id", clientID),
|
zap.Uint64("client_id", clientID),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. 发送历史回放消息
|
// 5. 提示客户端进行 seq 同步
|
||||||
go func() {
|
syncMsg := ws.ResponseMessage{
|
||||||
for _, ev := range replayEvents {
|
EventID: h.publisher.NextID(),
|
||||||
msg := ws.ResponseMessage{
|
Type: "sync_required",
|
||||||
EventID: ev.ID,
|
TS: time.Now().UnixMilli(),
|
||||||
Type: ev.Type,
|
|
||||||
TS: ev.TS,
|
|
||||||
Payload: ev.Payload,
|
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(msg)
|
if syncData, err := json.Marshal(syncMsg); err == nil {
|
||||||
select {
|
select {
|
||||||
case <-client.Quit:
|
case <-client.Quit:
|
||||||
return
|
case client.Send <- syncData:
|
||||||
case client.Send <- data:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
// 6. 启动读写goroutine
|
// 6. 启动读写goroutine
|
||||||
go h.writePump(conn, client)
|
go h.writePump(conn, client)
|
||||||
@@ -193,7 +212,7 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
conn.SetReadLimit(256 * 1024) // 256KB (SDP/ICE candidates can be large)
|
conn.SetReadLimit(256 * 1024) // 256KB
|
||||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
conn.SetPongHandler(func(string) error {
|
conn.SetPongHandler(func(string) error {
|
||||||
zap.L().Debug("WebSocket pong received",
|
zap.L().Debug("WebSocket pong received",
|
||||||
@@ -225,10 +244,22 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析消息
|
// 解析消息(支持 gzip 解压)
|
||||||
|
var rawData []byte
|
||||||
|
if client.CompressEnabled && ws.IsCompressed(message) {
|
||||||
|
decompressed, dErr := ws.DefaultCompressor.Decompress(message)
|
||||||
|
if dErr != nil {
|
||||||
|
h.publisher.SendError(client, "decompress_error", "failed to decompress gzip message")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawData = decompressed
|
||||||
|
} else {
|
||||||
|
rawData = message
|
||||||
|
}
|
||||||
|
|
||||||
var msg ws.Message
|
var msg ws.Message
|
||||||
if err := json.Unmarshal(message, &msg); err != nil {
|
if err := json.Unmarshal(rawData, &msg); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid message format")
|
h.publisher.SendError(client, "parse_error", "invalid message format")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,13 +293,46 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if client.CompressEnabled {
|
||||||
|
buf := make([]byte, 0, len(message)+64)
|
||||||
|
buf = append(buf, message...)
|
||||||
|
n := len(client.Send)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
buf = append(buf, '\n')
|
||||||
|
buf = append(buf, <-client.Send...)
|
||||||
|
}
|
||||||
|
compressed, cErr := ws.DefaultCompressor.Compress(buf)
|
||||||
|
if cErr != nil {
|
||||||
|
zap.L().Warn("WebSocket compress error, sending raw",
|
||||||
|
zap.String("user_id", client.UserID),
|
||||||
|
zap.Error(cErr),
|
||||||
|
)
|
||||||
w, err := conn.NextWriter(websocket.TextMessage)
|
w, err := conn.NextWriter(websocket.TextMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.Write(buf)
|
||||||
|
w.Close()
|
||||||
|
} else {
|
||||||
|
w, err := conn.NextWriter(websocket.BinaryMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(compressed)
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w, err := conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Warn("WebSocket write error (NextWriter)",
|
||||||
|
zap.String("user_id", client.UserID),
|
||||||
|
zap.Uint64("client_id", client.ID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Write(message)
|
w.Write(message)
|
||||||
|
|
||||||
// 批量发送缓冲区中的消息
|
|
||||||
n := len(client.Send)
|
n := len(client.Send)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
w.Write([]byte{'\n'})
|
w.Write([]byte{'\n'})
|
||||||
@@ -276,8 +340,14 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := w.Close(); err != nil {
|
if err := w.Close(); err != nil {
|
||||||
|
zap.L().Warn("WebSocket write error (Close)",
|
||||||
|
zap.String("user_id", client.UserID),
|
||||||
|
zap.Uint64("client_id", client.ID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
@@ -302,32 +372,36 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
|
|||||||
h.handleRead(ctx, client, msg.Payload)
|
h.handleRead(ctx, client, msg.Payload)
|
||||||
case "recall":
|
case "recall":
|
||||||
h.handleRecall(ctx, client, msg.Payload)
|
h.handleRecall(ctx, client, msg.Payload)
|
||||||
|
case "ack":
|
||||||
|
h.handleAck(client, msg.Payload)
|
||||||
// 通话信令
|
// 通话信令
|
||||||
case "call_invite":
|
case "call_invite":
|
||||||
h.handleCallInvite(ctx, client, msg.Payload)
|
h.handleCallInvite(ctx, client, msg.Payload)
|
||||||
|
case "call_group_invite":
|
||||||
|
h.handleCallGroupInvite(ctx, client, msg.Payload)
|
||||||
case "call_answer":
|
case "call_answer":
|
||||||
h.handleCallAnswer(ctx, client, msg.Payload)
|
h.handleCallAnswer(ctx, client, msg.Payload)
|
||||||
case "call_reject":
|
case "call_reject":
|
||||||
h.handleCallReject(ctx, client, msg.Payload)
|
h.handleCallReject(ctx, client, msg.Payload)
|
||||||
case "call_busy":
|
case "call_busy":
|
||||||
h.handleCallBusy(ctx, client, msg.Payload)
|
h.handleCallBusy(ctx, client, msg.Payload)
|
||||||
case "call_sdp":
|
case "call_ready":
|
||||||
h.handleCallSDP(ctx, client, msg.Payload)
|
h.handleCallReady(ctx, client, msg.Payload)
|
||||||
case "call_ice":
|
|
||||||
h.handleCallICE(ctx, client, msg.Payload)
|
|
||||||
case "call_end":
|
case "call_end":
|
||||||
h.handleCallEnd(ctx, client, msg.Payload)
|
h.handleCallEnd(ctx, client, msg.Payload)
|
||||||
case "call_mute":
|
case "call_mute":
|
||||||
h.handleCallMute(ctx, client, msg.Payload)
|
h.handleCallMute(ctx, client, msg.Payload)
|
||||||
|
case "call_participant_join":
|
||||||
|
h.handleCallParticipantJoin(ctx, client, msg.Payload)
|
||||||
default:
|
default:
|
||||||
h.wsHub.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
h.publisher.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePing 处理ping消息
|
// handlePing 处理ping消息
|
||||||
func (h *WSHandler) handlePing(client *ws.Client) {
|
func (h *WSHandler) handlePing(client *ws.Client) {
|
||||||
pong := ws.ResponseMessage{
|
pong := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "pong",
|
Type: "pong",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
@@ -349,27 +423,28 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
|
|||||||
DetailType string `json:"detail_type"`
|
DetailType string `json:"detail_type"`
|
||||||
Segments model.MessageSegments `json:"segments"`
|
Segments model.MessageSegments `json:"segments"`
|
||||||
ReplyToID *string `json:"reply_to_id,omitempty"`
|
ReplyToID *string `json:"reply_to_id,omitempty"`
|
||||||
|
ClientMsgID string `json:"client_msg_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid chat message format")
|
h.publisher.SendError(client, "parse_error", "invalid chat message format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ConversationID == "" {
|
if req.ConversationID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "conversation_id is required")
|
h.publisher.SendError(client, "invalid_params", "conversation_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Segments) == 0 {
|
if len(req.Segments) == 0 {
|
||||||
h.wsHub.SendError(client, "invalid_params", "segments is required")
|
h.publisher.SendError(client, "invalid_params", "segments is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID)
|
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "send_failed", err.Error())
|
h.publisher.SendError(client, "send_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,13 +460,13 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
|
|||||||
Type: "message",
|
Type: "message",
|
||||||
DetailType: detailType,
|
DetailType: detailType,
|
||||||
ConversationID: req.ConversationID,
|
ConversationID: req.ConversationID,
|
||||||
Seq: strconv.FormatInt(message.Seq, 10),
|
Seq: message.Seq,
|
||||||
Segments: req.Segments,
|
Segments: req.Segments,
|
||||||
SenderID: client.UserID,
|
SenderID: client.UserID,
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := ws.ResponseMessage{
|
msg := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "message_sent",
|
Type: "message_sent",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: resp,
|
Payload: resp,
|
||||||
@@ -459,24 +534,24 @@ func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid recall message format")
|
h.publisher.SendError(client, "parse_error", "invalid recall message format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.MessageID == "" {
|
if req.MessageID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "message_id is required")
|
h.publisher.SendError(client, "invalid_params", "message_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
|
err := h.chatService.RecallMessage(ctx, req.MessageID, client.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "recall_failed", err.Error())
|
h.publisher.SendError(client, "recall_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送撤回成功响应
|
// 发送撤回成功响应
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "message_recalled",
|
Type: "message_recalled",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]string{"message_id": req.MessageID},
|
Payload: map[string]string{"message_id": req.MessageID},
|
||||||
@@ -490,15 +565,26 @@ func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload
|
|||||||
|
|
||||||
const defaultUserBufferSize = 128
|
const defaultUserBufferSize = 128
|
||||||
|
|
||||||
|
// handleAck 处理客户端ACK确认
|
||||||
|
func (h *WSHandler) handleAck(client *ws.Client, payload json.RawMessage) {
|
||||||
|
var req struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &req); err != nil || req.MessageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.hub().AckMessage(client.UserID, req.MessageID)
|
||||||
|
}
|
||||||
|
|
||||||
// isVerified 检查用户是否已通过身份认证
|
// isVerified 检查用户是否已通过身份认证
|
||||||
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
||||||
user, err := h.userRepo.GetByID(client.UserID)
|
user, err := h.userService.GetUserByID(ctx, client.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "internal_error", "获取用户信息失败")
|
h.publisher.SendError(client, "internal_error", "获取用户信息失败")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if user.VerificationStatus != model.VerificationStatusApproved {
|
if user.VerificationStatus != model.VerificationStatusApproved {
|
||||||
h.wsHub.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
|
h.publisher.SendError(client, "VERIFICATION_REQUIRED", "请先完成身份认证")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -518,11 +604,11 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
|||||||
MediaType string `json:"call_type"` // voice 或 video
|
MediaType string `json:"call_type"` // voice 或 video
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
h.wsHub.SendError(client, "parse_error", "invalid call_invite format")
|
h.publisher.SendError(client, "parse_error", "invalid call_invite format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.CalleeID == "" || req.ConversationID == "" {
|
if req.CalleeID == "" || req.ConversationID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "callee_id and conversation_id are required")
|
h.publisher.SendError(client, "invalid_params", "callee_id and conversation_id are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,16 +626,16 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
|||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
if errors.Is(err, apperrors.ErrCallInProgress) {
|
if errors.Is(err, apperrors.ErrCallInProgress) {
|
||||||
h.wsHub.SendError(client, "call_in_progress", err.Error())
|
h.publisher.SendError(client, "call_in_progress", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.wsHub.SendError(client, "call_invite_failed", err.Error())
|
h.publisher.SendError(client, "call_invite_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回给呼叫方的响应
|
// 返回给呼叫方的响应
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "call_invited",
|
Type: "call_invited",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
@@ -566,13 +652,68 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCallGroupInvite 处理群组通话邀请
|
||||||
|
func (h *WSHandler) handleCallGroupInvite(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
|
if !h.isVerified(ctx, client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
MediaType string `json:"call_type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
|
h.publisher.SendError(client, "parse_error", "invalid call_group_invite format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.GroupID == "" || req.ConversationID == "" {
|
||||||
|
h.publisher.SendError(client, "invalid_params", "group_id and conversation_id are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType := req.MediaType
|
||||||
|
if mediaType != "video" {
|
||||||
|
mediaType = "voice"
|
||||||
|
}
|
||||||
|
|
||||||
|
call, err := h.callService.GroupInvite(ctx, client.UserID, req.GroupID, req.ConversationID, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Warn("Failed to invite group call",
|
||||||
|
zap.String("caller_id", client.UserID),
|
||||||
|
zap.String("group_id", req.GroupID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
h.publisher.SendError(client, "call_group_invite_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ws.ResponseMessage{
|
||||||
|
EventID: h.publisher.NextID(),
|
||||||
|
Type: "call_invited",
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
Payload: map[string]any{
|
||||||
|
"call_id": call.ID,
|
||||||
|
"conversation_id": req.ConversationID,
|
||||||
|
"group_id": req.GroupID,
|
||||||
|
"call_type": "group",
|
||||||
|
"lifetime": service.CallLifetimeMs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
select {
|
||||||
|
case <-client.Quit:
|
||||||
|
case client.Send <- data:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleCallAnswer 处理接听
|
// handleCallAnswer 处理接听
|
||||||
func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
var req struct {
|
var req struct {
|
||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,15 +721,15 @@ func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, pay
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// === 区分已接听错误 ===
|
// === 区分已接听错误 ===
|
||||||
if errors.Is(err, apperrors.ErrCallAlreadyAnswered) {
|
if errors.Is(err, apperrors.ErrCallAlreadyAnswered) {
|
||||||
h.wsHub.SendError(client, "call_already_answered", "通话已被其他设备接听")
|
h.publisher.SendError(client, "call_already_answered", "通话已被其他设备接听")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.wsHub.SendError(client, "call_accept_failed", err.Error())
|
h.publisher.SendError(client, "call_accept_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "call_accepted",
|
Type: "call_accepted",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
@@ -609,12 +750,12 @@ func (h *WSHandler) handleCallReject(ctx context.Context, client *ws.Client, pay
|
|||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil {
|
if err := h.callService.Reject(ctx, req.CallID, client.UserID); err != nil {
|
||||||
h.wsHub.SendError(client, "call_reject_failed", err.Error())
|
h.publisher.SendError(client, "call_reject_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,52 +765,27 @@ func (h *WSHandler) handleCallBusy(ctx context.Context, client *ws.Client, paylo
|
|||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil {
|
if err := h.callService.Busy(ctx, req.CallID, client.UserID); err != nil {
|
||||||
h.wsHub.SendError(client, "call_busy_failed", err.Error())
|
h.publisher.SendError(client, "call_busy_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCallSDP 转发 SDP
|
// handleCallReady 处理通话就绪(LiveKit 房间连接成功)
|
||||||
func (h *WSHandler) handleCallSDP(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
func (h *WSHandler) handleCallReady(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
var req struct {
|
var req struct {
|
||||||
CallID string `json:"call_id"`
|
CallID string `json:"call_id"`
|
||||||
SDPType string `json:"sdp_type"`
|
|
||||||
SDP json.RawMessage `json:"sdp"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" || req.SDPType == "" {
|
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id and sdp_type are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
signalPayload, _ := json.Marshal(map[string]any{
|
|
||||||
"sdp_type": req.SDPType,
|
|
||||||
"sdp": json.RawMessage(req.SDP),
|
|
||||||
})
|
|
||||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_sdp", signalPayload); err != nil {
|
|
||||||
h.wsHub.SendError(client, "call_sdp_failed", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCallICE 转发 ICE candidate
|
|
||||||
func (h *WSHandler) handleCallICE(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
|
||||||
var req struct {
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
Candidate json.RawMessage `json:"candidate"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signalPayload, _ := json.Marshal(map[string]any{
|
if err := h.callService.Ready(ctx, req.CallID, client.UserID); err != nil {
|
||||||
"candidate": json.RawMessage(req.Candidate),
|
h.publisher.SendError(client, "call_ready_failed", err.Error())
|
||||||
})
|
|
||||||
if err := h.callService.RelaySignal(ctx, req.CallID, client.UserID, "call_ice", signalPayload); err != nil {
|
|
||||||
h.wsHub.SendError(client, "call_ice_failed", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,18 +796,18 @@ func (h *WSHandler) handleCallEnd(ctx context.Context, client *ws.Client, payloa
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
call, err := h.callService.End(ctx, req.CallID, client.UserID, req.Reason)
|
call, err := h.callService.End(ctx, req.CallID, client.UserID, req.Reason)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.wsHub.SendError(client, "call_end_failed", err.Error())
|
h.publisher.SendError(client, "call_end_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := ws.ResponseMessage{
|
resp := ws.ResponseMessage{
|
||||||
EventID: h.wsHub.NextID(),
|
EventID: h.publisher.NextID(),
|
||||||
Type: "call_ended",
|
Type: "call_ended",
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
Payload: map[string]any{
|
Payload: map[string]any{
|
||||||
@@ -713,11 +829,26 @@ func (h *WSHandler) handleCallMute(ctx context.Context, client *ws.Client, paylo
|
|||||||
Muted bool `json:"muted"`
|
Muted bool `json:"muted"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
h.wsHub.SendError(client, "invalid_params", "call_id is required")
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.callService.SetMuted(ctx, req.CallID, client.UserID, req.Muted); err != nil {
|
if err := h.callService.SetMuted(ctx, req.CallID, client.UserID, req.Muted); err != nil {
|
||||||
h.wsHub.SendError(client, "call_mute_failed", err.Error())
|
h.publisher.SendError(client, "call_mute_failed", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallParticipantJoin 处理群组通话参与者加入
|
||||||
|
func (h *WSHandler) handleCallParticipantJoin(ctx context.Context, client *ws.Client, payload json.RawMessage) {
|
||||||
|
var req struct {
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
|
||||||
|
h.publisher.SendError(client, "invalid_params", "call_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.callService.ParticipantJoin(ctx, req.CallID, client.UserID); err != nil {
|
||||||
|
h.publisher.SendError(client, "call_participant_join_failed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
internal/middleware/active.go
Normal file
35
internal/middleware/active.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"with_you/internal/pkg/auth"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireActive 要求账户状态为 active。
|
||||||
|
//
|
||||||
|
// 业务策略:
|
||||||
|
// - active:放行。
|
||||||
|
// - pending_deletion:在 /users/me/* 路由允许(用户可取消注销),但在 /admin/* 与敏感操作拒绝。
|
||||||
|
// - banned/inactive:已由 RequireAuth 在认证管道拦截,不会到达此处。
|
||||||
|
//
|
||||||
|
// 应挂在需要严格 active 状态的路由组上(如 /admin)。
|
||||||
|
func RequireActive() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
p, ok := auth.GetPrincipal(c)
|
||||||
|
if !ok || p == nil {
|
||||||
|
response.ErrorWithStringCode(c, http.StatusUnauthorized, "UNAUTHORIZED", "未授权")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !p.IsActive() {
|
||||||
|
response.ErrorWithStringCode(c, http.StatusForbidden, "ACCOUNT_INACTIVE", "账号未激活")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,76 +1,6 @@
|
|||||||
package middleware
|
// Package middleware 提供路由层中间件。
|
||||||
|
//
|
||||||
import (
|
// 注意:旧的 Auth/OptionalAuth 函数已被 auth_pipeline.go 中的
|
||||||
"strings"
|
// RequireAuth / OptionalAuth 替代,新版本内置令牌类型校验、账户状态校验与会话校验。
|
||||||
|
// 路由层应使用 RequireAuth(jwt, idp, cache) 与 OptionalAuth(jwt, idp, cache)。
|
||||||
"github.com/gin-gonic/gin"
|
package middleware
|
||||||
|
|
||||||
"with_you/internal/pkg/response"
|
|
||||||
"with_you/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auth 认证中间件
|
|
||||||
func Auth(jwtService *service.JWTService) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
|
|
||||||
if authHeader == "" {
|
|
||||||
response.Unauthorized(c, "authorization header is required")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取Token
|
|
||||||
prefix, token, found := strings.Cut(authHeader, " ")
|
|
||||||
if !found || prefix != "Bearer" {
|
|
||||||
response.Unauthorized(c, "invalid authorization header format")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证Token
|
|
||||||
claims, err := jwtService.ParseToken(token)
|
|
||||||
if err != nil {
|
|
||||||
response.Unauthorized(c, "invalid token")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将用户信息存入上下文
|
|
||||||
c.Set("user_id", claims.UserID)
|
|
||||||
c.Set("username", claims.Username)
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OptionalAuth 可选认证中间件
|
|
||||||
func OptionalAuth(jwtService *service.JWTService) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取Token
|
|
||||||
prefix, token, found := strings.Cut(authHeader, " ")
|
|
||||||
if !found || prefix != "Bearer" {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证Token
|
|
||||||
claims, err := jwtService.ParseToken(token)
|
|
||||||
if err != nil {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将用户信息存入上下文
|
|
||||||
c.Set("user_id", claims.UserID)
|
|
||||||
c.Set("username", claims.Username)
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
287
internal/middleware/auth_pipeline.go
Normal file
287
internal/middleware/auth_pipeline.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"with_you/internal/cache"
|
||||||
|
apperrors "with_you/internal/errors"
|
||||||
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/auth"
|
||||||
|
"with_you/internal/pkg/jwt"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 认证管道相关常量。
|
||||||
|
const (
|
||||||
|
// principalCacheTTL Principal 缓存 TTL:账户状态/角色变更后最多延迟生效时间。
|
||||||
|
// 30s 内可容忍多一次 DB 查询,平衡安全即时性与登录热点流量。
|
||||||
|
principalCacheTTL = 30 * time.Second
|
||||||
|
|
||||||
|
// sessionCacheTTL 会话撤销状态缓存 TTL:登出/封禁后旧 token 在此时长内可能仍被放行。
|
||||||
|
// 由于 session 表由服务端控制,30s 内的撤销延迟可接受;后续可调整为 0(不缓存)。
|
||||||
|
sessionCacheTTL = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// IdentityProvider 认证管道依赖的身份与角色查询接口。
|
||||||
|
//
|
||||||
|
// 由 service 层实现并注入,避免中间件直接依赖具体 service;
|
||||||
|
// 同时便于测试以 fake 注入。
|
||||||
|
type IdentityProvider interface {
|
||||||
|
// GetUserByID 加载用户实体(含状态、verification_status)。
|
||||||
|
GetUserByID(ctx context.Context, userID string) (*model.User, error)
|
||||||
|
// GetRolesForUser 返回用户的角色集合。
|
||||||
|
GetRolesForUser(ctx context.Context, userID string) ([]string, error)
|
||||||
|
// GetSessionByID 返回会话实体(用于校验是否撤销/过期)。
|
||||||
|
// 当 sessionID 为空(旧 access token 兼容窗口)应返回 nil,nil。
|
||||||
|
GetSessionByID(ctx context.Context, sessionID string) (*model.Session, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// principalCacheValue 缓存 Principal 的载体,便于区分 hit/miss 与缓存空角色。
|
||||||
|
type principalCacheValue struct {
|
||||||
|
Principal *auth.Principal
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth 强制认证中间件。
|
||||||
|
//
|
||||||
|
// 流程:
|
||||||
|
// 1. 提取 Bearer token,缺失 → 401。
|
||||||
|
// 2. ParseAccessToken(拒绝 refresh token)。
|
||||||
|
// 3. 从 cache/DB 加载用户与角色,组装 Principal。
|
||||||
|
// 4. 校验账户状态:active 允许;pending_deletion 允许访问(业务策略:允许取消注销);
|
||||||
|
// banned/inactive/其他 → 403。
|
||||||
|
// 5. 校验 sessionID(sid 非空时):撤销/过期 → 401。
|
||||||
|
// 6. Principal 写入 context。
|
||||||
|
func RequireAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
claims, ok := extractAccessToken(c, jwtService)
|
||||||
|
if !ok {
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims)
|
||||||
|
if err != nil {
|
||||||
|
// 用户不存在或加载失败:以 401 处理(令牌虽有效但身份已失效)。
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkAccountStatus(principal); err != nil {
|
||||||
|
authDeny(c, http.StatusForbidden, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.WithContext(c, principal)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalAuth 可选认证中间件。
|
||||||
|
//
|
||||||
|
// 行为:
|
||||||
|
// - 缺失 Authorization 头 → 当游客,Principal 不写入 context,c.Next()。
|
||||||
|
// - 携带了 token 但解析/状态/会话失败 → 401(语义更清晰,避免静默降级到游客)。
|
||||||
|
func OptionalAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式明显错误(非 Bearer)→ 当游客,保持现有游客可见接口语义。
|
||||||
|
prefix, token, found := strings.Cut(authHeader, " ")
|
||||||
|
if !found || prefix != "Bearer" || token == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := jwtService.ParseAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims)
|
||||||
|
if err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkAccountStatus(principal); err != nil {
|
||||||
|
authDeny(c, http.StatusForbidden, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil {
|
||||||
|
authDeny(c, http.StatusUnauthorized, err, principal.UserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.WithContext(c, principal)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAccessToken 从请求中提取并解析 access token。
|
||||||
|
//
|
||||||
|
// 第二返回值 ok=false 表示应当拒绝(已写响应);true 表示已拿到 claims。
|
||||||
|
func extractAccessToken(c *gin.Context, jwtService service.JWTService) (*jwt.Claims, bool) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
prefix, token, found := strings.Cut(authHeader, " ")
|
||||||
|
if !found || prefix != "Bearer" || token == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := jwtService.ParseAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return claims, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPrincipal 从缓存或 IDP 加载用户/角色,组装 Principal。
|
||||||
|
func buildPrincipal(ctx context.Context, idp IdentityProvider, cch cache.Cache, claims *jwt.Claims) (*auth.Principal, error) {
|
||||||
|
cacheKey := ""
|
||||||
|
if cch != nil {
|
||||||
|
cacheKey = auth.PrincipalCacheKey(claims.UserID)
|
||||||
|
if cached, ok := cch.Get(cacheKey); ok {
|
||||||
|
if v, ok := cached.(*principalCacheValue); ok && v != nil {
|
||||||
|
return v.Principal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := idp.GetUserByID(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, apperrors.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := idp.GetRolesForUser(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &auth.Principal{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
SessionID: claims.SessionID,
|
||||||
|
Status: user.Status,
|
||||||
|
VerificationStatus: user.VerificationStatus,
|
||||||
|
Roles: roles,
|
||||||
|
TokenID: claims.ID,
|
||||||
|
IsLegacyToken: claims.TokenType == "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if cacheKey != "" {
|
||||||
|
cch.Set(cacheKey, &principalCacheValue{Principal: p}, principalCacheTTL)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAccountStatus 校验账户状态。
|
||||||
|
//
|
||||||
|
// 策略:active 允许;pending_deletion 允许(用户可访问取消注销接口);
|
||||||
|
// banned/inactive/其他拒绝。pending_deletion 在管理后台路由由 RequireActive 单独再加一道。
|
||||||
|
func checkAccountStatus(p *auth.Principal) error {
|
||||||
|
switch p.Status {
|
||||||
|
case model.UserStatusActive, model.UserStatusPendingDeletion:
|
||||||
|
return nil
|
||||||
|
case model.UserStatusBanned:
|
||||||
|
return apperrors.ErrUserBanned
|
||||||
|
case model.UserStatusInactive:
|
||||||
|
return apperrors.ErrAccountInactive
|
||||||
|
default:
|
||||||
|
return apperrors.ErrAccountInactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSession 校验会话有效性。
|
||||||
|
//
|
||||||
|
// 旧 access token(无 sid)在兼容窗口内跳过会话校验,由 access TTL 自然过期收口。
|
||||||
|
func validateSession(ctx context.Context, idp IdentityProvider, cch cache.Cache, p *auth.Principal) error {
|
||||||
|
if p.SessionID == "" {
|
||||||
|
// 旧 token 兼容窗口。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cch != nil {
|
||||||
|
key := auth.SessionCacheKey(p.SessionID)
|
||||||
|
if cached, ok := cch.Get(key); ok {
|
||||||
|
if valid, ok := cached.(bool); ok && !valid {
|
||||||
|
return apperrors.ErrSessionRevoked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := idp.GetSessionByID(ctx, p.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return apperrors.ErrSessionRevoked
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if session == nil || !session.IsValid(time.Now()) {
|
||||||
|
if cch != nil {
|
||||||
|
cch.Set(auth.SessionCacheKey(p.SessionID), false, sessionCacheTTL)
|
||||||
|
}
|
||||||
|
return apperrors.ErrSessionRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
if cch != nil {
|
||||||
|
cch.Set(auth.SessionCacheKey(p.SessionID), true, sessionCacheTTL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidatePrincipalCache 与 InvalidateSessionCache 已迁移至 internal/pkg/auth 包,
|
||||||
|
// 以避免 service → middleware 的包循环(service 层修改用户状态后需要失效缓存,
|
||||||
|
// 但 middleware 又依赖 service 接口)。
|
||||||
|
//
|
||||||
|
// 保留本文件中的 InvalidatePrincipalCache/InvalidateSessionCache 仅作为转发会引入
|
||||||
|
// 循环,故直接删除;调用方请使用 auth.InvalidatePrincipalCache / auth.InvalidateSessionCache。
|
||||||
|
|
||||||
|
// authDeny 拒绝访问的统一响应:记日志 + 返回结构化错误。
|
||||||
|
func authDeny(c *gin.Context, statusCode int, appErr error, userID string) {
|
||||||
|
zap.L().Warn("auth denied",
|
||||||
|
zap.Int("status", statusCode),
|
||||||
|
zap.String("user_id", userID),
|
||||||
|
zap.String("ip", c.ClientIP()),
|
||||||
|
zap.String("user_agent", c.GetHeader("User-Agent")),
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.Any("err", appErr),
|
||||||
|
)
|
||||||
|
|
||||||
|
code := apperrors.ErrInvalidToken.Code
|
||||||
|
msg := apperrors.ErrInvalidToken.Message
|
||||||
|
var target *apperrors.AppError
|
||||||
|
if errors.As(appErr, &target) {
|
||||||
|
code = target.Code
|
||||||
|
msg = target.Message
|
||||||
|
}
|
||||||
|
response.ErrorWithStringCode(c, statusCode, code, msg)
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
225
internal/middleware/auth_pipeline_test.go
Normal file
225
internal/middleware/auth_pipeline_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/jwt"
|
||||||
|
"with_you/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeIDP 实现 IdentityProvider 接口,用于测试。
|
||||||
|
type fakeIDP struct {
|
||||||
|
user *model.User
|
||||||
|
userErr error
|
||||||
|
roles []string
|
||||||
|
roleErr error
|
||||||
|
session *model.Session
|
||||||
|
sessErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIDP) GetUserByID(ctx context.Context, id string) (*model.User, error) {
|
||||||
|
if f.userErr != nil {
|
||||||
|
return nil, f.userErr
|
||||||
|
}
|
||||||
|
return f.user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIDP) GetRolesForUser(ctx context.Context, id string) ([]string, error) {
|
||||||
|
if f.roleErr != nil {
|
||||||
|
return nil, f.roleErr
|
||||||
|
}
|
||||||
|
if f.roles == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return f.roles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIDP) GetSessionByID(ctx context.Context, id string) (*model.Session, error) {
|
||||||
|
if f.sessErr != nil {
|
||||||
|
return nil, f.sessErr
|
||||||
|
}
|
||||||
|
return f.session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestJWT 构造测试用 JWT 服务。
|
||||||
|
func newTestJWT() service.JWTService {
|
||||||
|
return service.NewJWTService("test-secret", 3600, 86400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRequireAuth 挂载 RequireAuth 并发起请求,返回 (status, principal)。
|
||||||
|
//
|
||||||
|
// Authorization 头直接设在 req 上;旧实现里 r.Use 内重复 Set 对 NewRecorder 无效,已移除。
|
||||||
|
func runRequireAuth(t *testing.T, jwtSvc service.JWTService, idp IdentityProvider, token string) (int, *testPrincipal) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
nextCalled := false
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||||
|
nextCalled = true
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w.Code, &testPrincipal{called: nextCalled}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testPrincipal struct {
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_MissingHeader 缺失 Authorization → 401。
|
||||||
|
func TestRequireAuth_MissingHeader(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, "")
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_RefreshTokenRejected refresh token 走 RequireAuth → 401。
|
||||||
|
func TestRequireAuth_RefreshTokenRejected(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
// 用 SessionService.Issue 签发带 sid 的 refresh token 比较繁琐,这里直接用 JWT 层签发。
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
refreshTok, _ := jwtLib.GenerateRefreshToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, refreshTok)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("refresh token should be rejected, status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_BannedUser banned 用户 → 403。
|
||||||
|
func TestRequireAuth_BannedUser(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusBanned},
|
||||||
|
roles: []string{},
|
||||||
|
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
|
||||||
|
}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Errorf("banned user should be 403, status = %d, want %d", code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_RevokedSession 会话已撤销 → 401。
|
||||||
|
func TestRequireAuth_RevokedSession(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
revokedAt := time.Now().Add(-time.Minute)
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||||
|
roles: []string{},
|
||||||
|
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour), RevokedAt: &revokedAt},
|
||||||
|
}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("revoked session should be 401, status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_UserNotFound 用户不存在 → 401。
|
||||||
|
func TestRequireAuth_UserNotFound(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
userErr: gorm.ErrRecordNotFound,
|
||||||
|
}
|
||||||
|
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("user not found should be 401, status = %d, want %d", code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_ValidAccess 正常 access token → 200 且 next 被调用。
|
||||||
|
func TestRequireAuth_ValidAccess(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||||
|
roles: []string{model.RoleUser},
|
||||||
|
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
|
||||||
|
}
|
||||||
|
// 这里需要校验 next 被调用,所以用更完整的测试。
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
nextCalled := false
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||||
|
nextCalled = true
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessTok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("valid access token should be 200, status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !nextCalled {
|
||||||
|
t.Error("next handler should be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAuth_LegacyToken 旧 access token(无 sid)宽容通过。
|
||||||
|
func TestRequireAuth_LegacyToken(t *testing.T) {
|
||||||
|
jwtSvc := newTestJWT()
|
||||||
|
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
|
||||||
|
// 签发不带 sid 的 access token(模拟旧客户端)。
|
||||||
|
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "")
|
||||||
|
|
||||||
|
idp := &fakeIDP{
|
||||||
|
user: &model.User{ID: "u1", Status: model.UserStatusActive},
|
||||||
|
roles: []string{},
|
||||||
|
// 旧 token 无 sid,GetSessionByID 不应被调用。
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
nextCalled := false
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
|
||||||
|
nextCalled = true
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessTok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("legacy access token should be 200 (compat window), status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if !nextCalled {
|
||||||
|
t.Error("next handler should be called for legacy token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeIDP 的 session 路径校验:确保不传 session 时也不影响。
|
||||||
|
var _ = errors.New
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user