From 236518ade4e8f52440a8d9915a95d9e873acc077 Mon Sep 17 00:00:00 2001 From: yml Date: Fri, 22 May 2026 18:50:32 +0800 Subject: [PATCH] feat: add file upload workflow --- README.md | 4 +- backend/.env.example | 4 +- backend/go.mod | 26 ++- backend/go.sum | 39 ++++ backend/internal/config/config.go | 12 +- backend/internal/modules/file/dto.go | 9 + backend/internal/modules/file/handler.go | 86 +++++++++ backend/internal/modules/file/service.go | 76 ++++++++ backend/internal/modules/file/storage.go | 122 ++++++++++++ backend/internal/modules/listing/dto.go | 74 ++++---- .../internal/modules/listing/repository.go | 179 +++++++++++------- backend/internal/router/router.go | 18 ++ docs/api.md | 7 +- docs/business-rules.md | 13 +- frontend/src/api/files.ts | 33 ++++ frontend/src/api/listings.ts | 2 + frontend/src/styles/base.css | 35 ++++ .../src/views/account/OrderDetailView.vue | 22 +++ .../src/views/admin/AdminDisputesView.vue | 52 +++++ .../views/admin/AdminListingDetailView.vue | 31 +++ .../views/admin/AdminListingReviewView.vue | 47 +++++ .../views/seller/SellerListingCreateView.vue | 45 ++++- 22 files changed, 819 insertions(+), 117 deletions(-) create mode 100644 backend/internal/modules/file/dto.go create mode 100644 backend/internal/modules/file/handler.go create mode 100644 backend/internal/modules/file/service.go create mode 100644 backend/internal/modules/file/storage.go create mode 100644 frontend/src/api/files.ts diff --git a/README.md b/README.md index f03965b..b9a3595 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ npm run dev - 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。 - 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。 - 后端已接入开发态订单超时扫描任务,会按系统配置处理交接超时、确认收号超时、逾期未归还和确认归还超时。 +- 文件上传已接入 MinIO,发布账号资产截图和申诉证据可上传 JPG、PNG、WebP 或 PDF。 +- 如果本机有代理工具占用 `127.0.0.1:9000`,MinIO 开发地址使用 `http://localhost:9000`。 - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。 - 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 @@ -45,7 +47,7 @@ npm run dev - 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`,支持查看全量订单和交接记录。 - 商品管理后台已接入,页面为 `http://localhost:5173/admin/listings`,支持查看全量商品、商品详情、强制下架和标记异常。 - 订单详情后台已支持客服关闭和标记异常,相关操作会写入审计日志并通知双方。 -- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。 +- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`,审核员可查看账号资产截图后通过或拒绝。 - 审计日志后台已接入,页面为 `http://localhost:5173/admin/audit-logs`,支持查看高风险操作明细。 ## 文档 diff --git a/backend/.env.example b/backend/.env.example index 105ce80..29877ad 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,5 +7,7 @@ REDIS_PASSWORD= REDIS_DB=0 JWT_SECRET=change-me -STORAGE_ENDPOINT=http://127.0.0.1:9000 +STORAGE_ENDPOINT=http://localhost:9000 STORAGE_BUCKET=hfb-sys +STORAGE_ACCESS_KEY_ID=minioadmin +STORAGE_SECRET_ACCESS_KEY=minioadmin diff --git a/backend/go.mod b/backend/go.mod index c7ed384..e89cf25 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,7 +7,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/redis/go-redis/v9 v9.17.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.42.0 + golang.org/x/crypto v0.46.0 gorm.io/datatypes v1.2.7 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.1 @@ -20,8 +20,10 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect @@ -32,24 +34,34 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index c8c08a6..fcbd0b6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -17,12 +17,16 @@ 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/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/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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -62,8 +66,13 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= 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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -72,12 +81,20 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= +github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= @@ -86,6 +103,8 @@ github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQB github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -95,10 +114,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= @@ -107,23 +130,39 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 95032d3..a579975 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,8 +17,10 @@ type Config struct { } type StorageConfig struct { - Endpoint string - Bucket string + Endpoint string + Bucket string + AccessKeyID string + SecretAccessKey string } func Load() Config { @@ -31,8 +33,10 @@ func Load() Config { RedisDB: getEnvInt("REDIS_DB", 0), JWTSecret: getEnv("JWT_SECRET", "change-me"), Storage: StorageConfig{ - Endpoint: getEnv("STORAGE_ENDPOINT", "http://127.0.0.1:9000"), - Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"), + Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"), + Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"), + AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"), + SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"), }, } } diff --git a/backend/internal/modules/file/dto.go b/backend/internal/modules/file/dto.go new file mode 100644 index 0000000..8a54529 --- /dev/null +++ b/backend/internal/modules/file/dto.go @@ -0,0 +1,9 @@ +package file + +type UploadDTO struct { + ObjectKey string `json:"object_key"` + URL string `json:"url"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` +} diff --git a/backend/internal/modules/file/handler.go b/backend/internal/modules/file/handler.go new file mode 100644 index 0000000..5bcbdb2 --- /dev/null +++ b/backend/internal/modules/file/handler.go @@ -0,0 +1,86 @@ +package file + +import ( + "errors" + "net/http" + "strings" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service + storage *Storage +} + +func NewHandler(service *Service, storage *Storage) *Handler { + return &Handler{service: service, storage: storage} +} + +func (h *Handler) Upload(c *gin.Context) { + header, err := c.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择要上传的文件") + return + } + reader, err := header.Open() + if err != nil { + response.BadRequest(c, "文件读取失败") + return + } + defer func() { + _ = reader.Close() + }() + item, err := h.service.Upload(uploadRequest{ + Context: c.Request.Context(), + Scene: c.PostForm("scene"), + Header: header, + Reader: reader, + ContentType: header.Header.Get("Content-Type"), + }) + if err != nil { + writeFileError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) Object(c *gin.Context) { + if h.storage == nil { + response.ServiceUnavailable(c, "文件存储未连接") + return + } + key := strings.TrimSpace(c.Query("key")) + if key == "" || strings.Contains(key, "..") { + response.BadRequest(c, "文件 key 不正确") + return + } + object, err := h.storage.Get(c.Request.Context(), key) + if err != nil { + response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") + return + } + defer func() { + _ = object.Reader.Close() + }() + contentType := object.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "private, max-age=300") + c.DataFromReader(http.StatusOK, object.Size, contentType, object.Reader, nil) +} + +func writeFileError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "文件存储未连接") + case errors.Is(err, ErrInvalidFile): + response.BadRequest(c, "文件不符合规则,仅支持 10MB 内的 JPG、PNG、WebP 或 PDF") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "文件服务暂时不可用") + } +} diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go new file mode 100644 index 0000000..67f79ae --- /dev/null +++ b/backend/internal/modules/file/service.go @@ -0,0 +1,76 @@ +package file + +import ( + "context" + "errors" + "mime/multipart" + "strings" +) + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidFile = errors.New("invalid file") +) + +const maxUploadSize = 10 * 1024 * 1024 + +var allowedContentTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/webp": true, + "application/pdf": true, +} + +type Service struct { + storage *Storage +} + +func NewService(storage *Storage) *Service { + return &Service{storage: storage} +} + +func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { + if s.storage == nil { + return nil, ErrDependencyUnavailable + } + if req.Header == nil || req.Reader == nil || req.Header.Size <= 0 || req.Header.Size > maxUploadSize { + return nil, ErrInvalidFile + } + contentType := req.ContentType + if contentType == "" { + contentType = req.Header.Header.Get("Content-Type") + } + if !allowedContentTypes[contentType] { + return nil, ErrInvalidFile + } + scene := normalizeScene(req.Scene) + key, err := s.storage.Put(req.Context, scene, req.Header, req.Reader, contentType) + if err != nil { + return nil, err + } + return &UploadDTO{ + ObjectKey: key, + URL: "/api/files/object?key=" + key, + Filename: req.Header.Filename, + ContentType: contentType, + Size: req.Header.Size, + }, nil +} + +func normalizeScene(scene string) string { + scene = strings.TrimSpace(strings.ToLower(scene)) + switch scene { + case "listing", "handoff", "dispute", "realname", "avatar": + return scene + default: + return "misc" + } +} + +type uploadRequest struct { + Context context.Context + Scene string + Header *multipart.FileHeader + Reader multipart.File + ContentType string +} diff --git a/backend/internal/modules/file/storage.go b/backend/internal/modules/file/storage.go new file mode 100644 index 0000000..dbcd89c --- /dev/null +++ b/backend/internal/modules/file/storage.go @@ -0,0 +1,122 @@ +package file + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "net/url" + "path" + "strings" + "time" + + "hfb_sys/backend/internal/config" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type Storage struct { + client *minio.Client + bucket string +} + +type Object struct { + Reader io.ReadCloser + ContentType string + Size int64 +} + +func NewStorage(cfg config.StorageConfig) (*Storage, error) { + endpoint, secure, err := normalizeEndpoint(cfg.Endpoint) + if err != nil { + return nil, err + } + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + Secure: secure, + }) + if err != nil { + return nil, err + } + storage := &Storage{client: client, bucket: cfg.Bucket} + if err := storage.ensureBucket(context.Background()); err != nil { + return nil, err + } + return storage, nil +} + +func (s *Storage) Put(ctx context.Context, scene string, header *multipart.FileHeader, reader io.Reader, contentType string) (string, error) { + key, err := newObjectKey(scene, header.Filename) + if err != nil { + return "", err + } + _, err = s.client.PutObject(ctx, s.bucket, key, reader, header.Size, minio.PutObjectOptions{ + ContentType: contentType, + UserMetadata: map[string]string{ + "original-filename": header.Filename, + }, + }) + if err != nil { + return "", err + } + return key, nil +} + +func (s *Storage) Get(ctx context.Context, key string) (*Object, error) { + object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) + if err != nil { + return nil, err + } + info, err := object.Stat() + if err != nil { + _ = object.Close() + return nil, err + } + return &Object{ + Reader: object, + ContentType: info.ContentType, + Size: info.Size, + }, nil +} + +func (s *Storage) ensureBucket(ctx context.Context) error { + exists, err := s.client.BucketExists(ctx, s.bucket) + if err != nil { + errResp := minio.ToErrorResponse(err) + if errResp.Code != "NoSuchBucket" && !strings.Contains(strings.ToLower(err.Error()), "bucket does not exist") { + return err + } + } + if exists { + return nil + } + return s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}) +} + +func normalizeEndpoint(raw string) (string, bool, error) { + parsed, err := url.Parse(raw) + if err != nil { + return "", false, err + } + if parsed.Scheme == "" { + return raw, false, nil + } + return parsed.Host, parsed.Scheme == "https", nil +} + +func newObjectKey(scene string, filename string) (string, error) { + if scene == "" { + scene = "misc" + } + scene = strings.ToLower(scene) + now := time.Now() + token := make([]byte, 12) + if _, err := rand.Read(token); err != nil { + return "", err + } + ext := strings.ToLower(path.Ext(filename)) + return fmt.Sprintf("%s/%04d/%02d/%02d/%s%s", scene, now.Year(), now.Month(), now.Day(), hex.EncodeToString(token), ext), nil +} diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index f9f5959..8ba2104 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -3,45 +3,47 @@ package listing import "time" type ListingDTO struct { - ID uint64 `json:"id"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - OwnerPhone string `json:"owner_phone,omitempty"` - OwnerNickname string `json:"owner_nickname,omitempty"` - Title string `json:"title"` - Description string `json:"description"` - GameName string `json:"game_name"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - RankLevel string `json:"rank_level"` - HafCoinAmount int64 `json:"haf_coin_amount"` - PriceHourly float64 `json:"price_hourly"` - PriceDaily float64 `json:"price_daily"` - PriceWeekly float64 `json:"price_weekly"` - DepositAmount float64 `json:"deposit_amount"` - MinRentHours int `json:"min_rent_hours"` - MaxRentHours int `json:"max_rent_hours"` - Status string `json:"status"` - ReviewStatus string `json:"review_status"` - ReviewReason string `json:"review_reason"` - PublishedAt *time.Time `json:"published_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone,omitempty"` + OwnerNickname string `json:"owner_nickname,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + GameName string `json:"game_name"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + ScreenshotURLS []string `json:"screenshot_urls"` + PriceHourly float64 `json:"price_hourly"` + PriceDaily float64 `json:"price_daily"` + PriceWeekly float64 `json:"price_weekly"` + DepositAmount float64 `json:"deposit_amount"` + MinRentHours int `json:"min_rent_hours"` + MaxRentHours int `json:"max_rent_hours"` + Status string `json:"status"` + ReviewStatus string `json:"review_status"` + ReviewReason string `json:"review_reason"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { - Title string `json:"title" binding:"required"` - Description string `json:"description"` - ServerRegion string `json:"server_region" binding:"required"` - LoginPlatform string `json:"login_platform" binding:"required"` - RankLevel string `json:"rank_level"` - HafCoinAmount int64 `json:"haf_coin_amount"` - PriceHourly float64 `json:"price_hourly" binding:"required"` - PriceDaily float64 `json:"price_daily"` - PriceWeekly float64 `json:"price_weekly"` - DepositAmount float64 `json:"deposit_amount" binding:"required"` - MinRentHours int `json:"min_rent_hours" binding:"required"` - MaxRentHours int `json:"max_rent_hours" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description"` + ServerRegion string `json:"server_region" binding:"required"` + LoginPlatform string `json:"login_platform" binding:"required"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + ScreenshotURLS []string `json:"screenshot_urls"` + PriceHourly float64 `json:"price_hourly" binding:"required"` + PriceDaily float64 `json:"price_daily"` + PriceWeekly float64 `json:"price_weekly"` + DepositAmount float64 `json:"deposit_amount" binding:"required"` + MinRentHours int `json:"min_rent_hours" binding:"required"` + MaxRentHours int `json:"max_rent_hours" binding:"required"` } type UpdateRequest = CreateRequest diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 930a387..f0c5c63 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -3,6 +3,7 @@ package listing import ( "encoding/json" "errors" + "strings" "time" "hfb_sys/backend/internal/model" @@ -24,16 +25,21 @@ func NewRepository(db *gorm.DB) *Repository { func (r *Repository) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error) { var dto *ListingDTO err := r.db.Transaction(func(tx *gorm.DB) error { + screenshots, err := marshalScreenshots(req.ScreenshotURLS) + if err != nil { + return err + } account := model.GameAccount{ - OwnerID: ownerID, - GameName: "delta_force", - ServerRegion: req.ServerRegion, - LoginPlatform: req.LoginPlatform, - Title: req.Title, - Description: req.Description, - RankLevel: req.RankLevel, - HafCoinAmount: req.HafCoinAmount, - Status: "draft", + OwnerID: ownerID, + GameName: "delta_force", + ServerRegion: req.ServerRegion, + LoginPlatform: req.LoginPlatform, + Title: req.Title, + Description: req.Description, + RankLevel: req.RankLevel, + HafCoinAmount: req.HafCoinAmount, + ScreenshotURLS: screenshots, + Status: "draft", } if err := tx.Create(&account).Error; err != nil { return err @@ -76,6 +82,11 @@ func (r *Repository) Update(ownerID uint64, listingID uint64, req UpdateRequest) account.LoginPlatform = req.LoginPlatform account.RankLevel = req.RankLevel account.HafCoinAmount = req.HafCoinAmount + screenshots, err := marshalScreenshots(req.ScreenshotURLS) + if err != nil { + return err + } + account.ScreenshotURLS = screenshots if err := tx.Save(account).Error; err != nil { return err } @@ -386,7 +397,7 @@ func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) { func (r *Repository) baseQuery() *gorm.DB { return r.db.Table("rental_listings AS l"). Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, - a.haf_coin_amount, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`). + a.haf_coin_amount, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`). Joins("JOIN game_accounts AS a ON a.id = l.account_id"). Joins("LEFT JOIN users AS u ON u.id = l.owner_id") } @@ -405,15 +416,16 @@ func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model. type listingRow struct { model.RentalListing - Title string - OwnerPhone string - OwnerNickname string - Description string - GameName string - ServerRegion string - LoginPlatform string - RankLevel string - HafCoinAmount int64 + Title string + OwnerPhone string + OwnerNickname string + Description string + GameName string + ServerRegion string + LoginPlatform string + RankLevel string + HafCoinAmount int64 + ScreenshotURLS datatypes.JSON } func rowsToDTO(rows []listingRow) []ListingDTO { @@ -426,60 +438,97 @@ func rowsToDTO(rows []listingRow) []ListingDTO { func (row listingRow) toDTO() ListingDTO { return ListingDTO{ - ID: row.ID, - AccountID: row.AccountID, - OwnerID: row.OwnerID, - OwnerPhone: row.OwnerPhone, - OwnerNickname: row.OwnerNickname, - Title: row.Title, - Description: row.Description, - GameName: row.GameName, - ServerRegion: row.ServerRegion, - LoginPlatform: row.LoginPlatform, - RankLevel: row.RankLevel, - HafCoinAmount: row.HafCoinAmount, - PriceHourly: row.PriceHourly, - PriceDaily: row.PriceDaily, - PriceWeekly: row.PriceWeekly, - DepositAmount: row.DepositAmount, - MinRentHours: row.MinRentHours, - MaxRentHours: row.MaxRentHours, - Status: row.Status, - ReviewStatus: row.ReviewStatus, - ReviewReason: row.ReviewReason, - PublishedAt: row.PublishedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + AccountID: row.AccountID, + OwnerID: row.OwnerID, + OwnerPhone: row.OwnerPhone, + OwnerNickname: row.OwnerNickname, + Title: row.Title, + Description: row.Description, + GameName: row.GameName, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RankLevel: row.RankLevel, + HafCoinAmount: row.HafCoinAmount, + ScreenshotURLS: decodeScreenshots(row.ScreenshotURLS), + PriceHourly: row.PriceHourly, + PriceDaily: row.PriceDaily, + PriceWeekly: row.PriceWeekly, + DepositAmount: row.DepositAmount, + MinRentHours: row.MinRentHours, + MaxRentHours: row.MaxRentHours, + Status: row.Status, + ReviewStatus: row.ReviewStatus, + ReviewReason: row.ReviewReason, + PublishedAt: row.PublishedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { return &ListingDTO{ - ID: listing.ID, - AccountID: account.ID, - OwnerID: listing.OwnerID, - Title: account.Title, - Description: account.Description, - GameName: account.GameName, - ServerRegion: account.ServerRegion, - LoginPlatform: account.LoginPlatform, - RankLevel: account.RankLevel, - HafCoinAmount: account.HafCoinAmount, - PriceHourly: listing.PriceHourly, - PriceDaily: listing.PriceDaily, - PriceWeekly: listing.PriceWeekly, - DepositAmount: listing.DepositAmount, - MinRentHours: listing.MinRentHours, - MaxRentHours: listing.MaxRentHours, - Status: listing.Status, - ReviewStatus: listing.ReviewStatus, - ReviewReason: listing.ReviewReason, - PublishedAt: listing.PublishedAt, - CreatedAt: listing.CreatedAt, - UpdatedAt: listing.UpdatedAt, + ID: listing.ID, + AccountID: account.ID, + OwnerID: listing.OwnerID, + Title: account.Title, + Description: account.Description, + GameName: account.GameName, + ServerRegion: account.ServerRegion, + LoginPlatform: account.LoginPlatform, + RankLevel: account.RankLevel, + HafCoinAmount: account.HafCoinAmount, + ScreenshotURLS: decodeScreenshots(account.ScreenshotURLS), + PriceHourly: listing.PriceHourly, + PriceDaily: listing.PriceDaily, + PriceWeekly: listing.PriceWeekly, + DepositAmount: listing.DepositAmount, + MinRentHours: listing.MinRentHours, + MaxRentHours: listing.MaxRentHours, + Status: listing.Status, + ReviewStatus: listing.ReviewStatus, + ReviewReason: listing.ReviewReason, + PublishedAt: listing.PublishedAt, + CreatedAt: listing.CreatedAt, + UpdatedAt: listing.UpdatedAt, } } +func marshalScreenshots(urls []string) (datatypes.JSON, error) { + cleaned := make([]string, 0, len(urls)) + seen := make(map[string]struct{}, len(urls)) + for _, url := range urls { + url = strings.TrimSpace(url) + if url == "" { + continue + } + if _, ok := seen[url]; ok { + continue + } + seen[url] = struct{}{} + cleaned = append(cleaned, url) + if len(cleaned) >= 12 { + break + } + } + raw, err := json.Marshal(cleaned) + if err != nil { + return nil, err + } + return datatypes.JSON(raw), nil +} + +func decodeScreenshots(raw datatypes.JSON) []string { + if len(raw) == 0 { + return []string{} + } + var urls []string + if err := json.Unmarshal(raw, &urls); err != nil { + return []string{} + } + return urls +} + func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { raw, err := json.Marshal(detail) if err != nil { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 86fa827..4c8ff0e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -10,6 +10,7 @@ import ( "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/dispute" + filemodule "hfb_sys/backend/internal/modules/file" "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/order" @@ -108,6 +109,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } systemConfigService := systemconfig.NewService(systemConfigRepo) systemConfigHandler := systemconfig.NewHandler(systemConfigService) + var fileStorage *filemodule.Storage + if cfg.Storage.Endpoint != "" && cfg.Storage.Bucket != "" { + var err error + fileStorage, err = filemodule.NewStorage(cfg.Storage) + if err != nil { + logger.Warn("file storage unavailable; file APIs will return 503", zap.Error(err)) + } + } + fileService := filemodule.NewService(fileStorage) + fileHandler := filemodule.NewHandler(fileService, fileStorage) requireAuth := middleware.Auth(jwtManager) requireAdmin := middleware.AdminAuth(jwtManager) requireRealname := middleware.RequireRealname(userRepo) @@ -168,6 +179,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { walletRoutes.GET("/ledger", walletHandler.Ledger) } + fileRoutes := api.Group("/files", requireAuth) + { + fileRoutes.POST("/upload", fileHandler.Upload) + fileRoutes.GET("/object", fileHandler.Object) + } + notificationRoutes := api.Group("/notifications", requireAuth) { notificationRoutes.GET("", notificationHandler.List) @@ -191,6 +208,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) adminRoutes.GET("/dashboard", adminDashboardHandler.Summary) + adminRoutes.GET("/files/object", fileHandler.Object) adminRoutes.GET("/users", adminUserHandler.List) adminRoutes.POST("/users/:id/freeze", adminUserHandler.Freeze) adminRoutes.POST("/users/:id/unfreeze", adminUserHandler.Unfreeze) diff --git a/docs/api.md b/docs/api.md index a10e4c3..4733e25 100644 --- a/docs/api.md +++ b/docs/api.md @@ -44,6 +44,8 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/disputes/{id}` - `GET /api/wallet/balance` - `GET /api/wallet/ledger` +- `POST /api/files/upload` +- `GET /api/files/object` - `GET /api/notifications` - `POST /api/notifications/{id}/read` - `GET /api/admin/auth/captcha` @@ -72,7 +74,10 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/admin/system-configs` - `PUT /api/admin/system-configs/{key}` - `GET /api/admin/audit-logs` +- `GET /api/admin/files/object` -说明:`GET /api/admin/listings` 支持按 `owner_id`、`status`、`review_status` 和 `limit` 查询商品。`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`GET /api/admin/audit-logs` 支持按 `actor_id`、`action`、`biz_type` 和 `limit` 查询最近审计日志。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。 +说明:`POST /api/listings` 和 `PUT /api/listings/{id}` 支持 `screenshot_urls` 数组,用于保存号主上传的账号资产截图地址。`GET /api/admin/listings` 支持按 `owner_id`、`status`、`review_status` 和 `limit` 查询商品。`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`GET /api/admin/audit-logs` 支持按 `actor_id`、`action`、`biz_type` 和 `limit` 查询最近审计日志。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。 + +文件上传说明:`POST /api/files/upload` 使用 `multipart/form-data`,文件字段名为 `file`,可选 `scene` 为 `listing`、`handoff`、`dispute`、`realname`、`avatar`;当前允许 10MB 内的 JPG、PNG、WebP 和 PDF。返回的 `url` 为后端代理访问地址。后台查看私有文件使用 `GET /api/admin/files/object?key=...`。 订单超时扫描任务为后端内部任务,不暴露公开 API;超时阈值通过 `/api/admin/system-configs` 调整。 diff --git a/docs/business-rules.md b/docs/business-rules.md index 8a398d8..4890953 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -24,6 +24,7 @@ - 发布租号前必须登录并完成实名认证。 - 一期创建发布时同时创建 `game_accounts` 和 `rental_listings`。 - 哈夫币数量是号主手动填报值,不代表实时值。 +- 号主可上传账号资产截图,地址保存在 `game_accounts.screenshot_urls`,最多保留 12 个。 - 当前提交审核会进入 `review_status = pending`,由后台商品审核通过后才上架。 - 后台审核通过后,发布状态变为 `published`,审核状态变为 `approved`,账号状态变为 `published`。 - 后台审核拒绝后,发布保留为草稿,审核状态变为 `rejected`,拒绝原因写入 `review_reason`。 @@ -69,6 +70,16 @@ - 每个超时动作只推进一次状态,避免重复通知。 - 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`。 +## 开发态文件上传 + +- 文件上传接口为 `/api/files/upload`,文件访问接口为 `/api/files/object`。 +- 开发环境使用 MinIO,配置项包括 `STORAGE_ENDPOINT`、`STORAGE_BUCKET`、`STORAGE_ACCESS_KEY_ID` 和 `STORAGE_SECRET_ACCESS_KEY`。 +- 当前支持上传 10MB 内的 JPG、PNG、WebP 和 PDF。 +- 上传场景包括账号截图、交接附件、申诉证据、实名回执引用和头像等。 +- 账号资产截图已接入发布页面,商品审核和商品详情后台可通过后台文件代理查看。 +- 申诉证据已在订单详情页接入上传,上传成功后会把文件访问地址追加到证据列表。 +- 文件访问通过后端代理读取私有对象,后续接 OSS/COS 时保持业务接口不变。 + ## 开发态钱包账务 - 钱包账务当前为模拟流水,不代表真实支付、充值或提现。 @@ -151,7 +162,7 @@ - 商品管理接口为 `/api/admin/listings`,前端页面为 `/admin/listings`。 - 后台可查看全量商品、号主、账号 ID、区服、平台、段位、哈夫币、时租、押金、商品状态和审核状态。 - 当前支持按号主 ID、商品状态、审核状态和查询条数筛选。 -- 商品详情页为 `/admin/listings/:id`,支持查看完整账号、价格、租期和审核信息。 +- 商品详情页为 `/admin/listings/:id`,支持查看完整账号、价格、租期、审核信息和账号资产截图。 - 后台可对非租赁中的商品执行强制下架和标记异常。 - 强制下架会将商品和账号状态改为 `offline`,标记异常会将商品和账号状态改为 `abnormal`。 - 强制下架和标记异常必须填写原因,写入 `audit_logs`,并通知号主。 diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts new file mode 100644 index 0000000..91b5034 --- /dev/null +++ b/frontend/src/api/files.ts @@ -0,0 +1,33 @@ +import { apiClient } from './client' + +export interface UploadedFile { + object_key: string + url: string + filename: string + content_type: string + size: number +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function uploadFile(file: File, scene: string) { + const form = new FormData() + form.append('file', file) + form.append('scene', scene) + const { data } = await apiClient.post>('/files/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data.data +} + +export async function fetchAdminFileBlob(key: string) { + const { data } = await apiClient.get('/admin/files/object', { + params: { key }, + responseType: 'blob', + }) + return data +} diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts index f3c995b..cc1046c 100644 --- a/frontend/src/api/listings.ts +++ b/frontend/src/api/listings.ts @@ -13,6 +13,7 @@ export interface Listing { login_platform: string rank_level: string haf_coin_amount: number + screenshot_urls: string[] price_hourly: number price_daily: number price_weekly: number @@ -34,6 +35,7 @@ export interface ListingPayload { login_platform: string rank_level: string haf_coin_amount: number + screenshot_urls: string[] price_hourly: number price_daily: number price_weekly: number diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index a589b32..4fd8b94 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -505,6 +505,41 @@ h1 { margin-top: 14px; } +.upload-line input { + width: 100%; +} + +.upload-stack { + display: grid; + gap: 10px; + width: 100%; +} + +.upload-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + width: 100%; +} + +.evidence-list { + display: grid; + gap: 8px; +} + +.evidence-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.evidence-row span { + overflow-wrap: anywhere; + color: #52616f; +} + .full-control { width: 100%; } diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index 43731ec..89f1dde 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { createDispute } from '@/api/disputes' +import { uploadFile } from '@/api/files' import { cancelOrder, confirmReceive, @@ -27,6 +28,7 @@ const confirming = ref(false) const returning = ref(false) const completing = ref(false) const disputing = ref(false) +const uploadingEvidence = ref(false) const order = ref(null) const handoffRecords = ref([]) const handoffContent = ref('') @@ -150,6 +152,23 @@ async function handleCreateDispute() { } } +async function handleEvidenceUpload(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + uploadingEvidence.value = true + try { + const uploaded = await uploadFile(file, 'dispute') + disputeEvidenceText.value = [disputeEvidenceText.value, uploaded.url].filter(Boolean).join('\n') + ElMessage.success('证据文件已上传') + } catch (error) { + ElMessage.error(readError(error, '上传失败')) + } finally { + uploadingEvidence.value = false + } +} + function readError(error: unknown, fallback: string) { if (typeof error === 'object' && error && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response @@ -257,6 +276,9 @@ function readError(error: unknown, fallback: string) { :rows="3" placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接" /> +
+ +
提交申诉 diff --git a/frontend/src/views/admin/AdminDisputesView.vue b/frontend/src/views/admin/AdminDisputesView.vue index 0bdd582..3ecfba2 100644 --- a/frontend/src/views/admin/AdminDisputesView.vue +++ b/frontend/src/views/admin/AdminDisputesView.vue @@ -3,11 +3,13 @@ import { ElMessage } from 'element-plus' import { onMounted, ref } from 'vue' import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes' +import { fetchAdminFileBlob } from '@/api/files' const loading = ref(false) const submitting = ref(false) const disputes = ref([]) const activeDispute = ref(null) +const evidenceDispute = ref(null) const result = ref('release_deposit') const remark = ref('') const amount = ref() @@ -30,6 +32,38 @@ function openArbitration(row: Dispute) { amount.value = undefined } +function evidenceItems(row: Dispute | null) { + const raw = row?.evidence_urls + if (!raw) return [] + if (Array.isArray(raw)) return raw + return [] +} + +function extractObjectKey(url: string) { + try { + const parsed = new URL(url, window.location.origin) + return parsed.searchParams.get('key') || '' + } catch { + return '' + } +} + +async function openEvidence(url: string) { + const key = extractObjectKey(url) + if (!key) { + window.open(url, '_blank') + return + } + try { + const blob = await fetchAdminFileBlob(key) + const objectURL = URL.createObjectURL(blob) + window.open(objectURL, '_blank') + window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000) + } catch (error) { + ElMessage.error(readError(error, '证据文件打开失败')) + } +} + async function handleArbitrate() { if (!activeDispute.value) return submitting.value = true @@ -74,6 +108,11 @@ function readError(error: unknown, fallback: string) { + + + + + +
+

{{ evidenceDispute.order_no }} · {{ evidenceDispute.title }}

+
+ {{ item }} + 打开 +
+
+ +
diff --git a/frontend/src/views/admin/AdminListingDetailView.vue b/frontend/src/views/admin/AdminListingDetailView.vue index 504fdb5..7d873cf 100644 --- a/frontend/src/views/admin/AdminListingDetailView.vue +++ b/frontend/src/views/admin/AdminListingDetailView.vue @@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus' import { computed, onMounted, ref } from 'vue' import { useRoute } from 'vue-router' +import { fetchAdminFileBlob } from '@/api/files' import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/api/listings' const route = useRoute() @@ -54,6 +55,24 @@ function money(value: number) { return `¥${Number(value || 0).toFixed(2)}` } +function extractObjectKey(url: string) { + try { + const parsed = new URL(url, window.location.origin) + return parsed.searchParams.get('key') || url + } catch { + return url + } +} + +async function openScreenshot(url: string) { + try { + const blob = await fetchAdminFileBlob(extractObjectKey(url)) + window.open(URL.createObjectURL(blob), '_blank') + } catch { + window.open(url, '_blank') + } +} + function readError(error: unknown, fallback: string) { if (typeof error === 'object' && error && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response @@ -108,6 +127,7 @@ function readError(error: unknown, fallback: string) {

平台:{{ listing.login_platform }}

段位:{{ listing.rank_level || '-' }}

哈夫币:{{ listing.haf_coin_amount }}

+

资产截图:{{ listing.screenshot_urls?.length || 0 }} 个

@@ -129,6 +149,17 @@ function readError(error: unknown, fallback: string) {

更新时间:{{ listing.updated_at }}

+
+

资产截图

+
+
+ {{ url }} + 打开 +
+
+

暂无截图

+
+

{{ listing.title }}

diff --git a/frontend/src/views/admin/AdminListingReviewView.vue b/frontend/src/views/admin/AdminListingReviewView.vue index 1a045d6..a3cea94 100644 --- a/frontend/src/views/admin/AdminListingReviewView.vue +++ b/frontend/src/views/admin/AdminListingReviewView.vue @@ -2,12 +2,14 @@ import { ElMessage } from 'element-plus' import { onMounted, ref } from 'vue' +import { fetchAdminFileBlob } from '@/api/files' import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings' const loading = ref(false) const submitting = ref(false) const listings = ref([]) const activeListing = ref(null) +const evidenceListing = ref(null) const rejectReason = ref('') onMounted(loadListings) @@ -55,6 +57,28 @@ async function handleReject() { } } +function openEvidence(row: Listing) { + evidenceListing.value = row +} + +function extractObjectKey(url: string) { + try { + const parsed = new URL(url, window.location.origin) + return parsed.searchParams.get('key') || url + } catch { + return url + } +} + +async function openScreenshot(url: string) { + try { + const blob = await fetchAdminFileBlob(extractObjectKey(url)) + window.open(URL.createObjectURL(blob), '_blank') + } catch { + window.open(url, '_blank') + } +} + function readError(error: unknown, fallback: string) { if (typeof error === 'object' && error && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response @@ -84,6 +108,13 @@ function readError(error: unknown, fallback: string) { + + + + + +
+

{{ evidenceListing.title }}

+
+
+ {{ url }} + 打开 +
+
+

暂无截图

+
+ +
diff --git a/frontend/src/views/seller/SellerListingCreateView.vue b/frontend/src/views/seller/SellerListingCreateView.vue index f57dc38..1a41859 100644 --- a/frontend/src/views/seller/SellerListingCreateView.vue +++ b/frontend/src/views/seller/SellerListingCreateView.vue @@ -3,10 +3,13 @@ import { ElMessage } from 'element-plus' import { reactive, ref } from 'vue' import { useRouter } from 'vue-router' +import { uploadFile } from '@/api/files' import { createListing } from '@/api/listings' const router = useRouter() const loading = ref(false) +const uploading = ref(false) +const screenshotUrls = ref([]) const form = reactive({ title: '', description: '', @@ -25,7 +28,7 @@ const form = reactive({ async function handleSubmit() { loading.value = true try { - await createListing({ ...form }) + await createListing({ ...form, screenshot_urls: screenshotUrls.value }) ElMessage.success('发布已创建') await router.push('/seller/listings') } catch (error) { @@ -35,6 +38,27 @@ async function handleSubmit() { } } +async function handleScreenshotUpload(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + if (!file) return + uploading.value = true + try { + const uploaded = await uploadFile(file, 'listing') + screenshotUrls.value = [...screenshotUrls.value, uploaded.url] + ElMessage.success('截图已上传') + } catch (error) { + ElMessage.error(readError(error, '截图上传失败')) + } finally { + uploading.value = false + input.value = '' + } +} + +function removeScreenshot(index: number) { + screenshotUrls.value = screenshotUrls.value.filter((_, itemIndex) => itemIndex !== index) +} + function readError(error: unknown, fallback: string) { if (typeof error === 'object' && error && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response @@ -59,6 +83,25 @@ function readError(error: unknown, fallback: string) { + +
+
+ + 最多 12 个 +
+
+
+ {{ url }} + 移除 +
+
+
+