diff --git a/common/busi_error.go b/common/busi_error.go index 5a7e367..2e2659f 100644 --- a/common/busi_error.go +++ b/common/busi_error.go @@ -63,11 +63,13 @@ const ( LiveLeaseNotFound = 57006 // 计费 58xxx - TrafficNotEnough = 58001 - OrderPaid = 58002 - SimCardNotFound = 58003 - OrderNotFound = 58004 - CarrierUnavailable = 58005 + TrafficNotEnough = 58001 + OrderPaid = 58002 + SimCardNotFound = 58003 + OrderNotFound = 58004 + CarrierUnavailable = 58005 + PlatformTrafficExhausted = 58006 + TrafficLedgerConflict = 58007 ) // 预定义的业务错误 @@ -114,11 +116,13 @@ var ( ErrVideoNotFound = &BusiError{Code: VideoNotFound, Msg: "视频不存在"} ErrVideoUploading = &BusiError{Code: VideoUploading, Msg: "视频上传中"} - ErrTrafficNotEnough = &BusiError{Code: TrafficNotEnough, Msg: "流量不足,请充值"} - ErrOrderPaid = &BusiError{Code: OrderPaid, Msg: "该订单已支付"} - ErrSimCardNotFound = &BusiError{Code: SimCardNotFound, Msg: "SIM卡不存在"} - ErrOrderNotFound = &BusiError{Code: OrderNotFound, Msg: "订单不存在"} - ErrCarrierUnavailable = &BusiError{Code: CarrierUnavailable, Msg: "运营商通道未接入"} + ErrTrafficNotEnough = &BusiError{Code: TrafficNotEnough, Msg: "流量不足,请充值"} + ErrOrderPaid = &BusiError{Code: OrderPaid, Msg: "该订单已支付"} + ErrSimCardNotFound = &BusiError{Code: SimCardNotFound, Msg: "SIM卡不存在"} + ErrOrderNotFound = &BusiError{Code: OrderNotFound, Msg: "订单不存在"} + ErrCarrierUnavailable = &BusiError{Code: CarrierUnavailable, Msg: "运营商通道未接入"} + ErrPlatformTrafficExhausted = &BusiError{Code: PlatformTrafficExhausted, Msg: "资源异常,请联系管理员"} + ErrTrafficLedgerConflict = &BusiError{Code: TrafficLedgerConflict, Msg: "账务流水幂等冲突"} ) // NewBusiError 创建自定义业务错误 diff --git a/common/config.go b/common/config.go index 5632e98..d208bda 100644 --- a/common/config.go +++ b/common/config.go @@ -71,6 +71,10 @@ type Live struct { AppName string `mapstructure:"app-name"` AuthKey string `mapstructure:"auth-key"` PlayAuthKey string `mapstructure:"play-auth-key"` + AccessKeyID string `mapstructure:"access-key-id"` + AccessKeySecret string `mapstructure:"access-key-secret"` + RegionID string `mapstructure:"region-id"` + APIEndpoint string `mapstructure:"api-endpoint"` AuthExpireSeconds int64 `mapstructure:"auth-expire-seconds"` PlayURLTTLSeconds int64 `mapstructure:"play-url-ttl-seconds"` Protocol string `mapstructure:"protocol"` @@ -80,6 +84,7 @@ type Live struct { StopGraceSeconds int `mapstructure:"stop-grace-seconds"` ReconcileIntervalSeconds int `mapstructure:"reconcile-interval-seconds"` RecordPolicy string `mapstructure:"record-policy"` + CallbackAuthToken string `mapstructure:"callback-auth-token"` AllowRealCloud bool `mapstructure:"allow-real-cloud"` } diff --git a/config-prod.yaml b/config-prod.yaml index 9712f2e..ee32b0e 100644 --- a/config-prod.yaml +++ b/config-prod.yaml @@ -25,11 +25,16 @@ oss: region: cn-east-2 use-ssl: true live: - mode: fake - provider: fake - push-domain: local-push - play-domain: local-play + mode: aliyun + access-key-id: "LTAI5t93cEkHiKGMBJtrdHBv" + access-key-secret: "Phx6IcVqYAQTELXCKd6s3yj1ywSArn" + provider: aliyun + push-domain: "vpush.jiagutech.com" + play-domain: "vpull.jiagutech.com" app-name: dock-live + auth-key: "luMueASWWJhT2QFm" + play-auth-key: "bJSeSA1sYIfjZ9Rl" + region-id: "cn-shenzhen" auth-expire-seconds: 7200 play-url-ttl-seconds: 300 protocol: srt @@ -39,7 +44,8 @@ live: stop-grace-seconds: 15 reconcile-interval-seconds: 10 record-policy: disabled - allow-real-cloud: false + callback-auth-token: "db570a886e3d65c3f6c55d648ddb41506a658f6be4892719345056602fc2e7ac" + allow-real-cloud: true heartbeat: timeout-seconds: 30 scan-seconds: 5 diff --git a/config.yaml b/config.yaml index dc23898..0a326c5 100644 --- a/config.yaml +++ b/config.yaml @@ -30,6 +30,12 @@ live: push-domain: local-push play-domain: local-play app-name: dock-live + # Set LAIC_LIVE_ACCESS_KEY_ID and LAIC_LIVE_ACCESS_KEY_SECRET in the deployment environment. + access-key-id: "" + access-key-secret: "" + region-id: "" + api-endpoint: "" + callback-auth-token: "" auth-expire-seconds: 7200 play-url-ttl-seconds: 300 protocol: srt diff --git a/go.mod b/go.mod index 7b7f4a3..70e7037 100644 --- a/go.mod +++ b/go.mod @@ -26,9 +26,20 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect + github.com/alibabacloud-go/live-20161101 v1.1.1 // indirect + github.com/alibabacloud-go/openapi-util v0.1.0 // indirect + github.com/alibabacloud-go/tea v1.5.2 // indirect + github.com/alibabacloud-go/tea-utils v1.3.1 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect + github.com/aliyun/credentials-go v1.4.5 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect @@ -54,6 +65,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect @@ -64,4 +76,5 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index fe719ae..3b0d3ac 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,77 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.2/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/live-20161101 v1.1.1 h1:rUGfA8RHmCMtQ5M3yMSyRde+yRXWqVecmiXBU3XrGJ8= +github.com/alibabacloud-go/live-20161101 v1.1.1/go.mod h1:g84w6qeAodT0/IHdc0tEed2a8PyhQhYl7TAj3jGl4A4= +github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU= +github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0= +github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.0/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.1/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk= +github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -47,16 +104,34 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -64,12 +139,16 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -81,12 +160,16 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= @@ -97,6 +180,9 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -108,10 +194,12 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 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= @@ -123,10 +211,16 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/taosdata/driver-go/v3 v3.3.1 h1:UXIoE+TbdbdRHtaNjIuMAQn+SjAAKUJHyeONAt5F1eI= github.com/taosdata/driver-go/v3 v3.3.1/go.mod h1:H2vo/At+rOPY1aMzUV9P49SVX7NlXb3LAbKw+MCLrmU= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -147,30 +241,162 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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.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/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +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.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.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.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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +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.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.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +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.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= @@ -180,3 +406,5 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/handler/account_handler.go b/handler/account_handler.go index f055a9d..49e8c51 100644 --- a/handler/account_handler.go +++ b/handler/account_handler.go @@ -95,7 +95,7 @@ func PayTrafficOrder(c *gin.Context) { common.FailWithBusiError(c, e) return } - data, e := service.DefaultAccountService.PayTrafficOrder(id) + data, e := service.DefaultAccountService.PayTrafficOrder(id, common.GetUserId(c), c.GetHeader("Idempotency-Key")) if e != nil { common.FailWithBusiError(c, e) return diff --git a/handler/live_handler.go b/handler/live_handler.go index a50d697..368997a 100644 --- a/handler/live_handler.go +++ b/handler/live_handler.go @@ -1,6 +1,15 @@ package handler import ( + "crypto/subtle" + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "github.com/gin-gonic/gin" "laic-backend/common" @@ -119,15 +128,86 @@ func StopLive(c *gin.Context) { // GetLivePlayURL 获取直播播放地址 func GetLivePlayURL(c *gin.Context) { - dockID := c.Param("dockId") - if dockID == "" { + dockID, sessionID := c.Param("dockId"), c.Query("streamSessionId") + if dockID == "" || sessionID == "" { common.FailWithBusiError(c, common.ErrParam) return } - play, busiErr := service.DefaultLiveService.GetPlayURL(common.GetUserId(c), common.IsAdmin(c), dockID) + play, busiErr := service.DefaultLiveService.GetPlayURL(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID) if busiErr != nil { common.FailWithBusiError(c, busiErr) return } common.OKWithData(c, play) } + +const maxAliyunCallbackBodyBytes = 64 * 1024 + +type aliyunLiveCallback struct { + EventType string `json:"eventType"` + AppName string `json:"appName"` + StreamName string `json:"streamName"` + EventTime int64 `json:"eventTime"` +} + +func HandleAliyunLiveCallback(c *gin.Context) { + token := common.AppConf.Live.CallbackAuthToken + provided := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") + if token == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + c.Status(http.StatusUnauthorized) + return + } + + body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxAliyunCallbackBodyBytes)) + if err != nil { + c.Status(http.StatusRequestEntityTooLarge) + return + } + callback, err := parseAliyunLiveCallback(c.GetHeader("Content-Type"), body) + if err != nil || callback.EventType == "" || callback.AppName == "" || callback.StreamName == "" { + c.Status(http.StatusBadRequest) + return + } + if callback.EventTime > 0 && abs(time.Now().Unix()-normalizeAliyunCallbackTime(callback.EventTime)) > 300 { + c.Status(http.StatusUnauthorized) + return + } + if err := service.DefaultLiveService.HandleAliyunCallback(callback.EventType, callback.AppName, callback.StreamName, body); err != nil { + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusNoContent) +} + +func parseAliyunLiveCallback(contentType string, body []byte) (aliyunLiveCallback, error) { + var callback aliyunLiveCallback + if strings.HasPrefix(contentType, "application/json") { + err := json.Unmarshal(body, &callback) + return callback, err + } + values, err := url.ParseQuery(string(body)) + if err != nil { + return callback, err + } + callback.EventType = values.Get("eventType") + callback.AppName = values.Get("appName") + callback.StreamName = values.Get("streamName") + if value := values.Get("eventTime"); value != "" { + callback.EventTime, err = strconv.ParseInt(value, 10, 64) + } + return callback, err +} + +func normalizeAliyunCallbackTime(value int64) int64 { + if value > 1_000_000_000_000 { + return value / 1000 + } + return value +} + +func abs(value int64) int64 { + if value < 0 { + return -value + } + return value +} diff --git a/handler/platform_billing_handler.go b/handler/platform_billing_handler.go new file mode 100644 index 0000000..dc80fea --- /dev/null +++ b/handler/platform_billing_handler.go @@ -0,0 +1,113 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +func GetPlatformResourcePool(c *gin.Context) { + data, e := service.DefaultPlatformBillingService.GetPool() + if e != nil { + common.FailWithBusiError(c, e) + return + } + common.OKWithData(c, data) +} + +func CreatePlatformPurchase(c *gin.Context) { + var req vo.PlatformPurchaseCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + data, e := service.DefaultPlatformBillingService.CreatePurchase(common.GetUserId(c), &req) + if e != nil { + common.FailWithBusiError(c, e) + return + } + common.OKWithData(c, data) +} + +func GetPlatformPurchasePage(c *gin.Context) { + var req vo.PlatformPurchasePageReq + bindPage(c, &req.Pagination) + _ = c.ShouldBindQuery(&req) + data, e := service.DefaultPlatformBillingService.GetPurchasePage(&req) + if e != nil { + common.FailWithBusiError(c, e) + return + } + common.OKWithData(c, data) +} + +func GetTrafficPackagePage(c *gin.Context) { + var req vo.TrafficPackagePageReq + bindPage(c, &req.Pagination) + _ = c.ShouldBindQuery(&req) + data, e := service.DefaultPlatformBillingService.GetPackagePage(&req) + if e != nil { + common.FailWithBusiError(c, e) + return + } + common.OKWithData(c, data) +} + +func CreateTrafficPackage(c *gin.Context) { + var req vo.TrafficPackageCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + data, e := service.DefaultPlatformBillingService.CreatePackage(&req) + if e != nil { + common.FailWithBusiError(c, e) + return + } + common.OKWithData(c, data) +} + +func UpdateTrafficPackage(c *gin.Context) { + id, e := parseID(c) + if e != nil { + common.FailWithBusiError(c, e) + return + } + var req vo.TrafficPackageUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + if e := service.DefaultPlatformBillingService.UpdatePackage(id, &req); e != nil { + common.FailWithBusiError(c, e) + return + } + common.OK(c) +} + +func DeleteTrafficPackage(c *gin.Context) { + id, e := parseID(c) + if e != nil { + common.FailWithBusiError(c, e) + return + } + if e := service.DefaultPlatformBillingService.DisablePackage(id); e != nil { + common.FailWithBusiError(c, e) + return + } + common.OK(c) +} + +func GetTrafficLedgerPage(c *gin.Context) { + var req vo.TrafficLedgerPageReq + bindPage(c, &req.Pagination) + _ = c.ShouldBindQuery(&req) + data, e := service.DefaultPlatformBillingService.GetLedgerPage(&req) + if e != nil { + common.FailWithBusiError(c, e) + return + } + common.OKWithData(c, data) +} diff --git a/handler/video_handler.go b/handler/video_handler.go index 5a0e516..2721fa5 100644 --- a/handler/video_handler.go +++ b/handler/video_handler.go @@ -83,7 +83,9 @@ func DownloadVideo(c *gin.Context) { common.FailWithBusiError(c, busiErr) return } - result, busiErr := service.DefaultVideoService.Download(common.GetUserId(c), common.IsAdmin(c), videoID) + result, busiErr := service.DefaultVideoService.Download( + common.GetUserId(c), common.IsAdmin(c), videoID, c.GetHeader("Idempotency-Key"), + ) if busiErr != nil { common.FailWithBusiError(c, busiErr) return diff --git a/live/adapter.go b/live/adapter.go index e798b92..7c9cdc9 100644 --- a/live/adapter.go +++ b/live/adapter.go @@ -16,6 +16,10 @@ type Config struct { AppName string AuthKey string PlayAuthKey string + AccessKeyID string + AccessKeySecret string + RegionID string + APIEndpoint string AuthExpireSeconds int64 PlayURLTTLSeconds int64 Protocol string diff --git a/live/aliyun_client.go b/live/aliyun_client.go new file mode 100644 index 0000000..5561a03 --- /dev/null +++ b/live/aliyun_client.go @@ -0,0 +1,52 @@ +package live + +import ( + "fmt" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + aliyunlive "github.com/alibabacloud-go/live-20161101/client" + runtime "github.com/alibabacloud-go/tea-utils/v2/service" +) + +type aliyunLiveClient struct { + client *aliyunlive.Client + pushDomain string + appName string +} + +func newAliyunLiveClient(cfg Config) (*aliyunLiveClient, error) { + apiConfig := &openapi.Config{} + apiConfig.SetAccessKeyId(cfg.AccessKeyID). + SetAccessKeySecret(cfg.AccessKeySecret). + SetRegionId(cfg.RegionID) + if cfg.APIEndpoint != "" { + apiConfig.SetEndpoint(cfg.APIEndpoint) + } + client, err := aliyunlive.NewClient(apiConfig) + if err != nil { + return nil, fmt.Errorf("create aliyun live client: %w", err) + } + return &aliyunLiveClient{client: client, pushDomain: cfg.PushDomain, appName: cfg.AppName}, nil +} + +func (c *aliyunLiveClient) queryOnline(streamName string) (OnlineStatus, error) { + request := (&aliyunlive.DescribeLiveStreamsOnlineListRequest{}). + SetAppName(c.appName). + SetDomainName(c.pushDomain). + SetStreamName(streamName). + SetPageNum(1). + SetPageSize(100) + response, err := c.client.DescribeLiveStreamsOnlineListWithOptions(request, &runtime.RuntimeOptions{}) + if err != nil { + return OnlineStatus{}, fmt.Errorf("describe aliyun live stream: %w", err) + } + if response == nil || response.Body == nil || response.Body.OnlineInfo == nil { + return OnlineStatus{}, nil + } + for _, item := range response.Body.OnlineInfo.LiveStreamOnlineInfo { + if item != nil && item.StreamName != nil && *item.StreamName == streamName { + return OnlineStatus{Online: true}, nil + } + } + return OnlineStatus{}, nil +} diff --git a/live/factory.go b/live/factory.go index 70f6705..657654d 100644 --- a/live/factory.go +++ b/live/factory.go @@ -15,6 +15,10 @@ func NewAdapter(conf common.Live) (Adapter, error) { AppName: conf.AppName, AuthKey: conf.AuthKey, PlayAuthKey: conf.PlayAuthKey, + AccessKeyID: conf.AccessKeyID, + AccessKeySecret: conf.AccessKeySecret, + RegionID: conf.RegionID, + APIEndpoint: conf.APIEndpoint, AuthExpireSeconds: conf.AuthExpireSeconds, PlayURLTTLSeconds: conf.PlayURLTTLSeconds, Protocol: strings.ToLower(conf.Protocol), diff --git a/live/providers.go b/live/providers.go index bf0e2d2..cd37ed4 100644 --- a/live/providers.go +++ b/live/providers.go @@ -2,8 +2,8 @@ package live import ( "crypto/md5" + cryptorand "crypto/rand" "encoding/hex" - "errors" "fmt" "strconv" "time" @@ -44,20 +44,28 @@ func (f FakeAdapter) QueryOnline(string) (OnlineStatus, error) { } type AliyunAdapter struct { - cfg Config + cfg Config + client *aliyunLiveClient } func NewAliyunAdapter(cfg Config) (Adapter, error) { - if !cfg.AllowRealCloud || cfg.PushDomain == "" || cfg.PlayDomain == "" || cfg.AppName == "" || cfg.AuthKey == "" || cfg.PlayAuthKey == "" { + if !cfg.AllowRealCloud || cfg.PushDomain == "" || cfg.PlayDomain == "" || cfg.AppName == "" || cfg.AuthKey == "" || cfg.PlayAuthKey == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" || cfg.RegionID == "" { return nil, ErrInvalidConfig } if cfg.Protocol == "" { cfg.Protocol = "srt" } + if cfg.Protocol != "srt" && cfg.Protocol != "rtmps" { + return nil, ErrInvalidConfig + } if cfg.SRTPort <= 0 { cfg.SRTPort = 1105 } - return AliyunAdapter{cfg: cfg}, nil + client, err := newAliyunLiveClient(cfg) + if err != nil { + return nil, err + } + return AliyunAdapter{cfg: cfg, client: client}, nil } func (a AliyunAdapter) Provider() string { return "aliyun" } @@ -69,14 +77,20 @@ func (a AliyunAdapter) CreatePlayURLs(req PlayRequest) (PlayURLs, error) { path := "/" + a.cfg.AppName + "/" + req.StreamName signature := sign(path, expires.Unix(), a.cfg.PlayAuthKey) base := "https://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName - return PlayURLs{HLS: base + ".m3u8?auth_key=" + signature, FLV: base + ".flv?auth_key=" + signature, RTMP: "rtmp://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName, ExpiresAt: expires}, nil + rtmp := "rtmp://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName + "?auth_key=" + signature + return PlayURLs{HLS: base + ".m3u8?auth_key=" + signature, FLV: base + ".flv?auth_key=" + signature, RTMP: rtmp, ExpiresAt: expires}, nil } -func (a AliyunAdapter) QueryOnline(string) (OnlineStatus, error) { - return OnlineStatus{}, errors.New("aliyun online query is not configured") +func (a AliyunAdapter) QueryOnline(streamName string) (OnlineStatus, error) { + return a.client.queryOnline(streamName) } + func sign(path string, expiresAt int64, key string) string { - rand := strconv.FormatInt(expiresAt%10000, 10) - sum := md5.Sum([]byte(fmt.Sprintf("%s-%d-%s-%s-%s", path, expiresAt, rand, "0", key))) + var random [4]byte + if _, err := cryptorand.Read(random[:]); err != nil { + panic(fmt.Sprintf("generate live URL nonce: %v", err)) + } + rand := strconv.FormatUint(uint64(random[0])<<24|uint64(random[1])<<16|uint64(random[2])<<8|uint64(random[3]), 10) + sum := md5.Sum([]byte(fmt.Sprintf("%s-%d-%s-0-%s", path, expiresAt, rand, key))) return fmt.Sprintf("%d-%s-0-%s", expiresAt, rand, hex.EncodeToString(sum[:])) } diff --git a/model/billing.go b/model/billing.go index de1c602..de78634 100644 --- a/model/billing.go +++ b/model/billing.go @@ -4,14 +4,21 @@ import "time" // TrafficOrder 云媒体流量订单 type TrafficOrder struct { - ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` - UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` - AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` - UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"` - TotalPrice float64 `gorm:"column:total_price;type:DECIMAL(10,2)" json:"totalPrice"` - PayStatus string `gorm:"column:pay_status;type:VARCHAR(16);default:unpaid" json:"payStatus"` - CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` - PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"` + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + PackageID int64 `gorm:"column:package_id;type:BIGINT" json:"packageId"` + PackageCode string `gorm:"column:package_code;type:VARCHAR(64)" json:"packageCode"` + AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` + AmountBytes int64 `gorm:"column:amount_bytes;type:BIGINT;not null" json:"amountBytes"` + UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"` + TotalPrice float64 `gorm:"column:total_price;type:DECIMAL(10,2)" json:"totalPrice"` + PayStatus string `gorm:"column:pay_status;type:VARCHAR(16);default:unpaid" json:"payStatus"` + CreditStatus string `gorm:"column:credit_status;type:VARCHAR(16);default:pending" json:"creditStatus"` + CreditLedgerID int64 `gorm:"column:credit_ledger_id;type:BIGINT" json:"creditLedgerId"` + PaymentIdempotencyKey string `gorm:"column:payment_idempotency_key;type:VARCHAR(128);uniqueIndex:uk_traffic_order_payment_idem" json:"-"` + PaidBy int64 `gorm:"column:paid_by;type:BIGINT" json:"paidBy"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"` } func (TrafficOrder) TableName() string { @@ -20,14 +27,15 @@ func (TrafficOrder) TableName() string { // TrafficUsageLog 云媒体消费流水(账本,不可变) type TrafficUsageLog struct { - ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` - UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` - SourceType string `gorm:"column:source_type;type:VARCHAR(16)" json:"sourceType"` // live / replay / download - SourceID int64 `gorm:"column:source_id;type:BIGINT" json:"sourceId"` - BytesUsed int64 `gorm:"column:bytes_used;type:BIGINT" json:"bytesUsed"` - BalanceBefore int64 `gorm:"column:balance_before;type:BIGINT" json:"balanceBefore"` - BalanceAfter int64 `gorm:"column:balance_after;type:BIGINT" json:"balanceAfter"` - CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + SourceType string `gorm:"column:source_type;type:VARCHAR(16)" json:"sourceType"` // live / replay / download + SourceID int64 `gorm:"column:source_id;type:BIGINT" json:"sourceId"` + BytesUsed int64 `gorm:"column:bytes_used;type:BIGINT" json:"bytesUsed"` + BalanceBefore int64 `gorm:"column:balance_before;type:BIGINT" json:"balanceBefore"` + BalanceAfter int64 `gorm:"column:balance_after;type:BIGINT" json:"balanceAfter"` + IdempotencyKey string `gorm:"column:idempotency_key;type:VARCHAR(160);not null;uniqueIndex:uk_traffic_usage_idempotency" json:"-"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` } func (TrafficUsageLog) TableName() string { diff --git a/model/live_callback.go b/model/live_callback.go new file mode 100644 index 0000000..b94fde4 --- /dev/null +++ b/model/live_callback.go @@ -0,0 +1,14 @@ +package model + +import "time" + +type LiveCallbackEvent struct { + EventID string `gorm:"primaryKey;column:event_id;type:VARCHAR(256);not null" json:"eventId"` + StreamName string `gorm:"column:stream_name;type:VARCHAR(128);not null" json:"streamName"` + EventType string `gorm:"column:event_type;type:VARCHAR(32);not null" json:"eventType"` + PayloadHash string `gorm:"column:payload_hash;type:CHAR(64);not null" json:"payloadHash"` + ReceivedAt time.Time `gorm:"column:received_at;not null" json:"receivedAt"` + ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"` +} + +func (LiveCallbackEvent) TableName() string { return "live_callback_event" } diff --git a/model/media.go b/model/media.go index b4580e8..57c4eb4 100644 --- a/model/media.go +++ b/model/media.go @@ -19,6 +19,9 @@ type LiveSession struct { DeviceStateVersion int64 `gorm:"column:device_state_version;type:BIGINT" json:"deviceStateVersion"` DeviceEventID string `gorm:"column:device_event_id;type:VARCHAR(128)" json:"deviceEventId"` DeviceUpdatedAt int64 `gorm:"column:device_updated_at;type:BIGINT" json:"deviceUpdatedAt"` + DeviceStreaming bool `gorm:"column:device_streaming;type:TINYINT" json:"deviceStreaming"` + CloudOnline bool `gorm:"column:cloud_online;type:TINYINT" json:"cloudOnline"` + CloudCheckedAt *time.Time `gorm:"column:cloud_checked_at" json:"cloudCheckedAt"` CloudConfirmedAt *time.Time `gorm:"column:cloud_confirmed_at" json:"cloudConfirmedAt"` StartedAt *time.Time `gorm:"column:started_at" json:"startedAt"` StoppedAt *time.Time `gorm:"column:stopped_at" json:"stoppedAt"` @@ -68,11 +71,12 @@ func (Video) TableName() string { // DownloadLog 下载记录 type DownloadLog struct { - ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` - VideoID int64 `gorm:"column:video_id;type:BIGINT;not null" json:"videoId"` - UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` - Bytes int64 `gorm:"column:bytes;type:BIGINT;not null" json:"bytes"` - CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + VideoID int64 `gorm:"column:video_id;type:BIGINT;not null" json:"videoId"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + Bytes int64 `gorm:"column:bytes;type:BIGINT;not null" json:"bytes"` + IdempotencyKey string `gorm:"column:idempotency_key;type:VARCHAR(128);not null;uniqueIndex:uk_download_log_idempotency" json:"idempotencyKey"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` } func (DownloadLog) TableName() string { diff --git a/model/platform_billing.go b/model/platform_billing.go new file mode 100644 index 0000000..5c96ff7 --- /dev/null +++ b/model/platform_billing.go @@ -0,0 +1,86 @@ +package model + +import "time" + +// PlatformResourcePool 平台共享媒体资源池。 +type PlatformResourcePool struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + TotalBytes int64 `gorm:"column:total_bytes;type:BIGINT;not null" json:"totalBytes"` + AvailableBytes int64 `gorm:"column:available_bytes;type:BIGINT;not null" json:"availableBytes"` + ConsumedBytes int64 `gorm:"column:consumed_bytes;type:BIGINT;not null" json:"consumedBytes"` + Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` + Version int64 `gorm:"column:version;type:BIGINT;not null" json:"version"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (PlatformResourcePool) TableName() string { return "platform_resource_pool" } + +// PlatformResourcePurchase 平台从云服务商采购的资源批次。 +type PlatformResourcePurchase struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + BatchNo string `gorm:"column:batch_no;type:VARCHAR(64);not null;uniqueIndex:uk_resource_purchase_batch" json:"batchNo"` + Provider string `gorm:"column:provider;type:VARCHAR(32);not null" json:"provider"` + TotalBytes int64 `gorm:"column:total_bytes;type:BIGINT;not null" json:"totalBytes"` + UnitCost float64 `gorm:"column:unit_cost;type:DECIMAL(12,6)" json:"unitCost"` + TotalCost float64 `gorm:"column:total_cost;type:DECIMAL(14,2)" json:"totalCost"` + ExpiresAt *time.Time `gorm:"column:expires_at" json:"expiresAt"` + Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` + Remark string `gorm:"column:remark;type:VARCHAR(256)" json:"remark"` + CreatedBy int64 `gorm:"column:created_by;type:BIGINT;not null" json:"createdBy"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (PlatformResourcePurchase) TableName() string { return "platform_resource_purchase" } + +// TrafficPackage 用户购买的虚拟流量套餐。 +type TrafficPackage struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + Code string `gorm:"column:code;type:VARCHAR(64);not null;uniqueIndex:uk_traffic_package_code" json:"code"` + Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"` + AmountBytes int64 `gorm:"column:amount_bytes;type:BIGINT;not null" json:"amountBytes"` + Price float64 `gorm:"column:price;type:DECIMAL(10,2);not null" json:"price"` + ValidityDays int `gorm:"column:validity_days;type:INT" json:"validityDays"` + Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` + Sort int `gorm:"column:sort;type:INT;default:0" json:"sort"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (TrafficPackage) TableName() string { return "traffic_package" } + +// TrafficLedger 统一追加式账务流水。 +type TrafficLedger struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + AccountType string `gorm:"column:account_type;type:VARCHAR(16);not null" json:"accountType"` + AccountID int64 `gorm:"column:account_id;type:BIGINT;not null" json:"accountId"` + Direction string `gorm:"column:direction;type:VARCHAR(8);not null" json:"direction"` + AmountBytes int64 `gorm:"column:amount_bytes;type:BIGINT;not null" json:"amountBytes"` + BalanceBefore int64 `gorm:"column:balance_before;type:BIGINT;not null" json:"balanceBefore"` + BalanceAfter int64 `gorm:"column:balance_after;type:BIGINT;not null" json:"balanceAfter"` + SourceType string `gorm:"column:source_type;type:VARCHAR(32);not null" json:"sourceType"` + SourceID string `gorm:"column:source_id;type:VARCHAR(128)" json:"sourceId"` + IdempotencyKey string `gorm:"column:idempotency_key;type:VARCHAR(128);not null;uniqueIndex:uk_traffic_ledger_idempotency" json:"idempotencyKey"` + OperatorID int64 `gorm:"column:operator_id;type:BIGINT" json:"operatorId"` + Remark string `gorm:"column:remark;type:VARCHAR(256)" json:"remark"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (TrafficLedger) TableName() string { return "traffic_ledger" } + +// LiveBillingSegment 直播已结算的确定时间段。 +type LiveBillingSegment struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + SessionID string `gorm:"column:session_id;type:VARCHAR(128);not null" json:"sessionId"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + PeriodStart time.Time `gorm:"column:period_start;not null" json:"periodStart"` + PeriodEnd time.Time `gorm:"column:period_end;not null" json:"periodEnd"` + Bytes int64 `gorm:"column:bytes;type:BIGINT;not null" json:"bytes"` + Status string `gorm:"column:status;type:VARCHAR(16);default:pending" json:"status"` + IdempotencyKey string `gorm:"column:idempotency_key;type:VARCHAR(160);not null;uniqueIndex:uk_live_billing_segment_idempotency" json:"idempotencyKey"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + SettledAt *time.Time `gorm:"column:settled_at" json:"settledAt"` +} + +func (LiveBillingSegment) TableName() string { return "live_billing_segment" } diff --git a/route/route.go b/route/route.go index dd9d428..26ad21f 100644 --- a/route/route.go +++ b/route/route.go @@ -23,6 +23,9 @@ func InitRouter(port int32) { common.OKWithData(c, gin.H{"status": "up"}) }) + // 阿里云直播回调使用独立令牌鉴权,不能进入用户 JWT 或操作日志中间件。 + engine.POST("/internal/callbacks/aliyun/live", handler.HandleAliyunLiveCallback) + // v1 业务路由 v1 := common.RouteGroup("/v1") v1.Use(middleware.OperationLogMiddleware()) @@ -172,6 +175,19 @@ func InitRouter(port int32) { videos.POST("/:id/download", handler.DownloadVideo) } + // 平台流量资源与套餐管理(admin) + trafficAdmin := v1.Group("/admin/traffic", middleware.AuthMiddleware(), middleware.AdminMiddleware()) + { + trafficAdmin.GET("/pool", handler.GetPlatformResourcePool) + trafficAdmin.POST("/purchases", handler.CreatePlatformPurchase) + trafficAdmin.GET("/purchases", handler.GetPlatformPurchasePage) + trafficAdmin.GET("/packages", handler.GetTrafficPackagePage) + trafficAdmin.POST("/packages", handler.CreateTrafficPackage) + trafficAdmin.PUT("/packages/:id", handler.UpdateTrafficPackage) + trafficAdmin.DELETE("/packages/:id", handler.DeleteTrafficPackage) + trafficAdmin.GET("/ledger", handler.GetTrafficLedgerPage) + } + // 系统管理(admin) users := v1.Group("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware()) { diff --git a/service/account_service.go b/service/account_service.go index d3991ed..9e242fd 100644 --- a/service/account_service.go +++ b/service/account_service.go @@ -81,12 +81,12 @@ func (s *AccountService) GetOrderPage(userID int64, req *vo.OrderPageReq) (*comm // CreateTrafficOrder 创建流量充值订单 func (s *AccountService) CreateTrafficOrder(userID int64, req *vo.TrafficOrderCreateReq) (*model.TrafficOrder, *common.BusiError) { - return DefaultBillingService.CreateOrder(userID, req.AmountGb) + return DefaultBillingService.CreateOrderWithPackage(userID, req.PackageID, int(req.AmountGb)) } // PayTrafficOrder 订单支付入账(admin) -func (s *AccountService) PayTrafficOrder(orderID int64) (*model.TrafficOrder, *common.BusiError) { - return DefaultBillingService.PayOrder(orderID) +func (s *AccountService) PayTrafficOrder(orderID, operatorID int64, paymentKey string) (*model.TrafficOrder, *common.BusiError) { + return DefaultBillingService.PayOrder(orderID, operatorID, paymentKey) } // ListSimCards SIM 卡列表 diff --git a/service/billing_service.go b/service/billing_service.go index 1c6d76c..f0aa4e8 100644 --- a/service/billing_service.go +++ b/service/billing_service.go @@ -111,43 +111,93 @@ func (b *BillingService) currentBalance(userID int64) (int64, error) { return bal, nil } -// Deduct 扣减流量(直播/回放/下载计费),原子 Lua + 账本落库 -func (b *BillingService) Deduct(userID int64, bytes int64, sourceType string, sourceID int64) (bool, *common.BusiError) { +// Deduct 在一个事务内扣减用户余额和平台共享资源,并写入统一账务流水。 +// idempotencyKey 必须由调用方按业务事件稳定生成。 +func (b *BillingService) Deduct(userID int64, bytes int64, sourceType string, sourceID int64, idempotencyKey string) (bool, *common.BusiError) { if bytes <= 0 { return true, nil } - fallback, err := b.mysqlBalance(userID) - if err != nil { - logger.ERROR("读取流量快照失败", err) - return false, common.ErrInternal + if idempotencyKey == "" { + return false, common.ErrParam } - key := cache.TrafficKeyOf(userID) - res, err := common.GetLuaInt64s(trafficDeductLua, []string{key}, bytes, fallback) + now := time.Now() + var busiErr *common.BusiError + var alreadyDeducted bool + err := common.DB.Transaction(func(tx *gorm.DB) error { + var existing model.TrafficLedger + if err := tx.Where("idempotency_key = ?", idempotencyKey+":user").First(&existing).Error; err == nil { + alreadyDeducted = true + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "traffic_balance").First(&user, userID).Error; err != nil { + return err + } + if user.TrafficBalance < bytes { + busiErr = common.ErrTrafficNotEnough + return busiErr + } + + var pool model.PlatformResourcePool + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").First(&pool).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + busiErr = common.ErrPlatformTrafficExhausted + } + return err + } + if pool.AvailableBytes < bytes || pool.Status != "active" { + busiErr = common.ErrPlatformTrafficExhausted + return busiErr + } + + userAfter := user.TrafficBalance - bytes + platformAfter := pool.AvailableBytes - bytes + if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("traffic_balance", userAfter).Error; err != nil { + return err + } + if err := tx.Model(&pool).Updates(map[string]any{ + "available_bytes": platformAfter, + "consumed_bytes": gorm.Expr("consumed_bytes + ?", bytes), + "version": gorm.Expr("version + 1"), + "updated_at": now, + }).Error; err != nil { + return err + } + if err := tx.Create(&model.TrafficLedger{ + ID: mustID(), AccountType: "user", AccountID: userID, Direction: "debit", + AmountBytes: bytes, BalanceBefore: user.TrafficBalance, BalanceAfter: userAfter, + SourceType: sourceType, SourceID: strconv.FormatInt(sourceID, 10), IdempotencyKey: idempotencyKey + ":user", CreatedAt: now, + }).Error; err != nil { + return err + } + if err := tx.Create(&model.TrafficLedger{ + ID: mustID(), AccountType: "platform", AccountID: pool.ID, Direction: "debit", + AmountBytes: bytes, BalanceBefore: pool.AvailableBytes, BalanceAfter: platformAfter, + SourceType: sourceType, SourceID: strconv.FormatInt(sourceID, 10), IdempotencyKey: idempotencyKey + ":platform", CreatedAt: now, + }).Error; err != nil { + return err + } + return tx.Create(&model.TrafficUsageLog{ + ID: mustID(), UserID: userID, SourceType: sourceType, SourceID: sourceID, + BytesUsed: bytes, BalanceBefore: user.TrafficBalance, BalanceAfter: userAfter, + IdempotencyKey: idempotencyKey, CreatedAt: now, + }).Error + }) if err != nil { - logger.ERROR("流量扣减 Lua 执行失败", err) - return false, common.ErrInternal - } - if len(res) < 3 { + if busiErr != nil { + return false, busiErr + } + logger.ERROR("流量扣费事务失败", err) return false, common.ErrInternal } - status, before, after := res[0], res[1], res[2] - if status == 0 { - return false, common.ErrTrafficNotEnough + if alreadyDeducted { + return true, nil } - - log := &model.TrafficUsageLog{ - ID: mustID(), - UserID: userID, - SourceType: sourceType, - SourceID: sourceID, - BytesUsed: bytes, - BalanceBefore: before, - BalanceAfter: after, - CreatedAt: time.Now(), - } - if err := common.DB.Create(log).Error; err != nil { - logger.ERROR("流量账本落库失败", err) - return false, common.ErrInternal + if err := common.Delete(cache.TrafficKeyOf(userID)); err != nil { + logger.WARN("删除流量余额缓存失败", err) } return true, nil } @@ -196,16 +246,29 @@ func (b *BillingService) GetOrderPage(userID int64, req *vo.OrderPageReq) (*comm return common.Page(req.Pagination, total, list), nil } -// CreateOrder 创建流量充值订单(待支付) func (b *BillingService) CreateOrder(userID int64, amountGb int) (*model.TrafficOrder, *common.BusiError) { - order := &model.TrafficOrder{ - ID: mustID(), - UserID: userID, - AmountGb: amountGb, - UnitPrice: defaultTrafficUnitPrice, - TotalPrice: float64(amountGb) * defaultTrafficUnitPrice, - PayStatus: "unpaid", - CreatedAt: time.Now(), + return b.CreateOrderWithPackage(userID, 0, amountGb) +} + +func (b *BillingService) CreateOrderWithPackage(userID, packageID int64, amountGb int) (*model.TrafficOrder, *common.BusiError) { + order := &model.TrafficOrder{ID: mustID(), UserID: userID, PayStatus: "unpaid", CreditStatus: "pending", CreatedAt: time.Now()} + if packageID > 0 { + var pkg model.TrafficPackage + if err := common.DB.Where("id = ? AND status = ?", packageID, "active").First(&pkg).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrNotFound + } + return nil, common.ErrInternal + } + order.PackageID, order.PackageCode = pkg.ID, pkg.Code + order.AmountBytes, order.UnitPrice, order.TotalPrice = pkg.AmountBytes, pkg.Price/float64(pkg.AmountBytes)*float64(gbBytes), pkg.Price + order.AmountGb = int(pkg.AmountBytes / gbBytes) + } else { + if amountGb <= 0 { + return nil, common.ErrParam + } + order.AmountGb, order.AmountBytes = amountGb, int64(amountGb)*gbBytes + order.UnitPrice, order.TotalPrice = defaultTrafficUnitPrice, float64(amountGb)*defaultTrafficUnitPrice } if err := common.DB.Create(order).Error; err != nil { logger.ERROR("创建流量订单失败", err) @@ -214,41 +277,76 @@ func (b *BillingService) CreateOrder(userID int64, amountGb int) (*model.Traffic return order, nil } -// PayOrder 订单支付入账(admin 确认支付) -func (b *BillingService) PayOrder(orderID int64) (*model.TrafficOrder, *common.BusiError) { +// PayOrder 订单支付入账(admin 确认支付)。 +func (b *BillingService) PayOrder(orderID, operatorID int64, paymentKey string) (*model.TrafficOrder, *common.BusiError) { var order model.TrafficOrder now := time.Now() - if err := common.DB.Transaction(func(tx *gorm.DB) error { + var busiErr *common.BusiError + if paymentKey == "" { + paymentKey = strconv.FormatInt(orderID, 10) + } + paymentIdempotencyKey := "order:payment:" + paymentKey + err := common.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { return err } - if order.PayStatus == "paid" { - return common.ErrOrderPaid + if order.PayStatus == "paid" || order.CreditStatus == "credited" { + busiErr = common.ErrOrderPaid + return busiErr + } + bytes := order.AmountBytes + if bytes <= 0 { + bytes = int64(order.AmountGb) * gbBytes + } + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "traffic_balance").First(&user, order.UserID).Error; err != nil { + return err + } + after := user.TrafficBalance + bytes + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("traffic_balance", after).Error; err != nil { + return err + } + ledgerID := mustID() + if ledgerID == 0 { + return errors.New("generate ledger id failed") } - bytes := int64(order.AmountGb) * gbBytes - if err := tx.Model(&order).Updates(map[string]any{"pay_status": "paid", "paid_at": now}).Error; err != nil { + if err := tx.Create(&model.TrafficLedger{ + ID: ledgerID, AccountType: "user", AccountID: order.UserID, Direction: "credit", + AmountBytes: bytes, BalanceBefore: user.TrafficBalance, BalanceAfter: after, + SourceType: "traffic_order", SourceID: strconv.FormatInt(order.ID, 10), + IdempotencyKey: paymentIdempotencyKey, OperatorID: operatorID, CreatedAt: now, + }).Error; err != nil { return err } - return tx.Model(&model.User{}).Where("id = ?", order.UserID). - UpdateColumn("traffic_balance", gorm.Expr("traffic_balance + ?", bytes)).Error - }); err != nil { + if err := tx.Model(&order).Updates(map[string]any{ + "pay_status": "paid", "credit_status": "credited", "amount_bytes": bytes, + "credit_ledger_id": ledgerID, "paid_at": now, "paid_by": operatorID, + "payment_idempotency_key": paymentIdempotencyKey}).Error; err != nil { + return err + } + order.AmountBytes = bytes + order.CreditStatus = "credited" + order.CreditLedgerID = ledgerID + order.PayStatus = "paid" + order.PaidAt = &now + return nil + }) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrOrderNotFound } - if busiErr, ok := err.(*common.BusiError); ok { + if busiErr != nil { return nil, busiErr } + if typed, ok := err.(*common.BusiError); ok { + return nil, typed + } logger.ERROR("订单支付失败", err) return nil, common.ErrInternal } - - bytes := int64(order.AmountGb) * gbBytes - if err := b.credit(order.UserID, bytes); err != nil { - logger.ERROR("订单支付 Redis 入账失败", err) - return nil, common.ErrInternal + if err := common.Delete(cache.TrafficKeyOf(order.UserID)); err != nil { + logger.WARN("删除充值余额缓存失败", err) } - order.PayStatus = "paid" - order.PaidAt = &now return &order, nil } @@ -304,53 +402,128 @@ func mustID() int64 { return id } -// chargeLiveSessions 直播计费(定时任务,每 60s):扫描进行中的会话按码率估算扣减 +// chargeLiveSessions 只结算设备与云端均确认在线的 streaming 会话。 func (b *BillingService) chargeLiveSessions() { var sessions []model.LiveSession - if err := common.DB.Where("phase IN ('starting','streaming')").Find(&sessions).Error; err != nil { + if err := common.DB.Where("phase = 'streaming' AND device_streaming = ? AND cloud_online = ? AND started_at IS NOT NULL", true, true).Find(&sessions).Error; err != nil { logger.ERROR("扫描直播会话失败", err) return } - now := time.Now() + now := time.Now().Truncate(time.Second) for i := range sessions { b.chargeLiveSession(&sessions[i], now) } } -// chargeLiveSession 对单个直播会话按时长增量扣减;余额耗尽则停止推流 +// chargeLiveSession 通过会话行锁和唯一直播计费段避免多实例重复结算。 func (b *BillingService) chargeLiveSession(session *model.LiveSession, now time.Time) { - var dock model.Dock - if err := common.DB.Select("user_id").Where("dock_id = ?", session.DockID).First(&dock).Error; err != nil { - return - } + var userID int64 + var needStop bool + var charged bool + err := common.DB.Transaction(func(tx *gorm.DB) error { + var current model.LiveSession + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", session.ID).Error; err != nil { + return err + } + if current.Phase != "streaming" || !current.DeviceStreaming || !current.CloudOnline || current.StartedAt == nil { + return nil + } + var dock model.Dock + if err := tx.Select("user_id").Where("dock_id = ?", current.DockID).First(&dock).Error; err != nil { + return err + } + userID = dock.UserID - lastKey := cache.LiveBilledKeyOf(session.ID) - lastTs := int64(0) - if v, err := common.GetInt64(lastKey); err == nil { - lastTs = v - } else if session.StartedAt != nil { - lastTs = session.StartedAt.Unix() - } else { - lastTs = session.CreatedAt.Unix() - } + periodStart := *current.StartedAt + var last model.LiveBillingSegment + if err := tx.Where("session_id = ?", current.ID).Order("period_end DESC").First(&last).Error; err == nil { + periodStart = last.PeriodEnd + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if !now.After(periodStart) { + return nil + } + amount := int64(now.Sub(periodStart).Seconds() * float64(current.MaxBitrateBps) / 8) + if amount <= 0 { + return nil + } + idempotencyKey := fmt.Sprintf("live:%s:%d:%d", current.ID, periodStart.Unix(), now.Unix()) + segment := model.LiveBillingSegment{ + ID: mustID(), SessionID: current.ID, UserID: userID, PeriodStart: periodStart, PeriodEnd: now, + Bytes: amount, Status: "pending", IdempotencyKey: idempotencyKey, CreatedAt: now, + } + if err := tx.Create(&segment).Error; err != nil { + if code, _ := common.ParseError(err); code == 1062 { + return nil + } + return err + } - nowTs := now.Unix() - if nowTs <= lastTs { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "traffic_balance").First(&user, userID).Error; err != nil { + return err + } + var pool model.PlatformResourcePool + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").First(&pool).Error; err != nil { + return err + } + if user.TrafficBalance < amount || pool.Status != "active" || pool.AvailableBytes < amount { + if err := tx.Model(&segment).Update("status", "insufficient").Error; err != nil { + return err + } + result := tx.Model(&model.LiveSession{}).Where("id = ? AND phase = 'streaming'", current.ID).Updates(map[string]any{ + "phase": "stopping", "stop_reason": "no_balance", "error_code": "TRAFFIC_NOT_ENOUGH", "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + needStop = result.RowsAffected == 1 + return nil + } + + userAfter, platformAfter := user.TrafficBalance-amount, pool.AvailableBytes-amount + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("traffic_balance", userAfter).Error; err != nil { + return err + } + if err := tx.Model(&pool).Updates(map[string]any{ + "available_bytes": platformAfter, + "consumed_bytes": gorm.Expr("consumed_bytes + ?", amount), + "version": gorm.Expr("version + 1"), + "updated_at": now, + }).Error; err != nil { + return err + } + if err := tx.Create(&model.TrafficLedger{ID: mustID(), AccountType: "user", AccountID: user.ID, Direction: "debit", AmountBytes: amount, BalanceBefore: user.TrafficBalance, BalanceAfter: userAfter, SourceType: "live", SourceID: current.ID, IdempotencyKey: idempotencyKey + ":user", CreatedAt: now}).Error; err != nil { + return err + } + if err := tx.Create(&model.TrafficLedger{ID: mustID(), AccountType: "platform", AccountID: pool.ID, Direction: "debit", AmountBytes: amount, BalanceBefore: pool.AvailableBytes, BalanceAfter: platformAfter, SourceType: "live", SourceID: current.ID, IdempotencyKey: idempotencyKey + ":platform", CreatedAt: now}).Error; err != nil { + return err + } + if err := tx.Create(&model.TrafficUsageLog{ID: mustID(), UserID: user.ID, SourceType: "live", SourceID: 0, BytesUsed: amount, BalanceBefore: user.TrafficBalance, BalanceAfter: userAfter, IdempotencyKey: idempotencyKey, CreatedAt: now}).Error; err != nil { + return err + } + if err := tx.Model(&segment).Updates(map[string]any{"status": "settled", "settled_at": now}).Error; err != nil { + return err + } + charged = true + return nil + }) + if err != nil { + logger.ERROR("直播计费事务失败", err) return } - bytes := (nowTs - lastTs) * session.MaxBitrateBps / 8 - sourceID, _ := strconv.ParseInt(session.ID, 10, 64) - - if bytes > 0 { - ok, _ := b.Deduct(dock.UserID, bytes, "live", sourceID) - if !ok { - if _, busiErr := DefaultLiveService.stopForBilling(session.DockID, session.ID); busiErr != nil { - logger.ERROR("直播余额不足停止推流失败", busiErr) - } - logger.WARN("直播余额耗尽,停止推流:", session.DockID) + if needStop { + if _, busiErr := DefaultLiveService.stopForBilling(session.DockID, session.ID); busiErr != nil { + logger.ERROR("直播余额不足停止推流失败", busiErr) + } + return + } + if charged { + if err := common.Delete(cache.TrafficKeyOf(userID)); err != nil { + logger.WARN("删除直播余额缓存失败", err) } } - _ = common.SetValue(lastKey, nowTs) } // flushBalanceSnapshot 余额快照刷新(定时任务,每 30min):Redis 权威余额回写 MySQL diff --git a/service/command_service.go b/service/command_service.go index f372cd2..75ef1da 100644 --- a/service/command_service.go +++ b/service/command_service.go @@ -1,6 +1,8 @@ package service import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -156,7 +158,16 @@ func (s *CommandService) DispatchToDock(dockID, commandType string, params map[s return nil, err } requestID := strings.ReplaceAll(uuid.New().String(), "-", "") - paramsJSON, err := json.Marshal(params) + auditParams := params + if commandType == "video.start_stream" { + auditParams = cloneParams(params) + if pushURL, ok := auditParams["pushUrl"].(string); ok { + sum := sha256.Sum256([]byte(pushURL)) + auditParams["pushUrlHash"] = hex.EncodeToString(sum[:]) + delete(auditParams, "pushUrl") + } + } + auditJSON, err := json.Marshal(auditParams) if err != nil { return nil, err } @@ -165,7 +176,7 @@ func (s *CommandService) DispatchToDock(dockID, commandType string, params map[s ID: commandID, DockID: dockID, CommandType: commandType, - Params: string(paramsJSON), + Params: string(auditJSON), RequestID: requestID, TTLMs: 30000, Status: "sent", @@ -181,7 +192,14 @@ func (s *CommandService) DispatchToDock(dockID, commandType string, params map[s return cmd, nil } -// StartRetryScanner 启动指令超时重试扫描器 +func cloneParams(params map[string]any) map[string]any { + copy := make(map[string]any, len(params)) + for key, value := range params { + copy[key] = value + } + return copy +} + func (s *CommandService) StartRetryScanner() { go func() { ticker := time.NewTicker(10 * time.Second) @@ -213,7 +231,11 @@ func (s *CommandService) retryTimeout() { if cmd.RetryCount >= maxRetryCount { result := common.DB.Model(&model.DeviceCommandLog{}).Where("id = ? AND status = ?", cmd.ID, "sent").Update("status", "timeout") if result.Error == nil && result.RowsAffected > 0 { - failTaskExecution(cmd, "COMMAND_ACK_TIMEOUT") + if cmd.CommandType == "video.start_stream" || cmd.CommandType == "video.stop_stream" { + DefaultLiveService.OnCommandTimeout(cmd) + } else { + failTaskExecution(cmd, "COMMAND_ACK_TIMEOUT") + } } continue } @@ -230,6 +252,9 @@ func (s *CommandService) retryTimeout() { if cmd.Params != "" { _ = json.Unmarshal([]byte(cmd.Params), ¶ms) } + if cmd.CommandType == "video.start_stream" { + params = DefaultLiveService.RefreshStartParams(cmd.DockID, params) + } b, _ := json.Marshal(s.commandEnvelope(&cmd, requestID, params)) topic := fmt.Sprintf("dock-edge/v1/dock/%s/command", cmd.DockID) _ = mqtt.Publish(topic, 1, false, b) diff --git a/service/live_callback_service.go b/service/live_callback_service.go new file mode 100644 index 0000000..1113ef2 --- /dev/null +++ b/service/live_callback_service.go @@ -0,0 +1,46 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "gorm.io/gorm" + + "laic-backend/common" + "laic-backend/model" +) + +func (s *LiveService) HandleAliyunCallback(eventType, appName, streamName string, payload []byte) error { + if (eventType != "publish" && eventType != "publish_done") || appName != common.AppConf.Live.AppName || streamName == "" { + return nil + } + sum := sha256.Sum256(payload) + payloadHash := hex.EncodeToString(sum[:]) + idSum := sha256.Sum256([]byte(eventType + "\n" + appName + "\n" + streamName + "\n" + payloadHash)) + event := model.LiveCallbackEvent{ + EventID: hex.EncodeToString(idSum[:]), + StreamName: streamName, + EventType: eventType, + PayloadHash: payloadHash, + ReceivedAt: time.Now(), + } + if err := common.DB.Create(&event).Error; err != nil { + if code, _ := common.ParseError(err); code == 1062 || errors.Is(err, gorm.ErrDuplicatedKey) { + return nil + } + return err + } + now := time.Now() + updates := map[string]any{"cloud_checked_at": now} + if eventType == "publish" { + updates["cloud_online"] = true + } else { + updates["cloud_online"] = false + } + if err := common.DB.Model(&model.LiveSession{}).Where("stream_name = ? AND phase IN ('starting','streaming','reconnecting','stopping')", streamName).Updates(updates).Error; err != nil { + return err + } + return common.DB.Model(&event).Update("processed_at", now).Error +} diff --git a/service/live_service.go b/service/live_service.go index 546ba6c..41c21fb 100644 --- a/service/live_service.go +++ b/service/live_service.go @@ -1,6 +1,8 @@ package service import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "strconv" @@ -23,6 +25,11 @@ type LiveService struct { var DefaultLiveService = &LiveService{} +func pushURLHash(pushURL string) string { + sum := sha256.Sum256([]byte(pushURL)) + return hex.EncodeToString(sum[:]) +} + func InitLiveProvider(conf common.Live) error { provider, err := liveprovider.NewAdapter(conf) if err != nil { @@ -86,7 +93,7 @@ func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.Li return err } now := time.Now() - session = model.LiveSession{ID: streamName, DockID: dockID, Provider: credentials.Provider, StreamName: streamName, ExpiresAt: expiresAt.Unix(), MaxBitrateBps: maxBitrate, Phase: "starting", RequestedBy: userID, CreatedAt: now, UpdatedAt: now} + session = model.LiveSession{ID: streamName, DockID: dockID, Provider: credentials.Provider, StreamName: streamName, PushURLHash: pushURLHash(credentials.PushURL), ExpiresAt: expiresAt.Unix(), MaxBitrateBps: maxBitrate, Phase: "starting", RequestedBy: userID, CreatedAt: now, UpdatedAt: now} if err := tx.Create(&session).Error; err != nil { return err } @@ -211,6 +218,94 @@ func (s *LiveService) ReconcileLeases() { } } +func (s *LiveService) ReconcileCloudSessions() { + provider, busiErr := s.adapter() + if busiErr != nil { + return + } + var sessions []model.LiveSession + if err := common.DB.Where("phase IN ('starting','streaming','reconnecting','stopping')").Find(&sessions).Error; err != nil { + logger.ERROR("扫描直播云端对账会话失败", err) + return + } + now := time.Now() + for i := range sessions { + s.reconcileCloudSession(provider, &sessions[i], now) + } +} + +func (s *LiveService) reconcileCloudSession(provider liveprovider.Adapter, session *model.LiveSession, now time.Time) { + online, err := provider.QueryOnline(session.StreamName) + updates := map[string]any{"cloud_checked_at": now} + if err != nil { + updates["error_code"] = "PROVIDER_ONLINE_QUERY_FAILED" + _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", session.ID, session.DockID).Updates(updates).Error + return + } + updates["cloud_online"] = online.Online + if session.DeviceStreaming && online.Online { + updates["cloud_confirmed_at"] = now + } + switch session.Phase { + case "starting", "reconnecting": + if session.DeviceStreaming && online.Online { + updates["phase"] = "streaming" + updates["started_at"] = now + } + case "streaming": + if !session.DeviceStreaming || !online.Online { + updates["phase"] = "reconnecting" + } + case "stopping": + if !session.DeviceStreaming && !online.Online { + updates["phase"] = "stopped" + updates["stopped_at"] = now + } + } + _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase = ?", session.ID, session.DockID, session.Phase).Updates(updates).Error +} + +func (s *LiveService) RefreshStartParams(dockID string, params map[string]any) map[string]any { + if params == nil { + return params + } + streamSessionID, _ := params["streamSessionId"].(string) + if streamSessionID == "" { + return params + } + var session model.LiveSession + if common.DB.Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", streamSessionID, dockID).First(&session).Error != nil { + return params + } + provider, busiErr := s.adapter() + if busiErr != nil { + return params + } + credentials, err := provider.CreateStream(liveprovider.StreamRequest{StreamName: session.StreamName, ExpiresAt: time.Unix(session.ExpiresAt, 0), MaxBitrateBps: session.MaxBitrateBps}) + if err != nil { + return params + } + params["pushUrl"] = credentials.PushURL + params["expiresAt"] = credentials.ExpiresAt.UnixMilli() + return params +} + +func (s *LiveService) OnCommandTimeout(cmd model.DeviceCommandLog) { + var params struct { + StreamSessionID string `json:"streamSessionId"` + } + if json.Unmarshal([]byte(cmd.Params), ¶ms) != nil || params.StreamSessionID == "" { + return + } + updates := map[string]any{"error_code": "COMMAND_ACK_TIMEOUT"} + if cmd.CommandType == "video.start_stream" { + updates["phase"] = "failed" + } else { + updates["phase"] = "reconnecting" + } + _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", params.StreamSessionID, cmd.DockID).Updates(updates).Error +} + // Start 兼容旧启动接口,统一复用创建或加入会话的租约流程。 func (s *LiveService) Start(userID int64, isAdmin bool, dockID string, req *vo.LiveStartReq) (*model.LiveSession, *common.BusiError) { result, busiErr := s.Join(userID, isAdmin, dockID, &vo.LiveSessionCreateReq{MaxBitrateBps: req.MaxBitrateBps}) @@ -281,13 +376,13 @@ func (s *LiveService) GetPage(userID int64, isAdmin bool, req *vo.LivePageReq) ( } // GetPlayURL only returns a URL after both edge and provider confirmation are represented by streaming. -func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID string) (*vo.LivePlayURLVO, *common.BusiError) { +func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID, sessionID string) (*vo.LivePlayURLVO, *common.BusiError) { provider, busiErr := s.adapter() if busiErr != nil { return nil, busiErr } var session model.LiveSession - if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ? AND phase = 'streaming'", dockID).Order("created_at DESC, id DESC").First(&session).Error; err != nil { + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ? AND phase = 'streaming'", sessionID, dockID).First(&session).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrLiveNotFound } @@ -361,6 +456,7 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI "device_state_version": version, "device_event_id": eventID, "device_updated_at": updatedAt, + "device_streaming": streaming, "updated_at": now, } if streaming { @@ -368,7 +464,7 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI if busiErr != nil { return false } - online, queryErr := provider.QueryOnline(streamSessionID) + online, queryErr := provider.QueryOnline(session.StreamName) if queryErr != nil || !online.Online { updates["error_code"] = "PROVIDER_ONLINE_UNVERIFIED" return common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND device_state_version < ?", streamSessionID, dockID, version).Updates(updates).Error == nil @@ -376,10 +472,11 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI updates["phase"] = "streaming" updates["started_at"] = now updates["cloud_confirmed_at"] = now + } else if session.Phase == "stopping" { + updates["error_code"] = "DEVICE_STREAM_OFFLINE" } else { - updates["phase"] = "stopped" - updates["stop_reason"] = "device" - updates["stopped_at"] = now + updates["phase"] = "reconnecting" + updates["error_code"] = "DEVICE_STREAM_OFFLINE" } result := common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping') AND device_state_version < ?", streamSessionID, dockID, version).Updates(updates) return result.Error == nil && result.RowsAffected == 1 diff --git a/service/platform_billing_service.go b/service/platform_billing_service.go new file mode 100644 index 0000000..9cea616 --- /dev/null +++ b/service/platform_billing_service.go @@ -0,0 +1,224 @@ +package service + +import ( + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type PlatformBillingService struct{} + +var DefaultPlatformBillingService = &PlatformBillingService{} + +func (s *PlatformBillingService) GetPool() (*model.PlatformResourcePool, *common.BusiError) { + var pool model.PlatformResourcePool + err := common.DB.Order("id ASC").First(&pool).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrNotFound + } + if err != nil { + logger.ERROR("查询平台资源池失败", err) + return nil, common.ErrInternal + } + return &pool, nil +} + +func (s *PlatformBillingService) CreatePurchase(adminID int64, req *vo.PlatformPurchaseCreateReq) (*model.PlatformResourcePurchase, *common.BusiError) { + var purchase model.PlatformResourcePurchase + now := time.Now() + err := common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("batch_no = ?", req.BatchNo).First(&purchase).Error; err == nil { + if purchase.Provider == req.Provider && purchase.TotalBytes == req.TotalBytes && purchase.TotalCost == req.TotalCost { + return gorm.ErrDuplicatedKey + } + return common.NewBusiError(common.ParamError, "采购批次号已存在且内容不一致") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + id, err := tool.NextID() + if err != nil { + return err + } + purchase = model.PlatformResourcePurchase{ + ID: id, BatchNo: req.BatchNo, Provider: req.Provider, TotalBytes: req.TotalBytes, + UnitCost: req.UnitCost, TotalCost: req.TotalCost, ExpiresAt: req.ExpiresAt, + Status: "active", Remark: req.Remark, CreatedBy: adminID, CreatedAt: now, UpdatedAt: now, + } + if err := tx.Create(&purchase).Error; err != nil { + return err + } + + var pool model.PlatformResourcePool + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").First(&pool).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + pool = model.PlatformResourcePool{ID: mustPlatformID(), Status: "active", CreatedAt: now, UpdatedAt: now} + if pool.ID == 0 { + return common.ErrInternal + } + if err := tx.Create(&pool).Error; err != nil { + return err + } + err = nil + } + if err != nil { + return err + } + before := pool.AvailableBytes + if err := tx.Model(&pool).Updates(map[string]any{ + "total_bytes": gorm.Expr("total_bytes + ?", req.TotalBytes), + "available_bytes": gorm.Expr("available_bytes + ?", req.TotalBytes), + "version": gorm.Expr("version + 1"), "updated_at": now, + }).Error; err != nil { + return err + } + return tx.Create(&model.TrafficLedger{ + ID: mustPlatformID(), AccountType: "platform", AccountID: pool.ID, Direction: "credit", + AmountBytes: req.TotalBytes, BalanceBefore: before, BalanceAfter: before + req.TotalBytes, + SourceType: "purchase", SourceID: req.BatchNo, IdempotencyKey: "purchase:" + req.BatchNo, + OperatorID: adminID, CreatedAt: now, + }).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return &purchase, nil + } + if busiErr, ok := err.(*common.BusiError); ok { + return nil, busiErr + } + logger.ERROR("录入平台资源采购失败", err) + return nil, common.ErrInternal + } + return &purchase, nil +} + +func (s *PlatformBillingService) GetPurchasePage(req *vo.PlatformPurchasePageReq) (*common.PageResponse[model.PlatformResourcePurchase], *common.BusiError) { + db := common.DB.Model(&model.PlatformResourcePurchase{}) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.Provider != "" { + db = db.Where("provider = ?", req.Provider) + } + if req.Keyword != "" { + db = db.Where("batch_no LIKE ?", "%"+req.Keyword+"%") + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.PlatformResourcePurchase + if err := db.Scopes(req.Paginate).Order("created_at DESC, id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +func (s *PlatformBillingService) CreatePackage(req *vo.TrafficPackageCreateReq) (*model.TrafficPackage, *common.BusiError) { + id, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + now := time.Now() + pkg := &model.TrafficPackage{ID: id, Code: req.Code, Name: req.Name, AmountBytes: req.AmountBytes, Price: req.Price, ValidityDays: req.ValidityDays, Status: "active", Sort: req.Sort, CreatedAt: now, UpdatedAt: now} + if err := common.DB.Create(pkg).Error; err != nil { + if code, _ := common.ParseError(err); code == 1062 { + return nil, common.NewBusiError(common.ParamError, "套餐编码已存在") + } + return nil, common.ErrInternal + } + return pkg, nil +} + +func (s *PlatformBillingService) GetPackagePage(req *vo.TrafficPackagePageReq) (*common.PageResponse[model.TrafficPackage], *common.BusiError) { + db := common.DB.Model(&model.TrafficPackage{}) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.TrafficPackage + if err := db.Scopes(req.Paginate).Order("sort ASC, id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +func (s *PlatformBillingService) UpdatePackage(id int64, req *vo.TrafficPackageUpdateReq) *common.BusiError { + updates := map[string]any{"updated_at": time.Now(), "sort": req.Sort} + if req.Name != "" { + updates["name"] = req.Name + } + if req.AmountBytes > 0 { + updates["amount_bytes"] = req.AmountBytes + } + if req.Price > 0 { + updates["price"] = req.Price + } + if req.ValidityDays > 0 { + updates["validity_days"] = req.ValidityDays + } + if req.Status != "" { + updates["status"] = req.Status + } + res := common.DB.Model(&model.TrafficPackage{}).Where("id = ?", id).Updates(updates) + if res.Error != nil { + return common.ErrInternal + } + if res.RowsAffected == 0 { + return common.ErrNotFound + } + return nil +} + +func (s *PlatformBillingService) DisablePackage(id int64) *common.BusiError { + res := common.DB.Model(&model.TrafficPackage{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive", "updated_at": time.Now()}) + if res.Error != nil { + return common.ErrInternal + } + if res.RowsAffected == 0 { + return common.ErrNotFound + } + return nil +} + +func (s *PlatformBillingService) GetLedgerPage(req *vo.TrafficLedgerPageReq) (*common.PageResponse[model.TrafficLedger], *common.BusiError) { + db := common.DB.Model(&model.TrafficLedger{}) + if req.AccountType != "" { + db = db.Where("account_type = ?", req.AccountType) + } + if req.AccountID > 0 { + db = db.Where("account_id = ?", req.AccountID) + } + if req.SourceType != "" { + db = db.Where("source_type = ?", req.SourceType) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.TrafficLedger + if err := db.Scopes(req.Paginate).Order("created_at DESC, id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +func mustPlatformID() int64 { + id, err := tool.NextID() + if err != nil { + logger.ERROR("生成平台账务 ID 失败", err) + return 0 + } + return id +} diff --git a/service/scheduler.go b/service/scheduler.go index c848d54..52d6bce 100644 --- a/service/scheduler.go +++ b/service/scheduler.go @@ -31,6 +31,7 @@ func (s *Scheduler) Start() { interval = 5 } _, _ = s.cron.AddFunc("@every "+(time.Duration(interval)*time.Second).String(), DefaultLiveService.ReconcileLeases) + _, _ = s.cron.AddFunc("@every "+(time.Duration(interval)*time.Second).String(), DefaultLiveService.ReconcileCloudSessions) // 直播计费:每 60s 扫描进行中的 live_session 按码率扣减 _, _ = s.cron.AddFunc("@every 60s", DefaultBillingService.chargeLiveSessions) // 余额快照:每 30min 将 Redis 权威余额回写 MySQL diff --git a/service/video_service.go b/service/video_service.go index c54da00..9df371e 100644 --- a/service/video_service.go +++ b/service/video_service.go @@ -7,7 +7,9 @@ import ( "time" "gorm.io/gorm" + "gorm.io/gorm/clause" + "laic-backend/cache" "laic-backend/common" "laic-backend/logger" "laic-backend/model" @@ -204,8 +206,8 @@ func (s *VideoService) GetDetail(userID int64, isAdmin bool, videoID int64) (*mo return &video, nil } -// Download 下载视频:校验状态 → 流量扣减 → OSS 预签名 GET → 下载日志 -func (s *VideoService) Download(userID int64, isAdmin bool, videoID int64) (*vo.VideoDownloadVO, *common.BusiError) { +// Download 下载视频:校验状态 → 生成预签名地址 → 事务内扣费和记录下载。 +func (s *VideoService) Download(userID int64, isAdmin bool, videoID int64, idempotencyKey string) (*vo.VideoDownloadVO, *common.BusiError) { var video model.Video if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -216,32 +218,68 @@ func (s *VideoService) Download(userID int64, isAdmin bool, videoID int64) (*vo. if video.Status != "ready" { return nil, common.ErrVideoUploading } - - // 流量扣减(账本落库) - if ok, busiErr := DefaultBillingService.Deduct(userID, video.FileSize, "download", videoID); !ok { - return nil, busiErr + if idempotencyKey == "" { + return nil, common.NewBusiError(common.ParamError, "缺少 Idempotency-Key") } - + idempotencyKey = fmt.Sprintf("%d:%d:%s", userID, videoID, idempotencyKey) downloadURL, err := common.PresignOSS("GET", video.OssKey, downloadURLExpire) if err != nil { logger.ERROR("生成下载预签名 URL 失败", err) return nil, common.ErrInternal } - log := &model.DownloadLog{ - ID: mustID(), - VideoID: videoID, - UserID: userID, - Bytes: video.FileSize, - CreatedAt: time.Now(), + var log model.DownloadLog + now := time.Now() + var busiErr *common.BusiError + err = common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("idempotency_key = ?", idempotencyKey).First(&log).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "traffic_balance").First(&user, userID).Error; err != nil { + return err + } + if user.TrafficBalance < video.FileSize { + busiErr = common.ErrTrafficNotEnough + return busiErr + } + var pool model.PlatformResourcePool + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").First(&pool).Error; err != nil { + return err + } + if pool.Status != "active" || pool.AvailableBytes < video.FileSize { + busiErr = common.ErrPlatformTrafficExhausted + return busiErr + } + userAfter := user.TrafficBalance - video.FileSize + platformAfter := pool.AvailableBytes - video.FileSize + if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("traffic_balance", userAfter).Error; err != nil { + return err + } + if err := tx.Model(&pool).Updates(map[string]any{"available_bytes": platformAfter, "consumed_bytes": gorm.Expr("consumed_bytes + ?", video.FileSize), "version": gorm.Expr("version + 1"), "updated_at": now}).Error; err != nil { + return err + } + base := "download:" + idempotencyKey + if err := tx.Create(&model.TrafficLedger{ID: mustID(), AccountType: "user", AccountID: userID, Direction: "debit", AmountBytes: video.FileSize, BalanceBefore: user.TrafficBalance, BalanceAfter: userAfter, SourceType: "download", SourceID: fmt.Sprintf("%d", videoID), IdempotencyKey: base + ":user", CreatedAt: now}).Error; err != nil { + return err + } + if err := tx.Create(&model.TrafficLedger{ID: mustID(), AccountType: "platform", AccountID: pool.ID, Direction: "debit", AmountBytes: video.FileSize, BalanceBefore: pool.AvailableBytes, BalanceAfter: platformAfter, SourceType: "download", SourceID: fmt.Sprintf("%d", videoID), IdempotencyKey: base + ":platform", CreatedAt: now}).Error; err != nil { + return err + } + log = model.DownloadLog{ID: mustID(), VideoID: videoID, UserID: userID, Bytes: video.FileSize, IdempotencyKey: idempotencyKey, CreatedAt: now} + return tx.Create(&log).Error + }) + if err != nil { + if busiErr != nil { + return nil, busiErr + } + logger.ERROR("下载计费事务失败", err) + return nil, common.ErrInternal } - if err := common.DB.Create(log).Error; err != nil { - logger.ERROR("下载日志落库失败", err) + if err := common.Delete(cache.TrafficKeyOf(userID)); err != nil { + logger.WARN("删除下载余额缓存失败", err) } - - return &vo.VideoDownloadVO{ - DownloadURL: downloadURL, - ExpireAt: time.Now().Add(downloadURLExpire * time.Second).Unix(), - Bytes: video.FileSize, - }, nil + return &vo.VideoDownloadVO{DownloadURL: downloadURL, ExpireAt: time.Now().Add(downloadURLExpire * time.Second).Unix(), Bytes: video.FileSize}, nil } diff --git a/sql/001_schema.sql b/sql/001_schema.sql index 1df75b3..4bf3b09 100644 --- a/sql/001_schema.sql +++ b/sql/001_schema.sql @@ -11,18 +11,18 @@ USE laic; -- 5.1 用户 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS `user` ( - id BIGINT PRIMARY KEY, - name VARCHAR(32) NOT NULL, - phone VARCHAR(32) NOT NULL, - email VARCHAR(64), - password VARCHAR(64) NOT NULL, - role VARCHAR(16) DEFAULT 'user', -- admin / user - traffic_balance BIGINT DEFAULT 0, -- 云媒体剩余流量(字节),慢快照,权威在 Redis - status TINYINT DEFAULT 1, -- 1 正常 / 0 禁用 - last_login DATETIME, - password_changed_at DATETIME, - created_at DATETIME, - updated_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + name VARCHAR(32) NOT NULL COMMENT '名称', + phone VARCHAR(32) NOT NULL COMMENT '手机号', + email VARCHAR(64) COMMENT '电子邮箱', + password VARCHAR(64) NOT NULL COMMENT '密码哈希', + role VARCHAR(16) DEFAULT 'user' COMMENT '用户角色', -- admin / user + traffic_balance BIGINT DEFAULT 0 COMMENT '云媒体剩余流量(字节)', -- 云媒体剩余流量(字节),慢快照,权威在 Redis + status TINYINT DEFAULT 1 COMMENT '业务状态', -- 1 正常 / 0 禁用 + last_login DATETIME COMMENT '最后登录时间', + password_changed_at DATETIME COMMENT '密码最后修改时间', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', UNIQUE KEY idx_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -30,38 +30,38 @@ CREATE TABLE IF NOT EXISTS `user` ( -- 5.2 机巢 & 无人机 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS dock ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - dock_id VARCHAR(64) NOT NULL, -- MQTT dockId (dock-xxx) - name VARCHAR(64) DEFAULT '', - code VARCHAR(32) DEFAULT '', - sn VARCHAR(64) DEFAULT '', - iccid VARCHAR(32) DEFAULT '', -- 冗余,权威见 sim_card.iccid - longitude DOUBLE DEFAULT 0, - latitude DOUBLE DEFAULT 0, - altitude DOUBLE DEFAULT 0, - location VARCHAR(128) DEFAULT '', - register_status VARCHAR(16) DEFAULT 'pending', -- pending/registered - dock_id_source VARCHAR(32) DEFAULT '', - software_ver VARCHAR(16) DEFAULT '', - protocol_ver VARCHAR(8) DEFAULT '1.0', - created_at DATETIME, - updated_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', -- MQTT dockId (dock-xxx) + name VARCHAR(64) DEFAULT '' COMMENT '名称', + code VARCHAR(32) DEFAULT '' COMMENT '业务编码', + sn VARCHAR(64) DEFAULT '' COMMENT '设备序列号', + iccid VARCHAR(32) DEFAULT '' COMMENT 'SIM 卡 ICCID', -- 冗余,权威见 sim_card.iccid + longitude DOUBLE DEFAULT 0 COMMENT '经度', + latitude DOUBLE DEFAULT 0 COMMENT '纬度', + altitude DOUBLE DEFAULT 0 COMMENT '海拔高度(米)', + location VARCHAR(128) DEFAULT '' COMMENT '位置描述', + register_status VARCHAR(16) DEFAULT 'pending' COMMENT '注册状态', -- pending/registered + dock_id_source VARCHAR(32) DEFAULT '' COMMENT '机巢标识来源', + software_ver VARCHAR(16) DEFAULT '' COMMENT '软件版本', + protocol_ver VARCHAR(8) DEFAULT '1.0' COMMENT '协议版本', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', UNIQUE KEY idx_dock_id (dock_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS drone ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - drone_sn VARCHAR(32) NOT NULL, -- 飞控 HW_SN_NUM - dock_id VARCHAR(64) NOT NULL, -- 绑定的机巢 dock_id(1:1) - name VARCHAR(64) DEFAULT '', - code VARCHAR(32) DEFAULT '', - model VARCHAR(32) DEFAULT '', - battery INT DEFAULT 0, - firmware_ver VARCHAR(16) DEFAULT '', - created_at DATETIME, - updated_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + drone_sn VARCHAR(32) NOT NULL COMMENT '无人机序列号', -- 飞控 HW_SN_NUM + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', -- 绑定的机巢 dock_id(1:1) + name VARCHAR(64) DEFAULT '' COMMENT '名称', + code VARCHAR(32) DEFAULT '' COMMENT '业务编码', + model VARCHAR(32) DEFAULT '' COMMENT '设备型号', + battery INT DEFAULT 0 COMMENT '电池电量(百分比)', + firmware_ver VARCHAR(16) DEFAULT '' COMMENT '固件版本', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', UNIQUE KEY idx_drone_sn (drone_sn), UNIQUE KEY idx_drone_dock (dock_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -70,28 +70,28 @@ CREATE TABLE IF NOT EXISTS drone ( -- 5.3 告警 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS alarm_code ( - id BIGINT PRIMARY KEY, - code VARCHAR(64) NOT NULL, - category VARCHAR(32) NOT NULL, -- dock / drone - message_cn VARCHAR(256) NOT NULL, - level VARCHAR(16) DEFAULT 'critical', - source VARCHAR(128) DEFAULT '', - created_at DATETIME, - updated_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + code VARCHAR(64) NOT NULL COMMENT '业务编码', + category VARCHAR(32) NOT NULL COMMENT '告警类别', -- dock / drone + message_cn VARCHAR(256) NOT NULL COMMENT '中文告警信息', + level VARCHAR(16) DEFAULT 'critical' COMMENT '告警级别', + source VARCHAR(128) DEFAULT '' COMMENT '告警来源', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', UNIQUE KEY idx_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS alarm ( - id BIGINT PRIMARY KEY, - dock_id VARCHAR(64) NOT NULL, - code VARCHAR(64) NOT NULL, - device_type VARCHAR(8) NOT NULL, -- dock / drone - message_cn VARCHAR(256) NOT NULL, - level VARCHAR(16) NOT NULL, - status VARCHAR(16) DEFAULT 'active', -- active / acknowledged / resolved - triggered_at DATETIME, - resolved_at DATETIME, - created_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', + code VARCHAR(64) NOT NULL COMMENT '业务编码', + device_type VARCHAR(8) NOT NULL COMMENT '设备类型', -- dock / drone + message_cn VARCHAR(256) NOT NULL COMMENT '中文告警信息', + level VARCHAR(16) NOT NULL COMMENT '告警级别', + status VARCHAR(16) DEFAULT 'active' COMMENT '业务状态', -- active / acknowledged / resolved + triggered_at DATETIME COMMENT '触发时间', + resolved_at DATETIME COMMENT '解决时间', + created_at DATETIME COMMENT '创建时间', KEY idx_alarm_dock (dock_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -99,53 +99,53 @@ CREATE TABLE IF NOT EXISTS alarm ( -- 5.4 指令 & 工作流 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS device_command_log ( - id BIGINT PRIMARY KEY, -- = commandId (Snowflake) - dock_id VARCHAR(64) NOT NULL, - command_type VARCHAR(64) NOT NULL, -- dock.open / drone.takeoff / workflow.start_task - params JSON, - drone_sn VARCHAR(32), - request_id VARCHAR(64), - ttl_ms INT DEFAULT 30000, - ack_accepted TINYINT, - ack_result_code VARCHAR(64), - status VARCHAR(16) DEFAULT 'sent', -- sent / acked / timeout / terminal - retry_count INT DEFAULT 0, - sent_at DATETIME, - acked_at DATETIME, - created_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', -- = commandId (Snowflake) + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', + command_type VARCHAR(64) NOT NULL COMMENT '指令类型', -- dock.open / drone.takeoff / workflow.start_task + params JSON COMMENT '指令参数 JSON', + drone_sn VARCHAR(32) COMMENT '无人机序列号', + request_id VARCHAR(64) COMMENT '请求 ID', + ttl_ms INT DEFAULT 30000 COMMENT '指令有效期(毫秒)', + ack_accepted TINYINT COMMENT '设备是否接受指令', + ack_result_code VARCHAR(64) COMMENT '确认结果码', + status VARCHAR(16) DEFAULT 'sent' COMMENT '业务状态', -- sent / acked / timeout / terminal + retry_count INT DEFAULT 0 COMMENT '重试次数', + sent_at DATETIME COMMENT '发送时间', + acked_at DATETIME COMMENT '确认时间', + created_at DATETIME COMMENT '创建时间', KEY idx_cmd_log_dock (dock_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS workflow_state ( - id BIGINT PRIMARY KEY, - dock_id VARCHAR(64) NOT NULL, - command_id VARCHAR(64), - type VARCHAR(64), - task_id VARCHAR(128), - mission_id VARCHAR(128), - state VARCHAR(16) NOT NULL, -- idle / running / succeeded / failed / cancelled - step VARCHAR(64) NOT NULL, - result_code VARCHAR(64), - updated_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', + command_id VARCHAR(64) COMMENT '指令 ID', + type VARCHAR(64) COMMENT '工作流类型', + task_id VARCHAR(128) COMMENT '任务计划 ID', + mission_id VARCHAR(128) COMMENT '任务航线 ID', + state VARCHAR(16) NOT NULL COMMENT '工作流状态', -- idle / running / succeeded / failed / cancelled + step VARCHAR(64) NOT NULL COMMENT '当前步骤', + result_code VARCHAR(64) COMMENT '执行结果码', + updated_at DATETIME COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------- -- 5.5 固件管理 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS firmware ( - id BIGINT PRIMARY KEY, - component VARCHAR(32) NOT NULL, -- 固定 dock-edge-agent - version VARCHAR(32) NOT NULL, -- 1.3.0 - description VARCHAR(512) DEFAULT '', - file_url VARCHAR(512) NOT NULL, - sha256 VARCHAR(128) NOT NULL, - signature TEXT NOT NULL, - file_size BIGINT DEFAULT 0, - mandatory TINYINT DEFAULT 0, - status VARCHAR(16) DEFAULT 'draft', -- draft/released/deprecated - created_by BIGINT NOT NULL, - created_at DATETIME, - updated_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + component VARCHAR(32) NOT NULL COMMENT '固件组件', -- 固定 dock-edge-agent + version VARCHAR(32) NOT NULL COMMENT '固件版本号', -- 1.3.0 + description VARCHAR(512) DEFAULT '' COMMENT '描述信息', + file_url VARCHAR(512) NOT NULL COMMENT '固件文件地址', + sha256 VARCHAR(128) NOT NULL COMMENT '文件 SHA-256 摘要', + signature TEXT NOT NULL COMMENT '文件签名', + file_size BIGINT DEFAULT 0 COMMENT '文件大小(字节)', + mandatory TINYINT DEFAULT 0 COMMENT '是否强制升级', + status VARCHAR(16) DEFAULT 'draft' COMMENT '业务状态', -- draft/released/deprecated + created_by BIGINT NOT NULL COMMENT '创建人 ID', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', UNIQUE KEY idx_component_ver (component, version) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -153,54 +153,54 @@ CREATE TABLE IF NOT EXISTS firmware ( -- 5.6 任务 & 航线 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS task_plan ( - id VARCHAR(128) PRIMARY KEY, - user_id BIGINT NOT NULL, - name VARCHAR(128) NOT NULL, - dock_id VARCHAR(64) NOT NULL, - route_id BIGINT, - schedule_type VARCHAR(16) DEFAULT 'once', -- once / cron - schedule_cron VARCHAR(32), - video_policy VARCHAR(16) DEFAULT 'raw', - status VARCHAR(16) DEFAULT 'draft', - created_by BIGINT, - created_at DATETIME, - updated_at DATETIME + id VARCHAR(128) PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + name VARCHAR(128) NOT NULL COMMENT '名称', + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', + route_id BIGINT COMMENT '航线 ID', + schedule_type VARCHAR(16) DEFAULT 'once' COMMENT '调度类型', -- once / cron + schedule_cron VARCHAR(32) COMMENT 'Cron 调度表达式', + video_policy VARCHAR(16) DEFAULT 'raw' COMMENT '视频策略', + status VARCHAR(16) DEFAULT 'draft' COMMENT '业务状态', + created_by BIGINT COMMENT '创建人 ID', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS task_execution ( - id BIGINT PRIMARY KEY, - task_id VARCHAR(128), - command_id VARCHAR(64), - dock_id VARCHAR(64) NOT NULL, - drone_sn VARCHAR(32), - start_time DATETIME, - end_time DATETIME, - status VARCHAR(16) DEFAULT 'pending', - result_code VARCHAR(64), - trajectory_json JSON, - created_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + task_id VARCHAR(128) COMMENT '任务计划 ID', + command_id VARCHAR(64) COMMENT '指令 ID', + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', + drone_sn VARCHAR(32) COMMENT '无人机序列号', + start_time DATETIME COMMENT '开始时间', + end_time DATETIME COMMENT '结束时间', + status VARCHAR(16) DEFAULT 'pending' COMMENT '业务状态', + result_code VARCHAR(64) COMMENT '执行结果码', + trajectory_json JSON COMMENT '轨迹 JSON', + created_at DATETIME COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS route ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - name VARCHAR(128) NOT NULL, - description VARCHAR(256), - created_at DATETIME, - updated_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + name VARCHAR(128) NOT NULL COMMENT '名称', + description VARCHAR(256) COMMENT '描述信息', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS route_waypoint ( - id BIGINT PRIMARY KEY, - route_id BIGINT NOT NULL, - seq INT NOT NULL, - longitude DOUBLE, - latitude DOUBLE, - altitude DOUBLE, - speed DOUBLE, - yaw DOUBLE, - hold_sec INT DEFAULT 0, - created_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + route_id BIGINT NOT NULL COMMENT '航线 ID', + seq INT NOT NULL COMMENT '航点序号', + longitude DOUBLE COMMENT '经度', + latitude DOUBLE COMMENT '纬度', + altitude DOUBLE COMMENT '海拔高度(米)', + speed DOUBLE COMMENT '飞行速度', + yaw DOUBLE COMMENT '偏航角', + hold_sec INT DEFAULT 0 COMMENT '航点停留时间(秒)', + created_at DATETIME COMMENT '创建时间', UNIQUE KEY uk_route_seq (route_id, seq) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -208,29 +208,32 @@ CREATE TABLE IF NOT EXISTS route_waypoint ( -- 5.7 媒体 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS live_session ( - id VARCHAR(128) PRIMARY KEY, -- streamSessionId - dock_id VARCHAR(64) NOT NULL, - provider VARCHAR(16) DEFAULT 'aliyun', - stream_name VARCHAR(128) NOT NULL, -- 直播流名(每次会话唯一) - push_url_hash VARCHAR(128), - expires_at BIGINT NOT NULL, - max_bitrate_bps BIGINT DEFAULT 1500000, - phase VARCHAR(16) DEFAULT 'idle', - stop_reason VARCHAR(32), - error_code VARCHAR(64), - requested_by BIGINT, - stop_deadline DATETIME, - device_state_version BIGINT DEFAULT 0, - device_event_id VARCHAR(128), - device_updated_at BIGINT DEFAULT 0, - cloud_confirmed_at DATETIME, - started_at DATETIME, - stopped_at DATETIME, - created_at DATETIME, - updated_at DATETIME, + id VARCHAR(128) PRIMARY KEY COMMENT '主键 ID', -- streamSessionId + dock_id VARCHAR(64) NOT NULL COMMENT '机巢标识', + provider VARCHAR(16) DEFAULT 'aliyun' COMMENT '流媒体服务商', + stream_name VARCHAR(128) NOT NULL COMMENT '直播流名称', -- 直播流名(每次会话唯一) + push_url_hash VARCHAR(128) COMMENT '推流地址哈希', + expires_at BIGINT NOT NULL COMMENT '过期时间戳', + max_bitrate_bps BIGINT DEFAULT 1500000 COMMENT '最大码率(比特/秒)', + phase VARCHAR(16) DEFAULT 'idle' COMMENT '直播会话阶段', + stop_reason VARCHAR(32) COMMENT '停止原因', + error_code VARCHAR(64) COMMENT '错误码', + requested_by BIGINT COMMENT '请求人用户 ID', + stop_deadline DATETIME COMMENT '停止截止时间', + device_state_version BIGINT DEFAULT 0 COMMENT '设备状态版本', + device_event_id VARCHAR(128) COMMENT '设备事件 ID', + device_updated_at BIGINT DEFAULT 0 COMMENT '设备更新时间戳', + device_streaming TINYINT(1) NOT NULL DEFAULT 0 COMMENT '设备是否正在推流', + cloud_online TINYINT(1) NOT NULL DEFAULT 0 COMMENT '云端是否在线', + cloud_checked_at DATETIME COMMENT '云端检查时间', + cloud_confirmed_at DATETIME COMMENT '云端确认时间', + started_at DATETIME COMMENT '开始时间', + stopped_at DATETIME COMMENT '停止时间', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', active_dock_id VARCHAR(64) GENERATED ALWAYS AS ( CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END - ) STORED, + ) STORED COMMENT '活动机巢标识(生成列)', KEY idx_live_session_dock_phase (dock_id, phase, created_at), KEY idx_live_session_phase_deadline (phase, stop_deadline), UNIQUE KEY uk_live_session_device_event (device_event_id), @@ -238,108 +241,184 @@ CREATE TABLE IF NOT EXISTS live_session ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS live_viewer_lease ( - id BIGINT PRIMARY KEY, - stream_session_id VARCHAR(128) NOT NULL, - viewer_id BIGINT NOT NULL, - expires_at DATETIME NOT NULL, - released_at DATETIME NULL, - created_at DATETIME NULL, - updated_at DATETIME NULL, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + stream_session_id VARCHAR(128) NOT NULL COMMENT '直播会话 ID', + viewer_id BIGINT NOT NULL COMMENT '观看用户 ID', + expires_at DATETIME NOT NULL COMMENT '过期时间戳', + released_at DATETIME NULL COMMENT '释放时间', + created_at DATETIME NULL COMMENT '创建时间', + updated_at DATETIME NULL COMMENT '更新时间', UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id), KEY idx_live_lease_expire (stream_session_id, expires_at), KEY idx_live_lease_active (stream_session_id, released_at, expires_at), CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS live_callback_event ( + event_id VARCHAR(256) PRIMARY KEY COMMENT '回调事件 ID', + stream_name VARCHAR(128) NOT NULL COMMENT '直播流名称', + event_type VARCHAR(32) NOT NULL COMMENT '回调事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '回调载荷哈希', + received_at DATETIME NOT NULL COMMENT '接收时间', + processed_at DATETIME NULL COMMENT '处理时间', + KEY idx_live_callback_stream (stream_name, event_type, received_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS video ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - drone_sn VARCHAR(32), - execution_id BIGINT, - file_name VARCHAR(256), - file_size BIGINT, - duration INT, - oss_key VARCHAR(256), - oss_bucket VARCHAR(128), -- 预签名上传的目标 bucket - thumbnail_key VARCHAR(256), - status VARCHAR(16) DEFAULT 'uploading', - upload_expire_at DATETIME, -- 预签名 PUT URL 过期时间 - created_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + drone_sn VARCHAR(32) COMMENT '无人机序列号', + execution_id BIGINT COMMENT '任务执行 ID', + file_name VARCHAR(256) COMMENT '视频文件名', + file_size BIGINT COMMENT '文件大小(字节)', + duration INT COMMENT '视频时长(秒)', + oss_key VARCHAR(256) COMMENT '对象存储文件键', + oss_bucket VARCHAR(128) COMMENT '对象存储 Bucket', -- 预签名上传的目标 bucket + thumbnail_key VARCHAR(256) COMMENT '缩略图文件键', + status VARCHAR(16) DEFAULT 'uploading' COMMENT '业务状态', + upload_expire_at DATETIME COMMENT '预签名上传地址过期时间', -- 预签名 PUT URL 过期时间 + created_at DATETIME COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS download_log ( - id BIGINT PRIMARY KEY, - video_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - bytes BIGINT NOT NULL, - created_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + video_id BIGINT NOT NULL COMMENT '视频 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + bytes BIGINT NOT NULL COMMENT '传输字节数', + idempotency_key VARCHAR(128) NOT NULL COMMENT '下载计费幂等键', + created_at DATETIME COMMENT '创建时间', + UNIQUE KEY uk_download_log_idempotency (idempotency_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------- -- 5.8 计费 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS traffic_order ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - amount_gb INT NOT NULL, -- 购买流量(GB) - unit_price DECIMAL(6,2), - total_price DECIMAL(10,2), - pay_status VARCHAR(16) DEFAULT 'unpaid', -- unpaid/paid/cancelled - created_at DATETIME, - paid_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + package_id BIGINT COMMENT '套餐 ID', + package_code VARCHAR(64) COMMENT '套餐编码快照', + amount_gb INT NOT NULL COMMENT '流量额度(GB)', -- 购买流量(GB) + amount_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '流量额度(字节)', + unit_price DECIMAL(6,2) COMMENT '单价', + total_price DECIMAL(10,2) COMMENT '总价', + pay_status VARCHAR(16) DEFAULT 'unpaid' COMMENT '支付状态', -- unpaid/paid/cancelled + credit_status VARCHAR(16) DEFAULT 'pending' COMMENT '充值入账状态', + credit_ledger_id BIGINT COMMENT '充值流水 ID', + payment_idempotency_key VARCHAR(128) COMMENT '支付幂等键', + paid_by BIGINT COMMENT '确认支付的操作人 ID', + created_at DATETIME COMMENT '创建时间', + paid_at DATETIME COMMENT '支付时间', + UNIQUE KEY uk_traffic_order_payment_idem (payment_idempotency_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS traffic_usage_log ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - source_type VARCHAR(16), -- live / replay / download - source_id BIGINT, - bytes_used BIGINT, - balance_before BIGINT, -- 扣减前余额(字节) - balance_after BIGINT, -- 扣减后余额(字节) - created_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + source_type VARCHAR(16) COMMENT '流量来源类型', -- live / replay / download + source_id BIGINT COMMENT '来源记录 ID', + bytes_used BIGINT COMMENT '使用流量(字节)', + balance_before BIGINT COMMENT '扣减前余额(字节)', -- 扣减前余额(字节) + balance_after BIGINT COMMENT '扣减后余额(字节)', -- 扣减后余额(字节) + idempotency_key VARCHAR(160) NOT NULL COMMENT '消费幂等键', + created_at DATETIME COMMENT '创建时间', + UNIQUE KEY uk_traffic_usage_idempotency (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.8.1 平台共享资源与统一账务 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS platform_resource_pool ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', + total_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '累计采购资源(字节)', + available_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '可用共享资源(字节)', + consumed_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '已消费共享资源(字节)', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '资源池状态', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观版本号', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_resource_purchase ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', batch_no VARCHAR(64) NOT NULL COMMENT '采购批次号', + provider VARCHAR(32) NOT NULL COMMENT '资源供应商', total_bytes BIGINT NOT NULL COMMENT '采购资源量(字节)', + unit_cost DECIMAL(12,6) COMMENT '单位成本(元/字节)', total_cost DECIMAL(14,2) COMMENT '采购总成本(元)', + expires_at DATETIME COMMENT '资源到期时间', status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '批次状态', + remark VARCHAR(256) COMMENT '备注', created_by BIGINT NOT NULL COMMENT '操作人 ID', + created_at DATETIME COMMENT '创建时间', updated_at DATETIME COMMENT '更新时间', + UNIQUE KEY uk_resource_purchase_batch (batch_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS traffic_package ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', code VARCHAR(64) NOT NULL COMMENT '套餐编码', + name VARCHAR(128) NOT NULL COMMENT '套餐名称', amount_bytes BIGINT NOT NULL COMMENT '套餐额度(字节)', + price DECIMAL(10,2) NOT NULL COMMENT '销售价格(元)', validity_days INT COMMENT '有效期(天)', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '套餐状态', sort INT NOT NULL DEFAULT 0 COMMENT '显示排序', + created_at DATETIME COMMENT '创建时间', updated_at DATETIME COMMENT '更新时间', + UNIQUE KEY uk_traffic_package_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS traffic_ledger ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', account_type VARCHAR(16) NOT NULL COMMENT '账户类型', + account_id BIGINT NOT NULL COMMENT '账户 ID', direction VARCHAR(8) NOT NULL COMMENT '借贷方向', + amount_bytes BIGINT NOT NULL COMMENT '变动金额(字节)', balance_before BIGINT NOT NULL COMMENT '变动前余额(字节)', + balance_after BIGINT NOT NULL COMMENT '变动后余额(字节)', source_type VARCHAR(32) NOT NULL COMMENT '业务来源类型', + source_id VARCHAR(128) COMMENT '业务来源 ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '全局幂等键', + operator_id BIGINT COMMENT '操作人 ID', remark VARCHAR(256) COMMENT '备注', created_at DATETIME COMMENT '创建时间', + UNIQUE KEY uk_traffic_ledger_idempotency (idempotency_key), + KEY idx_traffic_ledger_account (account_type, account_id, created_at), KEY idx_traffic_ledger_source (source_type, source_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS live_billing_segment ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', session_id VARCHAR(128) NOT NULL COMMENT '直播会话 ID', + user_id BIGINT NOT NULL COMMENT '扣费用户 ID', period_start DATETIME NOT NULL COMMENT '计费开始时间', + period_end DATETIME NOT NULL COMMENT '计费结束时间', bytes BIGINT NOT NULL COMMENT '计费流量(字节)', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '结算状态', idempotency_key VARCHAR(160) NOT NULL COMMENT '结算幂等键', + created_at DATETIME COMMENT '创建时间', settled_at DATETIME COMMENT '结算时间', + UNIQUE KEY uk_live_billing_segment_period (session_id, period_start, period_end), + UNIQUE KEY uk_live_billing_segment_idempotency (idempotency_key), KEY idx_live_billing_segment_session (session_id, period_start) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS sim_card ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - dock_id VARCHAR(64), - carrier VARCHAR(16), -- 移动/联通/电信 - phone VARCHAR(16), - iccid VARCHAR(32), -- 权威字段(查询/充值的入参) - plan_gb INT, - used_gb DECIMAL(10,4) DEFAULT 0, - expired_at DATE, - status VARCHAR(16) DEFAULT 'active', -- 平台业务态: active/expired - carrier_status VARCHAR(16) DEFAULT 'normal', -- 运营商侧状态: normal/suspended/arrears/cancelled - last_sync_at DATETIME, - created_at DATETIME, - updated_at DATETIME + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + dock_id VARCHAR(64) COMMENT '机巢标识', + carrier VARCHAR(16) COMMENT '运营商', -- 移动/联通/电信 + phone VARCHAR(16) COMMENT '手机号', + iccid VARCHAR(32) COMMENT 'SIM 卡 ICCID', -- 权威字段(查询/充值的入参) + plan_gb INT COMMENT '套餐流量(GB)', + used_gb DECIMAL(10,4) DEFAULT 0 COMMENT '已使用流量(GB)', + expired_at DATE COMMENT '到期日期', + status VARCHAR(16) DEFAULT 'active' COMMENT '业务状态', -- 平台业务态: active/expired + carrier_status VARCHAR(16) DEFAULT 'normal' COMMENT '运营商侧状态', -- 运营商侧状态: normal/suspended/arrears/cancelled + last_sync_at DATETIME COMMENT '最后同步时间', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS sim_recharge_log ( - id BIGINT PRIMARY KEY, - sim_card_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - amount_gb INT NOT NULL, - unit_price DECIMAL(6,2), - total_price DECIMAL(10,2), - pay_status VARCHAR(16) DEFAULT 'unpaid', - recharge_status VARCHAR(16) DEFAULT 'pending', -- pending/success/failed - recharge_msg VARCHAR(256) DEFAULT '', - idempotent_key VARCHAR(64) NOT NULL, -- 平台充值单号,超时重试去重 - created_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + sim_card_id BIGINT NOT NULL COMMENT 'SIM 卡记录 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + amount_gb INT NOT NULL COMMENT '流量额度(GB)', + unit_price DECIMAL(6,2) COMMENT '单价', + total_price DECIMAL(10,2) COMMENT '总价', + pay_status VARCHAR(16) DEFAULT 'unpaid' COMMENT '支付状态', + recharge_status VARCHAR(16) DEFAULT 'pending' COMMENT '充值状态', -- pending/success/failed + recharge_msg VARCHAR(256) DEFAULT '' COMMENT '充值结果信息', + idempotent_key VARCHAR(64) NOT NULL COMMENT '幂等业务单号', -- 平台充值单号,超时重试去重 + created_at DATETIME COMMENT '创建时间', UNIQUE KEY uk_recharge_idem (idempotent_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS sim_usage_record ( - id BIGINT PRIMARY KEY, - sim_card_id BIGINT NOT NULL, - plan_gb INT, - used_gb DECIMAL(10,4), - remain_gb DECIMAL(10,4), - carrier_status VARCHAR(16), - synced_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + sim_card_id BIGINT NOT NULL COMMENT 'SIM 卡记录 ID', + plan_gb INT COMMENT '套餐流量(GB)', + used_gb DECIMAL(10,4) COMMENT '已使用流量(GB)', + remain_gb DECIMAL(10,4) COMMENT '剩余流量(GB)', + carrier_status VARCHAR(16) COMMENT '运营商侧状态', + synced_at DATETIME COMMENT '同步时间', KEY idx_record_sim (sim_card_id, synced_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -347,14 +426,14 @@ CREATE TABLE IF NOT EXISTS sim_usage_record ( -- 7.7 操作日志 -- --------------------------------------------------------------- CREATE TABLE IF NOT EXISTS operation_log ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - user_name VARCHAR(32), - module VARCHAR(32), -- 模块(用户管理/机巢管理/任务管理...) - action VARCHAR(64), -- 动作(新增/修改/删除/下发指令...) - detail VARCHAR(256), -- 详情(请求路径) - result VARCHAR(16), -- success / failed - ip VARCHAR(64), - created_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + user_name VARCHAR(32) COMMENT '用户名称', + module VARCHAR(32) COMMENT '业务模块', -- 模块(用户管理/机巢管理/任务管理...) + action VARCHAR(64) COMMENT '操作动作', -- 动作(新增/修改/删除/下发指令...) + detail VARCHAR(256) COMMENT '操作详情', -- 详情(请求路径) + result VARCHAR(16) COMMENT '操作结果', -- success / failed + ip VARCHAR(64) COMMENT '客户端 IP 地址', + created_at DATETIME COMMENT '创建时间', KEY idx_op_user (user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/sql/004_live_control.sql b/sql/004_live_control.sql index 88cd29d..ed6b708 100644 --- a/sql/004_live_control.sql +++ b/sql/004_live_control.sql @@ -2,24 +2,24 @@ -- 执行前应确认 live_session 没有重复活动会话,再创建活动机巢唯一约束。 ALTER TABLE live_session - ADD COLUMN requested_by BIGINT NULL, - ADD COLUMN stop_deadline DATETIME NULL, - ADD COLUMN updated_at DATETIME NULL, + ADD COLUMN requested_by BIGINT NULL COMMENT '请求人用户 ID', + ADD COLUMN stop_deadline DATETIME NULL COMMENT '停止截止时间', + ADD COLUMN updated_at DATETIME NULL COMMENT '更新时间', ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS ( CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END - ) STORED, + ) STORED COMMENT '活动机巢标识(生成列)', ADD KEY idx_live_session_dock_phase (dock_id, phase, created_at), ADD KEY idx_live_session_phase_deadline (phase, stop_deadline), ADD UNIQUE KEY uk_live_session_active_dock (active_dock_id); CREATE TABLE IF NOT EXISTS live_viewer_lease ( - id BIGINT PRIMARY KEY, - stream_session_id VARCHAR(128) NOT NULL, - viewer_id BIGINT NOT NULL, - expires_at DATETIME NOT NULL, - released_at DATETIME NULL, - created_at DATETIME NULL, - updated_at DATETIME NULL, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + stream_session_id VARCHAR(128) NOT NULL COMMENT '直播会话 ID', + viewer_id BIGINT NOT NULL COMMENT '观看用户 ID', + expires_at DATETIME NOT NULL COMMENT '过期时间戳', + released_at DATETIME NULL COMMENT '释放时间', + created_at DATETIME NULL COMMENT '创建时间', + updated_at DATETIME NULL COMMENT '更新时间', UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id), KEY idx_live_lease_expire (stream_session_id, expires_at), KEY idx_live_lease_active (stream_session_id, released_at, expires_at), diff --git a/sql/005_live_control_upgrade.sql b/sql/005_live_control_upgrade.sql index cbd5e61..512d936 100644 --- a/sql/005_live_control_upgrade.sql +++ b/sql/005_live_control_upgrade.sql @@ -12,49 +12,49 @@ BEGIN FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'requested_by'; IF item_count = 0 THEN - ALTER TABLE live_session ADD COLUMN requested_by BIGINT NULL; + ALTER TABLE live_session ADD COLUMN requested_by BIGINT NULL COMMENT '请求人用户 ID'; END IF; SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'stop_deadline'; IF item_count = 0 THEN - ALTER TABLE live_session ADD COLUMN stop_deadline DATETIME NULL; + ALTER TABLE live_session ADD COLUMN stop_deadline DATETIME NULL COMMENT '停止截止时间'; END IF; SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'updated_at'; IF item_count = 0 THEN - ALTER TABLE live_session ADD COLUMN updated_at DATETIME NULL; + ALTER TABLE live_session ADD COLUMN updated_at DATETIME NULL COMMENT '更新时间'; END IF; SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_state_version'; IF item_count = 0 THEN - ALTER TABLE live_session ADD COLUMN device_state_version BIGINT DEFAULT 0; + ALTER TABLE live_session ADD COLUMN device_state_version BIGINT DEFAULT 0 COMMENT '设备状态版本'; END IF; SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_event_id'; IF item_count = 0 THEN - ALTER TABLE live_session ADD COLUMN device_event_id VARCHAR(128); + ALTER TABLE live_session ADD COLUMN device_event_id VARCHAR(128) COMMENT '设备事件 ID'; END IF; SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_updated_at'; IF item_count = 0 THEN - ALTER TABLE live_session ADD COLUMN device_updated_at BIGINT DEFAULT 0; + ALTER TABLE live_session ADD COLUMN device_updated_at BIGINT DEFAULT 0 COMMENT '设备更新时间戳'; END IF; SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'cloud_confirmed_at'; IF item_count = 0 THEN - ALTER TABLE live_session ADD COLUMN cloud_confirmed_at DATETIME NULL; + ALTER TABLE live_session ADD COLUMN cloud_confirmed_at DATETIME NULL COMMENT '云端确认时间'; END IF; SELECT COUNT(*) INTO item_count @@ -70,7 +70,7 @@ BEGIN IF item_count = 0 THEN ALTER TABLE live_session ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS ( CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END - ) STORED; + ) STORED COMMENT '活动机巢标识(生成列)'; END IF; SELECT COUNT(*) INTO item_count @@ -95,13 +95,13 @@ BEGIN END IF; CREATE TABLE IF NOT EXISTS live_viewer_lease ( - id BIGINT PRIMARY KEY, - stream_session_id VARCHAR(128) NOT NULL, - viewer_id BIGINT NOT NULL, - expires_at DATETIME NOT NULL, - released_at DATETIME NULL, - created_at DATETIME NULL, - updated_at DATETIME NULL, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + stream_session_id VARCHAR(128) NOT NULL COMMENT '直播会话 ID', + viewer_id BIGINT NOT NULL COMMENT '观看用户 ID', + expires_at DATETIME NOT NULL COMMENT '过期时间戳', + released_at DATETIME NULL COMMENT '释放时间', + created_at DATETIME NULL COMMENT '创建时间', + updated_at DATETIME NULL COMMENT '更新时间', UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id), KEY idx_live_lease_expire (stream_session_id, expires_at), KEY idx_live_lease_active (stream_session_id, released_at, expires_at), diff --git a/sql/009_account_security_invoice.sql b/sql/009_account_security_invoice.sql index 51ad3a1..7db43fd 100644 --- a/sql/009_account_security_invoice.sql +++ b/sql/009_account_security_invoice.sql @@ -1,46 +1,46 @@ -ALTER TABLE `user` ADD COLUMN `password_changed_at` DATETIME NULL AFTER `last_login`; +ALTER TABLE `user` ADD COLUMN `password_changed_at` DATETIME NULL COMMENT '密码最后修改时间' AFTER `last_login`; CREATE TABLE IF NOT EXISTS `user_login_session` ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - access_token_id VARCHAR(64) NOT NULL, - refresh_token_id VARCHAR(64) NOT NULL, - user_agent VARCHAR(512), - ip VARCHAR(64), - last_active_at DATETIME NOT NULL, - expires_at DATETIME NOT NULL, - revoked_at DATETIME, - created_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + access_token_id VARCHAR(64) NOT NULL COMMENT '访问令牌 ID', + refresh_token_id VARCHAR(64) NOT NULL COMMENT '刷新令牌 ID', + user_agent VARCHAR(512) COMMENT '客户端 User-Agent', + ip VARCHAR(64) COMMENT '客户端 IP 地址', + last_active_at DATETIME NOT NULL COMMENT '最后活跃时间', + expires_at DATETIME NOT NULL COMMENT '过期时间戳', + revoked_at DATETIME COMMENT '撤销时间', + created_at DATETIME COMMENT '创建时间', UNIQUE KEY uk_session_access (access_token_id), UNIQUE KEY uk_session_refresh (refresh_token_id), KEY idx_session_user_active (user_id, revoked_at, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `invoice_profile` ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - title VARCHAR(128) NOT NULL, - taxpayer_number VARCHAR(64) NOT NULL, - email VARCHAR(128) NOT NULL, - is_default TINYINT DEFAULT 0, - created_at DATETIME, - updated_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + title VARCHAR(128) NOT NULL COMMENT '发票抬头', + taxpayer_number VARCHAR(64) NOT NULL COMMENT '纳税人识别号', + email VARCHAR(128) NOT NULL COMMENT '电子邮箱', + is_default TINYINT DEFAULT 0 COMMENT '是否默认发票信息', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', KEY idx_invoice_profile_user (user_id, is_default) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `invoice_request` ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - order_id BIGINT NOT NULL, - amount DECIMAL(10,2) NOT NULL, - title VARCHAR(128) NOT NULL, - taxpayer_number VARCHAR(64) NOT NULL, - email VARCHAR(128) NOT NULL, - status VARCHAR(16) DEFAULT 'pending', - invoice_number VARCHAR(64), - admin_remark VARCHAR(256), - requested_at DATETIME, - processed_at DATETIME, + id BIGINT PRIMARY KEY COMMENT '主键 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + order_id BIGINT NOT NULL COMMENT '订单 ID', + amount DECIMAL(10,2) NOT NULL COMMENT '开票金额', + title VARCHAR(128) NOT NULL COMMENT '发票抬头', + taxpayer_number VARCHAR(64) NOT NULL COMMENT '纳税人识别号', + email VARCHAR(128) NOT NULL COMMENT '电子邮箱', + status VARCHAR(16) DEFAULT 'pending' COMMENT '业务状态', + invoice_number VARCHAR(64) COMMENT '发票号码', + admin_remark VARCHAR(256) COMMENT '管理员备注', + requested_at DATETIME COMMENT '申请时间', + processed_at DATETIME COMMENT '处理时间', UNIQUE KEY uk_invoice_order (order_id), KEY idx_invoice_request_user (user_id, requested_at), KEY idx_invoice_request_status (status, requested_at) diff --git a/sql/010_task_workflow_timeout.sql b/sql/010_task_workflow_timeout.sql index fb7ed21..02e866f 100644 --- a/sql/010_task_workflow_timeout.sql +++ b/sql/010_task_workflow_timeout.sql @@ -1,2 +1,2 @@ ALTER TABLE task_execution - ADD COLUMN result_code VARCHAR(64) NULL; + ADD COLUMN result_code VARCHAR(64) NULL COMMENT '执行结果码'; diff --git a/sql/011_live_reconcile_callback.sql b/sql/011_live_reconcile_callback.sql new file mode 100644 index 0000000..6b5275c --- /dev/null +++ b/sql/011_live_reconcile_callback.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS live_callback_event ( + event_id VARCHAR(256) PRIMARY KEY COMMENT '回调事件 ID', + stream_name VARCHAR(128) NOT NULL COMMENT '直播流名称', + event_type VARCHAR(32) NOT NULL COMMENT '回调事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '回调载荷哈希', + received_at DATETIME NOT NULL COMMENT '接收时间', + processed_at DATETIME NULL COMMENT '处理时间', + KEY idx_live_callback_stream (stream_name, event_type, received_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +DELIMITER $$ +DROP PROCEDURE IF EXISTS upgrade_live_callback $$ +CREATE PROCEDURE upgrade_live_callback() +BEGIN + DECLARE item_count INT DEFAULT 0; + SELECT COUNT(*) INTO item_count FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_streaming'; + IF item_count = 0 THEN ALTER TABLE live_session ADD COLUMN device_streaming TINYINT(1) NOT NULL DEFAULT 0 COMMENT '设备是否正在推流'; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'cloud_online'; + IF item_count = 0 THEN ALTER TABLE live_session ADD COLUMN cloud_online TINYINT(1) NOT NULL DEFAULT 0 COMMENT '云端是否在线'; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'cloud_checked_at'; + IF item_count = 0 THEN ALTER TABLE live_session ADD COLUMN cloud_checked_at DATETIME NULL COMMENT '云端检查时间'; END IF; +END $$ +CALL upgrade_live_callback() $$ +DROP PROCEDURE upgrade_live_callback $$ +DELIMITER ; diff --git a/sql/012_platform_traffic_billing.sql b/sql/012_platform_traffic_billing.sql new file mode 100644 index 0000000..53da644 --- /dev/null +++ b/sql/012_platform_traffic_billing.sql @@ -0,0 +1,124 @@ +-- 平台共享媒体资源与统一账务升级脚本,可重复执行。 + +CREATE TABLE IF NOT EXISTS platform_resource_pool ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', + total_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '累计采购资源(字节)', + available_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '可用共享资源(字节)', + consumed_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '已消费共享资源(字节)', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '资源池状态', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观版本号', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_resource_purchase ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', + batch_no VARCHAR(64) NOT NULL COMMENT '采购批次号', + provider VARCHAR(32) NOT NULL COMMENT '资源供应商', + total_bytes BIGINT NOT NULL COMMENT '采购资源量(字节)', + unit_cost DECIMAL(12,6) COMMENT '单位成本(元/字节)', + total_cost DECIMAL(14,2) COMMENT '采购总成本(元)', + expires_at DATETIME COMMENT '资源到期时间', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '批次状态', + remark VARCHAR(256) COMMENT '备注', + created_by BIGINT NOT NULL COMMENT '操作人 ID', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', + UNIQUE KEY uk_resource_purchase_batch (batch_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS traffic_package ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', + code VARCHAR(64) NOT NULL COMMENT '套餐编码', + name VARCHAR(128) NOT NULL COMMENT '套餐名称', + amount_bytes BIGINT NOT NULL COMMENT '套餐额度(字节)', + price DECIMAL(10,2) NOT NULL COMMENT '销售价格(元)', + validity_days INT COMMENT '有效期(天)', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '套餐状态', + sort INT NOT NULL DEFAULT 0 COMMENT '显示排序', + created_at DATETIME COMMENT '创建时间', + updated_at DATETIME COMMENT '更新时间', + UNIQUE KEY uk_traffic_package_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS traffic_ledger ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', + account_type VARCHAR(16) NOT NULL COMMENT '账户类型', -- user / platform + account_id BIGINT NOT NULL COMMENT '账户 ID', + direction VARCHAR(8) NOT NULL COMMENT '借贷方向', -- credit / debit + amount_bytes BIGINT NOT NULL COMMENT '变动金额(字节)', + balance_before BIGINT NOT NULL COMMENT '变动前余额(字节)', + balance_after BIGINT NOT NULL COMMENT '变动后余额(字节)', + source_type VARCHAR(32) NOT NULL COMMENT '业务来源类型', + source_id VARCHAR(128) COMMENT '业务来源 ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '全局幂等键', + operator_id BIGINT COMMENT '操作人 ID', + remark VARCHAR(256) COMMENT '备注', + created_at DATETIME COMMENT '创建时间', + UNIQUE KEY uk_traffic_ledger_idempotency (idempotency_key), + KEY idx_traffic_ledger_account (account_type, account_id, created_at), + KEY idx_traffic_ledger_source (source_type, source_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS live_billing_segment ( + id BIGINT PRIMARY KEY COMMENT '主键 ID', + session_id VARCHAR(128) NOT NULL COMMENT '直播会话 ID', + user_id BIGINT NOT NULL COMMENT '扣费用户 ID', + period_start DATETIME NOT NULL COMMENT '计费开始时间', + period_end DATETIME NOT NULL COMMENT '计费结束时间', + bytes BIGINT NOT NULL COMMENT '计费流量(字节)', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '结算状态', + idempotency_key VARCHAR(160) NOT NULL COMMENT '结算幂等键', + created_at DATETIME COMMENT '创建时间', + settled_at DATETIME COMMENT '结算时间', + UNIQUE KEY uk_live_billing_segment_period (session_id, period_start, period_end), + UNIQUE KEY uk_live_billing_segment_idempotency (idempotency_key), + KEY idx_live_billing_segment_session (session_id, period_start) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 已部署数据库的新增字段;字段存在时跳过,避免重复执行失败。 +DELIMITER $$ +DROP PROCEDURE IF EXISTS upgrade_platform_traffic_billing $$ +CREATE PROCEDURE upgrade_platform_traffic_billing() +BEGIN + DECLARE item_count INT DEFAULT 0; + + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND column_name = 'package_id'; + IF item_count = 0 THEN ALTER TABLE traffic_order ADD COLUMN package_id BIGINT NULL COMMENT '套餐 ID' AFTER user_id; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND column_name = 'package_code'; + IF item_count = 0 THEN ALTER TABLE traffic_order ADD COLUMN package_code VARCHAR(64) NULL COMMENT '套餐编码快照' AFTER package_id; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND column_name = 'amount_bytes'; + IF item_count = 0 THEN ALTER TABLE traffic_order ADD COLUMN amount_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '流量额度(字节)' AFTER amount_gb; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND column_name = 'credit_status'; + IF item_count = 0 THEN ALTER TABLE traffic_order ADD COLUMN credit_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '充值入账状态' AFTER pay_status; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND column_name = 'credit_ledger_id'; + IF item_count = 0 THEN ALTER TABLE traffic_order ADD COLUMN credit_ledger_id BIGINT NULL COMMENT '充值流水 ID' AFTER credit_status; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND column_name = 'payment_idempotency_key'; + IF item_count = 0 THEN ALTER TABLE traffic_order ADD COLUMN payment_idempotency_key VARCHAR(128) NULL COMMENT '支付幂等键' AFTER credit_ledger_id; END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND column_name = 'paid_by'; + IF item_count = 0 THEN ALTER TABLE traffic_order ADD COLUMN paid_by BIGINT NULL COMMENT '确认支付的操作人 ID' AFTER payment_idempotency_key; END IF; + + SELECT COUNT(*) INTO item_count FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'traffic_order' AND index_name = 'uk_traffic_order_payment_idem'; + IF item_count = 0 THEN CREATE UNIQUE INDEX uk_traffic_order_payment_idem ON traffic_order (payment_idempotency_key); END IF; + + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'download_log' AND column_name = 'idempotency_key'; + IF item_count = 0 THEN + ALTER TABLE download_log ADD COLUMN idempotency_key VARCHAR(128) NULL COMMENT '下载计费幂等键'; + UPDATE download_log SET idempotency_key = CONCAT('legacy:', id) WHERE idempotency_key IS NULL; + ALTER TABLE download_log MODIFY COLUMN idempotency_key VARCHAR(128) NOT NULL COMMENT '下载计费幂等键'; + END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'download_log' AND index_name = 'uk_download_log_idempotency'; + IF item_count = 0 THEN CREATE UNIQUE INDEX uk_download_log_idempotency ON download_log (idempotency_key); END IF; + + SELECT COUNT(*) INTO item_count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'traffic_usage_log' AND column_name = 'idempotency_key'; + IF item_count = 0 THEN + ALTER TABLE traffic_usage_log ADD COLUMN idempotency_key VARCHAR(160) NULL COMMENT '消费幂等键' AFTER balance_after; + UPDATE traffic_usage_log SET idempotency_key = CONCAT('legacy:', id) WHERE idempotency_key IS NULL; + ALTER TABLE traffic_usage_log MODIFY COLUMN idempotency_key VARCHAR(160) NOT NULL COMMENT '消费幂等键'; + END IF; + SELECT COUNT(*) INTO item_count FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'traffic_usage_log' AND index_name = 'uk_traffic_usage_idempotency'; + IF item_count = 0 THEN CREATE UNIQUE INDEX uk_traffic_usage_idempotency ON traffic_usage_log (idempotency_key); END IF; +END $$ +CALL upgrade_platform_traffic_billing() $$ +DROP PROCEDURE upgrade_platform_traffic_billing $$ +DELIMITER ; diff --git a/sql/013_traffic_billing_audit.sql b/sql/013_traffic_billing_audit.sql new file mode 100644 index 0000000..c6a6464 --- /dev/null +++ b/sql/013_traffic_billing_audit.sql @@ -0,0 +1,57 @@ +-- 只读账务核查 SQL,不自动修正数据。 + +-- 用户余额与统一账务净额核对。 +SELECT + u.id AS user_id, + u.traffic_balance AS snapshot_bytes, + COALESCE(SUM(CASE WHEN l.direction = 'credit' THEN l.amount_bytes ELSE -l.amount_bytes END), 0) AS ledger_net_bytes, + u.traffic_balance - COALESCE(SUM(CASE WHEN l.direction = 'credit' THEN l.amount_bytes ELSE -l.amount_bytes END), 0) AS difference_bytes +FROM `user` u +LEFT JOIN traffic_ledger l ON l.account_type = 'user' AND l.account_id = u.id +GROUP BY u.id, u.traffic_balance +HAVING difference_bytes <> 0; + +-- 平台资源池与采购、平台消费流水核对。 +SELECT + p.id AS pool_id, + p.total_bytes, + p.available_bytes, + p.consumed_bytes, + COALESCE((SELECT SUM(total_bytes) FROM platform_resource_purchase WHERE status <> 'cancelled'), 0) AS purchased_bytes, + COALESCE((SELECT SUM(amount_bytes) FROM traffic_ledger WHERE account_type = 'platform' AND direction = 'debit'), 0) AS ledger_consumed_bytes, + p.total_bytes - COALESCE((SELECT SUM(total_bytes) FROM platform_resource_purchase WHERE status <> 'cancelled'), 0) AS purchase_difference_bytes, + p.consumed_bytes - COALESCE((SELECT SUM(amount_bytes) FROM traffic_ledger WHERE account_type = 'platform' AND direction = 'debit'), 0) AS consumption_difference_bytes +FROM platform_resource_pool p; + +-- 充值订单与充值流水核对。 +SELECT + o.id AS order_id, + o.user_id, + o.pay_status, + o.credit_status, + o.amount_bytes, + l.id AS ledger_id, + l.amount_bytes AS ledger_amount_bytes, + l.idempotency_key +FROM traffic_order o +LEFT JOIN traffic_ledger l ON l.id = o.credit_ledger_id +WHERE o.pay_status = 'paid' + AND (o.credit_status <> 'credited' OR l.id IS NULL OR l.direction <> 'credit' OR l.amount_bytes <> o.amount_bytes); + +-- 直播计费段中未完成结算的记录。 +SELECT id, session_id, user_id, period_start, period_end, bytes, status, idempotency_key +FROM live_billing_segment +WHERE status <> 'settled' +ORDER BY created_at ASC, id ASC; + +-- 下载记录与下载消费流水核对。 +SELECT + d.id AS download_id, + d.user_id, + d.video_id, + d.bytes, + l.id AS ledger_id, + l.amount_bytes AS ledger_amount_bytes +FROM download_log d +LEFT JOIN traffic_ledger l ON l.idempotency_key = CONCAT('download:', d.idempotency_key, ':user') +WHERE l.id IS NULL OR l.direction <> 'debit' OR l.amount_bytes <> d.bytes; diff --git a/vo/account_vo.go b/vo/account_vo.go index 2f816e8..c2171bc 100644 --- a/vo/account_vo.go +++ b/vo/account_vo.go @@ -44,7 +44,8 @@ type OrderPageReq struct { // TrafficOrderCreateReq 创建流量充值订单 type TrafficOrderCreateReq struct { - AmountGb int `json:"amountGb" validate:"required,min=1"` + AmountGb int64 `json:"amountGb" validate:"omitempty,min=1"` + PackageID int64 `json:"packageId" validate:"omitempty,gt=0"` } // SimCardPageReq SIM 卡分页 diff --git a/vo/platform_billing_vo.go b/vo/platform_billing_vo.go new file mode 100644 index 0000000..16a2d30 --- /dev/null +++ b/vo/platform_billing_vo.go @@ -0,0 +1,54 @@ +package vo + +import ( + "time" + + "laic-backend/common" +) + +type PlatformPurchaseCreateReq struct { + BatchNo string `json:"batchNo" validate:"required,max=64"` + Provider string `json:"provider" validate:"required,max=32"` + TotalBytes int64 `json:"totalBytes" validate:"required,gt=0"` + UnitCost float64 `json:"unitCost"` + TotalCost float64 `json:"totalCost"` + ExpiresAt *time.Time `json:"expiresAt"` + Remark string `json:"remark" validate:"max=256"` +} + +type PlatformPurchasePageReq struct { + common.Pagination + Status string `json:"status" form:"status"` + Provider string `json:"provider" form:"provider"` + Keyword string `json:"keyword" form:"keyword"` +} + +type TrafficPackageCreateReq struct { + Code string `json:"code" validate:"required,max=64"` + Name string `json:"name" validate:"required,max=128"` + AmountBytes int64 `json:"amountBytes" validate:"required,gt=0"` + Price float64 `json:"price" validate:"gte=0"` + ValidityDays int `json:"validityDays" validate:"gte=0"` + Sort int `json:"sort"` +} + +type TrafficPackageUpdateReq struct { + Name string `json:"name" validate:"max=128"` + AmountBytes int64 `json:"amountBytes" validate:"gt=0"` + Price float64 `json:"price" validate:"gte=0"` + ValidityDays int `json:"validityDays" validate:"gte=0"` + Status string `json:"status"` + Sort int `json:"sort"` +} + +type TrafficPackagePageReq struct { + common.Pagination + Status string `json:"status" form:"status"` +} + +type TrafficLedgerPageReq struct { + common.Pagination + AccountType string `json:"accountType" form:"accountType"` + AccountID int64 `json:"accountId" form:"accountId"` + SourceType string `json:"sourceType" form:"sourceType"` +}