Browse Source

fix:直播计费

master
刘浩东 3 weeks ago
parent
commit
ce3f46f60e
  1. 24
      common/busi_error.go
  2. 5
      common/config.go
  3. 16
      config-prod.yaml
  4. 6
      config.yaml
  5. 13
      go.mod
  6. 228
      go.sum
  7. 2
      handler/account_handler.go
  8. 86
      handler/live_handler.go
  9. 113
      handler/platform_billing_handler.go
  10. 4
      handler/video_handler.go
  11. 4
      live/adapter.go
  12. 52
      live/aliyun_client.go
  13. 4
      live/factory.go
  14. 32
      live/providers.go
  15. 40
      model/billing.go
  16. 14
      model/live_callback.go
  17. 14
      model/media.go
  18. 86
      model/platform_billing.go
  19. 16
      route/route.go
  20. 6
      service/account_service.go
  21. 345
      service/billing_service.go
  22. 33
      service/command_service.go
  23. 46
      service/live_callback_service.go
  24. 111
      service/live_service.go
  25. 224
      service/platform_billing_service.go
  26. 1
      service/scheduler.go
  27. 80
      service/video_service.go
  28. 553
      sql/001_schema.sql
  29. 22
      sql/004_live_control.sql
  30. 30
      sql/005_live_control_upgrade.sql
  31. 62
      sql/009_account_security_invoice.sql
  32. 2
      sql/010_task_workflow_timeout.sql
  33. 28
      sql/011_live_reconcile_callback.sql
  34. 124
      sql/012_platform_traffic_billing.sql
  35. 57
      sql/013_traffic_billing_audit.sql
  36. 3
      vo/account_vo.go
  37. 54
      vo/platform_billing_vo.go

24
common/busi_error.go

@ -63,11 +63,13 @@ const (
LiveLeaseNotFound = 57006 LiveLeaseNotFound = 57006
// 计费 58xxx // 计费 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: "视频不存在"} ErrVideoNotFound = &BusiError{Code: VideoNotFound, Msg: "视频不存在"}
ErrVideoUploading = &BusiError{Code: VideoUploading, 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 创建自定义业务错误 // NewBusiError 创建自定义业务错误

5
common/config.go

@ -71,6 +71,10 @@ type Live struct {
AppName string `mapstructure:"app-name"` AppName string `mapstructure:"app-name"`
AuthKey string `mapstructure:"auth-key"` AuthKey string `mapstructure:"auth-key"`
PlayAuthKey string `mapstructure:"play-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"` AuthExpireSeconds int64 `mapstructure:"auth-expire-seconds"`
PlayURLTTLSeconds int64 `mapstructure:"play-url-ttl-seconds"` PlayURLTTLSeconds int64 `mapstructure:"play-url-ttl-seconds"`
Protocol string `mapstructure:"protocol"` Protocol string `mapstructure:"protocol"`
@ -80,6 +84,7 @@ type Live struct {
StopGraceSeconds int `mapstructure:"stop-grace-seconds"` StopGraceSeconds int `mapstructure:"stop-grace-seconds"`
ReconcileIntervalSeconds int `mapstructure:"reconcile-interval-seconds"` ReconcileIntervalSeconds int `mapstructure:"reconcile-interval-seconds"`
RecordPolicy string `mapstructure:"record-policy"` RecordPolicy string `mapstructure:"record-policy"`
CallbackAuthToken string `mapstructure:"callback-auth-token"`
AllowRealCloud bool `mapstructure:"allow-real-cloud"` AllowRealCloud bool `mapstructure:"allow-real-cloud"`
} }

16
config-prod.yaml

@ -25,11 +25,16 @@ oss:
region: cn-east-2 region: cn-east-2
use-ssl: true use-ssl: true
live: 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 app-name: dock-live
auth-key: "luMueASWWJhT2QFm"
play-auth-key: "bJSeSA1sYIfjZ9Rl"
region-id: "cn-shenzhen"
auth-expire-seconds: 7200 auth-expire-seconds: 7200
play-url-ttl-seconds: 300 play-url-ttl-seconds: 300
protocol: srt protocol: srt
@ -39,7 +44,8 @@ live:
stop-grace-seconds: 15 stop-grace-seconds: 15
reconcile-interval-seconds: 10 reconcile-interval-seconds: 10
record-policy: disabled record-policy: disabled
allow-real-cloud: false
callback-auth-token: "db570a886e3d65c3f6c55d648ddb41506a658f6be4892719345056602fc2e7ac"
allow-real-cloud: true
heartbeat: heartbeat:
timeout-seconds: 30 timeout-seconds: 30
scan-seconds: 5 scan-seconds: 5

6
config.yaml

@ -30,6 +30,12 @@ live:
push-domain: local-push push-domain: local-push
play-domain: local-play play-domain: local-play
app-name: dock-live 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 auth-expire-seconds: 7200
play-url-ttl-seconds: 300 play-url-ttl-seconds: 300
protocol: srt protocol: srt

13
go.mod

@ -26,9 +26,20 @@ require (
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect 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/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.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/cloudwego/base64x v0.1.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@ -54,6 +65,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // 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/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
) )

228
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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 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 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= 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 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= 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/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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 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/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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/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.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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= 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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@ -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/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 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 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 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 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= 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 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.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/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 h1:UXIoE+TbdbdRHtaNjIuMAQn+SjAAKUJHyeONAt5F1eI=
github.com/taosdata/driver-go/v3 v3.3.1/go.mod h1:H2vo/At+rOPY1aMzUV9P49SVX7NlXb3LAbKw+MCLrmU= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= 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= 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= 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 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 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 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= 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 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= 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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.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 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/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 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/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 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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=

2
handler/account_handler.go

@ -95,7 +95,7 @@ func PayTrafficOrder(c *gin.Context) {
common.FailWithBusiError(c, e) common.FailWithBusiError(c, e)
return return
} }
data, e := service.DefaultAccountService.PayTrafficOrder(id)
data, e := service.DefaultAccountService.PayTrafficOrder(id, common.GetUserId(c), c.GetHeader("Idempotency-Key"))
if e != nil { if e != nil {
common.FailWithBusiError(c, e) common.FailWithBusiError(c, e)
return return

86
handler/live_handler.go

@ -1,6 +1,15 @@
package handler package handler
import ( import (
"crypto/subtle"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"laic-backend/common" "laic-backend/common"
@ -119,15 +128,86 @@ func StopLive(c *gin.Context) {
// GetLivePlayURL 获取直播播放地址 // GetLivePlayURL 获取直播播放地址
func GetLivePlayURL(c *gin.Context) { 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) common.FailWithBusiError(c, common.ErrParam)
return 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 { if busiErr != nil {
common.FailWithBusiError(c, busiErr) common.FailWithBusiError(c, busiErr)
return return
} }
common.OKWithData(c, play) 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
}

113
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)
}

4
handler/video_handler.go

@ -83,7 +83,9 @@ func DownloadVideo(c *gin.Context) {
common.FailWithBusiError(c, busiErr) common.FailWithBusiError(c, busiErr)
return 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 { if busiErr != nil {
common.FailWithBusiError(c, busiErr) common.FailWithBusiError(c, busiErr)
return return

4
live/adapter.go

@ -16,6 +16,10 @@ type Config struct {
AppName string AppName string
AuthKey string AuthKey string
PlayAuthKey string PlayAuthKey string
AccessKeyID string
AccessKeySecret string
RegionID string
APIEndpoint string
AuthExpireSeconds int64 AuthExpireSeconds int64
PlayURLTTLSeconds int64 PlayURLTTLSeconds int64
Protocol string Protocol string

52
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
}

4
live/factory.go

@ -15,6 +15,10 @@ func NewAdapter(conf common.Live) (Adapter, error) {
AppName: conf.AppName, AppName: conf.AppName,
AuthKey: conf.AuthKey, AuthKey: conf.AuthKey,
PlayAuthKey: conf.PlayAuthKey, PlayAuthKey: conf.PlayAuthKey,
AccessKeyID: conf.AccessKeyID,
AccessKeySecret: conf.AccessKeySecret,
RegionID: conf.RegionID,
APIEndpoint: conf.APIEndpoint,
AuthExpireSeconds: conf.AuthExpireSeconds, AuthExpireSeconds: conf.AuthExpireSeconds,
PlayURLTTLSeconds: conf.PlayURLTTLSeconds, PlayURLTTLSeconds: conf.PlayURLTTLSeconds,
Protocol: strings.ToLower(conf.Protocol), Protocol: strings.ToLower(conf.Protocol),

32
live/providers.go

@ -2,8 +2,8 @@ package live
import ( import (
"crypto/md5" "crypto/md5"
cryptorand "crypto/rand"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
@ -44,20 +44,28 @@ func (f FakeAdapter) QueryOnline(string) (OnlineStatus, error) {
} }
type AliyunAdapter struct { type AliyunAdapter struct {
cfg Config
cfg Config
client *aliyunLiveClient
} }
func NewAliyunAdapter(cfg Config) (Adapter, error) { 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 return nil, ErrInvalidConfig
} }
if cfg.Protocol == "" { if cfg.Protocol == "" {
cfg.Protocol = "srt" cfg.Protocol = "srt"
} }
if cfg.Protocol != "srt" && cfg.Protocol != "rtmps" {
return nil, ErrInvalidConfig
}
if cfg.SRTPort <= 0 { if cfg.SRTPort <= 0 {
cfg.SRTPort = 1105 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" } 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 path := "/" + a.cfg.AppName + "/" + req.StreamName
signature := sign(path, expires.Unix(), a.cfg.PlayAuthKey) signature := sign(path, expires.Unix(), a.cfg.PlayAuthKey)
base := "https://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName 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 { 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[:])) return fmt.Sprintf("%d-%s-0-%s", expiresAt, rand, hex.EncodeToString(sum[:]))
} }

40
model/billing.go

@ -4,14 +4,21 @@ import "time"
// TrafficOrder 云媒体流量订单 // TrafficOrder 云媒体流量订单
type TrafficOrder struct { 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 { func (TrafficOrder) TableName() string {
@ -20,14 +27,15 @@ func (TrafficOrder) TableName() string {
// TrafficUsageLog 云媒体消费流水(账本,不可变) // TrafficUsageLog 云媒体消费流水(账本,不可变)
type TrafficUsageLog struct { 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 { func (TrafficUsageLog) TableName() string {

14
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" }

14
model/media.go

@ -19,6 +19,9 @@ type LiveSession struct {
DeviceStateVersion int64 `gorm:"column:device_state_version;type:BIGINT" json:"deviceStateVersion"` DeviceStateVersion int64 `gorm:"column:device_state_version;type:BIGINT" json:"deviceStateVersion"`
DeviceEventID string `gorm:"column:device_event_id;type:VARCHAR(128)" json:"deviceEventId"` DeviceEventID string `gorm:"column:device_event_id;type:VARCHAR(128)" json:"deviceEventId"`
DeviceUpdatedAt int64 `gorm:"column:device_updated_at;type:BIGINT" json:"deviceUpdatedAt"` 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"` CloudConfirmedAt *time.Time `gorm:"column:cloud_confirmed_at" json:"cloudConfirmedAt"`
StartedAt *time.Time `gorm:"column:started_at" json:"startedAt"` StartedAt *time.Time `gorm:"column:started_at" json:"startedAt"`
StoppedAt *time.Time `gorm:"column:stopped_at" json:"stoppedAt"` StoppedAt *time.Time `gorm:"column:stopped_at" json:"stoppedAt"`
@ -68,11 +71,12 @@ func (Video) TableName() string {
// DownloadLog 下载记录 // DownloadLog 下载记录
type DownloadLog struct { 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 { func (DownloadLog) TableName() string {

86
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" }

16
route/route.go

@ -23,6 +23,9 @@ func InitRouter(port int32) {
common.OKWithData(c, gin.H{"status": "up"}) common.OKWithData(c, gin.H{"status": "up"})
}) })
// 阿里云直播回调使用独立令牌鉴权,不能进入用户 JWT 或操作日志中间件。
engine.POST("/internal/callbacks/aliyun/live", handler.HandleAliyunLiveCallback)
// v1 业务路由 // v1 业务路由
v1 := common.RouteGroup("/v1") v1 := common.RouteGroup("/v1")
v1.Use(middleware.OperationLogMiddleware()) v1.Use(middleware.OperationLogMiddleware())
@ -172,6 +175,19 @@ func InitRouter(port int32) {
videos.POST("/:id/download", handler.DownloadVideo) 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) // 系统管理(admin)
users := v1.Group("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware()) users := v1.Group("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware())
{ {

6
service/account_service.go

@ -81,12 +81,12 @@ func (s *AccountService) GetOrderPage(userID int64, req *vo.OrderPageReq) (*comm
// CreateTrafficOrder 创建流量充值订单 // CreateTrafficOrder 创建流量充值订单
func (s *AccountService) CreateTrafficOrder(userID int64, req *vo.TrafficOrderCreateReq) (*model.TrafficOrder, *common.BusiError) { 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) // 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 卡列表 // ListSimCards SIM 卡列表

345
service/billing_service.go

@ -111,43 +111,93 @@ func (b *BillingService) currentBalance(userID int64) (int64, error) {
return bal, nil 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 { if bytes <= 0 {
return true, nil 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 { 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 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 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 return common.Page(req.Pagination, total, list), nil
} }
// CreateOrder 创建流量充值订单(待支付)
func (b *BillingService) CreateOrder(userID int64, amountGb int) (*model.TrafficOrder, *common.BusiError) { 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 { if err := common.DB.Create(order).Error; err != nil {
logger.ERROR("创建流量订单失败", err) logger.ERROR("创建流量订单失败", err)
@ -214,41 +277,76 @@ func (b *BillingService) CreateOrder(userID int64, amountGb int) (*model.Traffic
return order, nil 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 var order model.TrafficOrder
now := time.Now() 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 { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err 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 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrOrderNotFound return nil, common.ErrOrderNotFound
} }
if busiErr, ok := err.(*common.BusiError); ok {
if busiErr != nil {
return nil, busiErr return nil, busiErr
} }
if typed, ok := err.(*common.BusiError); ok {
return nil, typed
}
logger.ERROR("订单支付失败", err) logger.ERROR("订单支付失败", err)
return nil, common.ErrInternal 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 return &order, nil
} }
@ -304,53 +402,128 @@ func mustID() int64 {
return id return id
} }
// chargeLiveSessions 直播计费(定时任务,每 60s):扫描进行中的会话按码率估算扣减
// chargeLiveSessions 只结算设备与云端均确认在线的 streaming 会话。
func (b *BillingService) chargeLiveSessions() { func (b *BillingService) chargeLiveSessions() {
var sessions []model.LiveSession 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) logger.ERROR("扫描直播会话失败", err)
return return
} }
now := time.Now()
now := time.Now().Truncate(time.Second)
for i := range sessions { for i := range sessions {
b.chargeLiveSession(&sessions[i], now) b.chargeLiveSession(&sessions[i], now)
} }
} }
// chargeLiveSession 对单个直播会话按时长增量扣减;余额耗尽则停止推流
// chargeLiveSession 通过会话行锁和唯一直播计费段避免多实例重复结算。
func (b *BillingService) chargeLiveSession(session *model.LiveSession, now time.Time) { 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(&current, "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 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 // flushBalanceSnapshot 余额快照刷新(定时任务,每 30min):Redis 权威余额回写 MySQL

33
service/command_service.go

@ -1,6 +1,8 @@
package service package service
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -156,7 +158,16 @@ func (s *CommandService) DispatchToDock(dockID, commandType string, params map[s
return nil, err return nil, err
} }
requestID := strings.ReplaceAll(uuid.New().String(), "-", "") 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 { if err != nil {
return nil, err return nil, err
} }
@ -165,7 +176,7 @@ func (s *CommandService) DispatchToDock(dockID, commandType string, params map[s
ID: commandID, ID: commandID,
DockID: dockID, DockID: dockID,
CommandType: commandType, CommandType: commandType,
Params: string(paramsJSON),
Params: string(auditJSON),
RequestID: requestID, RequestID: requestID,
TTLMs: 30000, TTLMs: 30000,
Status: "sent", Status: "sent",
@ -181,7 +192,14 @@ func (s *CommandService) DispatchToDock(dockID, commandType string, params map[s
return cmd, nil 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() { func (s *CommandService) StartRetryScanner() {
go func() { go func() {
ticker := time.NewTicker(10 * time.Second) ticker := time.NewTicker(10 * time.Second)
@ -213,7 +231,11 @@ func (s *CommandService) retryTimeout() {
if cmd.RetryCount >= maxRetryCount { if cmd.RetryCount >= maxRetryCount {
result := common.DB.Model(&model.DeviceCommandLog{}).Where("id = ? AND status = ?", cmd.ID, "sent").Update("status", "timeout") result := common.DB.Model(&model.DeviceCommandLog{}).Where("id = ? AND status = ?", cmd.ID, "sent").Update("status", "timeout")
if result.Error == nil && result.RowsAffected > 0 { 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 continue
} }
@ -230,6 +252,9 @@ func (s *CommandService) retryTimeout() {
if cmd.Params != "" { if cmd.Params != "" {
_ = json.Unmarshal([]byte(cmd.Params), &params) _ = json.Unmarshal([]byte(cmd.Params), &params)
} }
if cmd.CommandType == "video.start_stream" {
params = DefaultLiveService.RefreshStartParams(cmd.DockID, params)
}
b, _ := json.Marshal(s.commandEnvelope(&cmd, requestID, params)) b, _ := json.Marshal(s.commandEnvelope(&cmd, requestID, params))
topic := fmt.Sprintf("dock-edge/v1/dock/%s/command", cmd.DockID) topic := fmt.Sprintf("dock-edge/v1/dock/%s/command", cmd.DockID)
_ = mqtt.Publish(topic, 1, false, b) _ = mqtt.Publish(topic, 1, false, b)

46
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
}

111
service/live_service.go

@ -1,6 +1,8 @@
package service package service
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"strconv" "strconv"
@ -23,6 +25,11 @@ type LiveService struct {
var DefaultLiveService = &LiveService{} var DefaultLiveService = &LiveService{}
func pushURLHash(pushURL string) string {
sum := sha256.Sum256([]byte(pushURL))
return hex.EncodeToString(sum[:])
}
func InitLiveProvider(conf common.Live) error { func InitLiveProvider(conf common.Live) error {
provider, err := liveprovider.NewAdapter(conf) provider, err := liveprovider.NewAdapter(conf)
if err != nil { if err != nil {
@ -86,7 +93,7 @@ func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.Li
return err return err
} }
now := time.Now() 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 { if err := tx.Create(&session).Error; err != nil {
return err 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), &params) != 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 兼容旧启动接口,统一复用创建或加入会话的租约流程。 // Start 兼容旧启动接口,统一复用创建或加入会话的租约流程。
func (s *LiveService) Start(userID int64, isAdmin bool, dockID string, req *vo.LiveStartReq) (*model.LiveSession, *common.BusiError) { 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}) 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. // 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() provider, busiErr := s.adapter()
if busiErr != nil { if busiErr != nil {
return nil, busiErr return nil, busiErr
} }
var session model.LiveSession 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrLiveNotFound return nil, common.ErrLiveNotFound
} }
@ -361,6 +456,7 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI
"device_state_version": version, "device_state_version": version,
"device_event_id": eventID, "device_event_id": eventID,
"device_updated_at": updatedAt, "device_updated_at": updatedAt,
"device_streaming": streaming,
"updated_at": now, "updated_at": now,
} }
if streaming { if streaming {
@ -368,7 +464,7 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI
if busiErr != nil { if busiErr != nil {
return false return false
} }
online, queryErr := provider.QueryOnline(streamSessionID)
online, queryErr := provider.QueryOnline(session.StreamName)
if queryErr != nil || !online.Online { if queryErr != nil || !online.Online {
updates["error_code"] = "PROVIDER_ONLINE_UNVERIFIED" 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 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["phase"] = "streaming"
updates["started_at"] = now updates["started_at"] = now
updates["cloud_confirmed_at"] = now updates["cloud_confirmed_at"] = now
} else if session.Phase == "stopping" {
updates["error_code"] = "DEVICE_STREAM_OFFLINE"
} else { } 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) 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 return result.Error == nil && result.RowsAffected == 1

224
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
}

1
service/scheduler.go

@ -31,6 +31,7 @@ func (s *Scheduler) Start() {
interval = 5 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.ReconcileLeases)
_, _ = s.cron.AddFunc("@every "+(time.Duration(interval)*time.Second).String(), DefaultLiveService.ReconcileCloudSessions)
// 直播计费:每 60s 扫描进行中的 live_session 按码率扣减 // 直播计费:每 60s 扫描进行中的 live_session 按码率扣减
_, _ = s.cron.AddFunc("@every 60s", DefaultBillingService.chargeLiveSessions) _, _ = s.cron.AddFunc("@every 60s", DefaultBillingService.chargeLiveSessions)
// 余额快照:每 30min 将 Redis 权威余额回写 MySQL // 余额快照:每 30min 将 Redis 权威余额回写 MySQL

80
service/video_service.go

@ -7,7 +7,9 @@ import (
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"laic-backend/cache"
"laic-backend/common" "laic-backend/common"
"laic-backend/logger" "laic-backend/logger"
"laic-backend/model" "laic-backend/model"
@ -204,8 +206,8 @@ func (s *VideoService) GetDetail(userID int64, isAdmin bool, videoID int64) (*mo
return &video, nil 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 var video model.Video
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil { if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { 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" { if video.Status != "ready" {
return nil, common.ErrVideoUploading 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) downloadURL, err := common.PresignOSS("GET", video.OssKey, downloadURLExpire)
if err != nil { if err != nil {
logger.ERROR("生成下载预签名 URL 失败", err) logger.ERROR("生成下载预签名 URL 失败", err)
return nil, common.ErrInternal 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
} }

553
sql/001_schema.sql

@ -11,18 +11,18 @@ USE laic;
-- 5.1 用户 -- 5.1 用户
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `user` ( 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) UNIQUE KEY idx_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -30,38 +30,38 @@ CREATE TABLE IF NOT EXISTS `user` (
-- 5.2 机巢 & 无人机 -- 5.2 机巢 & 无人机
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS dock ( 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) UNIQUE KEY idx_dock_id (dock_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS drone ( 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_sn (drone_sn),
UNIQUE KEY idx_drone_dock (dock_id) UNIQUE KEY idx_drone_dock (dock_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -70,28 +70,28 @@ CREATE TABLE IF NOT EXISTS drone (
-- 5.3 告警 -- 5.3 告警
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm_code ( 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) UNIQUE KEY idx_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS alarm ( 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) KEY idx_alarm_dock (dock_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -99,53 +99,53 @@ CREATE TABLE IF NOT EXISTS alarm (
-- 5.4 指令 & 工作流 -- 5.4 指令 & 工作流
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS device_command_log ( 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) KEY idx_cmd_log_dock (dock_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS workflow_state ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
-- 5.5 固件管理 -- 5.5 固件管理
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS firmware ( 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) UNIQUE KEY idx_component_ver (component, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -153,54 +153,54 @@ CREATE TABLE IF NOT EXISTS firmware (
-- 5.6 任务 & 航线 -- 5.6 任务 & 航线
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS task_plan ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS task_execution ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS route ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS route_waypoint ( 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) UNIQUE KEY uk_route_seq (route_id, seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -208,29 +208,32 @@ CREATE TABLE IF NOT EXISTS route_waypoint (
-- 5.7 媒体 -- 5.7 媒体
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS live_session ( 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 ( active_dock_id VARCHAR(64) GENERATED ALWAYS AS (
CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END 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_dock_phase (dock_id, phase, created_at),
KEY idx_live_session_phase_deadline (phase, stop_deadline), KEY idx_live_session_phase_deadline (phase, stop_deadline),
UNIQUE KEY uk_live_session_device_event (device_event_id), 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS live_viewer_lease ( 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), UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id),
KEY idx_live_lease_expire (stream_session_id, expires_at), KEY idx_live_lease_expire (stream_session_id, expires_at),
KEY idx_live_lease_active (stream_session_id, released_at, 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) CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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 ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS download_log ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
-- 5.8 计费 -- 5.8 计费
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS traffic_order ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS traffic_usage_log ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_card ( 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_recharge_log ( 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) UNIQUE KEY uk_recharge_idem (idempotent_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_usage_record ( 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) KEY idx_record_sim (sim_card_id, synced_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -347,14 +426,14 @@ CREATE TABLE IF NOT EXISTS sim_usage_record (
-- 7.7 操作日志 -- 7.7 操作日志
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS operation_log ( 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) KEY idx_op_user (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

22
sql/004_live_control.sql

@ -2,24 +2,24 @@
-- 执行前应确认 live_session 没有重复活动会话,再创建活动机巢唯一约束。 -- 执行前应确认 live_session 没有重复活动会话,再创建活动机巢唯一约束。
ALTER TABLE 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 ( ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS (
CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END 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_dock_phase (dock_id, phase, created_at),
ADD KEY idx_live_session_phase_deadline (phase, stop_deadline), ADD KEY idx_live_session_phase_deadline (phase, stop_deadline),
ADD UNIQUE KEY uk_live_session_active_dock (active_dock_id); ADD UNIQUE KEY uk_live_session_active_dock (active_dock_id);
CREATE TABLE IF NOT EXISTS live_viewer_lease ( 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), UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id),
KEY idx_live_lease_expire (stream_session_id, expires_at), KEY idx_live_lease_expire (stream_session_id, expires_at),
KEY idx_live_lease_active (stream_session_id, released_at, expires_at), KEY idx_live_lease_active (stream_session_id, released_at, expires_at),

30
sql/005_live_control_upgrade.sql

@ -12,49 +12,49 @@ BEGIN
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'requested_by'; WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'requested_by';
IF item_count = 0 THEN 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; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'stop_deadline'; WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'stop_deadline';
IF item_count = 0 THEN 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; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'updated_at'; WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'updated_at';
IF item_count = 0 THEN 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; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_state_version'; WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_state_version';
IF item_count = 0 THEN 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; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_event_id'; WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_event_id';
IF item_count = 0 THEN 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; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_updated_at'; WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_updated_at';
IF item_count = 0 THEN 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; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'cloud_confirmed_at'; WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'cloud_confirmed_at';
IF item_count = 0 THEN 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; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
@ -70,7 +70,7 @@ BEGIN
IF item_count = 0 THEN IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS ( 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 CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END
) STORED;
) STORED COMMENT '活动机巢标识(生成列)';
END IF; END IF;
SELECT COUNT(*) INTO item_count SELECT COUNT(*) INTO item_count
@ -95,13 +95,13 @@ BEGIN
END IF; END IF;
CREATE TABLE IF NOT EXISTS live_viewer_lease ( 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), UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id),
KEY idx_live_lease_expire (stream_session_id, expires_at), KEY idx_live_lease_expire (stream_session_id, expires_at),
KEY idx_live_lease_active (stream_session_id, released_at, expires_at), KEY idx_live_lease_active (stream_session_id, released_at, expires_at),

62
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` ( 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_access (access_token_id),
UNIQUE KEY uk_session_refresh (refresh_token_id), UNIQUE KEY uk_session_refresh (refresh_token_id),
KEY idx_session_user_active (user_id, revoked_at, expires_at) KEY idx_session_user_active (user_id, revoked_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `invoice_profile` ( 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) KEY idx_invoice_profile_user (user_id, is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `invoice_request` ( 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), UNIQUE KEY uk_invoice_order (order_id),
KEY idx_invoice_request_user (user_id, requested_at), KEY idx_invoice_request_user (user_id, requested_at),
KEY idx_invoice_request_status (status, requested_at) KEY idx_invoice_request_status (status, requested_at)

2
sql/010_task_workflow_timeout.sql

@ -1,2 +1,2 @@
ALTER TABLE task_execution ALTER TABLE task_execution
ADD COLUMN result_code VARCHAR(64) NULL;
ADD COLUMN result_code VARCHAR(64) NULL COMMENT '执行结果码';

28
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 ;

124
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 ;

57
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;

3
vo/account_vo.go

@ -44,7 +44,8 @@ type OrderPageReq struct {
// TrafficOrderCreateReq 创建流量充值订单 // TrafficOrderCreateReq 创建流量充值订单
type TrafficOrderCreateReq struct { 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 卡分页 // SimCardPageReq SIM 卡分页

54
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"`
}
Loading…
Cancel
Save