feat: add file upload workflow

This commit is contained in:
yml
2026-05-22 18:50:32 +08:00
parent 6f915b37d3
commit 236518ade4
22 changed files with 819 additions and 117 deletions
+3 -1
View File
@@ -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`,支持查看高风险操作明细。
## 文档
+3 -1
View File
@@ -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
+19 -7
View File
@@ -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
)
+39
View File
@@ -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=
+8 -4
View File
@@ -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"),
},
}
}
+9
View File
@@ -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"`
}
+86
View File
@@ -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", "文件服务暂时不可用")
}
}
+76
View File
@@ -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
}
+122
View File
@@ -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
}
+38 -36
View File
@@ -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
+114 -65
View File
@@ -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 {
+18
View File
@@ -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)
+6 -1
View File
@@ -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` 调整。
+12 -1
View File
@@ -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`,并通知号主。
+33
View File
@@ -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<T> {
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<ApiResponse<UploadedFile>>('/files/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data.data
}
export async function fetchAdminFileBlob(key: string) {
const { data } = await apiClient.get<Blob>('/admin/files/object', {
params: { key },
responseType: 'blob',
})
return data
}
+2
View File
@@ -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
+35
View File
@@ -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%;
}
@@ -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<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
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="证据链接,一行一个。开发阶段可先填截图地址或备注链接"
/>
<div class="panel-action upload-line">
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
</div>
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">提交申诉</el-button>
</div>
</section>
@@ -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<Dispute[]>([])
const activeDispute = ref<Dispute | null>(null)
const evidenceDispute = ref<Dispute | null>(null)
const result = ref('release_deposit')
const remark = ref('')
const amount = ref<number | undefined>()
@@ -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) {
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
<el-table-column prop="arbitration_result" label="结果" width="150" />
<el-table-column label="证据" width="100">
<template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">查看</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
@@ -111,5 +150,18 @@ function readError(error: unknown, fallback: string) {
<el-button type="primary" :loading="submitting" @click="handleArbitrate">保存裁决</el-button>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceDispute" title="申诉证据" width="640px" @update:model-value="evidenceDispute = null">
<div v-if="evidenceDispute" class="dialog-body">
<p><strong>{{ evidenceDispute.order_no }}</strong> · {{ evidenceDispute.title }}</p>
<div v-for="item in evidenceItems(evidenceDispute)" :key="item" class="evidence-row">
<span>{{ item }}</span>
<el-button size="small" @click="openEvidence(item)">打开</el-button>
</div>
</div>
<template #footer>
<el-button type="primary" @click="evidenceDispute = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -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) {
<p>平台{{ listing.login_platform }}</p>
<p>段位{{ listing.rank_level || '-' }}</p>
<p>哈夫币{{ listing.haf_coin_amount }}</p>
<p>资产截图{{ listing.screenshot_urls?.length || 0 }} </p>
</div>
<div class="order-panel dashboard-panel">
@@ -129,6 +149,17 @@ function readError(error: unknown, fallback: string) {
<p>更新时间{{ listing.updated_at }}</p>
</div>
<div v-if="listing" class="order-panel dashboard-panel">
<h2>资产截图</h2>
<div v-if="listing.screenshot_urls?.length" class="evidence-list">
<div v-for="url in listing.screenshot_urls" :key="url" class="evidence-row">
<span>{{ url }}</span>
<el-button size="small" @click="openScreenshot(url)">打开</el-button>
</div>
</div>
<p v-else>暂无截图</p>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<div v-if="listing" class="dialog-body">
<p><strong>{{ listing.title }}</strong></p>
@@ -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<Listing[]>([])
const activeListing = ref<Listing | null>(null)
const evidenceListing = ref<Listing | null>(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) {
<el-table-column prop="rank_level" label="段位" width="110" />
<el-table-column prop="price_hourly" label="时租" width="90" />
<el-table-column prop="deposit_amount" label="押金" width="90" />
<el-table-column label="截图" width="90">
<template #default="{ row }">
<el-button size="small" :disabled="!row.screenshot_urls?.length" @click="openEvidence(row)">
{{ row.screenshot_urls?.length || 0 }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="提交时间" min-width="180" />
<el-table-column label="操作" width="170">
<template #default="{ row }">
@@ -103,5 +134,21 @@ function readError(error: unknown, fallback: string) {
<el-button type="danger" :loading="submitting" @click="handleReject">确认拒绝</el-button>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="680px" @update:model-value="evidenceListing = null">
<div v-if="evidenceListing" class="dialog-body">
<p><strong>{{ evidenceListing.title }}</strong></p>
<div v-if="evidenceListing.screenshot_urls?.length" class="evidence-list">
<div v-for="url in evidenceListing.screenshot_urls" :key="url" class="evidence-row">
<span>{{ url }}</span>
<el-button size="small" @click="openScreenshot(url)">打开</el-button>
</div>
</div>
<p v-else>暂无截图</p>
</div>
<template #footer>
<el-button @click="evidenceListing = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -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<string[]>([])
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) {
<el-form-item label="账号说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="账号资产截图">
<div class="upload-stack">
<div class="upload-line">
<input
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
:disabled="uploading || screenshotUrls.length >= 12"
@change="handleScreenshotUpload"
/>
<el-button :loading="uploading" disabled>最多 12 </el-button>
</div>
<div v-if="screenshotUrls.length" class="evidence-list">
<div v-for="(url, index) in screenshotUrls" :key="url" class="evidence-row">
<span>{{ url }}</span>
<el-button size="small" @click="removeScreenshot(index)">移除</el-button>
</div>
</div>
</div>
</el-form-item>
<div class="form-grid">
<el-form-item label="区服">
<el-input v-model="form.server_region" />