Browse Source

feat:功能增加

master
刘浩东 2 weeks ago
parent
commit
76ccd5e50a
  1. 39
      README.md
  2. 7
      common/config.go
  3. 2
      config-prod.yaml
  4. 2
      config.yaml
  5. 4
      handler/live_handler.go
  6. 21
      handler/platform_billing_handler.go
  7. 343
      mock/dock.go
  8. 35
      mock/main.go
  9. 19
      mock/mission.go
  10. 2
      model/billing.go
  11. 1
      route/route.go
  12. 78
      service/billing_service.go
  13. 58
      service/live_service.go
  14. 47
      service/payment_service.go
  15. 1
      sql/001_schema.sql
  16. 8
      vo/platform_billing_vo.go

39
README.md

@ -69,3 +69,42 @@ sudo tail -f /usr/local/laic-backend-prod/log/err.log
```bash ```bash
sudo supervisorctl restart laic-backend-prod sudo supervisorctl restart laic-backend-prod
``` ```
## Payment and SIM schema reconciliation
If a production database was upgraded from an older partial release, back it
up first and then run `sql/019_reconcile_payment_schema.sql`. The migration is
repeatable and fills missing `traffic_order.updated_at`, payment fields, pending
order guards, and normalizes legacy empty payment idempotency keys. It also
creates the payment/SIM tables. It does not create orders, charge users, or
create SIM cards. Verify the schema and platform resource pool after it finishes, then
restart the backend before retrying payment.
AGPay returns the WeChat QR code as binary `image/png`. The backend converts it
to a JSON-safe `providerPayload` data URI (`data:image/png;base64,...`); clients
should use that value directly as the image `src` and must not UTF-8 decode the
original response body.
## Test traffic grant
For QA environments, set the following configuration explicitly (it defaults
to disabled):
```yaml
testing:
allow-traffic-grant: true
```
An authenticated administrator can then credit a regular user without creating
a payment order:
```http
POST /v1/admin/traffic/test-grants
Content-Type: application/json
{"userId":8243938201600,"amountGb":10,"reason":"live test"}
```
The operation updates the user snapshot, appends a `test_grant` traffic ledger
entry, invalidates the Redis balance cache, and writes an operation log. Keep
the switch disabled in production after testing.

7
common/config.go

@ -20,6 +20,7 @@ type AppConfig struct {
Simboss Simboss `mapstructure:"simboss"` Simboss Simboss `mapstructure:"simboss"`
AGPay AGPay `mapstructure:"agpay"` AGPay AGPay `mapstructure:"agpay"`
Heartbeat Heartbeat `mapstructure:"heartbeat"` Heartbeat Heartbeat `mapstructure:"heartbeat"`
Testing Testing `mapstructure:"testing"`
} }
// AppConf 全局配置(LoadConfig 后可用) // AppConf 全局配置(LoadConfig 后可用)
@ -106,6 +107,12 @@ type Heartbeat struct {
ScanSeconds int `mapstructure:"scan-seconds"` ScanSeconds int `mapstructure:"scan-seconds"`
} }
// Testing contains explicitly opt-in helpers that are only appropriate for
// non-production environments.
type Testing struct {
AllowTrafficGrant bool `mapstructure:"allow-traffic-grant"`
}
// LoadConfig 使用 Viper 加载本地 config.yaml // LoadConfig 使用 Viper 加载本地 config.yaml
func LoadConfig(path string, conf *AppConfig) { func LoadConfig(path string, conf *AppConfig) {
viper.SetConfigFile(path) viper.SetConfigFile(path)

2
config-prod.yaml

@ -57,6 +57,8 @@ agpay:
heartbeat: heartbeat:
timeout-seconds: 30 timeout-seconds: 30
scan-seconds: 5 scan-seconds: 5
testing:
allow-traffic-grant: false
log: log:
level: info level: info
path: ./logs/ path: ./logs/

2
config.yaml

@ -58,6 +58,8 @@ agpay:
heartbeat: heartbeat:
timeout-seconds: 30 timeout-seconds: 30
scan-seconds: 5 scan-seconds: 5
testing:
allow-traffic-grant: false
log: log:
level: debug level: debug
path: ./logs/ path: ./logs/

4
handler/live_handler.go

@ -129,7 +129,9 @@ func StopLive(c *gin.Context) {
// GetLivePlayURL 获取直播播放地址 // GetLivePlayURL 获取直播播放地址
func GetLivePlayURL(c *gin.Context) { func GetLivePlayURL(c *gin.Context) {
dockID, sessionID := c.Param("dockId"), c.Query("streamSessionId") dockID, sessionID := c.Param("dockId"), c.Query("streamSessionId")
if dockID == "" || sessionID == "" {
// streamSessionId is optional for compatibility with older web clients. The
// service resolves it from the caller's active viewer lease when omitted.
if dockID == "" {
common.FailWithBusiError(c, common.ErrParam) common.FailWithBusiError(c, common.ErrParam)
return return
} }

21
handler/platform_billing_handler.go

@ -1,6 +1,8 @@
package handler package handler
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"laic-backend/common" "laic-backend/common"
@ -8,6 +10,25 @@ import (
"laic-backend/vo" "laic-backend/vo"
) )
// GrantTrafficForTest adds traffic to a regular user without a payment order.
func GrantTrafficForTest(c *gin.Context) {
if common.AppConf == nil || !common.AppConf.Testing.AllowTrafficGrant {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusForbidden, common.ErrForbidden)
return
}
var req vo.TrafficTestGrantReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
data, e := service.DefaultBillingService.GrantTrafficForTest(common.GetUserId(c), req.UserID, req.AmountGb, req.Reason)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, data)
}
func GetPlatformResourcePool(c *gin.Context) { func GetPlatformResourcePool(c *gin.Context) {
data, e := service.DefaultPlatformBillingService.GetPool() data, e := service.DefaultPlatformBillingService.GetPool()
if e != nil { if e != nil {

343
mock/dock.go

@ -1,9 +1,12 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"os"
"os/exec"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -51,6 +54,9 @@ type droneTelemetry struct {
// MockDock 模拟机巢:通过 MQTT 上云并直接上报机巢、无人机状态 // MockDock 模拟机巢:通过 MQTT 上云并直接上报机巢、无人机状态
type MockDock struct { type MockDock struct {
spec dockSpec spec dockSpec
clientIDPrefix string
videoFile string
bootID string
client paho.Client client paho.Client
@ -65,12 +71,32 @@ type MockDock struct {
uploadedRoute []missionWaypoint uploadedRoute []missionWaypoint
liveSessionID string liveSessionID string
liveStreaming bool liveStreaming bool
liveProvider string
liveProtocol string
liveMaxBitrateBps int64
liveError string
liveStopReason string
liveCancel context.CancelFunc
liveProcess *exec.Cmd
commandResults map[string]commandResult
commandPending map[string]chan struct{}
publishStop chan struct{}
publishDone chan struct{}
stopOnce sync.Once
videoVersion int64 videoVersion int64
} }
func newMockDock(spec dockSpec) *MockDock {
type commandResult struct {
accepted bool
resultCode string
}
func newMockDock(spec dockSpec, clientIDPrefix, videoFile string) *MockDock {
d := &MockDock{ d := &MockDock{
spec: spec, spec: spec,
clientIDPrefix: clientIDPrefix,
videoFile: videoFile,
bootID: fmt.Sprintf("boot-%s-%d", spec.DockID, time.Now().UnixNano()),
doorState: "closed", doorState: "closed",
tele: droneTelemetry{ tele: droneTelemetry{
flightMode: "STANDBY", flightMode: "STANDBY",
@ -96,25 +122,63 @@ func newMockDock(spec dockSpec) *MockDock {
func (d *MockDock) start(broker, username, password string) error { func (d *MockDock) start(broker, username, password string) error {
// 1. MQTT 连接后台 EMQX // 1. MQTT 连接后台 EMQX
offline, err := json.Marshal(d.wrap("", "", map[string]any{
"status": "offline",
"bootId": d.bootID,
"dockIdSource": "dmi_product_serial",
"softwareVersion": "1.3.0",
"protocolVersion": "1.0",
"timeSynced": true,
"mqttConnected": false,
"modbusConnected": false,
"mavlinkConnected": false,
"updating": false,
}))
if err != nil {
return err
}
opts := paho.NewClientOptions(). opts := paho.NewClientOptions().
AddBroker(broker). AddBroker(broker).
SetClientID("mock-dock-" + d.spec.DockID).
SetClientID(d.clientIDPrefix + d.spec.DockID).
SetUsername(username). SetUsername(username).
SetPassword(password). SetPassword(password).
SetAutoReconnect(true). SetAutoReconnect(true).
SetCleanSession(true) SetCleanSession(true)
opts.SetWill(d.topic("status/online"), string(offline), 1, true)
opts.SetOnConnectHandler(func(c paho.Client) { d.onMqttConnect(c) }) opts.SetOnConnectHandler(func(c paho.Client) { d.onMqttConnect(c) })
d.client = paho.NewClient(opts) d.client = paho.NewClient(opts)
if tok := d.client.Connect(); tok.Wait() && tok.Error() != nil { if tok := d.client.Connect(); tok.Wait() && tok.Error() != nil {
return fmt.Errorf("MQTT 连接失败: %w", tok.Error()) return fmt.Errorf("MQTT 连接失败: %w", tok.Error())
} }
d.publishStop = make(chan struct{})
d.publishDone = make(chan struct{})
go d.publishLoop() go d.publishLoop()
log.Printf("[%s] 机巢启动(MQTT 直接模拟无人机数据)", d.spec.DockID) log.Printf("[%s] 机巢启动(MQTT 直接模拟无人机数据)", d.spec.DockID)
return nil return nil
} }
// stop publishes an orderly offline status, stops any media publisher, and disconnects MQTT.
func (d *MockDock) stop() {
d.stopOnce.Do(func() {
if d.publishStop != nil {
close(d.publishStop)
}
})
d.publishStatus("offline")
d.stopLivePublisher()
if d.publishDone != nil {
select {
case <-d.publishDone:
case <-time.After(time.Second):
}
}
if d.client != nil && d.client.IsConnected() {
d.client.Disconnect(250)
}
}
func (d *MockDock) topic(sub string) string { func (d *MockDock) topic(sub string) string {
return fmt.Sprintf("dock-edge/v1/dock/%s/%s", d.spec.DockID, sub) return fmt.Sprintf("dock-edge/v1/dock/%s/%s", d.spec.DockID, sub)
} }
@ -146,7 +210,7 @@ func (d *MockDock) publishVideoState(payload any, version int64) {
} }
b, err := json.Marshal(map[string]any{ b, err := json.Marshal(map[string]any{
"requestId": nil, "requestId": nil,
"eventId": fmt.Sprintf("video-%s-%d", d.spec.DockID, version),
"eventId": fmt.Sprintf("video-%s-%s-%d", d.spec.DockID, d.bootID, version),
"version": version, "version": version,
"dockId": d.spec.DockID, "dockId": d.spec.DockID,
"droneSn": nil, "droneSn": nil,
@ -186,8 +250,11 @@ func (d *MockDock) publishLoop() {
tick5 := time.NewTicker(5 * time.Second) tick5 := time.NewTicker(5 * time.Second)
defer tick1.Stop() defer tick1.Stop()
defer tick5.Stop() defer tick5.Stop()
defer close(d.publishDone)
for { for {
select { select {
case <-d.publishStop:
return
case <-tick1.C: case <-tick1.C:
d.publishTelemetry() d.publishTelemetry()
case <-tick5.C: case <-tick5.C:
@ -215,6 +282,9 @@ func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) {
cmd.DroneSN = *env.DroneSN cmd.DroneSN = *env.DroneSN
} }
log.Printf("[%s] 收到指令 %s (commandId=%s)", d.spec.DockID, cmd.Type, cmd.CommandID) log.Printf("[%s] 收到指令 %s (commandId=%s)", d.spec.DockID, cmd.Type, cmd.CommandID)
if d.replayCommand(cmd) {
return
}
switch { switch {
case strings.HasPrefix(cmd.Type, "dock."): case strings.HasPrefix(cmd.Type, "dock."):
@ -226,7 +296,7 @@ func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) {
case strings.HasPrefix(cmd.Type, "video."): case strings.HasPrefix(cmd.Type, "video."):
d.handleVideoCommand(cmd) d.handleVideoCommand(cmd)
default: default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
d.ackCommand(cmd, false, "UNSUPPORTED_COMMAND")
} }
} }
@ -243,11 +313,11 @@ func (d *MockDock) handleDockCommand(cmd commandMsg) {
"dock.centering_loose", "dock.centering_tight", "dock.clear_alarm", "dock.emergency_stop": "dock.centering_loose", "dock.centering_tight", "dock.clear_alarm", "dock.emergency_stop":
// 无状态变化,仅确认 // 无状态变化,仅确认
default: default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
d.ackCommand(cmd, false, "UNSUPPORTED_COMMAND")
return return
} }
d.publishStateDock() d.publishStateDock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
} }
func (d *MockDock) handleDroneCommand(cmd commandMsg) { func (d *MockDock) handleDroneCommand(cmd commandMsg) {
@ -263,7 +333,7 @@ func (d *MockDock) handleDroneCommand(cmd commandMsg) {
case "drone.land", "drone.return": case "drone.land", "drone.return":
if d.cancelMission() { if d.cancelMission() {
// 任务协程收到取消后会自行返航落地 // 任务协程收到取消后会自行返航落地
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
return return
} }
d.snapHomeLanded() d.snapHomeLanded()
@ -278,14 +348,14 @@ func (d *MockDock) handleDroneCommand(cmd commandMsg) {
wps := append([]missionWaypoint(nil), d.uploadedRoute...) wps := append([]missionWaypoint(nil), d.uploadedRoute...)
d.mu.Unlock() d.mu.Unlock()
if len(wps) == 0 { if len(wps) == 0 {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "NO_ROUTE")
d.ackCommand(cmd, false, "NO_ROUTE")
return return
} }
if !d.beginMission(cmd.CommandID) { if !d.beginMission(cmd.CommandID) {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS")
d.ackCommand(cmd, false, "MISSION_IN_PROGRESS")
return return
} }
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
go d.flyUploadedMission(wps) go d.flyUploadedMission(wps)
return return
case "drone.mission_pause": case "drone.mission_pause":
@ -295,12 +365,12 @@ func (d *MockDock) handleDroneCommand(cmd commandMsg) {
case "drone.mission_cancel": case "drone.mission_cancel":
d.cancelMission() d.cancelMission()
default: default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
d.ackCommand(cmd, false, "UNSUPPORTED_COMMAND")
return return
} }
d.publishStateDrone() d.publishStateDrone()
d.publishStateDock() d.publishStateDock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
} }
func (d *MockDock) handleVideoCommand(cmd commandMsg) { func (d *MockDock) handleVideoCommand(cmd commandMsg) {
@ -308,35 +378,179 @@ func (d *MockDock) handleVideoCommand(cmd commandMsg) {
switch cmd.Type { switch cmd.Type {
case "video.start_stream": case "video.start_stream":
if streamSessionID == "" { if streamSessionID == "" {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_ID_REQUIRED")
d.ackCommand(cmd, false, "SESSION_ID_REQUIRED")
return return
} }
pushURL, _ := cmd.Params["pushUrl"].(string)
provider, _ := cmd.Params["provider"].(string)
maxBitrate := asInt64(cmd.Params["maxBitrateBps"])
if maxBitrate <= 0 {
maxBitrate = 1500000
}
d.mu.Lock() d.mu.Lock()
if d.liveStreaming && d.liveSessionID != streamSessionID { if d.liveStreaming && d.liveSessionID != streamSessionID {
d.mu.Unlock() d.mu.Unlock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "STREAM_IN_PROGRESS")
d.ackCommand(cmd, false, "STREAM_IN_PROGRESS")
return return
} }
d.liveSessionID = streamSessionID d.liveSessionID = streamSessionID
d.liveStreaming = true d.liveStreaming = true
d.liveProvider = provider
d.liveProtocol = pushProtocol(pushURL)
d.liveMaxBitrateBps = maxBitrate
d.liveStopReason = ""
d.liveError = ""
d.mu.Unlock()
if err := d.startLivePublisher(pushURL); err != nil {
d.mu.Lock()
d.liveStreaming = false
d.liveError = "PUBLISHER_START_FAILED"
d.mu.Unlock() d.mu.Unlock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, false, "PUBLISHER_START_FAILED")
d.publishStateVideo()
return
}
d.ackCommand(cmd, true, "OK")
d.publishStateVideo() d.publishStateVideo()
case "video.stop_stream": case "video.stop_stream":
d.mu.Lock() d.mu.Lock()
matches := streamSessionID != "" && streamSessionID == d.liveSessionID matches := streamSessionID != "" && streamSessionID == d.liveSessionID
if matches { if matches {
d.liveStreaming = false d.liveStreaming = false
d.liveStopReason, _ = cmd.Params["reason"].(string)
if d.liveStopReason == "" {
d.liveStopReason = "device_request"
}
} }
d.mu.Unlock() d.mu.Unlock()
if !matches { if !matches {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_NOT_FOUND")
d.ackCommand(cmd, false, "SESSION_NOT_FOUND")
return return
} }
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.stopLivePublisher()
d.ackCommand(cmd, true, "OK")
d.publishStateVideo() d.publishStateVideo()
default: default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
d.ackCommand(cmd, false, "UNSUPPORTED_COMMAND")
}
}
// startLivePublisher starts ffmpeg for real SRT/RTMP(S) push URLs. Fake URLs
// intentionally keep the control-plane simulation without requiring ffmpeg.
func (d *MockDock) startLivePublisher(pushURL string) error {
if strings.HasPrefix(strings.ToLower(pushURL), "fake://") || pushURL == "" {
return nil
}
if !isSupportedPushURL(pushURL) {
return fmt.Errorf("unsupported push URL scheme: %s", pushURL)
}
if d.videoFile == "" {
return fmt.Errorf("video file is not configured")
}
if _, err := os.Stat(d.videoFile); err != nil {
return fmt.Errorf("video file unavailable: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
ffmpeg := envOr("MOCK_FFMPEG_BIN", "ffmpeg")
d.mu.Lock()
maxBitrate := d.liveMaxBitrateBps
if d.liveProcess != nil {
d.mu.Unlock()
cancel()
return nil
}
d.mu.Unlock()
cmd := exec.CommandContext(ctx, ffmpeg, ffmpegArgs(d.videoFile, pushURL, maxBitrate)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
d.mu.Lock()
if d.liveProcess != nil {
d.mu.Unlock()
cancel()
return nil
}
d.liveCancel = cancel
d.liveProcess = cmd
d.mu.Unlock()
if err := cmd.Start(); err != nil {
cancel()
d.mu.Lock()
d.liveCancel = nil
d.liveProcess = nil
d.mu.Unlock()
return err
}
go d.waitLivePublisher(cmd)
return nil
}
func (d *MockDock) waitLivePublisher(cmd *exec.Cmd) {
err := cmd.Wait()
d.mu.Lock()
if d.liveProcess != cmd {
d.mu.Unlock()
return
}
d.liveProcess = nil
d.liveCancel = nil
wasStreaming := d.liveStreaming
d.liveStreaming = false
if err != nil {
d.liveError = "PUBLISHER_EXITED"
}
d.mu.Unlock()
if wasStreaming {
d.publishStateVideo()
}
}
func (d *MockDock) stopLivePublisher() {
d.mu.Lock()
cancel := d.liveCancel
d.liveCancel = nil
d.liveProcess = nil
d.mu.Unlock()
if cancel != nil {
cancel()
}
}
func isSupportedPushURL(pushURL string) bool {
url := strings.ToLower(pushURL)
return strings.HasPrefix(url, "srt://") || strings.HasPrefix(url, "rtmp://") || strings.HasPrefix(url, "rtmps://")
}
func ffmpegArgs(videoFile, pushURL string, maxBitrateBps int64) []string {
if maxBitrateBps <= 0 {
maxBitrateBps = 1500000
}
bitrate := fmt.Sprintf("%d", maxBitrateBps)
bufsize := fmt.Sprintf("%d", maxBitrateBps*2)
args := []string{"-hide_banner", "-loglevel", "warning", "-re", "-stream_loop", "-1", "-i", videoFile,
"-map", "0:v:0", "-map", "0:a:0?",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p",
"-b:v", bitrate, "-maxrate", bitrate, "-bufsize", bufsize, "-g", "60",
"-c:a", "aac", "-b:a", "128k",
}
if strings.HasPrefix(strings.ToLower(pushURL), "srt://") {
return append(args, "-f", "mpegts", pushURL)
}
return append(args, "-f", "flv", pushURL)
}
func pushProtocol(pushURL string) string {
url := strings.ToLower(pushURL)
switch {
case strings.HasPrefix(url, "srt://"):
return "srt"
case strings.HasPrefix(url, "rtmp://"):
return "rtmp"
case strings.HasPrefix(url, "rtmps://"):
return "rtmps"
case strings.HasPrefix(url, "fake://"):
return "fake"
default:
return ""
} }
} }
@ -344,25 +558,25 @@ func (d *MockDock) handleWorkflowCommand(cmd commandMsg) {
switch cmd.Type { switch cmd.Type {
case "workflow.cancel", "workflow.stop_task": case "workflow.cancel", "workflow.stop_task":
d.cancelMission() d.cancelMission()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
return return
case "workflow.one_key_return", "workflow.one_key_landing": case "workflow.one_key_return", "workflow.one_key_landing":
if !d.cancelMission() { if !d.cancelMission() {
go d.rtlAndLand() go d.rtlAndLand()
} }
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
return return
} }
if d.isRepeatCommand(cmd.CommandID) { if d.isRepeatCommand(cmd.CommandID) {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
return return
} }
if !d.beginMission(cmd.CommandID) { if !d.beginMission(cmd.CommandID) {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS")
d.ackCommand(cmd, false, "MISSION_IN_PROGRESS")
return return
} }
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.ackCommand(cmd, true, "OK")
switch cmd.Type { switch cmd.Type {
case "workflow.start_task": case "workflow.start_task":
@ -390,17 +604,67 @@ func (d *MockDock) ack(requestID, droneSN, commandID string, accepted bool, resu
}) })
} }
// replayCommand implements the commandId idempotency expected from an edge device.
// Retries carry a new requestId, so the original result is acknowledged with the
// current requestId without executing the command a second time. Concurrent
// duplicates wait for the first execution to publish its result.
func (d *MockDock) replayCommand(cmd commandMsg) bool {
if cmd.CommandID == "" {
return false
}
d.mu.Lock()
result, ok := d.commandResults[cmd.CommandID]
if !ok {
if pending, exists := d.commandPending[cmd.CommandID]; exists {
d.mu.Unlock()
<-pending
return d.replayCommand(cmd)
}
if d.commandPending == nil {
d.commandPending = make(map[string]chan struct{})
}
d.commandPending[cmd.CommandID] = make(chan struct{})
}
d.mu.Unlock()
if !ok {
return false
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, result.accepted, result.resultCode)
return true
}
func (d *MockDock) ackCommand(cmd commandMsg, accepted bool, resultCode string) {
if cmd.CommandID != "" {
d.mu.Lock()
if d.commandResults == nil {
d.commandResults = make(map[string]commandResult)
}
d.commandResults[cmd.CommandID] = commandResult{accepted: accepted, resultCode: resultCode}
if pending, ok := d.commandPending[cmd.CommandID]; ok {
delete(d.commandPending, cmd.CommandID)
close(pending)
}
d.mu.Unlock()
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, accepted, resultCode)
}
func (d *MockDock) publishStatusOnline() { func (d *MockDock) publishStatusOnline() {
d.publishStatus("online")
}
func (d *MockDock) publishStatus(status string) {
online := status == "online"
d.publish(d.topic("status/online"), 1, true, map[string]any{ d.publish(d.topic("status/online"), 1, true, map[string]any{
"status": "online",
"bootId": "boot-" + d.spec.DockID,
"status": status,
"bootId": d.bootID,
"dockIdSource": "dmi_product_serial", "dockIdSource": "dmi_product_serial",
"softwareVersion": "1.3.0", "softwareVersion": "1.3.0",
"protocolVersion": "1.0", "protocolVersion": "1.0",
"uptimeSec": 86400, "uptimeSec": 86400,
"timeSynced": true, "timeSynced": true,
"mqttConnected": true,
"modbusConnected": true,
"mqttConnected": online,
"modbusConnected": online,
"mavlinkConnected": false, "mavlinkConnected": false,
"updating": false, "updating": false,
"name": d.spec.Name, "name": d.spec.Name,
@ -542,6 +806,11 @@ func (d *MockDock) publishStateVideo() {
d.mu.Lock() d.mu.Lock()
streamSessionID := d.liveSessionID streamSessionID := d.liveSessionID
streaming := d.liveStreaming streaming := d.liveStreaming
provider := d.liveProvider
protocol := d.liveProtocol
maxBitrate := d.liveMaxBitrateBps
errorCode := d.liveError
stopReason := d.liveStopReason
d.videoVersion++ d.videoVersion++
version := d.videoVersion version := d.videoVersion
d.mu.Unlock() d.mu.Unlock()
@ -550,28 +819,36 @@ func (d *MockDock) publishStateVideo() {
if streaming { if streaming {
phase = "streaming" phase = "streaming"
} }
provider := "fake"
if provider == "" {
provider = "fake"
}
if protocol == "" {
protocol = "srt"
}
d.publishVideoState(map[string]any{ d.publishVideoState(map[string]any{
"provider": provider, "provider": provider,
"phase": phase, "phase": phase,
"inputOnline": streaming, "inputOnline": streaming,
"streaming": streaming, "streaming": streaming,
"inputCodec": "h264", "inputCodec": "h264",
"uplinkProtocol": "srt",
"uplinkProtocol": protocol,
"streamSessionId": nilIfEmpty(streamSessionID), "streamSessionId": nilIfEmpty(streamSessionID),
"width": nil, "width": nil,
"height": nil, "height": nil,
"frameRate": nil, "frameRate": nil,
"bitrateBps": liveBitrate(streaming),
"bitrateBps": liveBitrate(streaming, maxBitrate),
"retryCount": 0, "retryCount": 0,
"stopReason": nil,
"errorCode": nil,
"stopReason": nilIfEmpty(stopReason),
"errorCode": nilIfEmpty(errorCode),
"updatedAt": time.Now().UnixMilli(), "updatedAt": time.Now().UnixMilli(),
}, version) }, version)
} }
func liveBitrate(streaming bool) int {
func liveBitrate(streaming bool, maxBitrateBps int64) int64 {
if streaming { if streaming {
if maxBitrateBps > 0 {
return maxBitrateBps
}
return 1500000 return 1500000
} }
return 0 return 0

35
mock/main.go

@ -4,6 +4,7 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@ -26,9 +27,16 @@ type dockSpec struct {
} }
func main() { func main() {
broker := envOr("MOCK_MQTT_BROKER", "tcp://127.0.0.1:1883")
broker := envOr("MOCK_MQTT_BROKER", "tcp://roll.jiagutech.com:1883")
username := envOr("MOCK_MQTT_USERNAME", "laic") username := envOr("MOCK_MQTT_USERNAME", "laic")
password := envOr("MOCK_MQTT_PASSWORD", "") password := envOr("MOCK_MQTT_PASSWORD", "")
clientIDPrefix := envOr("MOCK_MQTT_CLIENT_ID_PREFIX", "mock-dock-")
videoFile := resolveVideoFile(envOr("MOCK_VIDEO_FILE", "mock/mock.mp4"))
if info, err := os.Stat(videoFile); err != nil {
log.Printf("视频素材不可用(仅影响真实推流和原始视频上传): %s: %v", videoFile, err)
} else {
log.Printf("视频素材: %s (%d bytes)", videoFile, info.Size())
}
specs := []dockSpec{ specs := []dockSpec{
{ {
@ -51,11 +59,13 @@ func main() {
}, },
} }
docks := make([]*MockDock, 0, len(specs))
for _, spec := range selectDockSpecs(specs, os.Getenv("MOCK_DOCKS")) { for _, spec := range selectDockSpecs(specs, os.Getenv("MOCK_DOCKS")) {
dock := newMockDock(spec)
dock := newMockDock(spec, clientIDPrefix, videoFile)
if err := dock.start(broker, username, password); err != nil { if err := dock.start(broker, username, password); err != nil {
log.Fatalf("启动机巢 %s 失败: %v", spec.DockID, err) log.Fatalf("启动机巢 %s 失败: %v", spec.DockID, err)
} }
docks = append(docks, dock)
} }
log.Println("模拟设备已启动(机巢 MQTT 直接上报机巢与无人机数据),按 Ctrl+C 退出") log.Println("模拟设备已启动(机巢 MQTT 直接上报机巢与无人机数据),按 Ctrl+C 退出")
@ -63,9 +73,30 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
log.Println("正在退出...") log.Println("正在退出...")
for _, dock := range docks {
dock.stop()
}
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
} }
// resolveVideoFile first checks the current working directory, then the executable directory.
// This keeps the default path usable both with `go run` and with a copied binary.
func resolveVideoFile(path string) string {
if filepath.IsAbs(path) {
return path
}
if _, err := os.Stat(path); err == nil {
return path
}
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), path)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return path
}
func selectDockSpecs(specs []dockSpec, selected string) []dockSpec { func selectDockSpecs(specs []dockSpec, selected string) []dockSpec {
if strings.TrimSpace(selected) == "" { if strings.TrimSpace(selected) == "" {
return specs return specs

19
mock/mission.go

@ -1,11 +1,11 @@
package main package main
import ( import (
"bytes"
"io" "io"
"log" "log"
"math" "math"
"net/http" "net/http"
"os"
"sort" "sort"
"strconv" "strconv"
"time" "time"
@ -382,12 +382,23 @@ func (d *MockDock) uploadOriginalVideo(cmd commandMsg, taskID string, video orig
return return
} }
body := []byte("mock original video fixture\n")
req, err := http.NewRequest(http.MethodPut, video.UploadURL, bytes.NewReader(body))
file, err := os.Open(d.videoFile)
if err != nil {
d.publishOriginalVideo(cmd, taskID, video, "failed", "VIDEO_FILE_NOT_FOUND", 0)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.Size() <= 0 {
d.publishOriginalVideo(cmd, taskID, video, "failed", "VIDEO_FILE_INVALID", 0)
return
}
req, err := http.NewRequest(http.MethodPut, video.UploadURL, file)
if err != nil { if err != nil {
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0) d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return return
} }
req.ContentLength = info.Size()
req.Header.Set("Content-Type", "video/mp4") req.Header.Set("Content-Type", "video/mp4")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil { if err != nil {
@ -400,7 +411,7 @@ func (d *MockDock) uploadOriginalVideo(cmd commandMsg, taskID string, video orig
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0) d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return return
} }
d.publishOriginalVideo(cmd, taskID, video, "completed", "", int64(len(body)))
d.publishOriginalVideo(cmd, taskID, video, "completed", "", info.Size())
} }
func (d *MockDock) publishOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload, eventType, errorCode string, fileSize int64) { func (d *MockDock) publishOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload, eventType, errorCode string, fileSize int64) {

2
model/billing.go

@ -20,7 +20,7 @@ type TrafficOrder struct {
CreditLedgerID int64 `gorm:"column:credit_ledger_id;type:BIGINT" json:"creditLedgerId"` CreditLedgerID int64 `gorm:"column:credit_ledger_id;type:BIGINT" json:"creditLedgerId"`
CreditAttemptCount int `gorm:"column:credit_attempt_count;type:INT;not null" json:"-"` CreditAttemptCount int `gorm:"column:credit_attempt_count;type:INT;not null" json:"-"`
NextCreditAt *time.Time `gorm:"column:next_credit_at" json:"-"` NextCreditAt *time.Time `gorm:"column:next_credit_at" json:"-"`
PaymentIdempotencyKey string `gorm:"column:payment_idempotency_key;type:VARCHAR(128);uniqueIndex:uk_traffic_order_payment_idem" json:"-"`
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"` PaidBy int64 `gorm:"column:paid_by;type:BIGINT" json:"paidBy"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"` PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"`

1
route/route.go

@ -189,6 +189,7 @@ func InitRouter(port int32) {
{ {
trafficAdmin.GET("/pool", handler.GetPlatformResourcePool) trafficAdmin.GET("/pool", handler.GetPlatformResourcePool)
trafficAdmin.POST("/purchases", handler.CreatePlatformPurchase) trafficAdmin.POST("/purchases", handler.CreatePlatformPurchase)
trafficAdmin.POST("/test-grants", handler.GrantTrafficForTest)
trafficAdmin.GET("/purchases", handler.GetPlatformPurchasePage) trafficAdmin.GET("/purchases", handler.GetPlatformPurchasePage)
trafficAdmin.GET("/packages", handler.GetTrafficPackagePage) trafficAdmin.GET("/packages", handler.GetTrafficPackagePage)
trafficAdmin.POST("/packages", handler.CreateTrafficPackage) trafficAdmin.POST("/packages", handler.CreateTrafficPackage)

78
service/billing_service.go

@ -24,6 +24,67 @@ const (
defaultTrafficUnitPrice = 10.0 // 云媒体流量单价(元/GB) defaultTrafficUnitPrice = 10.0 // 云媒体流量单价(元/GB)
) )
// GrantTrafficForTest credits a regular user's balance through the same
// snapshot and ledger fields used by paid traffic fulfillment. It is guarded
// by an explicit testing configuration switch and is intended for QA only.
func (b *BillingService) GrantTrafficForTest(operatorID, userID, amountGb int64, reason string) (*vo.TrafficBalanceVO, *common.BusiError) {
if common.AppConf == nil || !common.AppConf.Testing.AllowTrafficGrant {
return nil, common.ErrForbidden
}
if operatorID <= 0 || userID <= 0 || amountGb <= 0 || amountGb > int64(^uint64(0)>>1)/gbBytes {
return nil, common.ErrParam
}
amountBytes := amountGb * gbBytes
now := time.Now()
var after int64
err := common.DB.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
return err
}
if user.Role != common.RoleUser {
return common.NewBusiError(common.ParamError, "仅支持给普通用户添加测试流量")
}
if user.TrafficBalance > int64(^uint64(0)>>1)-amountBytes {
return common.NewBusiError(common.ParamError, "流量余额超出系统上限")
}
after = user.TrafficBalance + amountBytes
if err := tx.Model(&user).UpdateColumn("traffic_balance", after).Error; err != nil {
return err
}
ledgerID := mustID()
if ledgerID == 0 {
return errors.New("generate traffic grant ledger ID")
}
if reason == "" {
reason = "test grant"
}
return tx.Create(&model.TrafficLedger{
ID: ledgerID, AccountType: "user", AccountID: userID, Direction: "credit",
AmountBytes: amountBytes, BalanceBefore: user.TrafficBalance, BalanceAfter: after,
SourceType: "test_grant", SourceID: fmt.Sprintf("admin:%d", operatorID),
IdempotencyKey: fmt.Sprintf("test-grant:%d", ledgerID), OperatorID: operatorID,
Remark: reason, CreatedAt: now,
}).Error
})
if err != nil {
if busiErr, ok := err.(*common.BusiError); ok {
return nil, busiErr
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
logger.ERROR("测试流量入账事务失败", err)
return nil, common.ErrInternal
}
if err := common.Delete(cache.TrafficKeyOf(userID)); err != nil {
logger.WARN("删除测试流量余额缓存失败", err)
}
DefaultOperationLogService.RecordEvent(operatorID, "充值与履约", "测试流量入账", fmt.Sprintf("targetUserId=%d amountGb=%d", userID, amountGb), "success", "admin_test")
return &vo.TrafficBalanceVO{BalanceBytes: after, BalanceGb: float64(after) / float64(gbBytes)}, nil
}
// trafficDeductLua 原子扣减:key 不存在时用 MySQL 快照兜底初始化,再判断并扣减。 // trafficDeductLua 原子扣减:key 不存在时用 MySQL 快照兜底初始化,再判断并扣减。
// 返回 {status, before, after}:status=1 成功,0 余额不足。 // 返回 {status, before, after}:status=1 成功,0 余额不足。
const trafficDeductLua = ` const trafficDeductLua = `
@ -261,6 +322,7 @@ func (b *BillingService) CreateOrderWithPackage(userID, packageID int64, amountG
} }
var pending model.TrafficOrder var pending model.TrafficOrder
if err := tx.Where("user_id = ? AND pay_status IN ?", userID, []string{"unpaid", "processing"}).First(&pending).Error; err == nil { if err := tx.Where("user_id = ? AND pay_status IN ?", userID, []string{"unpaid", "processing"}).First(&pending).Error; err == nil {
logger.WARN("创建流量订单被待支付订单阻止", "userId", userID, "pendingOrderId", pending.ID, "payStatus", pending.PayStatus)
busiErr = common.ErrPendingOrderExists busiErr = common.ErrPendingOrderExists
return busiErr return busiErr
} else if !errors.Is(err, gorm.ErrRecordNotFound) { } else if !errors.Is(err, gorm.ErrRecordNotFound) {
@ -293,7 +355,7 @@ func (b *BillingService) CreateOrderWithPackage(userID, packageID int64, amountG
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound return nil, common.ErrUserNotFound
} }
if code, _ := common.ParseError(err); code == 1062 {
if code, constraint := common.ParseError(err); code == 1062 && constraint == "uk_traffic_order_pending_user" {
return nil, common.ErrPendingOrderExists return nil, common.ErrPendingOrderExists
} }
logger.ERROR("创建流量订单失败", err) logger.ERROR("创建流量订单失败", err)
@ -307,6 +369,12 @@ func (b *BillingService) CancelOrder(userID, orderID int64) *common.BusiError {
now := time.Now() now := time.Now()
var busiErr *common.BusiError var busiErr *common.BusiError
err := common.DB.Transaction(func(tx *gorm.DB) error { err := common.DB.Transaction(func(tx *gorm.DB) error {
// Serialize cancellation with CreateOrderWithPackage, which takes the
// same user-row lock before checking for pending orders.
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&user, userID).Error; err != nil {
return err
}
var order model.TrafficOrder var order model.TrafficOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
return err return err
@ -465,10 +533,12 @@ func (b *BillingService) chargeLiveSession(session *model.LiveSession, now time.
periodStart := *current.StartedAt periodStart := *current.StartedAt
var last model.LiveBillingSegment var last model.LiveBillingSegment
if err := tx.Where("session_id = ?", current.ID).Order("period_end DESC").First(&last).Error; err == nil {
// A missing prior segment is expected for the first billing tick. Use
// Find so GORM does not emit a misleading record-not-found error.
if result := tx.Where("session_id = ?", current.ID).Order("period_end DESC").Limit(1).Find(&last); result.Error != nil {
return result.Error
} else if result.RowsAffected == 1 {
periodStart = last.PeriodEnd periodStart = last.PeriodEnd
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
} }
if !now.After(periodStart) { if !now.After(periodStart) {
return nil return nil

58
service/live_service.go

@ -9,6 +9,7 @@ import (
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"laic-backend/cache" "laic-backend/cache"
"laic-backend/common" "laic-backend/common"
@ -70,8 +71,11 @@ func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.Li
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("dock_id = ?", dockID).First(&model.Dock{}).Error; err != nil { if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("dock_id = ?", dockID).First(&model.Dock{}).Error; err != nil {
return err return err
} }
if err := tx.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).Order("created_at DESC, id DESC").First(&session).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
// Find is intentional here: no active session is the normal first-join
// path, so it should not be logged by GORM as a record-not-found error.
if result := tx.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).
Order("created_at DESC, id DESC").Limit(1).Find(&session); result.Error != nil {
return result.Error
} }
if session.ID == "" { if session.ID == "" {
maxBitrate := req.MaxBitrateBps maxBitrate := req.MaxBitrateBps
@ -105,7 +109,14 @@ func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.Li
return err return err
} }
lease = model.LiveViewerLease{ID: leaseID, StreamSessionID: session.ID, ViewerID: userID, ExpiresAt: now.Add(time.Duration(leaseSeconds()) * time.Second), CreatedAt: now, UpdatedAt: now} lease = model.LiveViewerLease{ID: leaseID, StreamSessionID: session.ID, ViewerID: userID, ExpiresAt: now.Add(time.Duration(leaseSeconds()) * time.Second), CreatedAt: now, UpdatedAt: now}
return tx.Where("stream_session_id = ? AND viewer_id = ?", session.ID, userID).Assign(map[string]any{"expires_at": lease.ExpiresAt, "released_at": nil, "updated_at": now}).FirstOrCreate(&lease).Error
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "stream_session_id"}, {Name: "viewer_id"}},
DoUpdates: clause.Assignments(map[string]any{
"expires_at": lease.ExpiresAt,
"released_at": nil,
"updated_at": now,
}),
}).Create(&lease).Error
}) })
if err != nil { if err != nil {
logger.ERROR("创建直播观看租约失败", err) logger.ERROR("创建直播观看租约失败", err)
@ -387,7 +398,19 @@ func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID, sessionID s
return nil, busiErr return nil, busiErr
} }
var session model.LiveSession var session model.LiveSession
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ? AND phase = 'streaming'", sessionID, dockID).First(&session).Error; err != nil {
query := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ? AND phase = 'streaming'", dockID)
if sessionID != "" {
query = query.Where("id = ?", sessionID)
} else {
// Keep compatibility with older clients that did not send streamSessionId.
// The lease predicate prevents selecting another user's live session.
query = query.Where(`id IN (
SELECT stream_session_id
FROM live_viewer_lease
WHERE viewer_id = ? AND released_at IS NULL AND expires_at > ?
)`, userID, time.Now()).Order("created_at DESC, id DESC")
}
if err := query.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
} }
@ -457,6 +480,16 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI
if version <= session.DeviceStateVersion || (session.DeviceEventID != "" && session.DeviceEventID == eventID) || updatedAt < session.DeviceUpdatedAt { if version <= session.DeviceStateVersion || (session.DeviceEventID != "" && session.DeviceEventID == eventID) || updatedAt < session.DeviceUpdatedAt {
return false return false
} }
// eventId is expected to be globally unique. Reject a stale/reused device
// event that was already recorded for another live session before touching
// the unique device-event column.
var eventOwner model.LiveSession
if err := common.DB.Select("id").Where("device_event_id = ? AND id <> ?", eventID, streamSessionID).First(&eventOwner).Error; err == nil {
logger.WARN("忽略已被其他直播会话使用的视频状态事件", eventID, streamSessionID, eventOwner.ID)
return false
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return false
}
updates := map[string]any{ updates := map[string]any{
"device_state_version": version, "device_state_version": version,
"device_event_id": eventID, "device_event_id": eventID,
@ -472,7 +505,14 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI
online, queryErr := provider.QueryOnline(session.StreamName) 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
result := common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND device_state_version < ?", streamSessionID, dockID, version).Updates(updates)
if result.Error != nil {
if code, constraint := common.ParseError(result.Error); code == 1062 && constraint == "uk_live_session_device_event" {
return false
}
return false
}
return true
} }
updates["phase"] = "streaming" updates["phase"] = "streaming"
updates["started_at"] = now updates["started_at"] = now
@ -484,7 +524,13 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI
updates["error_code"] = "DEVICE_STREAM_OFFLINE" 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
if result.Error != nil {
if code, constraint := common.ParseError(result.Error); code == 1062 && constraint == "uk_live_session_device_event" {
return false
}
return false
}
return result.RowsAffected == 1
} }
func abs(value int64) int64 { func abs(value int64) int64 {

47
service/payment_service.go

@ -2,10 +2,14 @@ package service
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strconv" "strconv"
"strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@ -66,13 +70,17 @@ func (s *PaymentService) CreateWechatPayment(userID int64, req *vo.PaymentCreate
return nil, common.ErrPaymentAlreadyPaid return nil, common.ErrPaymentAlreadyPaid
} }
if !created { if !created {
if transaction.Status == "cancelled" || transaction.Status == "closed" || transaction.Status == "failed" || transaction.ProviderPayload == "" {
if transaction.Status == "cancelled" || transaction.Status == "closed" || transaction.Status == "failed" {
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "failed", "user_api") DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "failed", "user_api")
return nil, common.ErrPaymentOrderConflict return nil, common.ErrPaymentOrderConflict
} }
if isUsableProviderPayload(transaction.ProviderPayload) {
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "success", "user_api") DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "success", "user_api")
return paymentCreateVO(transaction, transaction.ProviderPayload), nil return paymentCreateVO(transaction, transaction.ProviderPayload), nil
} }
// Older versions stored raw PNG bytes as UTF-8. The stale payload is
// replaced below by requesting a fresh QR code for this transaction.
}
payload, err := s.agpay.CreateWechatQRCode(client.AGPayCreateRequest{ payload, err := s.agpay.CreateWechatQRCode(client.AGPayCreateRequest{
TotalFeeFen: transaction.TotalFeeFen, MerchantOrder: transaction.MerchantOrderNo, NotifyURL: s.notifyURL, TotalFeeFen: transaction.TotalFeeFen, MerchantOrder: transaction.MerchantOrderNo, NotifyURL: s.notifyURL,
@ -81,7 +89,11 @@ func (s *PaymentService) CreateWechatPayment(userID int64, req *vo.PaymentCreate
logger.WARN("创建 AGPay 微信支付请求失败,交易保留待确认", transaction.ID, err) logger.WARN("创建 AGPay 微信支付请求失败,交易保留待确认", transaction.ID, err)
return nil, common.ErrPaymentOrderConflict return nil, common.ErrPaymentOrderConflict
} }
payloadText := string(payload)
payloadText := encodeProviderPayload(payload)
if !isUsableProviderPayload(payloadText) {
logger.WARN("AGPay 返回了无法展示的支付二维码", transaction.ID)
return nil, common.ErrPaymentOrderConflict
}
if err := common.DB.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transaction.ID, "processing").Updates(map[string]any{ if err := common.DB.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transaction.ID, "processing").Updates(map[string]any{
"provider_payload": payloadText, "requested_at": time.Now(), "updated_at": time.Now(), "provider_payload": payloadText, "requested_at": time.Now(), "updated_at": time.Now(),
}).Error; err != nil { }).Error; err != nil {
@ -105,6 +117,37 @@ func (s *PaymentService) GetTransaction(userID, transactionID int64) (*vo.Paymen
return paymentTransactionVO(&transaction), nil return paymentTransactionVO(&transaction), nil
} }
// encodeProviderPayload converts AGPay's binary QR image into a JSON-safe data
// URI. JSON responses remain unchanged for compatibility with other gateways.
func encodeProviderPayload(payload []byte) string {
if len(payload) == 0 {
return ""
}
if json.Valid(payload) {
return string(payload)
}
contentType := http.DetectContentType(payload)
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(payload)
}
func isUsableProviderPayload(payload string) bool {
if payload == "" || strings.ContainsRune(payload, '\ufffd') {
return false
}
if json.Valid([]byte(payload)) {
return true
}
if !strings.HasPrefix(payload, "data:image/") {
return false
}
comma := strings.IndexByte(payload, ',')
if comma < 0 || !strings.HasSuffix(payload[:comma], ";base64") {
return false
}
decoded, err := base64.StdEncoding.DecodeString(payload[comma+1:])
return err == nil && len(decoded) > 0 && strings.HasPrefix(http.DetectContentType(decoded), "image/")
}
func (s *PaymentService) markPaymentRequestFailed(transactionID int64, businessType string, businessOrderID int64) { func (s *PaymentService) markPaymentRequestFailed(transactionID int64, businessType string, businessOrderID int64) {
if err := common.DB.Transaction(func(tx *gorm.DB) error { if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transactionID, "processing").Updates(map[string]any{"status": "failed", "updated_at": time.Now()}).Error; err != nil { if err := tx.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transactionID, "processing").Updates(map[string]any{"status": "failed", "updated_at": time.Now()}).Error; err != nil {

1
sql/001_schema.sql

@ -309,6 +309,7 @@ CREATE TABLE IF NOT EXISTS traffic_order (
paid_by BIGINT COMMENT '确认支付的操作人 ID', paid_by BIGINT COMMENT '确认支付的操作人 ID',
created_at DATETIME COMMENT '创建时间', created_at DATETIME COMMENT '创建时间',
paid_at DATETIME COMMENT '支付时间', paid_at DATETIME COMMENT '支付时间',
updated_at DATETIME COMMENT '更新时间',
UNIQUE KEY uk_traffic_order_payment_idem (payment_idempotency_key) UNIQUE KEY uk_traffic_order_payment_idem (payment_idempotency_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

8
vo/platform_billing_vo.go

@ -16,6 +16,14 @@ type PlatformPurchaseCreateReq struct {
Remark string `json:"remark" validate:"max=256"` Remark string `json:"remark" validate:"max=256"`
} }
// TrafficTestGrantReq is an admin-only test helper for crediting a regular
// user's cloud-media balance without creating a payment order.
type TrafficTestGrantReq struct {
UserID int64 `json:"userId" validate:"required,gt=0"`
AmountGb int64 `json:"amountGb" validate:"required,gt=0"`
Reason string `json:"reason" validate:"max=256"`
}
type PlatformPurchasePageReq struct { type PlatformPurchasePageReq struct {
common.Pagination common.Pagination
Status string `json:"status" form:"status"` Status string `json:"status" form:"status"`

Loading…
Cancel
Save