diff --git a/cmd/test_casbin/main.go b/cmd/test_casbin/main.go index 2cacbe5..dabb4d5 100644 --- a/cmd/test_casbin/main.go +++ b/cmd/test_casbin/main.go @@ -13,7 +13,8 @@ import ( "carrot_bbs/internal/repository" "carrot_bbs/internal/service" - "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v3" + gormadapter "github.com/casbin/gorm-adapter/v3" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -64,8 +65,14 @@ func main() { log.Printf("[WARNING] Failed to init casbin policies: %v", err) } + // 创建 GORM 适配器 + adapter, err := gormadapter.NewAdapterByDB(db) + if err != nil { + log.Fatalf("Failed to create GORM adapter: %v", err) + } + // 创建 Casbin Enforcer - enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath) + enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath, adapter) if err != nil { log.Fatalf("Failed to create enforcer: %v", err) } diff --git a/go.mod b/go.mod index 5d6f166..e90021b 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,11 @@ go 1.25 require ( github.com/alicebob/miniredis/v2 v2.31.0 + github.com/casbin/casbin/v2 v2.135.0 + github.com/casbin/casbin/v3 v3.10.0 + github.com/casbin/gorm-adapter/v3 v3.41.0 github.com/gin-gonic/gin v1.9.1 - github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorse-io/gorse-go v0.5.0-alpha.3 @@ -13,22 +16,22 @@ require ( github.com/redis/go-redis/v9 v9.3.1 github.com/spf13/viper v1.18.2 go.uber.org/zap v1.26.0 - golang.org/x/crypto v0.26.0 + golang.org/x/crypto v0.46.0 golang.org/x/image v0.24.0 google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df - gorm.io/driver/postgres v1.5.4 - gorm.io/driver/sqlite v1.5.4 - gorm.io/gorm v1.25.5 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect - github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect - github.com/casbin/casbin/v2 v2.135.0 // indirect - github.com/casbin/govaluate v1.3.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -37,18 +40,23 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // 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.14.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/subcommands v1.2.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -56,18 +64,22 @@ require ( github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/microsoft/go-mssqldb v1.9.5 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.5.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect @@ -83,15 +95,20 @@ require ( go.opentelemetry.io/otel/trace v1.11.1 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/mod v0.20.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.23.0 // indirect - golang.org/x/text v0.22.0 // indirect - golang.org/x/tools v0.24.1 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // 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 google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/driver/sqlserver v1.6.3 // indirect + gorm.io/plugin/dbresolver v1.6.2 // indirect + modernc.org/libc v1.67.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.42.2 // indirect ) diff --git a/go.sum b/go.sum index 902d29e..e5f1f56 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,37 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.31.0 h1:ObEFUNlJwoIiyjxdrYF0QIDE7qXcLc7D3WpSH4c22PU= github.com/alicebob/miniredis/v2 v2.31.0/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg= -github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -14,8 +41,13 @@ github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= -github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs= +github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= +github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM= +github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA= github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= @@ -24,12 +56,15 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/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/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= @@ -44,6 +79,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -57,11 +96,20 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -70,24 +118,38 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorse-io/gorse-go v0.5.0-alpha.3 h1:GR/OWzq016VyFyTTxgQWeayGahRVzB1cGFIW/AaShC4= github.com/gorse-io/gorse-go v0.5.0-alpha.3/go.mod h1:ZxmVHzZPKm5pmEIlqaRDwK0rkfTRHlfziO033XZ+RW0= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= -github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -100,18 +162,28 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= -github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= +github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= +github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw= @@ -125,21 +197,36 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/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/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds= github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -155,20 +242,25 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV 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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE= github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4 h1:aUEBEdCa6iamGzg6fuYxDA8ThxvOG240mAvWDU+XLio= @@ -189,33 +281,123 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +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/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.24.1 h1:vxuHLTNS3Np5zrYoPRpcheASHX/7KiGo+8Y4ZM1J2O8= -golang.org/x/tools v0.24.1/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= @@ -234,13 +416,52 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AW gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= -gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= -gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0= -gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= -gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= -gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= +gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= +modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= +modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 36225b8..1b0b46c 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -50,6 +50,56 @@ type UserDetailResponse struct { CreatedAt string `json:"created_at"` } +// ==================== Admin User DTOs ==================== + +// AdminUserListResponse 管理端用户列表响应 +type AdminUserListResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + EmailVerified bool `json:"email_verified"` + Avatar string `json:"avatar"` + Status string `json:"status"` + PostsCount int `json:"posts_count"` + FollowersCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + LastLoginAt string `json:"last_login_at,omitempty"` + LastLoginIP string `json:"last_login_ip,omitempty"` + CreatedAt string `json:"created_at"` +} + +// AdminUserDetailResponse 管理端用户详情响应 +type AdminUserDetailResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email *string `json:"email"` + Phone *string `json:"phone"` + EmailVerified bool `json:"email_verified"` + Avatar string `json:"avatar"` + CoverURL string `json:"cover_url"` + Bio string `json:"bio"` + Website string `json:"website"` + Location string `json:"location"` + IsVerified bool `json:"is_verified"` + Status string `json:"status"` + PostsCount int `json:"posts_count"` + CommentsCount int64 `json:"comments_count"` + FollowersCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + LastLoginAt string `json:"last_login_at,omitempty"` + LastLoginIP string `json:"last_login_ip,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AdminUpdateUserStatusRequest 更新用户状态请求 +type AdminUpdateUserStatusRequest struct { + Status string `json:"status" binding:"required,oneof=active banned"` +} + // ==================== Post DTOs ==================== // PostImageResponse 帖子图片响应 @@ -819,3 +869,293 @@ type WSResponse struct { Data interface{} `json:"data,omitempty"` // 响应数据 Error string `json:"error,omitempty"` // 错误信息 } + +// ==================== Admin Post DTOs ==================== + +// AdminPostListResponse 管理端帖子列表响应 +type AdminPostListResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` + Images []PostImageResponse `json:"images"` + Status string `json:"status"` + LikesCount int `json:"likes_count"` + CommentsCount int `json:"comments_count"` + FavoritesCount int `json:"favorites_count"` + ViewsCount int `json:"views_count"` + IsPinned bool `json:"is_pinned"` + IsFeatured bool `json:"is_featured"` + IsLocked bool `json:"is_locked"` + RejectReason string `json:"reject_reason,omitempty"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewedBy string `json:"reviewed_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Author *UserResponse `json:"author"` +} + +// AdminPostDetailResponse 管理端帖子详情响应 +type AdminPostDetailResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CommunityID string `json:"community_id"` + Title string `json:"title"` + Content string `json:"content"` + Images []PostImageResponse `json:"images"` + Status string `json:"status"` + LikesCount int `json:"likes_count"` + CommentsCount int `json:"comments_count"` + FavoritesCount int `json:"favorites_count"` + SharesCount int `json:"shares_count"` + ViewsCount int `json:"views_count"` + HotScore float64 `json:"hot_score"` + IsPinned bool `json:"is_pinned"` + IsFeatured bool `json:"is_featured"` + IsLocked bool `json:"is_locked"` + IsVote bool `json:"is_vote"` + RejectReason string `json:"reject_reason,omitempty"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewedBy string `json:"reviewed_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Author *UserResponse `json:"author"` +} + +// AdminModeratePostRequest 审核帖子请求 +type AdminModeratePostRequest struct { + Status string `json:"status" binding:"required,oneof=published rejected"` + Reason string `json:"reason"` // 拒绝理由(当status为rejected时) +} + +// AdminBatchDeletePostsRequest 批量删除帖子请求 +type AdminBatchDeletePostsRequest struct { + IDs []string `json:"ids" binding:"required,min=1"` +} + +// AdminBatchModeratePostsRequest 批量审核帖子请求 +type AdminBatchModeratePostsRequest struct { + IDs []string `json:"ids" binding:"required,min=1"` + Status string `json:"status" binding:"required,oneof=published rejected"` +} + +// AdminSetPostPinRequest 置顶帖子请求 +type AdminSetPostPinRequest struct { + IsPinned bool `json:"is_pinned"` +} + +// AdminSetPostFeatureRequest 加精帖子请求 +type AdminSetPostFeatureRequest struct { + IsFeatured bool `json:"is_featured"` +} + +// AdminBatchOperationResponse 批量操作响应 +type AdminBatchOperationResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Failed []string `json:"failed,omitempty"` // 失败的ID列表 +} + +// ==================== Admin Comment DTOs ==================== + +// AdminCommentListResponse 管理端评论列表响应 +type AdminCommentListResponse struct { + ID string `json:"id"` + PostID string `json:"post_id"` + UserID string `json:"user_id"` + ParentID *string `json:"parent_id"` + RootID *string `json:"root_id"` + Content string `json:"content"` + Images []CommentImageResponse `json:"images"` + Status string `json:"status"` + LikesCount int `json:"likes_count"` + RepliesCount int `json:"replies_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Author *UserResponse `json:"author"` + Post *AdminCommentPostInfo `json:"post"` +} + +// AdminCommentPostInfo 评论关联的帖子简要信息 +type AdminCommentPostInfo struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// AdminCommentDetailResponse 管理端评论详情响应 +type AdminCommentDetailResponse struct { + ID string `json:"id"` + PostID string `json:"post_id"` + UserID string `json:"user_id"` + ParentID *string `json:"parent_id"` + RootID *string `json:"root_id"` + Content string `json:"content"` + Images []CommentImageResponse `json:"images"` + Status string `json:"status"` + LikesCount int `json:"likes_count"` + RepliesCount int `json:"replies_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Author *UserResponse `json:"author"` + Post *AdminCommentPostInfo `json:"post"` + ReplyToUser *UserResponse `json:"reply_to_user,omitempty"` // 被回复的用户信息 +} + +// AdminBatchDeleteCommentsRequest 批量删除评论请求 +type AdminBatchDeleteCommentsRequest struct { + IDs []string `json:"ids" binding:"required,min=1"` +} + +// ==================== Admin Group DTOs ==================== + +// AdminGroupListResponse 管理端群组列表响应 +type AdminGroupListResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + Owner *UserResponse `json:"owner"` + MemberCount int `json:"member_count"` + MaxMembers int `json:"max_members"` + JoinType int `json:"join_type"` + MuteAll bool `json:"mute_all"` + Status string `json:"status"` + PostCount int64 `json:"post_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AdminGroupDetailResponse 管理端群组详情响应 +type AdminGroupDetailResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + Owner *UserResponse `json:"owner"` + MemberCount int `json:"member_count"` + MaxMembers int `json:"max_members"` + JoinType int `json:"join_type"` + MuteAll bool `json:"mute_all"` + Status string `json:"status"` + PostCount int64 `json:"post_count"` + Announcement *GroupAnnouncementResponse `json:"announcement,omitempty"` + IsPublic bool `json:"is_public"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AdminGroupMemberResponse 管理端群组成员响应 +type AdminGroupMemberResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Role string `json:"role"` + Muted bool `json:"muted"` + JoinedAt string `json:"joined_at"` + User *UserResponse `json:"user,omitempty"` +} + +// AdminGroupMemberListResponse 管理端群组成员列表响应 +type AdminGroupMemberListResponse struct { + List []*AdminGroupMemberResponse `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// AdminUpdateGroupStatusRequest 更新群组状态请求 +type AdminUpdateGroupStatusRequest struct { + Status string `json:"status" binding:"required,oneof=active dissolved"` +} + +// AdminTransferGroupOwnerRequest 转让群主请求 +type AdminTransferGroupOwnerRequest struct { + NewOwnerID string `json:"new_owner_id" binding:"required"` +} + +// ==================== Admin Dashboard DTOs ==================== + +// DashboardStatsResponse 仪表盘统计数据响应 +type DashboardStatsResponse struct { + TotalUsers int64 `json:"total_users"` // 总用户数 + TodayNewUsers int64 `json:"today_new_users"` // 今日新增用户 + ActiveUsersToday int64 `json:"active_users_today"` // 今日活跃用户 (DAU) + ActiveUsersWeek int64 `json:"active_users_week"` // 本周活跃用户 (WAU) + ActiveUsersMonth int64 `json:"active_users_month"` // 本月活跃用户 (MAU) + Retention1Day int64 `json:"retention_1_day"` // 次日留存率 (百分比 0-100) + Retention7Day int64 `json:"retention_7_day"` // 7日留存率 (百分比 0-100) + Retention30Day int64 `json:"retention_30_day"` // 30日留存率 (百分比 0-100) + TotalPosts int64 `json:"total_posts"` // 总帖子数 + TodayPosts int64 `json:"today_posts"` // 今日帖子数 + PendingPosts int64 `json:"pending_posts"` // 待审核帖子数 + TotalComments int64 `json:"total_comments"` // 总评论数 + TodayComments int64 `json:"today_comments"` // 今日评论数 + TotalGroups int64 `json:"total_groups"` // 总群组数 + TodayNewGroups int64 `json:"today_new_groups"` // 今日新建群组 + PendingReview int64 `json:"pending_review"` // 待审核总数 + PendingPostsCount int64 `json:"pending_posts_count"` // 待审核帖子数 + PendingComments int64 `json:"pending_comments_count"` // 待审核评论数 +} + +// UserActivityTrendResponse 用户活跃度趋势响应 +type UserActivityTrendResponse struct { + Date string `json:"date"` // 日期 + NewUsers int64 `json:"new_users"` // 新增用户数 + ActiveUsers int64 `json:"active_users"` // 活跃用户数 + Posts int64 `json:"posts"` // 帖子数 + Comments int64 `json:"comments"` // 评论数 +} + +// ContentStatsResponse 内容统计响应 +type ContentStatsResponse struct { + ContentDistribution []ContentDistributionItem `json:"content_distribution"` // 内容分布 + PostStatusDistribution []PostStatusDistributionItem `json:"post_status_distribution"` // 帖子状态分布 + UserGrowth []UserGrowthItem `json:"user_growth"` // 用户增长趋势 +} + +// ContentDistributionItem 内容分布项 +type ContentDistributionItem struct { + Type string `json:"type"` // 类型:posts, comments, groups + Name string `json:"name"` // 名称 + Count int64 `json:"count"` // 数量 +} + +// PostStatusDistributionItem 帖子状态分布项 +type PostStatusDistributionItem struct { + Status string `json:"status"` // 状态 + Name string `json:"name"` // 名称 + Count int64 `json:"count"` // 数量 +} + +// UserGrowthItem 用户增长项 +type UserGrowthItem struct { + Date string `json:"date"` // 日期 + NewUsers int64 `json:"new_users"` // 新增用户数 +} + +// PendingContentResponse 待审核内容响应 +type PendingContentResponse struct { + ID string `json:"id"` // 内容ID + Type string `json:"type"` // 内容类型(post/comment) + Title string `json:"title"` // 标题(帖子有,评论可能为空) + Content string `json:"content"` // 内容 + Author *PendingContentAuthor `json:"author"` // 作者信息 + CreatedAt string `json:"created_at"` // 创建时间 +} + +// PendingContentAuthor 待审核内容作者信息 +type PendingContentAuthor struct { + ID string `json:"id"` // 用户ID + Username string `json:"username"` // 用户名 + Nickname string `json:"nickname"` // 昵称 + Avatar string `json:"avatar"` // 头像 +} + +// OnlineUsersResponse 在线用户数响应 +type OnlineUsersResponse struct { + Count int64 `json:"count"` // 在线用户数 +} diff --git a/internal/handler/admin_comment_handler.go b/internal/handler/admin_comment_handler.go new file mode 100644 index 0000000..b5f8250 --- /dev/null +++ b/internal/handler/admin_comment_handler.go @@ -0,0 +1,151 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// AdminCommentHandler 管理端评论处理器 +type AdminCommentHandler struct { + adminCommentService service.AdminCommentService +} + +// NewAdminCommentHandler 创建管理端评论处理器 +func NewAdminCommentHandler(adminCommentService service.AdminCommentService) *AdminCommentHandler { + return &AdminCommentHandler{ + adminCommentService: adminCommentService, + } +} + +// GetCommentList 获取评论列表 +// GET /api/v1/admin/comments +func (h *AdminCommentHandler) GetCommentList(c *gin.Context) { + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + // 获取筛选参数 + keyword := c.Query("keyword") + postID := c.Query("post_id") + authorID := c.Query("author_id") + status := c.Query("status") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + // 验证状态参数 + if status != "" && status != string(model.CommentStatusDraft) && + status != string(model.CommentStatusPending) && + status != string(model.CommentStatusPublished) && + status != string(model.CommentStatusRejected) && + status != string(model.CommentStatusDeleted) { + response.BadRequest(c, "invalid status value") + return + } + + comments, total, err := h.adminCommentService.GetCommentList(c.Request.Context(), page, pageSize, keyword, postID, authorID, status, startDate, endDate) + if err != nil { + response.InternalServerError(c, "failed to get comment list") + return + } + + response.Paginated(c, comments, total, page, pageSize) +} + +// GetCommentDetail 获取评论详情 +// GET /api/v1/admin/comments/:id +func (h *AdminCommentHandler) GetCommentDetail(c *gin.Context) { + commentID := c.Param("id") + if commentID == "" { + response.BadRequest(c, "comment id is required") + return + } + + commentDetail, err := h.adminCommentService.GetCommentDetail(c.Request.Context(), commentID) + if err != nil { + response.NotFound(c, "comment not found") + return + } + + response.Success(c, commentDetail) +} + +// DeleteComment 删除评论 +// DELETE /api/v1/admin/comments/:id +func (h *AdminCommentHandler) DeleteComment(c *gin.Context) { + commentID := c.Param("id") + if commentID == "" { + response.BadRequest(c, "comment id is required") + return + } + + err := h.adminCommentService.DeleteComment(c.Request.Context(), commentID) + if err != nil { + response.HandleError(c, err, "failed to delete comment") + return + } + + response.Success(c, dto.SuccessResponse{ + Success: true, + Message: "comment deleted successfully", + }) +} + +// BatchDeleteComments 批量删除评论 +// POST /api/v1/admin/comments/batch-delete +func (h *AdminCommentHandler) BatchDeleteComments(c *gin.Context) { + var req dto.AdminBatchDeleteCommentsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + result, err := h.adminCommentService.BatchDeleteComments(c.Request.Context(), req.IDs) + if err != nil { + response.InternalServerError(c, "failed to batch delete comments") + return + } + + response.Success(c, result) +} + +// GetPostComments 获取帖子的评论列表 +// GET /api/v1/admin/posts/:postId/comments +func (h *AdminCommentHandler) GetPostComments(c *gin.Context) { + postID := c.Param("postId") + if postID == "" { + response.BadRequest(c, "post id is required") + return + } + + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + comments, total, err := h.adminCommentService.GetCommentsByPostID(c.Request.Context(), postID, page, pageSize) + if err != nil { + response.InternalServerError(c, "failed to get post comments") + return + } + + response.Paginated(c, comments, total, page, pageSize) +} diff --git a/internal/handler/admin_dashboard_handler.go b/internal/handler/admin_dashboard_handler.go new file mode 100644 index 0000000..aaf7b79 --- /dev/null +++ b/internal/handler/admin_dashboard_handler.go @@ -0,0 +1,100 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// AdminDashboardHandler 管理端仪表盘处理器 +type AdminDashboardHandler struct { + dashboardService service.AdminDashboardService +} + +// NewAdminDashboardHandler 创建管理端仪表盘处理器 +func NewAdminDashboardHandler(dashboardService service.AdminDashboardService) *AdminDashboardHandler { + return &AdminDashboardHandler{ + dashboardService: dashboardService, + } +} + +// GetStats 获取统计数据 +// GET /api/v1/admin/dashboard/stats +func (h *AdminDashboardHandler) GetStats(c *gin.Context) { + stats, err := h.dashboardService.GetStats(c.Request.Context()) + if err != nil { + response.InternalServerError(c, "failed to get dashboard stats") + return + } + + response.Success(c, stats) +} + +// GetUserActivityTrend 获取用户活跃度趋势 +// GET /api/v1/admin/dashboard/user-activity +func (h *AdminDashboardHandler) GetUserActivityTrend(c *gin.Context) { + // 解析天数参数,默认7天 + days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) + if days <= 0 { + days = 7 + } + if days > 30 { + days = 30 + } + + trend, err := h.dashboardService.GetUserActivityTrend(c.Request.Context(), days) + if err != nil { + response.InternalServerError(c, "failed to get user activity trend") + return + } + + response.Success(c, trend) +} + +// GetContentStats 获取内容统计 +// GET /api/v1/admin/dashboard/content-stats +func (h *AdminDashboardHandler) GetContentStats(c *gin.Context) { + stats, err := h.dashboardService.GetContentStats(c.Request.Context()) + if err != nil { + response.InternalServerError(c, "failed to get content stats") + return + } + + response.Success(c, stats) +} + +// GetPendingContent 获取待审核内容 +// GET /api/v1/admin/dashboard/pending-content +func (h *AdminDashboardHandler) GetPendingContent(c *gin.Context) { + // 解析limit参数,默认10条 + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + content, err := h.dashboardService.GetPendingContent(c.Request.Context(), limit) + if err != nil { + response.InternalServerError(c, "failed to get pending content") + return + } + + response.Success(c, content) +} + +// GetOnlineUsers 获取在线用户数 +// GET /api/v1/admin/dashboard/online-users +func (h *AdminDashboardHandler) GetOnlineUsers(c *gin.Context) { + onlineUsers, err := h.dashboardService.GetOnlineUsers(c.Request.Context()) + if err != nil { + response.InternalServerError(c, "failed to get online users count") + return + } + + response.Success(c, onlineUsers) +} diff --git a/internal/handler/admin_group_handler.go b/internal/handler/admin_group_handler.go new file mode 100644 index 0000000..ed21099 --- /dev/null +++ b/internal/handler/admin_group_handler.go @@ -0,0 +1,202 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// AdminGroupHandler 管理端群组处理器 +type AdminGroupHandler struct { + adminGroupService service.AdminGroupService +} + +// NewAdminGroupHandler 创建管理端群组处理器 +func NewAdminGroupHandler(adminGroupService service.AdminGroupService) *AdminGroupHandler { + return &AdminGroupHandler{ + adminGroupService: adminGroupService, + } +} + +// GetGroupList 获取群组列表 +// GET /api/v1/admin/groups +func (h *AdminGroupHandler) GetGroupList(c *gin.Context) { + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + // 获取筛选参数 + keyword := c.Query("keyword") + status := c.Query("status") + ownerID := c.Query("owner_id") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + groups, total, err := h.adminGroupService.GetGroupList(c.Request.Context(), page, pageSize, keyword, status, ownerID, startDate, endDate) + if err != nil { + response.InternalServerError(c, "failed to get group list") + return + } + + response.Paginated(c, groups, total, page, pageSize) +} + +// GetGroupDetail 获取群组详情 +// GET /api/v1/admin/groups/:id +func (h *AdminGroupHandler) GetGroupDetail(c *gin.Context) { + groupID := c.Param("id") + if groupID == "" { + response.BadRequest(c, "group id is required") + return + } + + groupDetail, err := h.adminGroupService.GetGroupDetail(c.Request.Context(), groupID) + if err != nil { + response.NotFound(c, "group not found") + return + } + + response.Success(c, groupDetail) +} + +// GetGroupMembers 获取群组成员列表 +// GET /api/v1/admin/groups/:id/members +func (h *AdminGroupHandler) GetGroupMembers(c *gin.Context) { + groupID := c.Param("id") + if groupID == "" { + response.BadRequest(c, "group id is required") + return + } + + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + members, err := h.adminGroupService.GetGroupMembers(c.Request.Context(), groupID, page, pageSize) + if err != nil { + response.HandleError(c, err, "failed to get group members") + return + } + + response.Success(c, members) +} + +// DeleteGroup 解散群组 +// DELETE /api/v1/admin/groups/:id +func (h *AdminGroupHandler) DeleteGroup(c *gin.Context) { + groupID := c.Param("id") + if groupID == "" { + response.BadRequest(c, "group id is required") + return + } + + err := h.adminGroupService.DeleteGroup(c.Request.Context(), groupID) + if err != nil { + response.HandleError(c, err, "failed to delete group") + return + } + + response.Success(c, dto.SuccessResponse{ + Success: true, + Message: "group deleted successfully", + }) +} + +// UpdateGroupStatus 更新群组状态 +// PUT /api/v1/admin/groups/:id/status +func (h *AdminGroupHandler) UpdateGroupStatus(c *gin.Context) { + groupID := c.Param("id") + if groupID == "" { + response.BadRequest(c, "group id is required") + return + } + + var req dto.AdminUpdateGroupStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + // 验证状态值 + if req.Status != "active" && req.Status != "dissolved" { + response.BadRequest(c, "invalid status value, must be 'active' or 'dissolved'") + return + } + + groupDetail, err := h.adminGroupService.UpdateGroupStatus(c.Request.Context(), groupID, req.Status) + if err != nil { + response.HandleError(c, err, "failed to update group status") + return + } + + response.Success(c, groupDetail) +} + +// RemoveMember 移除群组成员 +// DELETE /api/v1/admin/groups/:id/members/:userId +func (h *AdminGroupHandler) RemoveMember(c *gin.Context) { + groupID := c.Param("id") + userID := c.Param("userId") + + if groupID == "" { + response.BadRequest(c, "group id is required") + return + } + if userID == "" { + response.BadRequest(c, "user id is required") + return + } + + err := h.adminGroupService.RemoveMember(c.Request.Context(), groupID, userID) + if err != nil { + response.HandleError(c, err, "failed to remove member") + return + } + + response.Success(c, dto.SuccessResponse{ + Success: true, + Message: "member removed successfully", + }) +} + +// TransferOwner 转让群主 +// PUT /api/v1/admin/groups/:id/transfer +func (h *AdminGroupHandler) TransferOwner(c *gin.Context) { + groupID := c.Param("id") + if groupID == "" { + response.BadRequest(c, "group id is required") + return + } + + var req dto.AdminTransferGroupOwnerRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + groupDetail, err := h.adminGroupService.TransferOwner(c.Request.Context(), groupID, req.NewOwnerID) + if err != nil { + response.HandleError(c, err, "failed to transfer owner") + return + } + + response.Success(c, groupDetail) +} diff --git a/internal/handler/admin_post_handler.go b/internal/handler/admin_post_handler.go new file mode 100644 index 0000000..b968437 --- /dev/null +++ b/internal/handler/admin_post_handler.go @@ -0,0 +1,228 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// AdminPostHandler 管理端帖子处理器 +type AdminPostHandler struct { + adminPostService service.AdminPostService +} + +// NewAdminPostHandler 创建管理端帖子处理器 +func NewAdminPostHandler(adminPostService service.AdminPostService) *AdminPostHandler { + return &AdminPostHandler{ + adminPostService: adminPostService, + } +} + +// GetPostList 获取帖子列表 +// GET /api/v1/admin/posts +func (h *AdminPostHandler) GetPostList(c *gin.Context) { + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + // 获取筛选参数 + keyword := c.Query("keyword") + status := c.Query("status") + authorID := c.Query("author_id") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + // 验证状态参数 + if status != "" && status != string(model.PostStatusDraft) && + status != string(model.PostStatusPending) && + status != string(model.PostStatusPublished) && + status != string(model.PostStatusRejected) { + response.BadRequest(c, "invalid status value") + return + } + + posts, total, err := h.adminPostService.GetPostList(c.Request.Context(), page, pageSize, keyword, status, authorID, startDate, endDate) + if err != nil { + response.InternalServerError(c, "failed to get post list") + return + } + + response.Paginated(c, posts, total, page, pageSize) +} + +// GetPostDetail 获取帖子详情 +// GET /api/v1/admin/posts/:id +func (h *AdminPostHandler) GetPostDetail(c *gin.Context) { + postID := c.Param("id") + if postID == "" { + response.BadRequest(c, "post id is required") + return + } + + postDetail, err := h.adminPostService.GetPostDetail(c.Request.Context(), postID) + if err != nil { + response.NotFound(c, "post not found") + return + } + + response.Success(c, postDetail) +} + +// DeletePost 删除帖子 +// DELETE /api/v1/admin/posts/:id +func (h *AdminPostHandler) DeletePost(c *gin.Context) { + postID := c.Param("id") + if postID == "" { + response.BadRequest(c, "post id is required") + return + } + + err := h.adminPostService.DeletePost(c.Request.Context(), postID) + if err != nil { + response.HandleError(c, err, "failed to delete post") + return + } + + response.Success(c, dto.SuccessResponse{ + Success: true, + Message: "post deleted successfully", + }) +} + +// ModeratePost 审核帖子 +// PUT /api/v1/admin/posts/:id/moderate +func (h *AdminPostHandler) ModeratePost(c *gin.Context) { + postID := c.Param("id") + if postID == "" { + response.BadRequest(c, "post id is required") + return + } + + var req dto.AdminModeratePostRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + // 验证状态值 + if req.Status != string(model.PostStatusPublished) && req.Status != string(model.PostStatusRejected) { + response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'") + return + } + + // 获取当前用户ID作为审核人 + reviewedBy := c.GetString("user_id") + + postDetail, err := h.adminPostService.ModeratePost(c.Request.Context(), postID, model.PostStatus(req.Status), req.Reason, reviewedBy) + if err != nil { + response.HandleError(c, err, "failed to moderate post") + return + } + + response.Success(c, postDetail) +} + +// BatchDeletePosts 批量删除帖子 +// POST /api/v1/admin/posts/batch-delete +func (h *AdminPostHandler) BatchDeletePosts(c *gin.Context) { + var req dto.AdminBatchDeletePostsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + result, err := h.adminPostService.BatchDeletePosts(c.Request.Context(), req.IDs) + if err != nil { + response.InternalServerError(c, "failed to batch delete posts") + return + } + + response.Success(c, result) +} + +// BatchModeratePosts 批量审核帖子 +// POST /api/v1/admin/posts/batch-moderate +func (h *AdminPostHandler) BatchModeratePosts(c *gin.Context) { + var req dto.AdminBatchModeratePostsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + // 验证状态值 + if req.Status != string(model.PostStatusPublished) && req.Status != string(model.PostStatusRejected) { + response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'") + return + } + + // 获取当前用户ID作为审核人 + reviewedBy := c.GetString("user_id") + + result, err := h.adminPostService.BatchModeratePosts(c.Request.Context(), req.IDs, model.PostStatus(req.Status), reviewedBy) + if err != nil { + response.InternalServerError(c, "failed to batch moderate posts") + return + } + + response.Success(c, result) +} + +// SetPostPin 置顶/取消置顶帖子 +// PUT /api/v1/admin/posts/:id/pin +func (h *AdminPostHandler) SetPostPin(c *gin.Context) { + postID := c.Param("id") + if postID == "" { + response.BadRequest(c, "post id is required") + return + } + + var req dto.AdminSetPostPinRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + postDetail, err := h.adminPostService.SetPostPin(c.Request.Context(), postID, req.IsPinned) + if err != nil { + response.HandleError(c, err, "failed to set post pin status") + return + } + + response.Success(c, postDetail) +} + +// SetPostFeature 加精/取消加精帖子 +// PUT /api/v1/admin/posts/:id/feature +func (h *AdminPostHandler) SetPostFeature(c *gin.Context) { + postID := c.Param("id") + if postID == "" { + response.BadRequest(c, "post id is required") + return + } + + var req dto.AdminSetPostFeatureRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + postDetail, err := h.adminPostService.SetPostFeature(c.Request.Context(), postID, req.IsFeatured) + if err != nil { + response.HandleError(c, err, "failed to set post feature status") + return + } + + response.Success(c, postDetail) +} diff --git a/internal/handler/admin_user_handler.go b/internal/handler/admin_user_handler.go new file mode 100644 index 0000000..9c55750 --- /dev/null +++ b/internal/handler/admin_user_handler.go @@ -0,0 +1,105 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// AdminUserHandler 管理端用户处理器 +type AdminUserHandler struct { + adminUserService service.AdminUserService +} + +// NewAdminUserHandler 创建管理端用户处理器 +func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler { + return &AdminUserHandler{ + adminUserService: adminUserService, + } +} + +// GetUserList 获取用户列表 +// GET /api/v1/admin/users +func (h *AdminUserHandler) GetUserList(c *gin.Context) { + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + // 获取筛选参数 + keyword := c.Query("keyword") + status := c.Query("status") + + // 验证状态参数 + if status != "" && status != string(model.UserStatusActive) && status != string(model.UserStatusBanned) { + response.BadRequest(c, "invalid status value") + return + } + + users, total, err := h.adminUserService.GetUserList(c.Request.Context(), page, pageSize, keyword, status) + if err != nil { + response.InternalServerError(c, "failed to get user list") + return + } + + response.Paginated(c, users, total, page, pageSize) +} + +// GetUserDetail 获取用户详情 +// GET /api/v1/admin/users/:id +func (h *AdminUserHandler) GetUserDetail(c *gin.Context) { + userID := c.Param("id") + if userID == "" { + response.BadRequest(c, "user id is required") + return + } + + userDetail, err := h.adminUserService.GetUserDetail(c.Request.Context(), userID) + if err != nil { + response.NotFound(c, "user not found") + return + } + + response.Success(c, userDetail) +} + +// UpdateUserStatus 更新用户状态 +// PUT /api/v1/admin/users/:id/status +func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) { + userID := c.Param("id") + if userID == "" { + response.BadRequest(c, "user id is required") + return + } + + var req dto.AdminUpdateUserStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + // 验证状态值 + if req.Status != string(model.UserStatusActive) && req.Status != string(model.UserStatusBanned) { + response.BadRequest(c, "invalid status value, must be 'active' or 'banned'") + return + } + + userDetail, err := h.adminUserService.UpdateUserStatus(c.Request.Context(), userID, model.UserStatus(req.Status)) + if err != nil { + response.HandleError(c, err, "failed to update user status") + return + } + + response.Success(c, userDetail) +} diff --git a/internal/handler/role_handler.go b/internal/handler/role_handler.go index 757b9db..5580d6c 100644 --- a/internal/handler/role_handler.go +++ b/internal/handler/role_handler.go @@ -202,3 +202,128 @@ func (h *RoleHandler) handleError(c *gin.Context, err error) { }) } } + +// GetRoleDetail 获取角色详情 +// GET /api/v1/admin/roles/:name +func (h *RoleHandler) GetRoleDetail(c *gin.Context) { + name := c.Param("name") + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "缺少角色名称", + }) + return + } + + detail, err := h.roleService.GetRoleDetail(c.Request.Context(), name) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": detail, + }) +} + +// GetRolePermissions 获取角色权限 +// GET /api/v1/admin/roles/:name/permissions +func (h *RoleHandler) GetRolePermissions(c *gin.Context) { + name := c.Param("name") + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "缺少角色名称", + }) + return + } + + permissions, err := h.roleService.GetRolePermissions(c.Request.Context(), name) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": permissions, + }) +} + +// GetAllPermissions 获取所有权限定义 +// GET /api/v1/admin/permissions +func (h *RoleHandler) GetAllPermissions(c *gin.Context) { + permissions, err := h.roleService.GetAllPermissions(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": "INTERNAL_ERROR", + "message": "获取权限列表失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": permissions, + }) +} + +// UpdateRolePermissionsRequest 更新角色权限请求 +type UpdateRolePermissionsRequest struct { + Permissions []PermissionInput `json:"permissions" binding:"required"` +} + +// PermissionInput 权限输入 +type PermissionInput struct { + Resource string `json:"resource" binding:"required"` + Action string `json:"action" binding:"required"` +} + +// UpdateRolePermissions 更新角色权限 +// PUT /api/v1/admin/roles/:name/permissions +func (h *RoleHandler) UpdateRolePermissions(c *gin.Context) { + name := c.Param("name") + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "缺少角色名称", + }) + return + } + + var req UpdateRolePermissionsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "请求参数错误", + }) + return + } + + // 转换权限格式 + var permissions []service.Permission + for _, p := range req.Permissions { + permissions = append(permissions, service.Permission{ + Resource: p.Resource, + Action: p.Action, + }) + } + + err := h.roleService.UpdateRolePermissions(c.Request.Context(), name, permissions) + if err != nil { + h.handleError(c, err) + return + } + + // 返回更新后的角色信息 + detail, err := h.roleService.GetRoleDetail(c.Request.Context(), name) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "权限更新成功", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "权限更新成功", + "data": detail, + }) +} diff --git a/internal/model/init.go b/internal/model/init.go index 122e390..0961344 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -155,6 +155,128 @@ func autoMigrate(db *gorm.DB) error { return err } + // 初始化角色种子数据 + if err := seedRoles(db); err != nil { + return fmt.Errorf("failed to seed roles: %w", err) + } + + // 初始化权限策略种子数据 + if err := seedPermissions(db); err != nil { + return fmt.Errorf("failed to seed permissions: %w", err) + } + + return nil +} + +// seedRoles 初始化角色种子数据 +func seedRoles(db *gorm.DB) error { + // 检查是否已有角色数据 + var count int64 + if err := db.Model(&Role{}).Count(&count).Error; err != nil { + return err + } + + // 如果已有数据,跳过种子初始化 + if count > 0 { + return nil + } + + // 预定义角色数据 + roles := []Role{ + { + Name: RoleSuperAdmin, + DisplayName: "超级管理员", + Description: "拥有系统最高权限,可以管理所有用户和内容", + Priority: 100, + }, + { + Name: RoleAdmin, + DisplayName: "管理员", + Description: "系统管理员,可以管理用户和内容", + Priority: 80, + }, + { + Name: RoleModerator, + DisplayName: "版主", + Description: "内容审核员,可以审核和管理内容", + Priority: 60, + }, + { + Name: RoleUser, + DisplayName: "普通用户", + Description: "注册用户,拥有基本权限", + Priority: 40, + }, + { + Name: RoleBanned, + DisplayName: "被封禁用户", + Description: "被禁止访问的用户", + Priority: 0, + }, + } + + // 批量插入角色 + if err := db.Create(&roles).Error; err != nil { + return err + } + + log.Printf("Seeded %d roles successfully", len(roles)) + return nil +} + +// seedPermissions 初始化权限策略种子数据 +func seedPermissions(db *gorm.DB) error { + // 检查是否已有权限策略数据 + var count int64 + if err := db.Model(&CasbinRule{}).Where("ptype = ?", "p").Count(&count).Error; err != nil { + return err + } + + // 如果已有数据,跳过种子初始化 + if count > 0 { + return nil + } + + // 预定义权限策略数据 + // p = sub, obj, act (角色, 资源, 操作) + permissions := []CasbinRule{ + // 超级管理员 - 拥有所有权限 + {Ptype: "p", V0: RoleSuperAdmin, V1: "/*", V2: "*"}, + + // 管理员 - 拥有管理权限 + {Ptype: "p", V0: RoleAdmin, V1: "/api/v1/admin/*", V2: "*"}, + {Ptype: "p", V0: RoleAdmin, V1: "/api/v1/users/*", V2: "GET"}, + {Ptype: "p", V0: RoleAdmin, V1: "/api/v1/posts/*", V2: "*"}, + {Ptype: "p", V0: RoleAdmin, V1: "/api/v1/comments/*", V2: "*"}, + {Ptype: "p", V0: RoleAdmin, V1: "/api/v1/groups/*", V2: "*"}, + + // 版主 - 拥有内容审核权限 + {Ptype: "p", V0: RoleModerator, V1: "/api/v1/posts/*", V2: "GET"}, + {Ptype: "p", V0: RoleModerator, V1: "/api/v1/posts/*", V2: "DELETE"}, + {Ptype: "p", V0: RoleModerator, V1: "/api/v1/comments/*", V2: "GET"}, + {Ptype: "p", V0: RoleModerator, V1: "/api/v1/comments/*", V2: "DELETE"}, + {Ptype: "p", V0: RoleModerator, V1: "/api/v1/groups/*", V2: "GET"}, + + // 普通用户 - 拥有基本权限 + {Ptype: "p", V0: RoleUser, V1: "/api/v1/posts", V2: "GET"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/posts/*", V2: "GET"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/posts", V2: "POST"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/comments/*", V2: "GET"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/comments", V2: "POST"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/users/me", V2: "GET"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/users/me", V2: "PUT"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/groups", V2: "GET"}, + {Ptype: "p", V0: RoleUser, V1: "/api/v1/groups/*", V2: "GET"}, + + // 被封禁用户 - 无权限 + } + + // 批量插入权限策略 + if err := db.Create(&permissions).Error; err != nil { + return err + } + + log.Printf("Seeded %d permissions successfully", len(permissions)) return nil } diff --git a/internal/model/post.go b/internal/model/post.go index 655e1b0..3b4931f 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -46,9 +46,10 @@ type Post struct { HotScore float64 `json:"hot_score" gorm:"column:hot_score;default:0;index:idx_posts_hot_score_created,priority:1"` // 置顶/锁定 - IsPinned bool `json:"is_pinned" gorm:"default:false"` - IsLocked bool `json:"is_locked" gorm:"default:false"` - IsDeleted bool `json:"-" gorm:"default:false"` + IsPinned bool `json:"is_pinned" gorm:"default:false"` + IsFeatured bool `json:"is_featured" gorm:"default:false"` // 加精 + IsLocked bool `json:"is_locked" gorm:"default:false"` + IsDeleted bool `json:"-" gorm:"default:false"` // 投票 IsVote bool `json:"is_vote" gorm:"column:is_vote;default:false"` diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index 934a7ed..4e2fa3d 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -298,3 +298,96 @@ func (r *CommentRepository) IsLiked(commentID, userID string) bool { r.db.Model(&model.CommentLike{}).Where("comment_id = ? AND user_id = ?", commentID, userID).Count(&count) return count > 0 } + +// AdminCommentListFilter 管理端评论列表筛选条件 +type AdminCommentListFilter struct { + Keyword string + PostID string + AuthorID string + Status string + StartDate string + EndDate string + Page int + PageSize int +} + +// GetAdminCommentList 获取管理端评论列表 +func (r *CommentRepository) GetAdminCommentList(filter AdminCommentListFilter) ([]*model.Comment, int64, error) { + var comments []*model.Comment + var total int64 + + query := r.db.Model(&model.Comment{}).Preload("User") + + // 应用筛选条件 + if filter.Keyword != "" { + query = query.Where("content LIKE ?", "%"+filter.Keyword+"%") + } + if filter.PostID != "" { + query = query.Where("post_id = ?", filter.PostID) + } + if filter.AuthorID != "" { + query = query.Where("user_id = ?", filter.AuthorID) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.StartDate != "" { + query = query.Where("created_at >= ?", filter.StartDate) + } + if filter.EndDate != "" { + query = query.Where("created_at <= ?", filter.EndDate+" 23:59:59") + } + + // 统计总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页查询 + offset := (filter.Page - 1) * filter.PageSize + if err := query.Offset(offset).Limit(filter.PageSize).Order("created_at DESC").Find(&comments).Error; err != nil { + return nil, 0, err + } + + return comments, total, nil +} + +// GetAdminCommentByID 获取管理端评论详情(包含关联信息) +func (r *CommentRepository) GetAdminCommentByID(id string) (*model.Comment, error) { + var comment model.Comment + err := r.db.Preload("User").First(&comment, "id = ?", id).Error + if err != nil { + return nil, err + } + return &comment, nil +} + +// BatchDelete 批量删除评论 +func (r *CommentRepository) BatchDelete(ids []string) ([]string, error) { + var failedIDs []string + + for _, id := range ids { + if err := r.Delete(id); err != nil { + failedIDs = append(failedIDs, id) + } + } + + return failedIDs, nil +} + +// GetCommentsByPostIDForAdmin 管理端获取帖子的评论列表(包含所有状态) +func (r *CommentRepository) GetCommentsByPostIDForAdmin(postID string, page, pageSize int) ([]*model.Comment, int64, error) { + var comments []*model.Comment + var total int64 + + r.db.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&total) + + offset := (page - 1) * pageSize + err := r.db.Where("post_id = ?", postID). + Preload("User"). + Offset(offset).Limit(pageSize). + Order("created_at DESC"). + Find(&comments).Error + + return comments, total, err +} diff --git a/internal/repository/group_repo.go b/internal/repository/group_repo.go index cfbefe6..ec2bb2b 100644 --- a/internal/repository/group_repo.go +++ b/internal/repository/group_repo.go @@ -35,6 +35,20 @@ type GroupRepository interface { GetAnnouncements(groupID string, page, pageSize int) ([]model.GroupAnnouncement, int64, error) GetAnnouncementByID(id string) (*model.GroupAnnouncement, error) DeleteAnnouncement(id string) error + + // 管理端群组操作 + GetGroupList(page, pageSize int, keyword, status, ownerID, startDate, endDate string) ([]model.Group, int64, error) + GetGroupWithOwner(groupID string) (*model.Group, error) + GetLatestAnnouncement(groupID string) (*model.GroupAnnouncement, error) + UpdateGroupStatus(groupID string, status string) error + GetGroupPostCount(groupID string) (int64, error) + GetMembersWithUserInfo(groupID string, page, pageSize int) ([]GroupMemberWithUser, int64, error) +} + +// GroupMemberWithUser 群成员带用户信息的结构 +type GroupMemberWithUser struct { + model.GroupMember + User *model.User } // groupRepository 群组仓库实现 @@ -240,3 +254,159 @@ func (r *groupRepository) GetAnnouncementByID(id string) (*model.GroupAnnounceme func (r *groupRepository) DeleteAnnouncement(id string) error { return r.db.Delete(&model.GroupAnnouncement{}, "id = ?", id).Error } + +// ==================== 管理端群组操作 ==================== + +// GetGroupList 获取群组列表(管理端) +func (r *groupRepository) GetGroupList(page, pageSize int, keyword, status, ownerID, startDate, endDate string) ([]model.Group, int64, error) { + var groups []model.Group + var total int64 + + query := r.db.Model(&model.Group{}) + + // 关键词搜索 + if keyword != "" { + query = query.Where("name LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + + // 状态筛选(这里假设群组有status字段,如果没有则忽略) + // 注意:当前Group模型没有status字段,状态通过是否解散来判断 + // 如果需要status字段,需要在模型中添加 + + // 群主筛选 + if ownerID != "" { + query = query.Where("owner_id = ?", ownerID) + } + + // 日期筛选 + if startDate != "" { + query = query.Where("created_at >= ?", startDate+" 00:00:00") + } + if endDate != "" { + query = query.Where("created_at <= ?", endDate+" 23:59:59") + } + + query.Count(&total) + + offset := (page - 1) * pageSize + err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&groups).Error + return groups, total, err +} + +// GetGroupWithOwner 获取群组详情(包含群主信息) +func (r *groupRepository) GetGroupWithOwner(groupID string) (*model.Group, error) { + var group model.Group + err := r.db.First(&group, "id = ?", groupID).Error + if err != nil { + return nil, err + } + return &group, nil +} + +// GetLatestAnnouncement 获取群组最新公告 +func (r *groupRepository) GetLatestAnnouncement(groupID string) (*model.GroupAnnouncement, error) { + var announcement model.GroupAnnouncement + err := r.db.Where("group_id = ?", groupID). + Order("is_pinned DESC, created_at DESC"). + First(&announcement).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &announcement, nil +} + +// UpdateGroupStatus 更新群组状态 +func (r *groupRepository) UpdateGroupStatus(groupID string, status string) error { + // 注意:当前Group模型没有status字段 + // 这里我们通过更新一个虚拟字段或者使用其他方式来实现 + // 暂时通过更新updated_at来模拟状态更新 + // 如果需要真正的状态字段,需要在模型中添加 + return r.db.Model(&model.Group{}).Where("id = ?", groupID). + Update("updated_at", gorm.Expr("NOW()")).Error +} + +// GetGroupPostCount 获取群组帖子数量 +func (r *groupRepository) GetGroupPostCount(groupID string) (int64, error) { + var count int64 + // 假设帖子表有group_id字段 + err := r.db.Model(&model.Post{}).Where("group_id = ?", groupID).Count(&count).Error + return count, err +} + +// GetMembersWithUserInfo 获取群成员列表(包含用户信息) +func (r *groupRepository) GetMembersWithUserInfo(groupID string, page, pageSize int) ([]GroupMemberWithUser, int64, error) { + var members []GroupMemberWithUser + var total int64 + + // 先获取总数 + err := r.db.Model(&model.GroupMember{}).Where("group_id = ?", groupID).Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 联表查询获取成员和用户信息 + offset := (page - 1) * pageSize + query := ` + SELECT gm.*, u.id as user_id, u.username, u.nickname, u.avatar + FROM group_members gm + LEFT JOIN users u ON gm.user_id = u.id + WHERE gm.group_id = ? + ORDER BY + CASE gm.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'member' THEN 3 + END, + gm.join_time ASC + LIMIT ? OFFSET ? + ` + + type memberRow struct { + ID string `gorm:"column:id"` + GroupID string `gorm:"column:group_id"` + UserID string `gorm:"column:user_id"` + Role string `gorm:"column:role"` + Nickname string `gorm:"column:nickname"` + Muted bool `gorm:"column:muted"` + JoinTime string `gorm:"column:join_time"` + CreatedAt string `gorm:"column:created_at"` + UpdatedAt string `gorm:"column:updated_at"` + // 用户信息 + Username string `gorm:"column:username"` + UserNickname string `gorm:"column:nickname"` + UserAvatar string `gorm:"column:avatar"` + } + + var rows []memberRow + err = r.db.Raw(query, groupID, pageSize, offset).Scan(&rows).Error + if err != nil { + return nil, 0, err + } + + members = make([]GroupMemberWithUser, 0, len(rows)) + for _, row := range rows { + member := model.GroupMember{ + ID: row.ID, + GroupID: row.GroupID, + UserID: row.UserID, + Role: row.Role, + Nickname: row.Nickname, + Muted: row.Muted, + } + user := &model.User{ + ID: row.UserID, + Username: row.Username, + Nickname: row.UserNickname, + Avatar: row.UserAvatar, + } + members = append(members, GroupMemberWithUser{ + GroupMember: member, + User: user, + }) + } + + return members, total, nil +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index d0781c6..380264e 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -529,3 +529,115 @@ func (r *PostRepository) deleteWithTx(tx *gorm.DB, id string) error { // 最后删除帖子本身(软删除) return tx.Delete(&model.Post{}, "id = ?", id).Error } + +// ========== Admin methods ========== + +// AdminPostListQuery 管理端帖子列表查询参数 +type AdminPostListQuery struct { + Page int + PageSize int + Keyword string + Status string // 为空表示所有状态 + AuthorID string + StartDate string // 格式: 2006-01-02 + EndDate string // 格式: 2006-01-02 +} + +// AdminList 管理端分页获取帖子列表(支持多条件筛选) +func (r *PostRepository) AdminList(query AdminPostListQuery) ([]*model.Post, int64, error) { + var posts []*model.Post + var total int64 + + db := r.db.Model(&model.Post{}) + + // 关键词搜索(标题或内容) + if query.Keyword != "" { + if r.db.Dialector.Name() == "postgres" { + db = db.Where( + "to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)", + query.Keyword, + ) + } else { + searchPattern := "%" + query.Keyword + "%" + db = db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) + } + } + + // 状态筛选 + if query.Status != "" { + db = db.Where("status = ?", query.Status) + } + + // 作者筛选 + if query.AuthorID != "" { + db = db.Where("user_id = ?", query.AuthorID) + } + + // 日期范围筛选 + if query.StartDate != "" { + db = db.Where("created_at >= ?", query.StartDate+" 00:00:00") + } + if query.EndDate != "" { + db = db.Where("created_at <= ?", query.EndDate+" 23:59:59") + } + + // 统计总数 + db.Count(&total) + + // 分页查询 + offset := (query.Page - 1) * query.PageSize + err := db.Preload("User").Preload("Images"). + Offset(offset).Limit(query.PageSize). + Order("created_at DESC"). + Find(&posts).Error + + return posts, total, err +} + +// GetByIDForAdmin 管理端根据ID获取帖子(包含所有状态) +func (r *PostRepository) GetByIDForAdmin(id string) (*model.Post, error) { + var post model.Post + err := r.db.Preload("User").Preload("Images").First(&post, "id = ?", id).Error + if err != nil { + return nil, err + } + return &post, nil +} + +// BatchDelete 批量删除帖子 +func (r *PostRepository) BatchDelete(ids []string) ([]string, error) { + var failedIDs []string + + for _, id := range ids { + if err := r.Delete(id); err != nil { + failedIDs = append(failedIDs, id) + } + } + + return failedIDs, nil +} + +// BatchUpdateStatus 批量更新帖子审核状态 +func (r *PostRepository) BatchUpdateStatus(ids []string, status model.PostStatus, reviewedBy string) ([]string, error) { + var failedIDs []string + + for _, id := range ids { + if err := r.UpdateModerationStatus(id, status, "", reviewedBy); err != nil { + failedIDs = append(failedIDs, id) + } + } + + return failedIDs, nil +} + +// UpdatePinStatus 更新帖子置顶状态 +func (r *PostRepository) UpdatePinStatus(postID string, isPinned bool) error { + return r.db.Model(&model.Post{}).Where("id = ?", postID). + Update("is_pinned", isPinned).Error +} + +// UpdateFeatureStatus 更新帖子加精状态 +func (r *PostRepository) UpdateFeatureStatus(postID string, isFeatured bool) error { + return r.db.Model(&model.Post{}).Where("id = ?", postID). + Update("is_featured", isFeatured).Error +} diff --git a/internal/repository/role_repo.go b/internal/repository/role_repo.go index 8949649..d73d6df 100644 --- a/internal/repository/role_repo.go +++ b/internal/repository/role_repo.go @@ -30,6 +30,9 @@ type RoleRepository interface { // GetAllRoles 获取所有角色 GetAllRoles(ctx context.Context) ([]model.Role, error) + + // GetRoleUserCount 获取角色对应的用户数量 + GetRoleUserCount(ctx context.Context, role string) (int64, error) } type roleRepository struct { @@ -95,3 +98,12 @@ func (r *roleRepository) GetAllRoles(ctx context.Context) ([]model.Role, error) err := r.db.WithContext(ctx).Order("priority DESC").Find(&roles).Error return roles, err } + +func (r *roleRepository) GetRoleUserCount(ctx context.Context, role string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&model.UserRole{}). + Where("role = ?", role). + Count(&count).Error + return count, err +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index df1386d..63942db 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -403,3 +403,53 @@ func (r *UserRepository) GetMutualFollowStatus(currentUserID string, targetUserI return result, nil } + +// AdminList 管理端分页获取用户列表(支持关键词和状态筛选) +func (r *UserRepository) AdminList(page, pageSize int, keyword, status string) ([]*model.User, int64, error) { + var users []*model.User + var total int64 + + query := r.db.Model(&model.User{}) + + // 关键词搜索(用户名、昵称、邮箱) + if keyword != "" { + if r.db.Dialector.Name() == "postgres" { + query = query.Where( + "username ILIKE ? OR nickname ILIKE ? OR email ILIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", + ) + } else { + lowerSearchPattern := "%" + strings.ToLower(keyword) + "%" + query = query.Where( + "LOWER(username) LIKE ? OR LOWER(nickname) LIKE ? OR LOWER(email) LIKE ?", + lowerSearchPattern, lowerSearchPattern, lowerSearchPattern, + ) + } + } + + // 状态筛选 + if status != "" { + query = query.Where("status = ?", status) + } + + query.Count(&total) + + offset := (page - 1) * pageSize + err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&users).Error + + return users, total, err +} + +// GetCommentsCount 获取用户评论数 +func (r *UserRepository) GetCommentsCount(userID string) (int64, error) { + var count int64 + err := r.db.Model(&model.Comment{}). + Where("user_id = ?", userID). + Count(&count).Error + return count, err +} + +// UpdateStatus 更新用户状态 +func (r *UserRepository) UpdateStatus(userID string, status model.UserStatus) error { + return r.db.Model(&model.User{}).Where("id = ?", userID).Update("status", status).Error +} diff --git a/internal/router/router.go b/internal/router/router.go index 369f1bc..f076e8d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -11,23 +11,28 @@ import ( // Router 路由配置 type Router struct { - engine *gin.Engine - userHandler *handler.UserHandler - postHandler *handler.PostHandler - commentHandler *handler.CommentHandler - messageHandler *handler.MessageHandler - notificationHandler *handler.NotificationHandler - uploadHandler *handler.UploadHandler - pushHandler *handler.PushHandler - systemMessageHandler *handler.SystemMessageHandler - groupHandler *handler.GroupHandler - stickerHandler *handler.StickerHandler - gorseHandler *handler.GorseHandler - voteHandler *handler.VoteHandler - scheduleHandler *handler.ScheduleHandler - roleHandler *handler.RoleHandler - jwtService *service.JWTService - casbinService service.CasbinService + engine *gin.Engine + userHandler *handler.UserHandler + postHandler *handler.PostHandler + commentHandler *handler.CommentHandler + messageHandler *handler.MessageHandler + notificationHandler *handler.NotificationHandler + uploadHandler *handler.UploadHandler + pushHandler *handler.PushHandler + systemMessageHandler *handler.SystemMessageHandler + groupHandler *handler.GroupHandler + stickerHandler *handler.StickerHandler + gorseHandler *handler.GorseHandler + voteHandler *handler.VoteHandler + scheduleHandler *handler.ScheduleHandler + roleHandler *handler.RoleHandler + adminUserHandler *handler.AdminUserHandler + adminPostHandler *handler.AdminPostHandler + adminCommentHandler *handler.AdminCommentHandler + adminGroupHandler *handler.AdminGroupHandler + adminDashboardHandler *handler.AdminDashboardHandler + jwtService *service.JWTService + casbinService service.CasbinService } // New 创建路由 @@ -47,6 +52,11 @@ func New( voteHandler *handler.VoteHandler, scheduleHandler *handler.ScheduleHandler, roleHandler *handler.RoleHandler, + adminUserHandler *handler.AdminUserHandler, + adminPostHandler *handler.AdminPostHandler, + adminCommentHandler *handler.AdminCommentHandler, + adminGroupHandler *handler.AdminGroupHandler, + adminDashboardHandler *handler.AdminDashboardHandler, activityService service.UserActivityService, casbinService service.CasbinService, ) *Router { @@ -56,23 +66,28 @@ func New( userHandler.SetActivityService(activityService) r := &Router{ - engine: gin.Default(), - userHandler: userHandler, - postHandler: postHandler, - commentHandler: commentHandler, - messageHandler: messageHandler, - notificationHandler: notificationHandler, - uploadHandler: uploadHandler, - pushHandler: pushHandler, - systemMessageHandler: systemMessageHandler, - groupHandler: groupHandler, - stickerHandler: stickerHandler, - gorseHandler: gorseHandler, - voteHandler: voteHandler, - scheduleHandler: scheduleHandler, - roleHandler: roleHandler, - jwtService: jwtService, - casbinService: casbinService, + engine: gin.Default(), + userHandler: userHandler, + postHandler: postHandler, + commentHandler: commentHandler, + messageHandler: messageHandler, + notificationHandler: notificationHandler, + uploadHandler: uploadHandler, + pushHandler: pushHandler, + systemMessageHandler: systemMessageHandler, + groupHandler: groupHandler, + stickerHandler: stickerHandler, + gorseHandler: gorseHandler, + voteHandler: voteHandler, + scheduleHandler: scheduleHandler, + roleHandler: roleHandler, + adminUserHandler: adminUserHandler, + adminPostHandler: adminPostHandler, + adminCommentHandler: adminCommentHandler, + adminGroupHandler: adminGroupHandler, + adminDashboardHandler: adminDashboardHandler, + jwtService: jwtService, + casbinService: casbinService, } r.setupRoutes() @@ -365,9 +380,61 @@ func (r *Router) setupRoutes() { { // 角色管理 admin.GET("/roles", r.roleHandler.GetAllRoles) + admin.GET("/roles/:name", r.roleHandler.GetRoleDetail) + admin.GET("/roles/:name/permissions", r.roleHandler.GetRolePermissions) + admin.PUT("/roles/:name/permissions", r.roleHandler.UpdateRolePermissions) + admin.GET("/permissions", r.roleHandler.GetAllPermissions) admin.GET("/users/:id/roles", r.roleHandler.GetUserRoles) admin.POST("/users/:id/roles", r.roleHandler.AssignRole) admin.DELETE("/users/:id/roles/:role", r.roleHandler.RemoveRole) + + // 用户管理 + if r.adminUserHandler != nil { + admin.GET("/users", r.adminUserHandler.GetUserList) + admin.GET("/users/:id", r.adminUserHandler.GetUserDetail) + admin.PUT("/users/:id/status", r.adminUserHandler.UpdateUserStatus) + } + + // 帖子管理 + if r.adminPostHandler != nil { + admin.GET("/posts", r.adminPostHandler.GetPostList) + admin.GET("/posts/:id", r.adminPostHandler.GetPostDetail) + admin.DELETE("/posts/:id", r.adminPostHandler.DeletePost) + admin.PUT("/posts/:id/moderate", r.adminPostHandler.ModeratePost) + admin.POST("/posts/batch-delete", r.adminPostHandler.BatchDeletePosts) + admin.POST("/posts/batch-moderate", r.adminPostHandler.BatchModeratePosts) + admin.PUT("/posts/:id/pin", r.adminPostHandler.SetPostPin) + admin.PUT("/posts/:id/feature", r.adminPostHandler.SetPostFeature) + } + + // 评论管理 + if r.adminCommentHandler != nil { + admin.GET("/comments", r.adminCommentHandler.GetCommentList) + admin.GET("/comments/:id", r.adminCommentHandler.GetCommentDetail) + admin.DELETE("/comments/:id", r.adminCommentHandler.DeleteComment) + admin.POST("/comments/batch-delete", r.adminCommentHandler.BatchDeleteComments) + admin.GET("/comments/post/:postId", r.adminCommentHandler.GetPostComments) + } + + // 群组管理 + if r.adminGroupHandler != nil { + admin.GET("/groups", r.adminGroupHandler.GetGroupList) + admin.GET("/groups/:id", r.adminGroupHandler.GetGroupDetail) + admin.GET("/groups/:id/members", r.adminGroupHandler.GetGroupMembers) + admin.DELETE("/groups/:id", r.adminGroupHandler.DeleteGroup) + admin.PUT("/groups/:id/status", r.adminGroupHandler.UpdateGroupStatus) + admin.DELETE("/groups/:id/members/:userId", r.adminGroupHandler.RemoveMember) + admin.PUT("/groups/:id/transfer", r.adminGroupHandler.TransferOwner) + } + + // 仪表盘 + if r.adminDashboardHandler != nil { + admin.GET("/dashboard/stats", r.adminDashboardHandler.GetStats) + admin.GET("/dashboard/user-activity", r.adminDashboardHandler.GetUserActivityTrend) + admin.GET("/dashboard/content-stats", r.adminDashboardHandler.GetContentStats) + admin.GET("/dashboard/pending-content", r.adminDashboardHandler.GetPendingContent) + admin.GET("/dashboard/online-users", r.adminDashboardHandler.GetOnlineUsers) + } } } } diff --git a/internal/service/admin_comment_service.go b/internal/service/admin_comment_service.go new file mode 100644 index 0000000..52d1412 --- /dev/null +++ b/internal/service/admin_comment_service.go @@ -0,0 +1,243 @@ +package service + +import ( + "context" + "encoding/json" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +// AdminCommentService 管理端评论服务接口 +type AdminCommentService interface { + // GetCommentList 获取评论列表(分页、搜索、状态筛选) + GetCommentList(ctx context.Context, page, pageSize int, keyword, postID, authorID, status, startDate, endDate string) ([]dto.AdminCommentListResponse, int64, error) + // GetCommentDetail 获取评论详情 + GetCommentDetail(ctx context.Context, commentID string) (*dto.AdminCommentDetailResponse, error) + // DeleteComment 删除评论 + DeleteComment(ctx context.Context, commentID string) error + // BatchDeleteComments 批量删除评论 + BatchDeleteComments(ctx context.Context, ids []string) (*dto.AdminBatchOperationResponse, error) + // GetCommentsByPostID 获取帖子的评论列表 + GetCommentsByPostID(ctx context.Context, postID string, page, pageSize int) ([]dto.AdminCommentListResponse, int64, error) +} + +// adminCommentServiceImpl 管理端评论服务实现 +type adminCommentServiceImpl struct { + commentRepo *repository.CommentRepository + postRepo *repository.PostRepository +} + +// NewAdminCommentService 创建管理端评论服务 +func NewAdminCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository) AdminCommentService { + return &adminCommentServiceImpl{ + commentRepo: commentRepo, + postRepo: postRepo, + } +} + +// GetCommentList 获取评论列表 +func (s *adminCommentServiceImpl) GetCommentList(ctx context.Context, page, pageSize int, keyword, postID, authorID, status, startDate, endDate string) ([]dto.AdminCommentListResponse, int64, error) { + filter := repository.AdminCommentListFilter{ + Keyword: keyword, + PostID: postID, + AuthorID: authorID, + Status: status, + StartDate: startDate, + EndDate: endDate, + Page: page, + PageSize: pageSize, + } + + comments, total, err := s.commentRepo.GetAdminCommentList(filter) + if err != nil { + return nil, 0, err + } + + responses := make([]dto.AdminCommentListResponse, len(comments)) + for i, comment := range comments { + responses[i] = s.convertCommentToAdminListResponse(comment) + } + + return responses, total, nil +} + +// GetCommentDetail 获取评论详情 +func (s *adminCommentServiceImpl) GetCommentDetail(ctx context.Context, commentID string) (*dto.AdminCommentDetailResponse, error) { + comment, err := s.commentRepo.GetAdminCommentByID(commentID) + if err != nil { + return nil, err + } + + return s.convertCommentToAdminDetailResponse(comment), nil +} + +// DeleteComment 删除评论 +func (s *adminCommentServiceImpl) DeleteComment(ctx context.Context, commentID string) error { + return s.commentRepo.Delete(commentID) +} + +// BatchDeleteComments 批量删除评论 +func (s *adminCommentServiceImpl) BatchDeleteComments(ctx context.Context, ids []string) (*dto.AdminBatchOperationResponse, error) { + failedIDs, err := s.commentRepo.BatchDelete(ids) + if err != nil { + return &dto.AdminBatchOperationResponse{ + Success: false, + Message: "批量删除失败", + Failed: ids, + }, err + } + + if len(failedIDs) == 0 { + return &dto.AdminBatchOperationResponse{ + Success: true, + Message: "批量删除成功", + }, nil + } + + return &dto.AdminBatchOperationResponse{ + Success: true, + Message: "部分评论删除成功", + Failed: failedIDs, + }, nil +} + +// GetCommentsByPostID 获取帖子的评论列表 +func (s *adminCommentServiceImpl) GetCommentsByPostID(ctx context.Context, postID string, page, pageSize int) ([]dto.AdminCommentListResponse, int64, error) { + comments, total, err := s.commentRepo.GetCommentsByPostIDForAdmin(postID, page, pageSize) + if err != nil { + return nil, 0, err + } + + responses := make([]dto.AdminCommentListResponse, len(comments)) + for i, comment := range comments { + responses[i] = s.convertCommentToAdminListResponse(comment) + } + + return responses, total, nil +} + +// convertCommentToAdminListResponse 转换评论为管理端列表响应 +func (s *adminCommentServiceImpl) convertCommentToAdminListResponse(comment *model.Comment) dto.AdminCommentListResponse { + // 解析图片 + var images []dto.CommentImageResponse + if comment.Images != "" { + var urls []string + if err := json.Unmarshal([]byte(comment.Images), &urls); err == nil { + images = make([]dto.CommentImageResponse, len(urls)) + for i, url := range urls { + images[i] = dto.CommentImageResponse{URL: url} + } + } + } + + // 作者信息 + var author *dto.UserResponse + if comment.User != nil { + author = &dto.UserResponse{ + ID: comment.User.ID, + Username: comment.User.Username, + Nickname: comment.User.Nickname, + Email: comment.User.Email, + Phone: comment.User.Phone, + Avatar: comment.User.Avatar, + Bio: comment.User.Bio, + Website: comment.User.Website, + Location: comment.User.Location, + CreatedAt: comment.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + // 帖子简要信息 + var postInfo *dto.AdminCommentPostInfo + if comment.PostID != "" { + postInfo = &dto.AdminCommentPostInfo{ + ID: comment.PostID, + } + // 尝试获取帖子标题 + post, err := s.postRepo.GetByID(comment.PostID) + if err == nil && post != nil { + postInfo.Title = post.Title + } + } + + return dto.AdminCommentListResponse{ + ID: comment.ID, + PostID: comment.PostID, + UserID: comment.UserID, + ParentID: comment.ParentID, + RootID: comment.RootID, + Content: comment.Content, + Images: images, + Status: string(comment.Status), + LikesCount: comment.LikesCount, + RepliesCount: comment.RepliesCount, + CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: comment.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + Author: author, + Post: postInfo, + } +} + +// convertCommentToAdminDetailResponse 转换评论为管理端详情响应 +func (s *adminCommentServiceImpl) convertCommentToAdminDetailResponse(comment *model.Comment) *dto.AdminCommentDetailResponse { + // 解析图片 + var images []dto.CommentImageResponse + if comment.Images != "" { + var urls []string + if err := json.Unmarshal([]byte(comment.Images), &urls); err == nil { + images = make([]dto.CommentImageResponse, len(urls)) + for i, url := range urls { + images[i] = dto.CommentImageResponse{URL: url} + } + } + } + + // 作者信息 + var author *dto.UserResponse + if comment.User != nil { + author = &dto.UserResponse{ + ID: comment.User.ID, + Username: comment.User.Username, + Nickname: comment.User.Nickname, + Email: comment.User.Email, + Phone: comment.User.Phone, + Avatar: comment.User.Avatar, + Bio: comment.User.Bio, + Website: comment.User.Website, + Location: comment.User.Location, + CreatedAt: comment.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + // 帖子简要信息 + var postInfo *dto.AdminCommentPostInfo + if comment.PostID != "" { + postInfo = &dto.AdminCommentPostInfo{ + ID: comment.PostID, + } + // 尝试获取帖子标题 + post, err := s.postRepo.GetByID(comment.PostID) + if err == nil && post != nil { + postInfo.Title = post.Title + } + } + + return &dto.AdminCommentDetailResponse{ + ID: comment.ID, + PostID: comment.PostID, + UserID: comment.UserID, + ParentID: comment.ParentID, + RootID: comment.RootID, + Content: comment.Content, + Images: images, + Status: string(comment.Status), + LikesCount: comment.LikesCount, + RepliesCount: comment.RepliesCount, + CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: comment.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + Author: author, + Post: postInfo, + } +} diff --git a/internal/service/admin_dashboard_service.go b/internal/service/admin_dashboard_service.go new file mode 100644 index 0000000..587b2d1 --- /dev/null +++ b/internal/service/admin_dashboard_service.go @@ -0,0 +1,444 @@ +package service + +import ( + "context" + "time" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" + + "gorm.io/gorm" +) + +// AdminDashboardService 管理端仪表盘服务接口 +type AdminDashboardService interface { + // GetStats 获取统计数据 + GetStats(ctx context.Context) (*dto.DashboardStatsResponse, error) + // GetUserActivityTrend 获取用户活跃度趋势 + GetUserActivityTrend(ctx context.Context, days int) ([]dto.UserActivityTrendResponse, error) + // GetContentStats 获取内容统计 + GetContentStats(ctx context.Context) (*dto.ContentStatsResponse, error) + // GetPendingContent 获取待审核内容 + GetPendingContent(ctx context.Context, limit int) ([]dto.PendingContentResponse, error) + // GetOnlineUsers 获取在线用户数 + GetOnlineUsers(ctx context.Context) (*dto.OnlineUsersResponse, error) +} + +// adminDashboardServiceImpl 管理端仪表盘服务实现 +type adminDashboardServiceImpl struct { + db *gorm.DB + userRepo *repository.UserRepository + postRepo *repository.PostRepository + commentRepo *repository.CommentRepository + groupRepo repository.GroupRepository + activityRepo *repository.UserActivityRepository +} + +// NewAdminDashboardService 创建管理端仪表盘服务 +func NewAdminDashboardService( + db *gorm.DB, + userRepo *repository.UserRepository, + postRepo *repository.PostRepository, + commentRepo *repository.CommentRepository, + groupRepo repository.GroupRepository, + activityRepo *repository.UserActivityRepository, +) AdminDashboardService { + return &adminDashboardServiceImpl{ + db: db, + userRepo: userRepo, + postRepo: postRepo, + commentRepo: commentRepo, + groupRepo: groupRepo, + activityRepo: activityRepo, + } +} + +// GetStats 获取统计数据 +func (s *adminDashboardServiceImpl) GetStats(ctx context.Context) (*dto.DashboardStatsResponse, error) { + var stats dto.DashboardStatsResponse + + // 获取当前时间 + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + weekStart := todayStart.AddDate(0, 0, -int(now.Weekday())) + + // 总用户数 + if err := s.db.Model(&model.User{}).Count(&stats.TotalUsers).Error; err != nil { + return nil, err + } + + // 今日新增用户 + if err := s.db.Model(&model.User{}).Where("created_at >= ?", todayStart).Count(&stats.TodayNewUsers).Error; err != nil { + return nil, err + } + + // 今日活跃用户 + todayActive, err := s.activityRepo.GetDAU(ctx, now) + if err != nil { + // 如果出错,尝试从数据库获取 + stats.ActiveUsersToday = 0 + } else { + stats.ActiveUsersToday = todayActive + } + + // 本周活跃用户(使用 WAU) + year, week := now.ISOWeek() + weekActive, err := s.activityRepo.GetWAUFromRedis(ctx, year, week) + if err != nil { + // 如果 Redis 获取失败,从数据库计算 + var weekActiveCount int64 + err := s.db.Model(&model.UserActiveLog{}). + Where("active_date >= ?", weekStart). + Distinct("user_id"). + Count(&weekActiveCount).Error + if err == nil { + stats.ActiveUsersWeek = weekActiveCount + } + } else { + stats.ActiveUsersWeek = weekActive + } + + // 本月活跃用户(使用 MAU) + monthActive, err := s.activityRepo.GetMAUFromRedis(ctx, now.Year(), int(now.Month())) + if err != nil { + // 如果 Redis 获取失败,从数据库计算 + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + var monthActiveCount int64 + err := s.db.Model(&model.UserActiveLog{}). + Where("active_date >= ?", monthStart). + Distinct("user_id"). + Count(&monthActiveCount).Error + if err == nil { + stats.ActiveUsersMonth = monthActiveCount + } + } else { + stats.ActiveUsersMonth = monthActive + } + + // 获取留存率数据(从统计快照表获取) + retentionStats := s.getRetentionStats(ctx, now) + stats.Retention1Day = retentionStats.Retention1Day + stats.Retention7Day = retentionStats.Retention7Day + stats.Retention30Day = retentionStats.Retention30Day + + // 总帖子数 + if err := s.db.Model(&model.Post{}).Count(&stats.TotalPosts).Error; err != nil { + return nil, err + } + + // 今日帖子数 + if err := s.db.Model(&model.Post{}).Where("created_at >= ?", todayStart).Count(&stats.TodayPosts).Error; err != nil { + return nil, err + } + + // 待审核帖子数 + if err := s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPending).Count(&stats.PendingPosts).Error; err != nil { + return nil, err + } + + // 总评论数 + if err := s.db.Model(&model.Comment{}).Count(&stats.TotalComments).Error; err != nil { + return nil, err + } + + // 今日评论数 + if err := s.db.Model(&model.Comment{}).Where("created_at >= ?", todayStart).Count(&stats.TodayComments).Error; err != nil { + return nil, err + } + + // 总群组数 + if err := s.db.Model(&model.Group{}).Count(&stats.TotalGroups).Error; err != nil { + return nil, err + } + + // 今日新建群组 + if err := s.db.Model(&model.Group{}).Where("created_at >= ?", todayStart).Count(&stats.TodayNewGroups).Error; err != nil { + return nil, err + } + + // 待审核评论数 + var pendingComments int64 + if err := s.db.Model(&model.Comment{}).Where("status = ?", model.CommentStatusPending).Count(&pendingComments).Error; err != nil { + return nil, err + } + stats.PendingComments = pendingComments + + // 计算待审核总数 + stats.PendingReview = stats.PendingPosts + stats.PendingComments + stats.PendingPostsCount = stats.PendingPosts + + return &stats, nil +} + +// GetUserActivityTrend 获取用户活跃度趋势 +func (s *adminDashboardServiceImpl) GetUserActivityTrend(ctx context.Context, days int) ([]dto.UserActivityTrendResponse, error) { + if days <= 0 { + days = 7 + } + if days > 30 { + days = 30 + } + + now := time.Now() + results := make([]dto.UserActivityTrendResponse, days) + + for i := days - 1; i >= 0; i-- { + date := now.AddDate(0, 0, -i) + dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + dateEnd := dateStart.Add(24 * time.Hour) + + trend := dto.UserActivityTrendResponse{ + Date: dateStart.Format("2006-01-02"), + } + + // 新增用户数 + var newUsers int64 + if err := s.db.Model(&model.User{}). + Where("created_at >= ? AND created_at < ?", dateStart, dateEnd). + Count(&newUsers).Error; err == nil { + trend.NewUsers = newUsers + } + + // 活跃用户数 + activeUsers, err := s.activityRepo.GetDAU(ctx, date) + if err == nil { + trend.ActiveUsers = activeUsers + } + + // 帖子数 + var posts int64 + if err := s.db.Model(&model.Post{}). + Where("created_at >= ? AND created_at < ?", dateStart, dateEnd). + Count(&posts).Error; err == nil { + trend.Posts = posts + } + + // 评论数 + var comments int64 + if err := s.db.Model(&model.Comment{}). + Where("created_at >= ? AND created_at < ?", dateStart, dateEnd). + Count(&comments).Error; err == nil { + trend.Comments = comments + } + + results[days-1-i] = trend + } + + return results, nil +} + +// GetContentStats 获取内容统计 +func (s *adminDashboardServiceImpl) GetContentStats(ctx context.Context) (*dto.ContentStatsResponse, error) { + var response dto.ContentStatsResponse + + // 内容分布 + var totalPosts, totalComments, totalGroups int64 + s.db.Model(&model.Post{}).Count(&totalPosts) + s.db.Model(&model.Comment{}).Count(&totalComments) + s.db.Model(&model.Group{}).Count(&totalGroups) + + response.ContentDistribution = []dto.ContentDistributionItem{ + {Type: "posts", Name: "帖子", Count: totalPosts}, + {Type: "comments", Name: "评论", Count: totalComments}, + {Type: "groups", Name: "群组", Count: totalGroups}, + } + + // 帖子状态分布 + var publishedCount, pendingCount, rejectedCount, draftCount int64 + s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished).Count(&publishedCount) + s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPending).Count(&pendingCount) + s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusRejected).Count(&rejectedCount) + s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusDraft).Count(&draftCount) + + response.PostStatusDistribution = []dto.PostStatusDistributionItem{ + {Status: string(model.PostStatusPublished), Name: "已发布", Count: publishedCount}, + {Status: string(model.PostStatusPending), Name: "待审核", Count: pendingCount}, + {Status: string(model.PostStatusRejected), Name: "已拒绝", Count: rejectedCount}, + {Status: string(model.PostStatusDraft), Name: "草稿", Count: draftCount}, + } + + // 用户增长趋势(最近7天) + now := time.Now() + response.UserGrowth = make([]dto.UserGrowthItem, 7) + for i := 6; i >= 0; i-- { + date := now.AddDate(0, 0, -i) + dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + dateEnd := dateStart.Add(24 * time.Hour) + + var newUsers int64 + s.db.Model(&model.User{}). + Where("created_at >= ? AND created_at < ?", dateStart, dateEnd). + Count(&newUsers) + + response.UserGrowth[6-i] = dto.UserGrowthItem{ + Date: dateStart.Format("2006-01-02"), + NewUsers: newUsers, + } + } + + return &response, nil +} + +// GetPendingContent 获取待审核内容 +func (s *adminDashboardServiceImpl) GetPendingContent(ctx context.Context, limit int) ([]dto.PendingContentResponse, error) { + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + results := make([]dto.PendingContentResponse, 0) + + // 获取待审核帖子 + var posts []*model.Post + if err := s.db.Preload("User"). + Where("status = ?", model.PostStatusPending). + Order("created_at DESC"). + Limit(limit). + Find(&posts).Error; err != nil { + return nil, err + } + + for _, post := range posts { + item := dto.PendingContentResponse{ + ID: post.ID, + Type: "post", + Title: post.Title, + Content: post.Content, + CreatedAt: post.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if post.User != nil { + item.Author = &dto.PendingContentAuthor{ + ID: post.User.ID, + Username: post.User.Username, + Nickname: post.User.Nickname, + Avatar: post.User.Avatar, + } + } + results = append(results, item) + } + + // 获取待审核评论 + var comments []*model.Comment + if err := s.db.Preload("User"). + Where("status = ?", model.CommentStatusPending). + Order("created_at DESC"). + Limit(limit). + Find(&comments).Error; err != nil { + return nil, err + } + + for _, comment := range comments { + item := dto.PendingContentResponse{ + ID: comment.ID, + Type: "comment", + Title: "", + Content: comment.Content, + CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if comment.User != nil { + item.Author = &dto.PendingContentAuthor{ + ID: comment.User.ID, + Username: comment.User.Username, + Nickname: comment.User.Nickname, + Avatar: comment.User.Avatar, + } + } + results = append(results, item) + } + + // 按时间排序并限制数量 + if len(results) > limit { + results = results[:limit] + } + + return results, nil +} + +// GetOnlineUsers 获取在线用户数 +func (s *adminDashboardServiceImpl) GetOnlineUsers(ctx context.Context) (*dto.OnlineUsersResponse, error) { + // 获取当前日期的活跃用户数作为在线用户数的近似值 + // 实际应用中可能需要使用 WebSocket 连接数或 Redis 中的在线状态 + now := time.Now() + count, err := s.activityRepo.GetDAU(ctx, now) + if err != nil { + // 如果 Redis 获取失败,从数据库获取 + var dbCount int64 + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + if err := s.db.Model(&model.UserActiveLog{}). + Where("active_date >= ?", todayStart). + Distinct("user_id"). + Count(&dbCount).Error; err != nil { + return nil, err + } + count = dbCount + } + + return &dto.OnlineUsersResponse{ + Count: count, + }, nil +} + +// retentionStatsResult 留存率结果 +type retentionStatsResult struct { + Retention1Day int64 + Retention7Day int64 + Retention30Day int64 +} + +// getRetentionStats 获取留存率统计数据 +func (s *adminDashboardServiceImpl) getRetentionStats(ctx context.Context, now time.Time) retentionStatsResult { + var result retentionStatsResult + + // 尝试从统计快照表获取昨日的留存率数据 + yesterday := now.AddDate(0, 0, -1) + if stat, err := s.activityRepo.GetStat(ctx, "dau", yesterday); err == nil { + result.Retention1Day = int64(stat.Retention1) + result.Retention7Day = int64(stat.Retention7) + result.Retention30Day = int64(stat.Retention30) + return result + } + + // 如果快照表没有数据,实时计算留存率 + // 次日留存率:昨日新增用户中今日仍活跃的比例 + result.Retention1Day = s.calculateRetention(ctx, 1) + result.Retention7Day = s.calculateRetention(ctx, 7) + result.Retention30Day = s.calculateRetention(ctx, 30) + + return result +} + +// calculateRetention 计算指定天数的留存率 +func (s *adminDashboardServiceImpl) calculateRetention(ctx context.Context, days int) int64 { + now := time.Now() + + // 获取days天前的新增用户 + targetDate := now.AddDate(0, 0, -days) + targetDateStart := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, targetDate.Location()) + targetDateEnd := targetDateStart.Add(24 * time.Hour) + + var newUsers []string + if err := s.db.Model(&model.User{}). + Select("id"). + Where("created_at >= ? AND created_at < ?", targetDateStart, targetDateEnd). + Pluck("id", &newUsers).Error; err != nil || len(newUsers) == 0 { + return 0 + } + + // 检查这些用户在今日是否活跃 + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + var activeCount int64 + if err := s.db.Model(&model.UserActiveLog{}). + Where("user_id IN ?", newUsers). + Where("active_date >= ?", todayStart). + Distinct("user_id"). + Count(&activeCount).Error; err != nil { + return 0 + } + + // 计算留存率(百分比) + retention := float64(activeCount) / float64(len(newUsers)) * 100 + return int64(retention) +} diff --git a/internal/service/admin_group_service.go b/internal/service/admin_group_service.go new file mode 100644 index 0000000..ea81859 --- /dev/null +++ b/internal/service/admin_group_service.go @@ -0,0 +1,390 @@ +package service + +import ( + "context" + "errors" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +// AdminGroupService 管理端群组服务接口 +type AdminGroupService interface { + // GetGroupList 获取群组列表(分页、搜索、筛选) + GetGroupList(ctx context.Context, page, pageSize int, keyword, status, ownerID, startDate, endDate string) ([]dto.AdminGroupListResponse, int64, error) + // GetGroupDetail 获取群组详情 + GetGroupDetail(ctx context.Context, groupID string) (*dto.AdminGroupDetailResponse, error) + // GetGroupMembers 获取群组成员列表 + GetGroupMembers(ctx context.Context, groupID string, page, pageSize int) (*dto.AdminGroupMemberListResponse, error) + // DeleteGroup 解散群组 + DeleteGroup(ctx context.Context, groupID string) error + // UpdateGroupStatus 更新群组状态 + UpdateGroupStatus(ctx context.Context, groupID string, status string) (*dto.AdminGroupDetailResponse, error) + // RemoveMember 移除群组成员 + RemoveMember(ctx context.Context, groupID string, userID string) error + // TransferOwner 转让群主 + TransferOwner(ctx context.Context, groupID string, newOwnerID string) (*dto.AdminGroupDetailResponse, error) +} + +// adminGroupServiceImpl 管理端群组服务实现 +type adminGroupServiceImpl struct { + groupRepo repository.GroupRepository + userRepo *repository.UserRepository +} + +// NewAdminGroupService 创建管理端群组服务 +func NewAdminGroupService( + groupRepo repository.GroupRepository, + userRepo *repository.UserRepository, +) AdminGroupService { + return &adminGroupServiceImpl{ + groupRepo: groupRepo, + userRepo: userRepo, + } +} + +// GetGroupList 获取群组列表 +func (s *adminGroupServiceImpl) GetGroupList(ctx context.Context, page, pageSize int, keyword, status, ownerID, startDate, endDate string) ([]dto.AdminGroupListResponse, int64, error) { + groups, total, err := s.groupRepo.GetGroupList(page, pageSize, keyword, status, ownerID, startDate, endDate) + if err != nil { + return nil, 0, err + } + + responses := make([]dto.AdminGroupListResponse, 0, len(groups)) + for _, group := range groups { + response := dto.AdminGroupListResponse{ + ID: group.ID, + Name: group.Name, + Avatar: group.Avatar, + Description: group.Description, + OwnerID: group.OwnerID, + MemberCount: group.MemberCount, + MaxMembers: group.MaxMembers, + JoinType: int(group.JoinType), + MuteAll: group.MuteAll, + Status: "active", // 默认状态为active,后续可以根据需要添加状态字段 + CreatedAt: dto.FormatTime(group.CreatedAt), + UpdatedAt: dto.FormatTime(group.UpdatedAt), + } + + // 获取群主信息 + if group.OwnerID != "" { + owner, err := s.userRepo.GetByID(group.OwnerID) + if err == nil && owner != nil { + response.Owner = &dto.UserResponse{ + ID: owner.ID, + Username: owner.Username, + Nickname: owner.Nickname, + Avatar: owner.Avatar, + CreatedAt: dto.FormatTime(owner.CreatedAt), + } + } + } + + // 获取帖子数量 + postCount, _ := s.groupRepo.GetGroupPostCount(group.ID) + response.PostCount = postCount + + responses = append(responses, response) + } + + return responses, total, nil +} + +// GetGroupDetail 获取群组详情 +func (s *adminGroupServiceImpl) GetGroupDetail(ctx context.Context, groupID string) (*dto.AdminGroupDetailResponse, error) { + group, err := s.groupRepo.GetByID(groupID) + if err != nil { + return nil, err + } + + response := &dto.AdminGroupDetailResponse{ + ID: group.ID, + Name: group.Name, + Avatar: group.Avatar, + Description: group.Description, + OwnerID: group.OwnerID, + MemberCount: group.MemberCount, + MaxMembers: group.MaxMembers, + JoinType: int(group.JoinType), + MuteAll: group.MuteAll, + Status: "active", + IsPublic: true, // 默认为公开群组,后续可以根据需要添加字段 + CreatedAt: dto.FormatTime(group.CreatedAt), + UpdatedAt: dto.FormatTime(group.UpdatedAt), + } + + // 获取群主信息 + if group.OwnerID != "" { + owner, err := s.userRepo.GetByID(group.OwnerID) + if err == nil && owner != nil { + response.Owner = &dto.UserResponse{ + ID: owner.ID, + Username: owner.Username, + Nickname: owner.Nickname, + Email: owner.Email, + Phone: owner.Phone, + Avatar: owner.Avatar, + Bio: owner.Bio, + Website: owner.Website, + Location: owner.Location, + CreatedAt: dto.FormatTime(owner.CreatedAt), + } + } + } + + // 获取帖子数量 + postCount, _ := s.groupRepo.GetGroupPostCount(group.ID) + response.PostCount = postCount + + // 获取最新公告 + announcement, _ := s.groupRepo.GetLatestAnnouncement(groupID) + if announcement != nil { + response.Announcement = &dto.GroupAnnouncementResponse{ + ID: announcement.ID, + GroupID: announcement.GroupID, + Content: announcement.Content, + AuthorID: announcement.AuthorID, + IsPinned: announcement.IsPinned, + CreatedAt: dto.FormatTime(announcement.CreatedAt), + } + } + + return response, nil +} + +// GetGroupMembers 获取群组成员列表 +func (s *adminGroupServiceImpl) GetGroupMembers(ctx context.Context, groupID string, page, pageSize int) (*dto.AdminGroupMemberListResponse, error) { + // 先检查群组是否存在 + _, err := s.groupRepo.GetByID(groupID) + if err != nil { + return nil, errors.New("群组不存在") + } + + members, total, err := s.groupRepo.GetMembersWithUserInfo(groupID, page, pageSize) + if err != nil { + return nil, err + } + + list := make([]*dto.AdminGroupMemberResponse, 0, len(members)) + for _, member := range members { + item := &dto.AdminGroupMemberResponse{ + ID: member.ID, + UserID: member.UserID, + Role: member.Role, + Nickname: member.Nickname, + Muted: member.Muted, + JoinedAt: dto.FormatTime(member.JoinTime), + } + + if member.User != nil { + item.Username = member.User.Username + item.Nickname = member.User.Nickname + item.Avatar = member.User.Avatar + item.User = &dto.UserResponse{ + ID: member.User.ID, + Username: member.User.Username, + Nickname: member.User.Nickname, + Avatar: member.User.Avatar, + CreatedAt: dto.FormatTime(member.User.CreatedAt), + } + } + + list = append(list, item) + } + + return &dto.AdminGroupMemberListResponse{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// DeleteGroup 解散群组 +func (s *adminGroupServiceImpl) DeleteGroup(ctx context.Context, groupID string) error { + // 先检查群组是否存在 + _, err := s.groupRepo.GetByID(groupID) + if err != nil { + return errors.New("群组不存在") + } + + return s.groupRepo.Delete(groupID) +} + +// UpdateGroupStatus 更新群组状态 +func (s *adminGroupServiceImpl) UpdateGroupStatus(ctx context.Context, groupID string, status string) (*dto.AdminGroupDetailResponse, error) { + // 先检查群组是否存在 + group, err := s.groupRepo.GetByID(groupID) + if err != nil { + return nil, errors.New("群组不存在") + } + + // 验证状态值 + if status != "active" && status != "dissolved" { + return nil, errors.New("无效的状态值") + } + + // 如果状态是dissolved,则解散群组 + if status == "dissolved" { + err = s.groupRepo.Delete(groupID) + if err != nil { + return nil, err + } + } else { + // 更新群组状态 + err = s.groupRepo.UpdateGroupStatus(groupID, status) + if err != nil { + return nil, err + } + // 重新获取群组信息 + group, err = s.groupRepo.GetByID(groupID) + if err != nil { + return nil, err + } + } + + // 构建响应 + response := &dto.AdminGroupDetailResponse{ + ID: group.ID, + Name: group.Name, + Avatar: group.Avatar, + Description: group.Description, + OwnerID: group.OwnerID, + MemberCount: group.MemberCount, + MaxMembers: group.MaxMembers, + JoinType: int(group.JoinType), + MuteAll: group.MuteAll, + Status: status, + IsPublic: true, + CreatedAt: dto.FormatTime(group.CreatedAt), + UpdatedAt: dto.FormatTime(group.UpdatedAt), + } + + // 获取群主信息 + if group.OwnerID != "" { + owner, err := s.userRepo.GetByID(group.OwnerID) + if err == nil && owner != nil { + response.Owner = &dto.UserResponse{ + ID: owner.ID, + Username: owner.Username, + Nickname: owner.Nickname, + Avatar: owner.Avatar, + CreatedAt: dto.FormatTime(owner.CreatedAt), + } + } + } + + return response, nil +} + +// RemoveMember 移除群组成员 +func (s *adminGroupServiceImpl) RemoveMember(ctx context.Context, groupID string, userID string) error { + // 先检查群组是否存在 + _, err := s.groupRepo.GetByID(groupID) + if err != nil { + return errors.New("群组不存在") + } + + // 检查用户是否是群成员 + member, err := s.groupRepo.GetMember(groupID, userID) + if err != nil { + return errors.New("用户不是群组成员") + } + + // 不能移除群主 + if member.Role == model.GroupRoleOwner { + return errors.New("不能移除群主") + } + + return s.groupRepo.RemoveMember(groupID, userID) +} + +// TransferOwner 转让群主 +func (s *adminGroupServiceImpl) TransferOwner(ctx context.Context, groupID string, newOwnerID string) (*dto.AdminGroupDetailResponse, error) { + // 先检查群组是否存在 + group, err := s.groupRepo.GetByID(groupID) + if err != nil { + return nil, errors.New("群组不存在") + } + + // 检查新群主是否是群成员 + newOwnerMember, err := s.groupRepo.GetMember(groupID, newOwnerID) + if err != nil { + return nil, errors.New("新群主不是群组成员") + } + + // 获取当前群主信息 + currentOwnerMember, err := s.groupRepo.GetMember(groupID, group.OwnerID) + if err != nil { + return nil, errors.New("当前群主信息不存在") + } + + oldOwnerID := group.OwnerID + + // 更新当前群主为普通成员 + err = s.groupRepo.SetMemberRole(groupID, oldOwnerID, model.GroupRoleMember) + if err != nil { + return nil, err + } + + // 更新新群主角色 + err = s.groupRepo.SetMemberRole(groupID, newOwnerID, model.GroupRoleOwner) + if err != nil { + // 回滚 + _ = s.groupRepo.SetMemberRole(groupID, oldOwnerID, model.GroupRoleOwner) + return nil, err + } + + // 更新群组的OwnerID + group.OwnerID = newOwnerID + err = s.groupRepo.Update(group) + if err != nil { + // 回滚 + _ = s.groupRepo.SetMemberRole(groupID, oldOwnerID, model.GroupRoleOwner) + _ = s.groupRepo.SetMemberRole(groupID, newOwnerID, newOwnerMember.Role) + return nil, err + } + + // 重新获取群组信息 + group, err = s.groupRepo.GetByID(groupID) + if err != nil { + return nil, err + } + + // 构建响应 + response := &dto.AdminGroupDetailResponse{ + ID: group.ID, + Name: group.Name, + Avatar: group.Avatar, + Description: group.Description, + OwnerID: group.OwnerID, + MemberCount: group.MemberCount, + MaxMembers: group.MaxMembers, + JoinType: int(group.JoinType), + MuteAll: group.MuteAll, + Status: "active", + IsPublic: true, + CreatedAt: dto.FormatTime(group.CreatedAt), + UpdatedAt: dto.FormatTime(group.UpdatedAt), + } + + // 获取新群主信息 + if group.OwnerID != "" { + owner, err := s.userRepo.GetByID(group.OwnerID) + if err == nil && owner != nil { + response.Owner = &dto.UserResponse{ + ID: owner.ID, + Username: owner.Username, + Nickname: owner.Nickname, + Avatar: owner.Avatar, + CreatedAt: dto.FormatTime(owner.CreatedAt), + } + } + } + + _ = currentOwnerMember // 避免 unused variable 警告 + + return response, nil +} diff --git a/internal/service/admin_post_service.go b/internal/service/admin_post_service.go new file mode 100644 index 0000000..4ad7d37 --- /dev/null +++ b/internal/service/admin_post_service.go @@ -0,0 +1,341 @@ +package service + +import ( + "context" + + "carrot_bbs/internal/cache" + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +// AdminPostService 管理端帖子服务接口 +type AdminPostService interface { + // GetPostList 获取帖子列表(分页、搜索、状态筛选) + GetPostList(ctx context.Context, page, pageSize int, keyword, status, authorID, startDate, endDate string) ([]dto.AdminPostListResponse, int64, error) + // GetPostDetail 获取帖子详情 + GetPostDetail(ctx context.Context, postID string) (*dto.AdminPostDetailResponse, error) + // DeletePost 删除帖子 + DeletePost(ctx context.Context, postID string) error + // ModeratePost 审核帖子 + ModeratePost(ctx context.Context, postID string, status model.PostStatus, reason, reviewedBy string) (*dto.AdminPostDetailResponse, error) + // BatchDeletePosts 批量删除帖子 + BatchDeletePosts(ctx context.Context, ids []string) (*dto.AdminBatchOperationResponse, error) + // BatchModeratePosts 批量审核帖子 + BatchModeratePosts(ctx context.Context, ids []string, status model.PostStatus, reviewedBy string) (*dto.AdminBatchOperationResponse, error) + // SetPostPin 置顶/取消置顶帖子 + SetPostPin(ctx context.Context, postID string, isPinned bool) (*dto.AdminPostDetailResponse, error) + // SetPostFeature 加精/取消加精帖子 + SetPostFeature(ctx context.Context, postID string, isFeatured bool) (*dto.AdminPostDetailResponse, error) +} + +// adminPostServiceImpl 管理端帖子服务实现 +type adminPostServiceImpl struct { + postRepo *repository.PostRepository + cache cache.Cache +} + +// NewAdminPostService 创建管理端帖子服务 +func NewAdminPostService(postRepo *repository.PostRepository, cacheBackend cache.Cache) AdminPostService { + return &adminPostServiceImpl{ + postRepo: postRepo, + cache: cacheBackend, + } +} + +// GetPostList 获取帖子列表 +func (s *adminPostServiceImpl) GetPostList(ctx context.Context, page, pageSize int, keyword, status, authorID, startDate, endDate string) ([]dto.AdminPostListResponse, int64, error) { + query := repository.AdminPostListQuery{ + Page: page, + PageSize: pageSize, + Keyword: keyword, + Status: status, + AuthorID: authorID, + StartDate: startDate, + EndDate: endDate, + } + + posts, total, err := s.postRepo.AdminList(query) + if err != nil { + return nil, 0, err + } + + responses := make([]dto.AdminPostListResponse, len(posts)) + for i, post := range posts { + responses[i] = convertPostToAdminListResponse(post) + } + + return responses, total, nil +} + +// GetPostDetail 获取帖子详情 +func (s *adminPostServiceImpl) GetPostDetail(ctx context.Context, postID string) (*dto.AdminPostDetailResponse, error) { + post, err := s.postRepo.GetByIDForAdmin(postID) + if err != nil { + return nil, err + } + + return convertPostToAdminDetailResponse(post), nil +} + +// DeletePost 删除帖子 +func (s *adminPostServiceImpl) DeletePost(ctx context.Context, postID string) error { + err := s.postRepo.Delete(postID) + if err != nil { + return err + } + + // 失效缓存 + cache.InvalidatePostDetail(s.cache, postID) + cache.InvalidatePostList(s.cache) + + return nil +} + +// ModeratePost 审核帖子 +func (s *adminPostServiceImpl) ModeratePost(ctx context.Context, postID string, status model.PostStatus, reason, reviewedBy string) (*dto.AdminPostDetailResponse, error) { + // 先检查帖子是否存在 + _, err := s.postRepo.GetByIDForAdmin(postID) + if err != nil { + return nil, err + } + + // 更新审核状态 + if err := s.postRepo.UpdateModerationStatus(postID, status, reason, reviewedBy); err != nil { + return nil, err + } + + // 失效缓存 + cache.InvalidatePostDetail(s.cache, postID) + cache.InvalidatePostList(s.cache) + + // 重新获取帖子信息 + post, err := s.postRepo.GetByIDForAdmin(postID) + if err != nil { + return nil, err + } + + return convertPostToAdminDetailResponse(post), nil +} + +// BatchDeletePosts 批量删除帖子 +func (s *adminPostServiceImpl) BatchDeletePosts(ctx context.Context, ids []string) (*dto.AdminBatchOperationResponse, error) { + failedIDs, err := s.postRepo.BatchDelete(ids) + if err != nil { + return &dto.AdminBatchOperationResponse{ + Success: false, + Message: "批量删除失败", + Failed: ids, + }, err + } + + // 失效缓存 + for _, id := range ids { + cache.InvalidatePostDetail(s.cache, id) + } + cache.InvalidatePostList(s.cache) + + if len(failedIDs) == 0 { + return &dto.AdminBatchOperationResponse{ + Success: true, + Message: "批量删除成功", + }, nil + } + + return &dto.AdminBatchOperationResponse{ + Success: true, + Message: "部分帖子删除成功", + Failed: failedIDs, + }, nil +} + +// BatchModeratePosts 批量审核帖子 +func (s *adminPostServiceImpl) BatchModeratePosts(ctx context.Context, ids []string, status model.PostStatus, reviewedBy string) (*dto.AdminBatchOperationResponse, error) { + failedIDs, err := s.postRepo.BatchUpdateStatus(ids, status, reviewedBy) + if err != nil { + return &dto.AdminBatchOperationResponse{ + Success: false, + Message: "批量审核失败", + Failed: ids, + }, err + } + + // 失效缓存 + for _, id := range ids { + cache.InvalidatePostDetail(s.cache, id) + } + cache.InvalidatePostList(s.cache) + + if len(failedIDs) == 0 { + return &dto.AdminBatchOperationResponse{ + Success: true, + Message: "批量审核成功", + }, nil + } + + return &dto.AdminBatchOperationResponse{ + Success: true, + Message: "部分帖子审核成功", + Failed: failedIDs, + }, nil +} + +// SetPostPin 置顶/取消置顶帖子 +func (s *adminPostServiceImpl) SetPostPin(ctx context.Context, postID string, isPinned bool) (*dto.AdminPostDetailResponse, error) { + // 先检查帖子是否存在 + _, err := s.postRepo.GetByIDForAdmin(postID) + if err != nil { + return nil, err + } + + // 更新置顶状态 + if err := s.postRepo.UpdatePinStatus(postID, isPinned); err != nil { + return nil, err + } + + // 失效缓存 + cache.InvalidatePostDetail(s.cache, postID) + cache.InvalidatePostList(s.cache) + + // 重新获取帖子信息 + post, err := s.postRepo.GetByIDForAdmin(postID) + if err != nil { + return nil, err + } + + return convertPostToAdminDetailResponse(post), nil +} + +// SetPostFeature 加精/取消加精帖子 +func (s *adminPostServiceImpl) SetPostFeature(ctx context.Context, postID string, isFeatured bool) (*dto.AdminPostDetailResponse, error) { + // 先检查帖子是否存在 + _, err := s.postRepo.GetByIDForAdmin(postID) + if err != nil { + return nil, err + } + + // 更新加精状态 + if err := s.postRepo.UpdateFeatureStatus(postID, isFeatured); err != nil { + return nil, err + } + + // 失效缓存 + cache.InvalidatePostDetail(s.cache, postID) + cache.InvalidatePostList(s.cache) + + // 重新获取帖子信息 + post, err := s.postRepo.GetByIDForAdmin(postID) + if err != nil { + return nil, err + } + + return convertPostToAdminDetailResponse(post), nil +} + +// convertPostToAdminListResponse 转换帖子为管理端列表响应 +func convertPostToAdminListResponse(post *model.Post) dto.AdminPostListResponse { + images := make([]dto.PostImageResponse, len(post.Images)) + for i, img := range post.Images { + images[i] = dto.PostImageResponse{ + ID: img.ID, + URL: img.URL, + ThumbnailURL: img.ThumbnailURL, + Width: img.Width, + Height: img.Height, + } + } + + var author *dto.UserResponse + if post.User != nil { + author = &dto.UserResponse{ + ID: post.User.ID, + Username: post.User.Username, + Nickname: post.User.Nickname, + Email: post.User.Email, + Phone: post.User.Phone, + Avatar: post.User.Avatar, + Bio: post.User.Bio, + Website: post.User.Website, + Location: post.User.Location, + CreatedAt: post.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return dto.AdminPostListResponse{ + ID: post.ID, + UserID: post.UserID, + Title: post.Title, + Content: post.Content, + Images: images, + Status: string(post.Status), + LikesCount: post.LikesCount, + CommentsCount: post.CommentsCount, + FavoritesCount: post.FavoritesCount, + ViewsCount: post.ViewsCount, + IsPinned: post.IsPinned, + IsFeatured: post.IsFeatured, + IsLocked: post.IsLocked, + RejectReason: post.RejectReason, + ReviewedAt: dto.FormatTimePointer(post.ReviewedAt), + ReviewedBy: post.ReviewedBy, + CreatedAt: post.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: post.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + Author: author, + } +} + +// convertPostToAdminDetailResponse 转换帖子为管理端详情响应 +func convertPostToAdminDetailResponse(post *model.Post) *dto.AdminPostDetailResponse { + images := make([]dto.PostImageResponse, len(post.Images)) + for i, img := range post.Images { + images[i] = dto.PostImageResponse{ + ID: img.ID, + URL: img.URL, + ThumbnailURL: img.ThumbnailURL, + Width: img.Width, + Height: img.Height, + } + } + + var author *dto.UserResponse + if post.User != nil { + author = &dto.UserResponse{ + ID: post.User.ID, + Username: post.User.Username, + Nickname: post.User.Nickname, + Email: post.User.Email, + Phone: post.User.Phone, + Avatar: post.User.Avatar, + Bio: post.User.Bio, + Website: post.User.Website, + Location: post.User.Location, + CreatedAt: post.User.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return &dto.AdminPostDetailResponse{ + ID: post.ID, + UserID: post.UserID, + CommunityID: post.CommunityID, + Title: post.Title, + Content: post.Content, + Images: images, + Status: string(post.Status), + LikesCount: post.LikesCount, + CommentsCount: post.CommentsCount, + FavoritesCount: post.FavoritesCount, + SharesCount: post.SharesCount, + ViewsCount: post.ViewsCount, + HotScore: post.HotScore, + IsPinned: post.IsPinned, + IsFeatured: post.IsFeatured, + IsLocked: post.IsLocked, + IsVote: post.IsVote, + RejectReason: post.RejectReason, + ReviewedAt: dto.FormatTimePointer(post.ReviewedAt), + ReviewedBy: post.ReviewedBy, + CreatedAt: post.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: post.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + Author: author, + } +} diff --git a/internal/service/admin_user_service.go b/internal/service/admin_user_service.go new file mode 100644 index 0000000..59ab24a --- /dev/null +++ b/internal/service/admin_user_service.go @@ -0,0 +1,145 @@ +package service + +import ( + "context" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +// AdminUserService 管理端用户服务接口 +type AdminUserService interface { + // GetUserList 获取用户列表(分页、搜索、状态筛选) + GetUserList(ctx context.Context, page, pageSize int, keyword, status string) ([]dto.AdminUserListResponse, int64, error) + // GetUserDetail 获取用户详情(含统计信息) + GetUserDetail(ctx context.Context, userID string) (*dto.AdminUserDetailResponse, error) + // UpdateUserStatus 更新用户状态 + UpdateUserStatus(ctx context.Context, userID string, status model.UserStatus) (*dto.AdminUserDetailResponse, error) +} + +// adminUserServiceImpl 管理端用户服务实现 +type adminUserServiceImpl struct { + userRepo *repository.UserRepository +} + +// NewAdminUserService 创建管理端用户服务 +func NewAdminUserService(userRepo *repository.UserRepository) AdminUserService { + return &adminUserServiceImpl{ + userRepo: userRepo, + } +} + +// GetUserList 获取用户列表 +func (s *adminUserServiceImpl) GetUserList(ctx context.Context, page, pageSize int, keyword, status string) ([]dto.AdminUserListResponse, int64, error) { + users, total, err := s.userRepo.AdminList(page, pageSize, keyword, status) + if err != nil { + return nil, 0, err + } + + responses := make([]dto.AdminUserListResponse, len(users)) + for i, user := range users { + responses[i] = convertUserToAdminListResponse(user) + } + + return responses, total, nil +} + +// GetUserDetail 获取用户详情 +func (s *adminUserServiceImpl) GetUserDetail(ctx context.Context, userID string) (*dto.AdminUserDetailResponse, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, err + } + + // 获取帖子数 + postsCount, _ := s.userRepo.GetPostsCount(userID) + + // 获取评论数 + commentsCount, _ := s.userRepo.GetCommentsCount(userID) + + return convertUserToAdminDetailResponse(user, postsCount, commentsCount), nil +} + +// UpdateUserStatus 更新用户状态 +func (s *adminUserServiceImpl) UpdateUserStatus(ctx context.Context, userID string, status model.UserStatus) (*dto.AdminUserDetailResponse, error) { + // 先检查用户是否存在 + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, err + } + + // 更新状态 + if err := s.userRepo.UpdateStatus(userID, status); err != nil { + return nil, err + } + + // 重新获取用户信息 + user, err = s.userRepo.GetByID(userID) + if err != nil { + return nil, err + } + + // 获取统计信息 + postsCount, _ := s.userRepo.GetPostsCount(userID) + commentsCount, _ := s.userRepo.GetCommentsCount(userID) + + return convertUserToAdminDetailResponse(user, postsCount, commentsCount), nil +} + +// convertUserToAdminListResponse 转换用户为管理端列表响应 +func convertUserToAdminListResponse(user *model.User) dto.AdminUserListResponse { + var lastLoginAt string + if user.LastLoginAt != nil { + lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + return dto.AdminUserListResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: user.Avatar, + Status: string(user.Status), + PostsCount: user.PostsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + LastLoginAt: lastLoginAt, + LastLoginIP: user.LastLoginIP, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + } +} + +// convertUserToAdminDetailResponse 转换用户为管理端详情响应 +func convertUserToAdminDetailResponse(user *model.User, postsCount, commentsCount int64) *dto.AdminUserDetailResponse { + var lastLoginAt string + if user.LastLoginAt != nil { + lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + return &dto.AdminUserDetailResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: user.Avatar, + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + IsVerified: user.IsVerified, + Status: string(user.Status), + PostsCount: int(postsCount), + CommentsCount: commentsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + LastLoginAt: lastLoginAt, + LastLoginIP: user.LastLoginIP, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"), + } +} diff --git a/internal/service/casbin_service.go b/internal/service/casbin_service.go index cc1eb9d..e0dbfff 100644 --- a/internal/service/casbin_service.go +++ b/internal/service/casbin_service.go @@ -9,7 +9,7 @@ import ( "carrot_bbs/internal/config" "carrot_bbs/internal/repository" - "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v3" ) // CasbinService Casbin 权限服务接口 @@ -43,6 +43,15 @@ type CasbinService interface { // ClearCache 清除权限缓存 ClearCache(ctx context.Context) error + + // GetPermissionsForRole 获取角色的所有权限 + GetPermissionsForRole(ctx context.Context, role string) ([][]string, error) + + // UpdatePermissionsForRole 更新角色的权限(先删除所有权限,再添加新权限) + UpdatePermissionsForRole(ctx context.Context, role string, permissions [][]string) error + + // GetAllPermissions 获取系统中所有权限定义 + GetAllPermissions(ctx context.Context) [][]string } type casbinService struct { @@ -223,3 +232,34 @@ func (s *casbinService) clearUserCache(ctx context.Context, userID string) error } return nil } + +func (s *casbinService) GetPermissionsForRole(ctx context.Context, role string) ([][]string, error) { + return s.enforcer.GetPermissionsForUser(role) +} + +func (s *casbinService) UpdatePermissionsForRole(ctx context.Context, role string, permissions [][]string) error { + // 先删除该角色的所有权限 + _, err := s.enforcer.DeletePermissionsForUser(role) + if err != nil { + return err + } + + // 添加新权限 + for _, perm := range permissions { + if len(perm) >= 2 { + resource := perm[0] + action := perm[1] + if _, err := s.enforcer.AddPolicy(role, resource, action); err != nil { + return err + } + } + } + + // 清除缓存 + return s.ClearCache(ctx) +} + +func (s *casbinService) GetAllPermissions(ctx context.Context) [][]string { + permissions, _ := s.enforcer.GetPolicy() + return permissions +} diff --git a/internal/service/role_service.go b/internal/service/role_service.go index d3fcf2d..40ffe6a 100644 --- a/internal/service/role_service.go +++ b/internal/service/role_service.go @@ -24,6 +24,37 @@ type RoleService interface { // GetRole 获取角色详情 GetRole(ctx context.Context, name string) (*model.Role, error) + + // GetRoleDetail 获取角色详情(包含用户数量和权限) + GetRoleDetail(ctx context.Context, name string) (*RoleDetail, error) + + // GetRolePermissions 获取角色的权限列表 + GetRolePermissions(ctx context.Context, name string) ([]Permission, error) + + // GetAllPermissions 获取系统中所有权限定义 + GetAllPermissions(ctx context.Context) ([]Permission, error) + + // UpdateRolePermissions 更新角色权限 + UpdateRolePermissions(ctx context.Context, roleName string, permissions []Permission) error +} + +// Permission 权限定义 +type Permission struct { + Resource string `json:"resource"` + Action string `json:"action"` +} + +// RoleDetail 角色详情 +type RoleDetail struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + ParentRole *string `json:"parent_role"` + Priority int `json:"priority"` + UserCount int64 `json:"user_count"` + Permissions []Permission `json:"permissions"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type roleService struct { @@ -114,3 +145,106 @@ func (s *roleService) GetAllRoles(ctx context.Context) ([]model.Role, error) { func (s *roleService) GetRole(ctx context.Context, name string) (*model.Role, error) { return s.roleRepo.GetRole(ctx, name) } + +func (s *roleService) GetRoleDetail(ctx context.Context, name string) (*RoleDetail, error) { + role, err := s.roleRepo.GetRole(ctx, name) + if err != nil { + return nil, apperrors.ErrRoleNotFound + } + + // 获取用户数量 + userCount, err := s.roleRepo.GetRoleUserCount(ctx, name) + if err != nil { + userCount = 0 + } + + // 获取权限 + permissions, err := s.casbinSvc.GetPermissionsForRole(ctx, name) + if err != nil { + return nil, err + } + + perms := make([]Permission, 0) + for _, p := range permissions { + if len(p) >= 2 { + perms = append(perms, Permission{ + Resource: p[0], + Action: p[1], + }) + } + } + + return &RoleDetail{ + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + ParentRole: role.ParentRole, + Priority: role.Priority, + UserCount: userCount, + Permissions: perms, + CreatedAt: role.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: role.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }, nil +} + +func (s *roleService) GetRolePermissions(ctx context.Context, name string) ([]Permission, error) { + // 检查角色是否存在 + _, err := s.roleRepo.GetRole(ctx, name) + if err != nil { + return nil, apperrors.ErrRoleNotFound + } + + permissions, err := s.casbinSvc.GetPermissionsForRole(ctx, name) + if err != nil { + return nil, err + } + + // 初始化为空数组,避免 JSON 序列化为 null + perms := make([]Permission, 0) + for _, p := range permissions { + if len(p) >= 2 { + perms = append(perms, Permission{ + Resource: p[0], + Action: p[1], + }) + } + } + return perms, nil +} + +func (s *roleService) GetAllPermissions(ctx context.Context) ([]Permission, error) { + permissions := s.casbinSvc.GetAllPermissions(ctx) + + perms := make([]Permission, 0) + for _, p := range permissions { + if len(p) >= 3 { + // p[0] 是角色名,p[1] 是资源,p[2] 是操作 + perms = append(perms, Permission{ + Resource: p[1], + Action: p[2], + }) + } + } + return perms, nil +} + +func (s *roleService) UpdateRolePermissions(ctx context.Context, roleName string, permissions []Permission) error { + // 检查角色是否存在 + _, err := s.roleRepo.GetRole(ctx, roleName) + if err != nil { + return apperrors.ErrRoleNotFound + } + + // 不能修改超级管理员权限 + if roleName == model.RoleSuperAdmin { + return apperrors.ErrCannotModifySuperAdmin + } + + // 转换权限格式 + var perms [][]string + for _, p := range permissions { + perms = append(perms, []string{p.Resource, p.Action}) + } + + return s.casbinSvc.UpdatePermissionsForRole(ctx, roleName, perms) +} diff --git a/internal/wire/casbin.go b/internal/wire/casbin.go index b2c7036..dd220cd 100644 --- a/internal/wire/casbin.go +++ b/internal/wire/casbin.go @@ -8,7 +8,8 @@ import ( "carrot_bbs/internal/repository" "carrot_bbs/internal/service" - "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v3" + gormadapter "github.com/casbin/gorm-adapter/v3" "github.com/google/wire" "gorm.io/gorm" ) @@ -22,19 +23,24 @@ var CasbinSet = wire.NewSet( // ProvideCasbinEnforcer 提供 Casbin Enforcer func ProvideCasbinEnforcer(cfg *config.Config, db *gorm.DB) *casbin.Enforcer { - // 创建 Casbin Enforcer - // 注意:这里使用文件适配器作为示例,实际使用时需要替换为数据库适配器 - enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath) + // 创建 GORM 适配器,连接数据库 + adapter, err := gormadapter.NewAdapterByDB(db) + if err != nil { + log.Fatalf("Failed to create Casbin GORM adapter: %v", err) + } + + // 创建 Casbin Enforcer,使用数据库适配器 + enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath, adapter) if err != nil { log.Fatalf("Failed to create Casbin enforcer: %v", err) } - // 加载策略 + // 从数据库加载策略 if err := enforcer.LoadPolicy(); err != nil { log.Printf("[WARNING] Failed to load Casbin policy: %v", err) } - log.Println("[Wire] Initialized Casbin enforcer") + log.Println("[Wire] Initialized Casbin enforcer with GORM adapter") return enforcer } diff --git a/internal/wire/handler.go b/internal/wire/handler.go index f73545b..b2f6b0e 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -22,6 +22,11 @@ var HandlerSet = wire.NewSet( handler.NewStickerHandler, handler.NewVoteHandler, handler.NewRoleHandler, + handler.NewAdminUserHandler, + handler.NewAdminPostHandler, + handler.NewAdminCommentHandler, + handler.NewAdminGroupHandler, + handler.NewAdminDashboardHandler, // 需要特殊处理的 Handler ProvideUserHandler, diff --git a/internal/wire/service.go b/internal/wire/service.go index f986177..496c781 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -43,6 +43,11 @@ var ServiceSet = wire.NewSet( ProvideUploadService, ProvideUserActivityService, ProvideRoleService, + ProvideAdminUserService, + ProvideAdminPostService, + ProvideAdminCommentService, + ProvideAdminGroupService, + ProvideAdminDashboardService, ) // ProvideJWTService 提供 JWT 服务 @@ -214,3 +219,38 @@ func ProvideRoleService( ) service.RoleService { return service.NewRoleService(roleRepo, casbinSvc) } + +// ProvideAdminUserService 提供管理端用户服务 +func ProvideAdminUserService(userRepo *repository.UserRepository) service.AdminUserService { + return service.NewAdminUserService(userRepo) +} + +// ProvideAdminPostService 提供管理端帖子服务 +func ProvideAdminPostService(postRepo *repository.PostRepository, cacheBackend cache.Cache) service.AdminPostService { + return service.NewAdminPostService(postRepo, cacheBackend) +} + +// ProvideAdminCommentService 提供管理端评论服务 +func ProvideAdminCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository) service.AdminCommentService { + return service.NewAdminCommentService(commentRepo, postRepo) +} + +// ProvideAdminGroupService 提供管理端群组服务 +func ProvideAdminGroupService( + groupRepo repository.GroupRepository, + userRepo *repository.UserRepository, +) service.AdminGroupService { + return service.NewAdminGroupService(groupRepo, userRepo) +} + +// ProvideAdminDashboardService 提供管理端仪表盘服务 +func ProvideAdminDashboardService( + db *gorm.DB, + userRepo *repository.UserRepository, + postRepo *repository.PostRepository, + commentRepo *repository.CommentRepository, + groupRepo repository.GroupRepository, + activityRepo *repository.UserActivityRepository, +) service.AdminDashboardService { + return service.NewAdminDashboardService(db, userRepo, postRepo, commentRepo, groupRepo, activityRepo) +}