Browse Source

feat: complete auth, account, media, and OBS workflows

Integrate account sessions and invoices, finish authentication verification, remove Casbin runtime dependencies, and switch video storage configuration to Huawei OBS with storage-backed upload completion checks.
master
刘浩东 4 weeks ago
parent
commit
ee70de986e
  1. 15
      DESIGN.md
  2. 10
      cache/cache_key.go
  3. 14
      casbin.model
  4. 2
      common/busi_error.go
  5. 54
      common/casbin.go
  6. 9
      common/config.go
  7. 12
      config.yaml
  8. 31
      go.mod
  9. 253
      go.sum
  10. 163
      handler/account_handler.go
  11. 139
      handler/account_security_invoice_handler.go
  12. 48
      handler/auth_handler.go
  13. 5
      handler/video_handler.go
  14. 4
      main.go
  15. 34
      middleware/auth.go
  16. 2
      model/billing.go
  17. 2
      model/dock.go
  18. 35
      model/invoice.go
  19. 1
      model/user.go
  20. 19
      model/user_session.go
  21. 16
      route/route.go
  22. 68
      service/account_resource_service.go
  23. 25
      service/account_service.go
  24. 92
      service/billing_service.go
  25. 120
      service/captcha_service.go
  26. 90
      service/casbin_bootstrap.go
  27. 11
      service/dock_service.go
  28. 9
      service/drone_service.go
  29. 19
      service/heartbeat.go
  30. 130
      service/invoice_service.go
  31. 2
      service/operation_log_service.go
  32. 6
      service/system_service.go
  33. 282
      service/user_service.go
  34. 18
      service/video_service.go
  35. 5
      sql/001_schema.sql
  36. 5
      sql/003_seed.sql
  37. 26
      sql/006_remove_device_status.sql
  38. 2
      sql/007_remove_casbin_rule.sql
  39. 9
      sql/008_billing_indexes.sql
  40. 49
      sql/009_account_security_invoice.sql
  41. 16
      token/token.go
  42. 52
      vo/account_extra_vo.go
  43. 18
      vo/account_vo.go
  44. 12
      vo/auth_vo.go
  45. 1
      vo/dock_vo.go
  46. 1
      vo/drone_vo.go

15
DESIGN.md

@ -14,7 +14,7 @@
- **Controller → Service → Model/VO** 分层,Service 直操 `common.DB`,无独立 Repository 层
- **包级单例**:`common.DB`、`common.RedisPool`、`service.DefaultXxxService`
- **本地 YAML 配置**:一个 `config.yaml`,Viper 加载
- **JWT + Casbin RBAC**:`gorm-adapter` 持久化,Redis 缓存权限。admin 全量数据,user`user_id` 过滤
- **JWT + 路由管理员鉴权**:JWT 负责身份认证,`user.role` 和 `AdminMiddleware` 限制平台管理接口;业务数据由 service`user_id` 过滤
- **统一响应** `{code, msg, data}` + 中文验证错误翻译
- **Redigo Redis Pool**(非 go-redis)
@ -30,10 +30,10 @@
| 时序数据库 | **TDengine 3.x** | 遥测/轨迹等时序数据,对齐 pilot-data 的用法 |
| 缓存 | **Redis 7+(Redigo Pool)** | 设备实时状态、Session、权限、流量余额 |
| MQTT Broker | **EMQX** | 设备通信,由甲方/边缘侧部署,后台只作为客户端连接 |
| 对象存储 | **MinIO / 阿里云 OSS** | S3 兼容 API,视频和升级包存储 |
| 对象存储 | **华为云 OBS** | S3 兼容 API,视频和升级包存储;通过 path-style SigV4 预签名直传 |
| 直播 | **阿里云直播 + SRT 推流** | 后台生成签名推流地址,通过 MQTT 下发给工控机 |
| 配置 | **Viper + config.yaml** | 本地配置文件 |
| 认证 | **JWT HS256 + Casbin** | 无状态认证 + RBAC,7 天 access / 30 天 refresh |
| 认证 | **JWT HS256 + 路由管理员鉴权** | 身份认证 + `user.role` 管理员接口限制,7 天 access / 30 天 refresh |
| 日志 | **Zap + Lumberjack** | 结构化 + 日志轮转,对齐 pilot-train-server |
| 验证 | **go-playground/validator** | 中文翻译(`zh`) |
| ID 生成 | **Snowflake** | `tool/snowflake.go` |
@ -48,7 +48,6 @@
laic-backend/
├── main.go # 入口,初始化 → MQTT + WS + HTTP
├── go.mod / go.sum
├── casbin.model
├── config.yaml # 本地配置文件
├── Makefile
@ -58,14 +57,13 @@ laic-backend/
│ ├── tdengine.go # TDengine 连接池
│ ├── redis.go # Redigo Pool、Lua 原子操作
│ ├── config.go # Viper 加载 config.yaml
│ ├── casbin.go # Casbin 初始化 + Redis 缓存适配器
│ ├── busi_error.go # BusiError + 错误码常量
│ ├── auth_util.go # admin 全量 / user 按 user_id 过滤
│ ├── pageutil.go # 分页
│ └── validator.go # 自定义验证器(phone, password 等)
├── middleware/ # HTTP 中间件
│ ├── auth.go # JWT 校验 + Casbin 鉴权
│ ├── auth.go # JWT 校验 + 管理员角色鉴权
│ ├── cros.go # CORS
│ ├── trace.go # X-Trace-Id
│ ├── default_log.go # 访问日志
@ -259,7 +257,7 @@ CREATE TABLE user (
- 用户自行注册,默认角色 `user`
- 管理员在系统初始化时手动指定,不需要组织/租户概念
- Casbin 策略:`admin` 全量数据,`user` 按 `user_id` 过滤自己的机巢
- 业务数据按 `user_id` 过滤,管理员通过路由 `AdminMiddleware` 访问平台管理接口
### 5.2 机巢 & 无人机
@ -959,7 +957,6 @@ func main() {
common.InitConnection(&conf.Mysql) // GORM + MySQL
common.InitTDengine(&conf.TDengine) // TDengine 连接池
common.InitRedis(conf.Redis) // Redis Pool
common.InitCasbinEnforcer() // Casbin RBAC
// 4. 预热缓存
go cache.WarmUp()
@ -1013,7 +1010,7 @@ log:
| 数据库 | MySQL | MySQL(相同) |
| 时序数据 | TDengine | TDengine(相同) |
| 设备协议 | MAVLink over TCP | **MQTT**(EMQX broker) |
| 对象存储 | 华为云 OBS | **MinIO / 阿里云 OSS**(S3 兼容) |
| 对象存储 | 华为云 OBS | **华为云 OBS**(S3 兼容,path-style SigV4 预签名) |
| 直播 | 无 | **阿里云直播 SRT 推流** |
| 计费 | 无 | **流量余额 + 订单 + 消费明细** |
| 告警 | 无 | **alarmCodes diff 引擎 + 告警生命周期** |

10
cache/cache_key.go

@ -6,6 +6,8 @@ const (
UserTokenKey = "laic:token:%d:%s" // 用户 access token 存证(userId, shortId)
RefreshTokenKey = "laic:refresh:%d:%s" // 用户 refresh token 存证(userId, shortId)
SmsCodeKey = "laic:sms:%s" // 短信验证码(phone)
SmsRateKey = "laic:sms-rate:%s" // 短信发送频率限制
CaptchaKey = "laic:captcha:%s" // 图片验证码(captcha id)
DockStatusKey = "device:dock:%s:status" // 机巢实时状态 Hash(dockId)
DockAlarmKey = "device:dock:%s:alarm_codes" // 机巢告警码 Set(dockId)
@ -37,6 +39,14 @@ func SmsCodeKeyOf(phone string) string {
return fmt.Sprintf(SmsCodeKey, phone)
}
func SmsRateKeyOf(phone string) string {
return fmt.Sprintf(SmsRateKey, phone)
}
func CaptchaKeyOf(id string) string {
return fmt.Sprintf(CaptchaKey, id)
}
func DockStatusKeyOf(dockID string) string {
return fmt.Sprintf(DockStatusKey, dockID)
}

14
casbin.model

@ -1,14 +0,0 @@
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")

2
common/busi_error.go

@ -67,6 +67,7 @@ const (
OrderPaid = 58002
SimCardNotFound = 58003
OrderNotFound = 58004
CarrierUnavailable = 58005
)
// 预定义的业务错误
@ -117,6 +118,7 @@ var (
ErrOrderPaid = &BusiError{Code: OrderPaid, Msg: "该订单已支付"}
ErrSimCardNotFound = &BusiError{Code: SimCardNotFound, Msg: "SIM卡不存在"}
ErrOrderNotFound = &BusiError{Code: OrderNotFound, Msg: "订单不存在"}
ErrCarrierUnavailable = &BusiError{Code: CarrierUnavailable, Msg: "运营商通道未接入"}
)
// NewBusiError 创建自定义业务错误

54
common/casbin.go

@ -1,54 +0,0 @@
package common
import (
"fmt"
"strconv"
"laic-backend/logger"
"github.com/casbin/casbin/v3"
gormadapter "github.com/casbin/gorm-adapter/v3"
)
var CasbinEnforcer *casbin.Enforcer
// SetUserRole 同步用户在 Casbin 中的角色映射(g 策略),role 为空则清空
func SetUserRole(userID int64, role string) error {
if CasbinEnforcer == nil {
return nil
}
uid := strconv.FormatInt(userID, 10)
if _, err := CasbinEnforcer.DeleteRolesForUser(uid); err != nil {
return err
}
if role == "" {
return nil
}
_, err := CasbinEnforcer.AddRoleForUser(uid, role)
return err
}
func InitCasbinEnforcer() {
e, err := mysqlCasbin()
if err != nil {
logger.ERROR("初始化Casbin失败", err)
panic(fmt.Sprintf("初始化Casbin失败: %v", err))
}
CasbinEnforcer = e
logger.INFO("初始化Casbin完成")
}
func mysqlCasbin() (*casbin.Enforcer, error) {
a, err := gormadapter.NewAdapterByDB(DB)
if err != nil {
return nil, err
}
e, err := casbin.NewEnforcer("casbin.model", a)
if err != nil {
return nil, err
}
if err = e.LoadPolicy(); err != nil {
return nil, err
}
return e, nil
}

9
common/config.go

@ -1,6 +1,8 @@
package common
import (
"strings"
"github.com/spf13/viper"
)
@ -49,9 +51,9 @@ type JWT struct {
RefreshExpireH int `mapstructure:"refresh-expire-hours"`
}
// OSS 对象存储(S3 兼容,MinIO / 阿里云 OSS
// OSS 对象存储(S3 兼容,华为云 OBS / MinIO
type OSS struct {
Endpoint string `mapstructure:"endpoint"` // http://127.0.0.1:9000 或 https://oss-cn-hangzhou.aliyuncs.com
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access-key"`
SecretKey string `mapstructure:"secret-key"`
Bucket string `mapstructure:"bucket"`
@ -89,6 +91,9 @@ type Heartbeat struct {
func LoadConfig(path string, conf *AppConfig) {
viper.SetConfigFile(path)
viper.SetConfigType("yaml")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
viper.SetEnvPrefix("LAIC")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
panic("加载配置文件失败: " + err.Error())
}

12
config.yaml

@ -17,12 +17,12 @@ jwt:
access-expire-hours: 168 # 7 天
refresh-expire-hours: 720 # 30 天
oss:
endpoint: http://127.0.0.1:9000
access-key: minioadmin
secret-key: minioadmin
bucket: laic-media
region: us-east-1
use-ssl: false
endpoint: https://obs.cn-east-2.myhuaweicloud.com
access-key: ""
secret-key: ""
bucket: gcs-edu
region: cn-east-2
use-ssl: true
live:
mode: fake
provider: fake

31
go.mod

@ -3,9 +3,6 @@ module laic-backend
go 1.26.4
require (
github.com/bluenviron/gomavlib/v3 v3.3.5
github.com/casbin/casbin/v3 v3.8.1
github.com/casbin/gorm-adapter/v3 v3.41.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/locales v0.14.1
@ -27,46 +24,29 @@ require (
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microsoft/go-mssqldb v1.9.5 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@ -74,21 +54,12 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.bug.st/serial v1.7.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlserver v1.6.3 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)

253
go.sum

@ -1,57 +1,16 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/bluenviron/gomavlib/v3 v3.3.5 h1:4e0rxKqPH1ar1cJFkft0mjsdBDnVJKWUhDQQuuqhvyk=
github.com/bluenviron/gomavlib/v3 v3.3.5/go.mod h1:tm3CnJMWdWqDGtHwUpmM2B8VY7TF67hCrCNBrwjYJpc=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo=
github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@ -64,10 +23,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -84,49 +39,19 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@ -135,66 +60,35 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.6 h1:k1mQU06bmmX143qSWgXFqSH1KUJceQvIUuVH/K5ELWw=
github.com/pion/transport/v3 v3.0.6/go.mod h1:HvJr2N/JwNJAfipsRleqwFoR3t/pWyHeZUs89v3+t5s=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@ -210,13 +104,10 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@ -228,10 +119,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
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.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@ -240,118 +127,17 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.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/sync v0.0.0-20190423024810-112230192c58/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.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -359,51 +145,12 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

163
handler/account_handler.go

@ -2,154 +2,132 @@ package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetProfile 查询个人资料
func GetProfile(c *gin.Context) {
user, busiErr := service.DefaultAccountService.GetProfile(common.GetUserId(c))
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.GetProfile(common.GetUserId(c))
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, user)
common.OKWithData(c, data)
}
// UpdateProfile 更新个人资料
func UpdateProfile(c *gin.Context) {
var req vo.ProfileUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
user, busiErr := service.DefaultAccountService.UpdateProfile(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.UpdateProfile(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, user)
common.OKWithData(c, data)
}
// GetTrafficBalance 查询流量余额
func GetTrafficBalance(c *gin.Context) {
bal, busiErr := service.DefaultAccountService.GetTrafficBalance(common.GetUserId(c))
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.GetTrafficBalance(common.GetUserId(c))
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, bal)
common.OKWithData(c, data)
}
func GetResourceSummary(c *gin.Context) {
data, e := service.DefaultAccountService.GetResourceSummary(common.GetUserId(c))
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, data)
}
// GetUsagePage 流量消费流水分页
func GetUsagePage(c *gin.Context) {
var req vo.UsagePageReq
bindPage(c, &req.Pagination)
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
data, e := service.DefaultAccountService.GetUsagePage(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
if req.PageSize <= 0 {
req.PageSize = 10
common.OKWithData(c, data)
}
page, busiErr := service.DefaultAccountService.GetUsagePage(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
func GetDownloadPage(c *gin.Context) {
var req vo.UsagePageReq
bindPage(c, &req.Pagination)
data, e := service.DefaultAccountService.GetDownloadPage(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, page)
common.OKWithData(c, data)
}
// GetOrderPage 流量订单分页
func GetOrderPage(c *gin.Context) {
var req vo.OrderPageReq
bindPage(c, &req.Pagination)
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAccountService.GetOrderPage(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.GetOrderPage(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, page)
common.OKWithData(c, data)
}
// CreateTrafficOrder 创建流量充值订单
func CreateTrafficOrder(c *gin.Context) {
var req vo.TrafficOrderCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
order, busiErr := service.DefaultAccountService.CreateTrafficOrder(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.CreateTrafficOrder(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, order)
common.OKWithData(c, data)
}
// PayTrafficOrder 订单支付入账(admin)
func PayTrafficOrder(c *gin.Context) {
orderID, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
order, busiErr := service.DefaultAccountService.PayTrafficOrder(orderID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.PayTrafficOrder(id)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, order)
common.OKWithData(c, data)
}
// ListSimCards SIM 卡列表
func ListSimCards(c *gin.Context) {
var req vo.SimCardPageReq
bindPage(c, &req.Pagination)
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAccountService.ListSimCards(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.ListSimCards(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, page)
common.OKWithData(c, data)
}
// GetSimRechargeLogPage SIM 卡充值记录分页
func GetSimRechargeLogPage(c *gin.Context) {
var req vo.SimRechargeLogPageReq
bindPage(c, &req.Pagination)
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAccountService.GetSimRechargeLogPage(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.GetSimRechargeLogPage(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, page)
common.OKWithData(c, data)
}
// RechargeSimCard SIM 卡充值
func RechargeSimCard(c *gin.Context) {
simCardID, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
var req vo.SimRechargeReq
@ -157,10 +135,19 @@ func RechargeSimCard(c *gin.Context) {
common.FailWithBindError(c, common.ErrParam, err)
return
}
log, busiErr := service.DefaultAccountService.RechargeSimCard(common.GetUserId(c), simCardID, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
data, e := service.DefaultAccountService.RechargeSimCard(common.GetUserId(c), id, &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, log)
common.OKWithData(c, data)
}
func bindPage(c *gin.Context, p *common.Pagination) {
_ = c.ShouldBindQuery(p)
if p.PageNum <= 0 {
p.PageNum = 1
}
if p.PageSize <= 0 {
p.PageSize = 10
}
}

139
handler/account_security_invoice_handler.go

@ -0,0 +1,139 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
"strconv"
)
func ChangePassword(c *gin.Context) {
var req vo.ChangePasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if e := service.DefaultUserService.ChangePassword(common.GetUserId(c), &req); e != nil {
common.FailWithBusiError(c, e)
return
}
common.OK(c)
}
func ListSessions(c *gin.Context) {
data, e := service.DefaultUserService.ListSessions(common.GetUserId(c), c.GetInt64("session_id"))
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, data)
}
func RevokeSession(c *gin.Context) {
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
if be := service.DefaultUserService.RevokeSession(common.GetUserId(c), id); be != nil {
common.FailWithBusiError(c, be)
return
}
common.OK(c)
}
func RevokeOtherSessions(c *gin.Context) {
if e := service.DefaultUserService.RevokeOtherSessions(common.GetUserId(c), c.GetInt64("session_id")); e != nil {
common.FailWithBusiError(c, e)
return
}
common.OK(c)
}
func ListInvoiceProfiles(c *gin.Context) {
d, e := service.DefaultInvoiceService.ListProfiles(common.GetUserId(c))
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, d)
}
func SaveInvoiceProfile(c *gin.Context) {
var req vo.InvoiceProfileReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
id := int64(0)
if raw := c.Param("id"); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
common.FailWithBusiError(c, common.ErrParam)
return
}
id = parsed
}
d, e := service.DefaultInvoiceService.SaveProfile(common.GetUserId(c), id, &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, d)
}
func DeleteInvoiceProfile(c *gin.Context) {
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
if e = service.DefaultInvoiceService.DeleteProfile(common.GetUserId(c), id); e != nil {
common.FailWithBusiError(c, e)
return
}
common.OK(c)
}
func ListInvoiceOrders(c *gin.Context) {
d, e := service.DefaultInvoiceService.ListEligibleOrders(common.GetUserId(c))
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, d)
}
func CreateInvoiceRequest(c *gin.Context) {
var req vo.InvoiceRequestCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
d, e := service.DefaultInvoiceService.CreateRequest(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, d)
}
func ListInvoiceRequests(c *gin.Context) {
d, e := service.DefaultInvoiceService.ListRequests(common.GetUserId(c))
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, d)
}
func ReviewInvoiceRequest(c *gin.Context) {
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
var req vo.InvoiceReviewReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
d, e := service.DefaultInvoiceService.ReviewRequest(id, &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, d)
}

48
handler/auth_handler.go

@ -10,6 +10,31 @@ import (
"laic-backend/vo"
)
func Captcha(c *gin.Context) {
id, image, err := service.NewCaptcha()
if err != nil {
common.FailWithBusiErrorByServer(c, common.ErrInternal)
return
}
c.SetCookie(service.CaptchaCookieName(), id, int(service.CaptchaTTL().Seconds()), "/", "", false, true)
c.Header("Cache-Control", "no-store")
c.Data(200, "image/png", image)
}
// SmsCode 发送短信验证码
func SmsCode(c *gin.Context) {
var req vo.SmsCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if _, busiErr := service.DefaultUserService.SendSmsCode(req.Phone, req.Scene); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// Register 用户注册
func Register(c *gin.Context) {
var req vo.RegisterReq
@ -17,7 +42,7 @@ func Register(c *gin.Context) {
common.FailWithBindError(c, common.ErrParam, err)
return
}
resp, busiErr := service.DefaultUserService.Register(req.Name, req.Phone, req.Email, req.Password)
resp, busiErr := service.DefaultUserService.Register(req.Name, req.Phone, req.Email, req.Password, req.SmsCode, c.GetHeader("User-Agent"), c.ClientIP())
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
@ -32,7 +57,8 @@ func Login(c *gin.Context) {
common.FailWithBindError(c, common.ErrParam, err)
return
}
resp, busiErr := service.DefaultUserService.Login(req.Phone, req.Password)
captchaID, _ := c.Cookie(service.CaptchaCookieName())
resp, busiErr := service.DefaultUserService.Login(req.Phone, req.Password, req.Captcha, captchaID, req.SmsCode, c.GetHeader("User-Agent"), c.ClientIP())
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
@ -47,7 +73,7 @@ func Refresh(c *gin.Context) {
common.FailWithBindError(c, common.ErrParam, err)
return
}
resp, busiErr := service.DefaultUserService.Refresh(req.RefreshToken)
resp, busiErr := service.DefaultUserService.Refresh(req.RefreshToken, c.GetHeader("User-Agent"), c.ClientIP())
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
@ -62,21 +88,7 @@ func Logout(c *gin.Context) {
accessToken := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
userID := common.GetUserId(c)
if busiErr := service.DefaultUserService.Logout(userID, accessToken, req.RefreshToken); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// SmsCode 发送短信验证码
func SmsCode(c *gin.Context) {
var req vo.SmsCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if _, busiErr := service.DefaultUserService.SendSmsCode(req.Phone); busiErr != nil {
if busiErr := service.DefaultUserService.Logout(userID, c.GetInt64("session_id"), accessToken, req.RefreshToken); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}

5
handler/video_handler.go

@ -31,7 +31,10 @@ func CompleteVideoUpload(c *gin.Context) {
return
}
var req vo.VideoCompleteReq
_ = c.ShouldBindJSON(&req)
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
video, busiErr := service.DefaultVideoService.Complete(common.GetUserId(c), videoID, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)

4
main.go

@ -32,11 +32,9 @@ func main() {
common.InitConnection(&conf.Mysql)
common.InitTDengine(&conf.TDengine)
common.InitRedis(conf.Redis)
common.InitCasbinEnforcer()
// 4. 预热缓存 + 引导 Casbin 策略 + 启动遥测批量写入
// 4. 预热缓存 + 启动遥测批量写入
cache.WarmUp()
service.BootstrapCasbin()
service.BootstrapOperationLog()
service.DefaultTelemetryStore.Start()

34
middleware/auth.go

@ -3,71 +3,53 @@ package middleware
import (
"net/http"
"slices"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/service"
"laic-backend/token"
)
// AuthMiddleware 鉴权中间件:解析 JWT + Redis 存证校验 + 注入上下文
// AuthMiddleware 鉴权中间件:解析 JWT + Redis 存证校验 + 会话校验。
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString := c.GetHeader("Authorization")
tokenString := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if tokenString == "" {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrUnauthorized)
return
}
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
if tokenString == "" {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrUnauthorized)
return
}
claims, err := token.ParseToken(tokenString)
if err != nil {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrToken)
return
}
// Redis 存证校验:登出会删除该 key,从而让已签发 token 失效
storeKey := cache.UserTokenKeyOf(claims.UserID, token.GenerateShortID(tokenString))
if !common.HasKey(storeKey) {
if !common.HasKey(storeKey) || !service.DefaultUserService.SessionValid(claims.UserID, claims.SessionID, tokenString) {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.SignOutToken)
return
}
roles := claims.Roles
if roles == nil {
roles = []string{}
}
isAdmin := slices.Contains(roles, common.RoleAdmin)
c.Set("user_id", claims.UserID)
c.Set("session_id", claims.SessionID)
c.Set("username", claims.Username)
c.Set("roles", roles)
c.Set("is_admin", isAdmin)
// Casbin 权限校验(admin 与普通用户均按 p/g 策略校验,admin 仅限平台管理职能)
if common.CasbinEnforcer != nil {
ok, err := common.CasbinEnforcer.Enforce(strconv.FormatInt(claims.UserID, 10), c.Request.URL.Path, c.Request.Method)
if err != nil || !ok {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusForbidden, common.ErrForbidden)
c.Abort()
return
}
}
c.Set("is_admin", slices.Contains(roles, common.RoleAdmin))
service.DefaultUserService.TouchSession(claims.UserID, claims.SessionID)
c.Next()
}
}
// AdminMiddleware 仅管理员可访问
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !c.GetBool("is_admin") {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusForbidden, common.ErrForbidden)
c.Abort()
return
}
c.Next()

2
model/billing.go

@ -64,7 +64,7 @@ type SimRechargeLog struct {
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:paid" json:"payStatus"`
PayStatus string `gorm:"column:pay_status;type:VARCHAR(16);default:unpaid" json:"payStatus"`
RechargeStatus string `gorm:"column:recharge_status;type:VARCHAR(16);default:pending" json:"rechargeStatus"` // pending / success / failed
RechargeMsg string `gorm:"column:recharge_msg;type:VARCHAR(256);default:''" json:"rechargeMsg"`
IdempotentKey string `gorm:"column:idempotent_key;type:VARCHAR(64);not null;uniqueIndex:uk_recharge_idem" json:"idempotentKey"`

2
model/dock.go

@ -15,7 +15,6 @@ type Dock struct {
Latitude float64 `gorm:"column:latitude;type:DOUBLE;default:0" json:"latitude"`
Altitude float64 `gorm:"column:altitude;type:DOUBLE;default:0" json:"altitude"`
Location string `gorm:"column:location;type:VARCHAR(128);default:''" json:"location"`
Status string `gorm:"column:status;type:VARCHAR(32);default:offline" json:"status"`
RegisterStatus string `gorm:"column:register_status;type:VARCHAR(16);default:pending" json:"registerStatus"`
DockIDSource string `gorm:"column:dock_id_source;type:VARCHAR(32);default:''" json:"dockIdSource"`
SoftwareVer string `gorm:"column:software_ver;type:VARCHAR(16);default:''" json:"softwareVer"`
@ -37,7 +36,6 @@ type Drone struct {
Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"`
Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"`
Model string `gorm:"column:model;type:VARCHAR(32);default:''" json:"model"`
Status string `gorm:"column:status;type:VARCHAR(32);default:offline" json:"status"`
Battery int `gorm:"column:battery;type:INT;default:0" json:"battery"`
FirmwareVer string `gorm:"column:firmware_ver;type:VARCHAR(16);default:''" json:"firmwareVer"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`

35
model/invoice.go

@ -0,0 +1,35 @@
package model
import "time"
// InvoiceProfile 用户发票资料
type InvoiceProfile struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null;index" json:"userId"`
Title string `gorm:"column:title;type:VARCHAR(128);not null" json:"title"`
TaxpayerNumber string `gorm:"column:taxpayer_number;type:VARCHAR(64);not null" json:"taxpayerNumber"`
Email string `gorm:"column:email;type:VARCHAR(128);not null" json:"email"`
IsDefault bool `gorm:"column:is_default;type:TINYINT;default:0" json:"isDefault"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (InvoiceProfile) TableName() string { return "invoice_profile" }
// InvoiceRequest 人工发票申请
type InvoiceRequest struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null;index" json:"userId"`
OrderID int64 `gorm:"column:order_id;type:BIGINT;not null;uniqueIndex" json:"orderId"`
Amount float64 `gorm:"column:amount;type:DECIMAL(10,2);not null" json:"amount"`
Title string `gorm:"column:title;type:VARCHAR(128);not null" json:"title"`
TaxpayerNumber string `gorm:"column:taxpayer_number;type:VARCHAR(64);not null" json:"taxpayerNumber"`
Email string `gorm:"column:email;type:VARCHAR(128);not null" json:"email"`
Status string `gorm:"column:status;type:VARCHAR(16);default:pending" json:"status"`
InvoiceNumber string `gorm:"column:invoice_number;type:VARCHAR(64)" json:"invoiceNumber"`
AdminRemark string `gorm:"column:admin_remark;type:VARCHAR(256)" json:"adminRemark"`
RequestedAt time.Time `gorm:"column:requested_at" json:"requestedAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
}
func (InvoiceRequest) TableName() string { return "invoice_request" }

1
model/user.go

@ -13,6 +13,7 @@ type User struct {
TrafficBalance int64 `gorm:"column:traffic_balance;type:BIGINT;default:0" json:"trafficBalance"`
Status int8 `gorm:"column:status;type:TINYINT;default:1" json:"status"`
LastLogin *time.Time `gorm:"column:last_login" json:"lastLogin"`
PasswordChangedAt *time.Time `gorm:"column:password_changed_at" json:"passwordChangedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

19
model/user_session.go

@ -0,0 +1,19 @@
package model
import "time"
// UserLoginSession stores token fingerprints and the device metadata for one login.
type UserLoginSession struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null;index" json:"userId"`
AccessTokenID string `gorm:"column:access_token_id;type:VARCHAR(64);not null;uniqueIndex" json:"-"`
RefreshTokenID string `gorm:"column:refresh_token_id;type:VARCHAR(64);not null;uniqueIndex" json:"-"`
UserAgent string `gorm:"column:user_agent;type:VARCHAR(512)" json:"userAgent"`
IP string `gorm:"column:ip;type:VARCHAR(64)" json:"ip"`
LastActiveAt time.Time `gorm:"column:last_active_at;not null" json:"lastActiveAt"`
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expiresAt"`
RevokedAt *time.Time `gorm:"column:revoked_at" json:"revokedAt"`
CreatedAt time.Time `gorm:"column:created_at;not null" json:"createdAt"`
}
func (UserLoginSession) TableName() string { return "user_login_session" }

16
route/route.go

@ -33,6 +33,7 @@ func InitRouter(port int32) {
// 认证(公开)
auth := v1.Group("/auth")
{
auth.GET("/captcha", handler.Captcha)
auth.POST("/register", handler.Register)
auth.POST("/login", handler.Login)
auth.POST("/refresh", handler.Refresh)
@ -47,13 +48,26 @@ func InitRouter(port int32) {
account.GET("/profile", handler.GetProfile)
account.PUT("/profile", handler.UpdateProfile)
account.GET("/traffic/balance", handler.GetTrafficBalance)
account.GET("/resource-summary", handler.GetResourceSummary)
account.GET("/downloads", handler.GetDownloadPage)
account.GET("/traffic/usage", handler.GetUsagePage)
account.GET("/traffic/orders", handler.GetOrderPage)
account.POST("/traffic/orders", handler.CreateTrafficOrder)
account.POST("/traffic/orders/:id/pay", middleware.AdminMiddleware(), handler.PayTrafficOrder)
account.GET("/sim-cards", handler.ListSimCards)
account.GET("/sim-recharge-logs", handler.GetSimRechargeLogPage)
account.POST("/sim-cards/:id/recharge", handler.RechargeSimCard)
account.POST("/security/change-password", handler.ChangePassword)
account.GET("/security/sessions", handler.ListSessions)
account.DELETE("/security/sessions", handler.RevokeOtherSessions)
account.DELETE("/security/sessions/:id", handler.RevokeSession)
account.GET("/invoice-profiles", handler.ListInvoiceProfiles)
account.POST("/invoice-profiles", handler.SaveInvoiceProfile)
account.PUT("/invoice-profiles/:id", handler.SaveInvoiceProfile)
account.DELETE("/invoice-profiles/:id", handler.DeleteInvoiceProfile)
account.GET("/invoice-orders", handler.ListInvoiceOrders)
account.GET("/invoice-requests", handler.ListInvoiceRequests)
account.POST("/invoice-requests", handler.CreateInvoiceRequest)
account.PUT("/invoice-requests/:id/review", middleware.AdminMiddleware(), handler.ReviewInvoiceRequest)
}
// 机巢 & 无人机

68
service/account_resource_service.go

@ -0,0 +1,68 @@
package service
import (
"time"
"laic-backend/common"
"laic-backend/model"
"laic-backend/vo"
)
const bytesPerGB = 1024 * 1024 * 1024
func (s *AccountService) GetDownloadPage(userID int64, req *vo.UsagePageReq) (*common.PageResponse[vo.DownloadRecordVO], *common.BusiError) {
type row struct {
model.DownloadLog
FileName string
FileSize int64
DroneSN string
ExecutionID int64
}
db := common.DB.Table("download_log").Select("download_log.*, video.file_name, video.file_size, video.drone_sn, video.execution_id").Joins("JOIN video ON video.id = download_log.video_id").Where("download_log.user_id = ?", userID)
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var rows []row
if err := db.Scopes(req.Paginate).Order("download_log.id DESC").Find(&rows).Error; err != nil {
return nil, common.ErrInternal
}
list := make([]vo.DownloadRecordVO, 0, len(rows))
for _, row := range rows {
list = append(list, vo.DownloadRecordVO{ID: row.ID, VideoID: row.VideoID, FileName: row.FileName, FileSize: row.FileSize, DroneSN: row.DroneSN, ExecutionID: row.ExecutionID, Bytes: row.Bytes, CreatedAt: row.CreatedAt})
}
return common.Page(req.Pagination, total, list), nil
}
func (s *AccountService) GetResourceSummary(userID int64) (*vo.ResourceSummaryVO, *common.BusiError) {
balance, busiErr := s.GetTrafficBalance(userID)
if busiErr != nil {
return nil, busiErr
}
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
var usage struct{ Total, Live, Replay, Download int64 }
if err := common.DB.Model(&model.TrafficUsageLog{}).Where("user_id = ? AND created_at >= ?", userID, monthStart).Select("COALESCE(SUM(bytes_used), 0) AS total, COALESCE(SUM(CASE WHEN source_type = 'live' THEN bytes_used ELSE 0 END), 0) AS live, COALESCE(SUM(CASE WHEN source_type = 'replay' THEN bytes_used ELSE 0 END), 0) AS replay, COALESCE(SUM(CASE WHEN source_type = 'download' THEN bytes_used ELSE 0 END), 0) AS download").Scan(&usage).Error; err != nil {
return nil, common.ErrInternal
}
var simCount, dockCount, droneCount, videoCount, downloadCount int64
queries := []struct {
model any
target *int64
where string
args []any
}{
{&model.SimCard{}, &simCount, "user_id = ?", []any{userID}},
{&model.Dock{}, &dockCount, "user_id = ?", []any{userID}},
{&model.Drone{}, &droneCount, "user_id = ?", []any{userID}},
{&model.Video{}, &videoCount, "user_id = ?", []any{userID}},
{&model.DownloadLog{}, &downloadCount, "user_id = ? AND created_at >= ?", []any{userID, monthStart}},
}
for _, query := range queries {
if err := common.DB.Model(query.model).Where(query.where, query.args...).Count(query.target).Error; err != nil {
return nil, common.ErrInternal
}
}
toGB := func(value int64) float64 { return float64(value) / bytesPerGB }
return &vo.ResourceSummaryVO{BalanceGb: balance.BalanceGb, MonthUsageGb: toGB(usage.Total), MonthLiveGb: toGB(usage.Live), MonthReplayGb: toGB(usage.Replay), MonthDownloadGb: toGB(usage.Download), SimCount: simCount, DockCount: dockCount, DroneCount: droneCount, VideoCount: videoCount, MonthDownloadCnt: downloadCount}, nil
}

25
service/account_service.go

@ -16,20 +16,26 @@ type AccountService struct{}
var DefaultAccountService = &AccountService{}
// GetProfile 查询个人资料
func (s *AccountService) GetProfile(userID int64) (*model.User, *common.BusiError) {
func (s *AccountService) GetProfile(userID int64) (*vo.ProfileVO, *common.BusiError) {
var user model.User
if err := common.DB.First(&user, userID).Error; err != nil {
if err := common.DB.Select("id", "name", "phone", "email", "role", "status", "created_at", "updated_at").First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
return nil, common.ErrInternal
}
return &user, nil
return profileVO(&user), nil
}
func profileVO(user *model.User) *vo.ProfileVO {
return &vo.ProfileVO{
ID: user.ID, Name: user.Name, Phone: user.Phone, Email: user.Email,
Role: user.Role, Status: user.Status, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt,
}
}
// UpdateProfile 更新个人资料(姓名/邮箱)
func (s *AccountService) UpdateProfile(userID int64, req *vo.ProfileUpdateReq) (*model.User, *common.BusiError) {
func (s *AccountService) UpdateProfile(userID int64, req *vo.ProfileUpdateReq) (*vo.ProfileVO, *common.BusiError) {
var user model.User
if err := common.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@ -49,8 +55,13 @@ func (s *AccountService) UpdateProfile(userID int64, req *vo.ProfileUpdateReq) (
logger.ERROR("更新个人资料失败", err)
return nil, common.ErrInternal
}
common.DB.First(&user, userID)
return &user, nil
if err := common.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
return nil, common.ErrInternal
}
return profileVO(&user), nil
}
// GetTrafficBalance 查询云媒体流量余额

92
service/billing_service.go

@ -8,6 +8,7 @@ import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"laic-backend/cache"
"laic-backend/common"
@ -52,7 +53,7 @@ type CarrierAPI interface {
Recharge(iccid string, amountGb int) (orderNo string, err error)
}
// MockCarrierAPI 运营商 Mock(未接入真实通道前使用
// MockCarrierAPI 运营商 Mock(仅供开发注入,不作为默认生产通道
type MockCarrierAPI struct{}
func (m *MockCarrierAPI) QueryUsage(iccid string) (float64, string, error) {
@ -63,8 +64,8 @@ func (m *MockCarrierAPI) Recharge(iccid string, amountGb int) (string, error) {
return fmt.Sprintf("MOCK-%d", time.Now().UnixNano()), nil
}
// carrier 当前使用的运营商通道(后续可替换为真实实现)
var carrier CarrierAPI = &MockCarrierAPI{}
// carrier 默认禁用,未接入真实运营商前不产生虚假用量或充值成功。
var carrier CarrierAPI
// mysqlBalance 读取用户 MySQL 流量快照
func (b *BillingService) mysqlBalance(userID int64) (int64, error) {
@ -146,6 +147,7 @@ func (b *BillingService) Deduct(userID int64, bytes int64, sourceType string, so
}
if err := common.DB.Create(log).Error; err != nil {
logger.ERROR("流量账本落库失败", err)
return false, common.ErrInternal
}
return true, nil
}
@ -156,12 +158,9 @@ func (b *BillingService) credit(userID int64, bytes int64) error {
if _, err := b.currentBalance(userID); err != nil {
return err
}
if _, err := common.IncrBy(key, bytes); err != nil {
_, err := common.IncrBy(key, bytes)
return err
}
return common.DB.Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("traffic_balance", gorm.Expr("traffic_balance + ?", bytes)).Error
}
// GetUsagePage 流量消费流水分页
func (b *BillingService) GetUsagePage(userID int64, req *vo.UsagePageReq) (*common.PageResponse[model.TrafficUsageLog], *common.BusiError) {
@ -218,33 +217,32 @@ func (b *BillingService) CreateOrder(userID int64, amountGb int) (*model.Traffic
// PayOrder 订单支付入账(admin 确认支付)
func (b *BillingService) PayOrder(orderID int64) (*model.TrafficOrder, *common.BusiError) {
var order model.TrafficOrder
if err := common.DB.First(&order, orderID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrOrderNotFound
}
return nil, common.ErrInternal
now := time.Now()
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if order.PayStatus == "paid" {
return nil, common.ErrOrderPaid
return common.ErrOrderPaid
}
bytes := int64(order.AmountGb) * gbBytes
now := time.Now()
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{
"pay_status": "paid",
"paid_at": now,
}).Error; err != nil {
if err := tx.Model(&order).Updates(map[string]any{"pay_status": "paid", "paid_at": now}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", order.UserID).
UpdateColumn("traffic_balance", gorm.Expr("traffic_balance + ?", bytes)).Error
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrOrderNotFound
}
if busiErr, ok := err.(*common.BusiError); ok {
return nil, busiErr
}
logger.ERROR("订单支付失败", err)
return nil, common.ErrInternal
}
// Redis 权威余额入账(幂等:多次支付由 pay_status 校验拦截)
bytes := int64(order.AmountGb) * gbBytes
if err := b.credit(order.UserID, bytes); err != nil {
logger.ERROR("订单支付 Redis 入账失败", err)
return nil, common.ErrInternal
@ -291,53 +289,9 @@ func (b *BillingService) GetSimRechargeLogPage(userID int64, req *vo.SimRecharge
return common.Page(req.Pagination, total, list), nil
}
// RechargeSimCard SIM 卡充值(运营商下单 + 回写状态)
// RechargeSimCard 当前未接入支付确认流程,不向运营商提交充值订单。
func (b *BillingService) RechargeSimCard(userID, simCardID int64, amountGb int) (*model.SimRechargeLog, *common.BusiError) {
var card model.SimCard
if err := common.DB.Where("id = ? AND user_id = ?", simCardID, userID).First(&card).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrSimCardNotFound
}
return nil, common.ErrInternal
}
log := &model.SimRechargeLog{
ID: mustID(),
SimCardID: simCardID,
UserID: userID,
AmountGb: amountGb,
UnitPrice: defaultTrafficUnitPrice,
TotalPrice: float64(amountGb) * defaultTrafficUnitPrice,
PayStatus: "paid",
RechargeStatus: "pending",
IdempotentKey: fmt.Sprintf("%d-%d", simCardID, time.Now().UnixNano()),
CreatedAt: time.Now(),
}
if err := common.DB.Create(log).Error; err != nil {
logger.ERROR("创建 SIM 充值记录失败", err)
return nil, common.ErrInternal
}
orderNo, err := carrier.Recharge(card.Iccid, amountGb)
if err != nil {
log.RechargeStatus = "failed"
log.RechargeMsg = err.Error()
common.DB.Model(log).Updates(map[string]any{"recharge_status": "failed", "recharge_msg": err.Error()})
return log, nil
}
updates := map[string]any{
"recharge_status": "success",
"recharge_msg": orderNo,
}
common.DB.Model(log).Updates(updates)
common.DB.Model(&card).Updates(map[string]any{
"plan_gb": gorm.Expr("plan_gb + ?", amountGb),
"status": "active",
})
log.RechargeStatus = "success"
log.RechargeMsg = orderNo
return log, nil
return nil, common.ErrCarrierUnavailable
}
// mustID 生成雪花 ID(失败返回 0,由 DB 约束兜底)
@ -437,6 +391,10 @@ func (b *BillingService) syncSimUsage() {
}
func (b *BillingService) syncSimCard(card *model.SimCard, now time.Time) {
if carrier == nil {
logger.INFO("SIM 运营商通道未接入:", card.Iccid)
return
}
usedGb, carrierStatus, err := carrier.QueryUsage(card.Iccid)
if err != nil {
logger.WARN("查询 SIM 用量失败:", card.Iccid, err)

120
service/captcha_service.go

@ -0,0 +1,120 @@
package service
import (
"bytes"
"crypto/rand"
"image"
"image/color"
"image/png"
"math/big"
"strings"
"time"
"github.com/google/uuid"
"laic-backend/cache"
"laic-backend/common"
)
const captchaCookie = "laic_captcha"
var captchaChars = []byte("23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
var captchaGlyphs = map[byte][7]string{
'2': {"11110", "00001", "00010", "00100", "01000", "10000", "11111"},
'3': {"11110", "00001", "00110", "00001", "00001", "00001", "11110"},
'4': {"00010", "00110", "01010", "10010", "11111", "00010", "00010"},
'5': {"11111", "10000", "11110", "00001", "00001", "10001", "01110"},
'6': {"00110", "01000", "10000", "11110", "10001", "10001", "01110"},
'7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"},
'8': {"01110", "10001", "10001", "01110", "10001", "10001", "01110"},
'9': {"01110", "10001", "10001", "01111", "00001", "00010", "11100"},
'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
'B': {"11110", "10001", "10001", "11110", "10001", "10001", "11110"},
'C': {"01111", "10000", "10000", "10000", "10000", "10000", "01111"},
'D': {"11110", "10001", "10001", "10001", "10001", "10001", "11110"},
'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
'F': {"11111", "10000", "10000", "11110", "10000", "10000", "10000"},
'G': {"01111", "10000", "10000", "10111", "10001", "10001", "01111"},
'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
'J': {"00001", "00001", "00001", "00001", "10001", "10001", "01110"},
'K': {"10001", "10010", "10100", "11000", "10100", "10010", "10001"},
'M': {"10001", "11011", "10101", "10101", "10001", "10001", "10001"},
'N': {"10001", "11001", "10101", "10011", "10001", "10001", "10001"},
'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
'Q': {"01110", "10001", "10001", "10001", "10101", "10010", "01101"},
'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
'S': {"01111", "10000", "10000", "01110", "00001", "00001", "11110"},
'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
'W': {"10001", "10001", "10001", "10101", "10101", "11011", "10001"},
'X': {"10001", "10001", "01010", "00100", "01010", "10001", "10001"},
'Y': {"10001", "10001", "01010", "00100", "00100", "00100", "00100"},
'Z': {"11111", "00001", "00010", "00100", "01000", "10000", "11111"},
}
func NewCaptcha() (string, []byte, error) {
id := uuid.NewString()
answer := make([]byte, 4)
for i := range answer {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(captchaChars))))
if err != nil {
return "", nil, err
}
answer[i] = captchaChars[n.Int64()]
}
if err := common.SetValueWithExpired(cache.CaptchaKeyOf(id), string(answer), 5*60); err != nil {
return "", nil, err
}
img := image.NewRGBA(image.Rect(0, 0, 160, 48))
for y := 0; y < 48; y++ {
for x := 0; x < 160; x++ {
img.Set(x, y, color.RGBA{R: 242, G: 247, B: 250, A: 255})
}
}
for i := 0; i < 7; i++ {
x := 9 + i*23
for y := 5; y < 43; y++ {
img.Set(x, y, color.RGBA{R: 130, G: 180, B: 205, A: 180})
}
}
for i, ch := range answer {
drawGlyph(img, ch, 15+i*35, 12, color.RGBA{R: 31, G: 91, B: 130, A: 255})
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
return "", nil, err
}
return id, out.Bytes(), nil
}
func drawGlyph(img *image.RGBA, ch byte, left, top int, fill color.Color) {
glyph, ok := captchaGlyphs[ch]
if !ok {
return
}
for y, row := range glyph {
for x, bit := range row {
if bit == '1' {
for dy := 0; dy < 3; dy++ {
for dx := 0; dx < 3; dx++ {
img.Set(left+x*3+dx, top+y*3+dy, fill)
}
}
}
}
}
}
func CheckCaptcha(id, answer string) bool {
stored, err := common.GetString(cache.CaptchaKeyOf(id))
if err != nil || stored == "" || !strings.EqualFold(strings.TrimSpace(stored), strings.TrimSpace(answer)) {
return false
}
_ = common.Delete(cache.CaptchaKeyOf(id))
return true
}
func CaptchaCookieName() string { return captchaCookie }
func CaptchaTTL() time.Duration { return 5 * time.Minute }

90
service/casbin_bootstrap.go

@ -1,90 +0,0 @@
package service
import (
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
)
// BootstrapCasbin 引导默认 RBAC 策略(幂等):p 策略(角色→路径→方法)+ 用户角色映射 g 策略
func BootstrapCasbin() {
e := common.CasbinEnforcer
if e == nil {
return
}
// 清理旧的 admin 全量策略,防止历史 "admin /* *" 残留导致越权(幂等)
if _, err := e.RemoveFilteredPolicy(0, "admin"); err != nil {
logger.ERROR("清理 admin 旧策略失败", err)
}
// p 策略:
// admin —— 平台管理职能:查看用户/机巢/无人机,管理固件,管理主账号云媒体流量
// user —— 业务操作:机巢/无人机/任务/航线等(自注册普通用户)
// keyMatch2 下 "/prefix/*" 匹配所有子路径(含多级),再补 "/prefix" 精确匹配列表/新增基路径。
adminPolicies := [][]string{
// 查看所有用户信息(暂只读)
{"admin", "/v1/users", "GET"},
{"admin", "/v1/users/*", "GET"},
// 查看机巢 / 无人机(含状态与遥测)
{"admin", "/v1/docks", "GET"},
{"admin", "/v1/docks/*", "GET"},
{"admin", "/v1/drones", "GET"},
{"admin", "/v1/drones/*", "GET"},
// 固件包管理
{"admin", "/v1/firmwares", "*"},
{"admin", "/v1/firmwares/*", "*"},
// 主账号云媒体流量管理(流量余额/订单/SIM 卡)
{"admin", "/v1/account/traffic", "*"},
{"admin", "/v1/account/traffic/*", "*"},
{"admin", "/v1/account/sim-cards", "*"},
{"admin", "/v1/account/sim-cards/*", "*"},
// 平台监控:角色字典
{"admin", "/v1/roles", "GET"},
}
userPrefixes := []string{
"/v1/docks",
"/v1/drones",
"/v1/alarms",
"/v1/commands",
"/v1/tasks",
"/v1/routes",
"/v1/executions",
"/v1/account",
"/v1/live",
"/v1/videos",
"/v1/ws",
}
policies := append([][]string{}, adminPolicies...)
for _, prefix := range userPrefixes {
policies = append(policies, []string{"user", prefix, "*"}, []string{"user", prefix + "/*", "*"})
}
// 普通用户可读取已发布固件列表(选择升级)
policies = append(policies, []string{"user", "/v1/firmwares/released", "GET"})
// 普通用户查看自己的操作记录
policies = append(policies, []string{"user", "/v1/logs/operation", "GET"})
for _, p := range policies {
args := make([]any, len(p))
for i, v := range p {
args[i] = v
}
if _, err := e.AddPolicy(args...); err != nil {
logger.ERROR("添加 Casbin 策略失败", err)
}
}
// g 策略:同步现有用户角色映射
var users []model.User
if err := common.DB.Find(&users).Error; err != nil {
logger.ERROR("查询用户同步 Casbin 角色失败", err)
return
}
for _, u := range users {
if err := common.SetUserRole(u.ID, u.Role); err != nil {
logger.ERROR("同步用户角色失败", err)
}
}
}

11
service/dock_service.go

@ -39,9 +39,6 @@ func withDockFilter(userID int64, isAdmin bool) func(*gorm.DB) *gorm.DB {
// GetPage 机巢分页列表
func (s *DockService) GetPage(userID int64, isAdmin bool, req *vo.DockPageReq) (*common.PageResponse[model.Dock], *common.BusiError) {
db := common.DB.Model(&model.Dock{}).Scopes(withUserFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.RegisterStatus != "" {
db = db.Where("register_status = ?", req.RegisterStatus)
}
@ -113,7 +110,6 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
Longitude: req.Longitude,
Latitude: req.Latitude,
Altitude: req.Altitude,
Status: "offline",
RegisterStatus: "registered",
DockIDSource: "manual",
ProtocolVer: "1.0",
@ -276,10 +272,6 @@ func (s *DockService) realtime(dockID string) (bool, map[string]string) {
// EnsureFromOnline 设备自发现:不存在则登记为 pending,存在则更新在线状态与软件版本
func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
online := st.Status == "online"
status := "offline"
if online {
status = "online"
}
var dock model.Dock
err := common.DB.Where("dock_id = ?", dockID).First(&dock).Error
@ -304,7 +296,6 @@ func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
DockIDSource: "auto",
SoftwareVer: st.SoftwareVersion,
ProtocolVer: st.ProtocolVersion,
Status: status,
CreatedAt: now,
UpdatedAt: now,
}
@ -313,7 +304,7 @@ func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
return
}
} else if err == nil {
updates := map[string]any{"status": status, "updated_at": time.Now()}
updates := map[string]any{"updated_at": time.Now()}
if st.SoftwareVersion != "" {
updates["software_ver"] = st.SoftwareVersion
}

9
service/drone_service.go

@ -21,9 +21,6 @@ var DefaultDroneService = &DroneService{}
// GetPage 无人机分页列表
func (s *DroneService) GetPage(userID int64, isAdmin bool, req *vo.DronePageReq) (*common.PageResponse[model.Drone], *common.BusiError) {
db := common.DB.Model(&model.Drone{}).Scopes(withUserFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("drone_sn LIKE ? OR name LIKE ? OR code LIKE ? OR model LIKE ?", kw, kw, kw, kw)
@ -126,10 +123,6 @@ func (s *DroneService) realtime(dockID, droneSN string) (bool, map[string]string
// EnsureFromState 无人机自动关联:按 droneSn 或 dockId 创建/更新(1:1 绑定)
func (s *DroneService) EnsureFromState(dockID, droneSN, name string, online bool, battery int, firmwareVer string) {
status := "offline"
if online {
status = "online"
}
if droneSN == "" {
return
}
@ -157,7 +150,6 @@ func (s *DroneService) EnsureFromState(dockID, droneSN, name string, online bool
DroneSN: droneSN,
DockID: dockID,
Name: name,
Status: status,
Battery: battery,
FirmwareVer: firmwareVer,
CreatedAt: now,
@ -172,7 +164,6 @@ func (s *DroneService) EnsureFromState(dockID, droneSN, name string, online bool
"user_id": dock.UserID,
"drone_sn": droneSN,
"dock_id": dockID,
"status": status,
"battery": battery,
"updated_at": time.Now(),
}

19
service/heartbeat.go

@ -6,7 +6,6 @@ import (
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
)
func heartbeatTimeout() uint {
@ -57,30 +56,12 @@ func expireDeviceHeartbeats() {
}
func markDockOffline(dockID string) {
result := common.DB.Model(&model.Dock{}).
Where("dock_id = ? AND status = ?", dockID, "online").
Update("status", "offline")
if result.Error != nil {
logger.WARN("更新机巢离线状态失败:", result.Error)
return
}
if result.RowsAffected > 0 {
broadcast("dock.status", map[string]any{"dockId": dockID, "online": false})
}
}
func markDroneOffline(droneSN string) {
result := common.DB.Model(&model.Drone{}).
Where("drone_sn = ? AND status = ?", droneSN, "online").
Update("status", "offline")
if result.Error != nil {
logger.WARN("更新无人机离线状态失败:", result.Error)
return
}
if result.RowsAffected > 0 {
broadcast("drone.status", map[string]any{"droneSn": droneSN, "online": false})
}
}
func heartbeatScanInterval() time.Duration {
if common.AppConf != nil && common.AppConf.Heartbeat.ScanSeconds > 0 {

130
service/invoice_service.go

@ -0,0 +1,130 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"laic-backend/common"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type InvoiceService struct{}
var DefaultInvoiceService = &InvoiceService{}
func (s *InvoiceService) ListProfiles(userID int64) ([]model.InvoiceProfile, *common.BusiError) {
var profiles []model.InvoiceProfile
if err := common.DB.Where("user_id = ?", userID).Order("is_default DESC, id DESC").Find(&profiles).Error; err != nil {
return nil, common.ErrInternal
}
return profiles, nil
}
func (s *InvoiceService) SaveProfile(userID, profileID int64, req *vo.InvoiceProfileReq) (*model.InvoiceProfile, *common.BusiError) {
var profile model.InvoiceProfile
if profileID > 0 {
if err := common.DB.Where("id = ? AND user_id = ?", profileID, userID).First(&profile).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrNotFound
}
return nil, common.ErrInternal
}
} else {
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
profile = model.InvoiceProfile{ID: id, UserID: userID, CreatedAt: time.Now()}
}
profile.Title, profile.TaxpayerNumber, profile.Email, profile.IsDefault, profile.UpdatedAt = req.Title, req.TaxpayerNumber, req.Email, req.IsDefault, time.Now()
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if req.IsDefault {
if err := tx.Model(&model.InvoiceProfile{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
return err
}
}
return tx.Save(&profile).Error
}).Error; err != nil {
return nil, common.ErrInternal
}
return &profile, nil
}
func (s *InvoiceService) DeleteProfile(userID, profileID int64) *common.BusiError {
result := common.DB.Where("id = ? AND user_id = ?", profileID, userID).Delete(&model.InvoiceProfile{})
if result.Error != nil {
return common.ErrInternal
}
if result.RowsAffected == 0 {
return common.ErrNotFound
}
return nil
}
func (s *InvoiceService) ListEligibleOrders(userID int64) ([]model.TrafficOrder, *common.BusiError) {
var orders []model.TrafficOrder
if err := common.DB.Where("user_id = ? AND pay_status = ? AND NOT EXISTS (SELECT 1 FROM invoice_request WHERE invoice_request.order_id = traffic_order.id)", userID, "paid").Order("paid_at DESC").Find(&orders).Error; err != nil {
return nil, common.ErrInternal
}
return orders, nil
}
func (s *InvoiceService) CreateRequest(userID int64, req *vo.InvoiceRequestCreateReq) (*model.InvoiceRequest, *common.BusiError) {
var profile model.InvoiceProfile
if err := common.DB.Where("user_id = ? AND is_default = ?", userID, true).First(&profile).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.NewBusiError(common.ParamError, "请先设置默认发票资料")
}
return nil, common.ErrInternal
}
var order model.TrafficOrder
if err := common.DB.Where("id = ? AND user_id = ? AND pay_status = ?", req.OrderID, userID, "paid").First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrOrderNotFound
}
return nil, common.ErrInternal
}
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
request := &model.InvoiceRequest{ID: id, UserID: userID, OrderID: order.ID, Amount: order.TotalPrice, Title: profile.Title, TaxpayerNumber: profile.TaxpayerNumber, Email: profile.Email, Status: "pending", RequestedAt: time.Now()}
if err := common.DB.Create(request).Error; err != nil {
if code, _ := common.ParseError(err); code == 1062 {
return nil, common.NewBusiError(common.ParamError, "该订单已申请发票")
}
return nil, common.ErrInternal
}
return request, nil
}
func (s *InvoiceService) ListRequests(userID int64) ([]model.InvoiceRequest, *common.BusiError) {
var requests []model.InvoiceRequest
if err := common.DB.Where("user_id = ?", userID).Order("requested_at DESC").Find(&requests).Error; err != nil {
return nil, common.ErrInternal
}
return requests, nil
}
func (s *InvoiceService) ReviewRequest(requestID int64, req *vo.InvoiceReviewReq) (*model.InvoiceRequest, *common.BusiError) {
if req.Status != "issued" && req.Status != "rejected" {
return nil, common.ErrParam
}
var request model.InvoiceRequest
if err := common.DB.Where("id = ? AND status = ?", requestID, "pending").First(&request).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrNotFound
}
return nil, common.ErrInternal
}
now := time.Now()
if err := common.DB.Model(&request).Updates(map[string]any{"status": req.Status, "invoice_number": req.InvoiceNumber, "admin_remark": req.AdminRemark, "processed_at": now}).Error; err != nil {
return nil, common.ErrInternal
}
request.Status, request.InvoiceNumber, request.AdminRemark, request.ProcessedAt = req.Status, req.InvoiceNumber, req.AdminRemark, &now
return &request, nil
}

2
service/operation_log_service.go

@ -16,7 +16,7 @@ var DefaultOperationLogService = &OperationLogService{}
// BootstrapOperationLog 确保基础运行时表存在(历史库未跑 DDL 时兜底)。
func BootstrapOperationLog() {
if err := common.DB.AutoMigrate(&model.OperationLog{}, &model.LiveViewerLease{}); err != nil {
if err := common.DB.AutoMigrate(&model.User{}, &model.OperationLog{}, &model.LiveViewerLease{}, &model.UserLoginSession{}, &model.InvoiceProfile{}, &model.InvoiceRequest{}); err != nil {
logger.ERROR("迁移基础运行时表失败", err)
}
}

6
service/system_service.go

@ -86,7 +86,6 @@ func (s *SystemService) CreateUser(req *vo.UserCreateReq) (*model.User, *common.
logger.ERROR("创建用户失败", err)
return nil, common.ErrInternal
}
_ = common.SetUserRole(id, role)
return user, nil
}
@ -125,10 +124,6 @@ func (s *SystemService) UpdateUser(id int64, req *vo.UserUpdateReq) (*model.User
logger.ERROR("更新用户失败", err)
return nil, common.ErrInternal
}
common.DB.First(&user, id)
if req.Role != "" {
_ = common.SetUserRole(id, req.Role)
}
return &user, nil
}
@ -142,7 +137,6 @@ func (s *SystemService) DeleteUser(id int64) *common.BusiError {
if res.RowsAffected == 0 {
return common.ErrUserNotFound
}
_ = common.SetUserRole(id, "")
return nil
}

282
service/user_service.go

@ -2,6 +2,7 @@ package service
import (
"errors"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
@ -20,9 +21,10 @@ type UserService struct{}
var DefaultUserService = &UserService{}
// Register 注册用户(默认角色 user),成功后直接返回登录态
func (s *UserService) Register(name, phone, email, password string) (*vo.LoginResp, *common.BusiError) {
// 手机号唯一性预检(兼顾并发,唯一键兜底)
func (s *UserService) Register(name, phone, email, password, smsCode, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
if !s.consumeSmsCode(phone, smsCode) {
return nil, common.ErrSmsCodeError
}
var count int64
if err := common.DB.Model(&model.User{}).Where("phone = ?", phone).Count(&count).Error; err != nil {
return nil, common.ErrInternal
@ -30,7 +32,6 @@ func (s *UserService) Register(name, phone, email, password string) (*vo.LoginRe
if count > 0 {
return nil, common.ErrUserPhoneExists
}
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
@ -39,18 +40,8 @@ func (s *UserService) Register(name, phone, email, password string) (*vo.LoginRe
if err != nil {
return nil, common.ErrInternal
}
user := &model.User{
ID: id,
Name: name,
Phone: phone,
Email: email,
Password: string(hash),
Role: common.RoleUser,
Status: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
now := time.Now()
user := &model.User{ID: id, Name: name, Phone: phone, Email: email, Password: string(hash), Role: common.RoleUser, Status: 1, CreatedAt: now, UpdatedAt: now}
if err := common.DB.Create(user).Error; err != nil {
if code, _ := common.ParseError(err); code == 1062 {
return nil, common.ErrUserPhoneExists
@ -58,45 +49,48 @@ func (s *UserService) Register(name, phone, email, password string) (*vo.LoginRe
logger.ERROR("注册用户失败", err)
return nil, common.ErrInternal
}
_ = common.SetUserRole(id, common.RoleUser)
return s.buildLoginResp(user)
return s.buildLoginResp(user, userAgent, ip)
}
// Login 手机号 + 密码登录
func (s *UserService) Login(phone, password string) (*vo.LoginResp, *common.BusiError) {
func (s *UserService) Login(phone, password, captchaValue, captchaID, smsCode, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
passwordLogin := password != ""
if passwordLogin {
if !CheckCaptcha(captchaID, captchaValue) {
return nil, common.NewBusiError(common.ParamError, "图形验证码错误或已过期")
}
} else if !s.consumeSmsCode(phone, smsCode) {
return nil, common.ErrSmsCodeError
}
var user model.User
if err := common.DB.Where("phone = ? AND status = 1", phone).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrPasswordError // 统一返回"用户名或密码错误",避免暴露账号是否存在
return nil, common.ErrPasswordError
}
logger.ERROR("登录查询用户失败", err)
return nil, common.ErrInternal
}
if !checkPassword(password, user.Password) {
if passwordLogin && !checkPassword(password, user.Password) {
return nil, common.ErrPasswordError
}
// 更新最后登录时间(失败不阻断登录)
common.DB.Model(&user).Update("last_login", time.Now())
return s.buildLoginResp(&user)
_ = common.DB.Model(&user).Update("last_login", time.Now()).Error
return s.buildLoginResp(&user, userAgent, ip)
}
// Refresh 用 refresh token 换取新的 access + refresh token(刷新令牌轮换)
func (s *UserService) Refresh(refreshToken string) (*vo.LoginResp, *common.BusiError) {
userID, err := token.ParseRefreshToken(refreshToken)
func (s *UserService) Refresh(refreshToken, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
userID, sessionID, err := token.ParseRefreshToken(refreshToken)
if err != nil {
return nil, common.ErrTokenInvalid
}
// 校验 refresh token 是否仍存证(登出会删除)
refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken))
refreshID := token.GenerateShortID(refreshToken)
refreshKey := cache.RefreshTokenKeyOf(userID, refreshID)
stored, err := common.GetString(refreshKey)
if err != nil || stored != refreshToken {
if err != nil || stored != sessionValue(sessionID) {
return nil, common.ErrTokenInvalid
}
var session model.UserLoginSession
if err := common.DB.Where("id = ? AND user_id = ? AND refresh_token_id = ? AND revoked_at IS NULL AND expires_at > ?", sessionID, userID, refreshID, time.Now()).First(&session).Error; err != nil {
return nil, common.ErrTokenInvalid
}
var user model.User
if err := common.DB.Where("id = ? AND status = 1", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@ -104,45 +98,141 @@ func (s *UserService) Refresh(refreshToken string) (*vo.LoginResp, *common.BusiE
}
return nil, common.ErrInternal
}
// 轮换:删除旧 refresh 存证
_ = common.Delete(refreshKey)
return s.buildLoginResp(&user)
_ = common.Delete(cache.UserTokenKeyOf(userID, session.AccessTokenID))
return s.issueForSession(&user, &session, userAgent, ip)
}
// Logout 登出:删除 access 存证(及可选 refresh 存证),使 token 立即失效
func (s *UserService) Logout(userID int64, accessToken, refreshToken string) *common.BusiError {
accessKey := cache.UserTokenKeyOf(userID, token.GenerateShortID(accessToken))
_ = common.Delete(accessKey)
func (s *UserService) Logout(userID, sessionID int64, accessToken, refreshToken string) *common.BusiError {
if sessionID == 0 {
_ = common.Delete(cache.UserTokenKeyOf(userID, token.GenerateShortID(accessToken)))
if refreshToken != "" {
refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken))
_ = common.Delete(refreshKey)
_ = common.Delete(cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken)))
}
return nil
}
return s.revokeSession(userID, sessionID)
}
func (s *UserService) ChangePassword(userID int64, req *vo.ChangePasswordReq) *common.BusiError {
var user model.User
if err := common.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrUserNotFound
}
return common.ErrInternal
}
if !checkPassword(req.OldPassword, user.Password) {
return common.ErrPasswordError
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return common.ErrInternal
}
now := time.Now()
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&user).Updates(map[string]any{"password": string(hash), "password_changed_at": now, "updated_at": now}).Error; err != nil {
return err
}
return tx.Model(&model.UserLoginSession{}).Where("user_id = ? AND revoked_at IS NULL", userID).Update("revoked_at", now).Error
}).Error; err != nil {
return common.ErrInternal
}
s.deleteUserSessions(userID)
return nil
}
func (s *UserService) ListSessions(userID, currentSessionID int64) ([]vo.SessionVO, *common.BusiError) {
var sessions []model.UserLoginSession
if err := common.DB.Where("user_id = ? AND revoked_at IS NULL", userID).Order("last_active_at DESC").Find(&sessions).Error; err != nil {
return nil, common.ErrInternal
}
result := make([]vo.SessionVO, 0, len(sessions))
for _, item := range sessions {
result = append(result, vo.SessionVO{ID: item.ID, UserAgent: item.UserAgent, IP: item.IP, LastActiveAt: item.LastActiveAt, ExpiresAt: item.ExpiresAt, Current: item.ID == currentSessionID})
}
return result, nil
}
func (s *UserService) RevokeSession(userID, sessionID int64) *common.BusiError {
return s.revokeSession(userID, sessionID)
}
func (s *UserService) RevokeOtherSessions(userID, currentSessionID int64) *common.BusiError {
var sessions []model.UserLoginSession
if err := common.DB.Where("user_id = ? AND id <> ? AND revoked_at IS NULL", userID, currentSessionID).Find(&sessions).Error; err != nil {
return common.ErrInternal
}
now := time.Now()
if err := common.DB.Model(&model.UserLoginSession{}).Where("user_id = ? AND id <> ? AND revoked_at IS NULL", userID, currentSessionID).Update("revoked_at", now).Error; err != nil {
return common.ErrInternal
}
for _, session := range sessions {
s.deleteSessionTokens(userID, &session)
}
return nil
}
// SendSmsCode 生成并"发送"短信验证码(暂存 Redis,短信通道未接入前记日志)
func (s *UserService) SendSmsCode(phone string) (string, *common.BusiError) {
func (s *UserService) TouchSession(userID, sessionID int64) {
if sessionID == 0 {
return
}
_ = common.DB.Model(&model.UserLoginSession{}).Where("id = ? AND user_id = ? AND revoked_at IS NULL AND last_active_at < ?", sessionID, userID, time.Now().Add(-5*time.Minute)).Update("last_active_at", time.Now()).Error
}
func (s *UserService) SessionValid(userID, sessionID int64, accessToken string) bool {
if sessionID == 0 {
return false
}
var count int64
err := common.DB.Model(&model.UserLoginSession{}).Where("id = ? AND user_id = ? AND access_token_id = ? AND revoked_at IS NULL AND expires_at > ?", sessionID, userID, token.GenerateShortID(accessToken), time.Now()).Count(&count).Error
return err == nil && count == 1
}
func (s *UserService) SendSmsCode(phone, scene string) (string, *common.BusiError) {
var count int64
if err := common.DB.Model(&model.User{}).Where("phone = ?", phone).Count(&count).Error; err != nil {
return "", common.ErrInternal
}
if (scene == "login" || scene == "reset") && count == 0 {
return "", common.ErrUserNotFound
}
if scene == "register" && count > 0 {
return "", common.ErrUserPhoneExists
}
if common.HasKey(cache.SmsRateKeyOf(phone)) {
return "", common.NewBusiError(common.ParamError, "验证码发送过于频繁,请稍后再试")
}
code := tool.RandDigit(6)
key := cache.SmsCodeKeyOf(phone)
if err := common.SetValueWithExpired(key, code, 5*60); err != nil {
logger.ERROR("短信验证码写 Redis 失败", err)
if err := common.SetValueWithExpired(cache.SmsCodeKeyOf(phone), code, 5*60); err != nil {
return "", common.ErrInternal
}
if err := common.SetValueWithExpired(cache.SmsRateKeyOf(phone), "1", 60); err != nil {
return "", common.ErrInternal
}
// TODO: 接入短信服务商后,此处调用真实发送接口;当前仅在日志输出验证码便于联调
logger.INFO("send sms code to", phone, "code:", code)
return code, nil
}
// ResetPassword 通过短信验证码重置密码
func (s *UserService) consumeSmsCode(phone, code string) bool {
if code == "" {
return false
}
key := cache.SmsCodeKeyOf(phone)
stored, err := common.GetString(key)
if err != nil || stored != code {
return false
}
_ = common.Delete(key)
return true
}
func (s *UserService) ResetPassword(phone, smsCode, newPassword string) *common.BusiError {
key := cache.SmsCodeKeyOf(phone)
stored, err := common.GetString(key)
if err != nil || stored == "" || stored != smsCode {
return common.ErrSmsCodeError
}
var user model.User
if err := common.DB.Where("phone = ?", phone).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@ -150,22 +240,24 @@ func (s *UserService) ResetPassword(phone, smsCode, newPassword string) *common.
}
return common.ErrInternal
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return common.ErrInternal
}
if err := common.DB.Model(&user).Updates(map[string]any{
"password": string(hash),
"updated_at": time.Now(),
now := time.Now()
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&user).Updates(map[string]any{"password": string(hash), "password_changed_at": now, "updated_at": now}).Error; err != nil {
return err
}
return tx.Model(&model.UserLoginSession{}).Where("user_id = ? AND revoked_at IS NULL", user.ID).Update("revoked_at", now).Error
}).Error; err != nil {
return common.ErrInternal
}
s.deleteUserSessions(user.ID)
_ = common.Delete(key)
return nil
}
// GetByID 按 ID 查询用户
func (s *UserService) GetByID(id int64) (*model.User, *common.BusiError) {
var user model.User
if err := common.DB.First(&user, id).Error; err != nil {
@ -177,31 +269,75 @@ func (s *UserService) GetByID(id int64) (*model.User, *common.BusiError) {
return &user, nil
}
// buildLoginResp 生成 token 并存证 Redis,返回登录响应
func (s *UserService) buildLoginResp(user *model.User) (*vo.LoginResp, *common.BusiError) {
roles := []string{user.Role}
accessToken, refreshToken, err := token.GenerateToken(user.ID, user.Phone, roles)
func (s *UserService) buildLoginResp(user *model.User, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
id, err := tool.NextID()
if err != nil {
logger.ERROR("生成 token 失败", err)
return nil, common.ErrInternal
}
now := time.Now()
session := &model.UserLoginSession{ID: id, UserID: user.ID, UserAgent: userAgent, IP: ip, LastActiveAt: now, ExpiresAt: now.Add(time.Duration(token.RefreshExpireSeconds()) * time.Second), CreatedAt: now}
return s.issueForSession(user, session, userAgent, ip)
}
accessKey := cache.UserTokenKeyOf(user.ID, token.GenerateShortID(accessToken))
refreshKey := cache.RefreshTokenKeyOf(user.ID, token.GenerateShortID(refreshToken))
if err := common.SetValueWithExpired(accessKey, "1", token.AccessExpireSeconds()); err != nil {
func (s *UserService) issueForSession(user *model.User, session *model.UserLoginSession, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
accessToken, refreshToken, err := token.GenerateToken(user.ID, session.ID, user.Phone, []string{user.Role})
if err != nil {
return nil, common.ErrInternal
}
if err := common.SetValueWithExpired(refreshKey, refreshToken, token.RefreshExpireSeconds()); err != nil {
session.AccessTokenID = token.GenerateShortID(accessToken)
session.RefreshTokenID = token.GenerateShortID(refreshToken)
session.UserAgent, session.IP = userAgent, ip
session.LastActiveAt = time.Now()
session.ExpiresAt = time.Now().Add(time.Duration(token.RefreshExpireSeconds()) * time.Second)
if session.CreatedAt.IsZero() {
session.CreatedAt = time.Now()
}
if err := common.DB.Save(session).Error; err != nil {
logger.ERROR("保存登录会话失败", err)
return nil, common.ErrInternal
}
if err := common.SetValueWithExpired(cache.UserTokenKeyOf(user.ID, session.AccessTokenID), sessionValue(session.ID), token.AccessExpireSeconds()); err != nil {
return nil, common.ErrInternal
}
if err := common.SetValueWithExpired(cache.RefreshTokenKeyOf(user.ID, session.RefreshTokenID), sessionValue(session.ID), token.RefreshExpireSeconds()); err != nil {
return nil, common.ErrInternal
}
return &vo.LoginResp{AccessToken: accessToken, RefreshToken: refreshToken, User: vo.NewUserVO(user)}, nil
}
func (s *UserService) revokeSession(userID, sessionID int64) *common.BusiError {
var session model.UserLoginSession
if err := common.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrNotFound
}
return common.ErrInternal
}
now := time.Now()
if session.RevokedAt == nil {
_ = common.DB.Model(&session).Update("revoked_at", now).Error
}
s.deleteSessionTokens(userID, &session)
return nil
}
func (s *UserService) deleteUserSessions(userID int64) {
var sessions []model.UserLoginSession
if common.DB.Where("user_id = ?", userID).Find(&sessions).Error != nil {
return
}
for _, session := range sessions {
s.deleteSessionTokens(userID, &session)
}
}
return &vo.LoginResp{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: vo.NewUserVO(user),
}, nil
func (s *UserService) deleteSessionTokens(userID int64, session *model.UserLoginSession) {
_ = common.Delete(cache.UserTokenKeyOf(userID, session.AccessTokenID))
_ = common.Delete(cache.RefreshTokenKeyOf(userID, session.RefreshTokenID))
}
func sessionValue(sessionID int64) string { return stringID(sessionID) }
func stringID(id int64) string { return fmt.Sprintf("%d", id) }
func checkPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

18
service/video_service.go

@ -142,6 +142,19 @@ func (s *VideoService) Complete(userID int64, videoID int64, req *vo.VideoComple
}
return nil, common.ErrInternal
}
if video.Status == "ready" {
return &video, nil
}
if video.Status != "uploading" && video.Status != "pending" {
return nil, common.ErrVideoUploading
}
if video.UploadExpireAt != nil && time.Now().After(*video.UploadExpireAt) {
return nil, common.NewBusiError(common.ParamError, "视频上传已过期,请重新申请上传")
}
if err := headOSS(video.OssKey); err != nil {
logger.ERROR("确认视频对象失败", err)
return nil, common.NewBusiError(common.ParamError, "视频文件尚未上传完成")
}
updates := map[string]any{"status": "ready"}
if req.FileSize > 0 {
updates["file_size"] = req.FileSize
@ -149,8 +162,11 @@ func (s *VideoService) Complete(userID int64, videoID int64, req *vo.VideoComple
if req.Duration > 0 {
updates["duration"] = req.Duration
}
if err := common.DB.Model(&video).Updates(updates).Error; err != nil {
if result := common.DB.Model(&video).Where("id = ? AND user_id = ? AND status IN ?", videoID, userID, []string{"pending", "uploading"}).Updates(updates); result.Error != nil {
return nil, common.ErrInternal
} else if result.RowsAffected == 0 {
common.DB.First(&video, videoID)
return &video, nil
}
common.DB.First(&video, videoID)
return &video, nil

5
sql/001_schema.sql

@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `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,
UNIQUE KEY idx_phone (phone)
@ -40,7 +41,6 @@ CREATE TABLE IF NOT EXISTS dock (
latitude DOUBLE DEFAULT 0,
altitude DOUBLE DEFAULT 0,
location VARCHAR(128) DEFAULT '',
status VARCHAR(32) DEFAULT 'offline',
register_status VARCHAR(16) DEFAULT 'pending', -- pending/registered
dock_id_source VARCHAR(32) DEFAULT '',
software_ver VARCHAR(16) DEFAULT '',
@ -58,7 +58,6 @@ CREATE TABLE IF NOT EXISTS drone (
name VARCHAR(64) DEFAULT '',
code VARCHAR(32) DEFAULT '',
model VARCHAR(32) DEFAULT '',
status VARCHAR(32) DEFAULT 'offline',
battery INT DEFAULT 0,
firmware_ver VARCHAR(16) DEFAULT '',
created_at DATETIME,
@ -324,7 +323,7 @@ CREATE TABLE IF NOT EXISTS sim_recharge_log (
amount_gb INT NOT NULL,
unit_price DECIMAL(6,2),
total_price DECIMAL(10,2),
pay_status VARCHAR(16) DEFAULT 'paid',
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, -- 平台充值单号,超时重试去重

5
sql/003_seed.sql

@ -67,7 +67,4 @@ ON DUPLICATE KEY UPDATE
DELETE FROM drone WHERE drone_sn = 'JG-UAV-MOCK01';
DELETE FROM dock WHERE dock_id IN ('JG-DK-MOCK01', '333');
-- ---------------------------------------------------------------
-- 说明: Casbin RBAC 策略(g: user_id -> role / p: role -> path -> method)
-- 由应用首次启动时通过 gorm-adapter 自动建表并引导,不在本脚本中静态写入。
-- ---------------------------------------------------------------
-- 角色由 user.role 维护,管理员接口由路由 AdminMiddleware 保护。

26
sql/006_remove_device_status.sql

@ -0,0 +1,26 @@
-- 设备在线状态仅由 Redis 在线集合和心跳维护;执行前确认所有应用实例已升级。
DELIMITER $$
DROP PROCEDURE IF EXISTS remove_device_status_columns $$
CREATE PROCEDURE remove_device_status_columns()
BEGIN
DECLARE item_count INT DEFAULT 0;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'dock' AND column_name = 'status';
IF item_count > 0 THEN
ALTER TABLE dock DROP COLUMN status;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'drone' AND column_name = 'status';
IF item_count > 0 THEN
ALTER TABLE drone DROP COLUMN status;
END IF;
END $$
CALL remove_device_status_columns() $$
DROP PROCEDURE remove_device_status_columns $$
DELIMITER ;

2
sql/007_remove_casbin_rule.sql

@ -0,0 +1,2 @@
-- Casbin 已移除,策略表不再使用。
DROP TABLE IF EXISTS `casbin_rule`;

9
sql/008_billing_indexes.sql

@ -0,0 +1,9 @@
-- 账户中心分页与用户隔离查询索引
CREATE INDEX idx_traffic_order_user_created ON traffic_order (user_id, created_at DESC);
CREATE INDEX idx_traffic_order_user_status_created ON traffic_order (user_id, pay_status, created_at DESC);
CREATE INDEX idx_traffic_usage_user_created ON traffic_usage_log (user_id, created_at DESC);
CREATE INDEX idx_traffic_usage_user_source_created ON traffic_usage_log (user_id, source_type, created_at DESC);
CREATE INDEX idx_sim_card_user_created ON sim_card (user_id, created_at DESC);
CREATE INDEX idx_sim_card_user_status_created ON sim_card (user_id, status, created_at DESC);
CREATE INDEX idx_sim_recharge_user_created ON sim_recharge_log (user_id, created_at DESC);
CREATE INDEX idx_sim_recharge_user_status_created ON sim_recharge_log (user_id, recharge_status, created_at DESC);

49
sql/009_account_security_invoice.sql

@ -0,0 +1,49 @@
ALTER TABLE `user` ADD COLUMN `password_changed_at` DATETIME NULL AFTER `last_login`;
CREATE TABLE IF NOT EXISTS `user_login_session` (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
access_token_id VARCHAR(64) NOT NULL,
refresh_token_id VARCHAR(64) NOT NULL,
user_agent VARCHAR(512),
ip VARCHAR(64),
last_active_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME,
created_at DATETIME,
UNIQUE KEY uk_session_access (access_token_id),
UNIQUE KEY uk_session_refresh (refresh_token_id),
KEY idx_session_user_active (user_id, revoked_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `invoice_profile` (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(128) NOT NULL,
taxpayer_number VARCHAR(64) NOT NULL,
email VARCHAR(128) NOT NULL,
is_default TINYINT DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
KEY idx_invoice_profile_user (user_id, is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `invoice_request` (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
title VARCHAR(128) NOT NULL,
taxpayer_number VARCHAR(64) NOT NULL,
email VARCHAR(128) NOT NULL,
status VARCHAR(16) DEFAULT 'pending',
invoice_number VARCHAR(64),
admin_remark VARCHAR(256),
requested_at DATETIME,
processed_at DATETIME,
UNIQUE KEY uk_invoice_order (order_id),
KEY idx_invoice_request_user (user_id, requested_at),
KEY idx_invoice_request_status (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX idx_download_log_user_created ON download_log (user_id, created_at DESC);

16
token/token.go

@ -31,22 +31,25 @@ type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Roles []string `json:"roles"`
SessionID int64 `json:"session_id"`
jwt.RegisteredClaims
}
// RefreshClaims refresh token 载荷
type RefreshClaims struct {
UserID int64 `json:"user_id"`
SessionID int64 `json:"session_id"`
jwt.RegisteredClaims
}
// GenerateToken 生成 access + refresh token
func GenerateToken(userID int64, username string, roles []string) (string, string, error) {
func GenerateToken(userID, sessionID int64, username string, roles []string) (string, string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
Roles: roles,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(accessExpireH) * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
@ -60,6 +63,7 @@ func GenerateToken(userID int64, username string, roles []string) (string, strin
refreshClaims := RefreshClaims{
UserID: userID,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(refreshExpireH) * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
@ -85,16 +89,16 @@ func ParseToken(tokenString string) (*Claims, error) {
return nil, errors.New("invalid token")
}
// ParseRefreshToken 解析并校验 refresh token,返回用户 ID
func ParseRefreshToken(tokenString string) (int64, error) {
// ParseRefreshToken 解析并校验 refresh token,返回用户 ID 和会话 ID
func ParseRefreshToken(tokenString string) (int64, int64, error) {
token, err := jwt.ParseWithClaims(tokenString, &RefreshClaims{}, keyFunc)
if err != nil {
return 0, err
return 0, 0, err
}
if claims, ok := token.Claims.(*RefreshClaims); ok && token.Valid {
return claims.UserID, nil
return claims.UserID, claims.SessionID, nil
}
return 0, errors.New("invalid refresh token")
return 0, 0, errors.New("invalid refresh token")
}
func keyFunc(t *jwt.Token) (any, error) {

52
vo/account_extra_vo.go

@ -0,0 +1,52 @@
package vo
import "time"
type SessionVO struct {
ID int64 `json:"id"`
UserAgent string `json:"userAgent"`
IP string `json:"ip"`
LastActiveAt time.Time `json:"lastActiveAt"`
ExpiresAt time.Time `json:"expiresAt"`
Current bool `json:"current"`
}
type DownloadRecordVO struct {
ID int64 `json:"id"`
VideoID int64 `json:"videoId"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
DroneSN string `json:"droneSn"`
ExecutionID int64 `json:"executionId"`
Bytes int64 `json:"bytes"`
CreatedAt time.Time `json:"createdAt"`
}
type ResourceSummaryVO struct {
BalanceGb float64 `json:"balanceGb"`
MonthUsageGb float64 `json:"monthUsageGb"`
MonthLiveGb float64 `json:"monthLiveGb"`
MonthReplayGb float64 `json:"monthReplayGb"`
MonthDownloadGb float64 `json:"monthDownloadGb"`
SimCount int64 `json:"simCount"`
DockCount int64 `json:"dockCount"`
DroneCount int64 `json:"droneCount"`
VideoCount int64 `json:"videoCount"`
MonthDownloadCnt int64 `json:"monthDownloadCount"`
}
type InvoiceProfileReq struct {
Title string `json:"title" validate:"required,max=128"`
TaxpayerNumber string `json:"taxpayerNumber" validate:"required,max=64"`
Email string `json:"email" validate:"required,email"`
IsDefault bool `json:"isDefault"`
}
type InvoiceRequestCreateReq struct {
OrderID int64 `json:"orderId" validate:"required"`
}
type InvoiceReviewReq struct {
Status string `json:"status" validate:"required"`
InvoiceNumber string `json:"invoiceNumber"`
AdminRemark string `json:"adminRemark"`
}

18
vo/account_vo.go

@ -1,6 +1,22 @@
package vo
import "laic-backend/common"
import (
"time"
"laic-backend/common"
)
// ProfileVO 账户资料
type ProfileVO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
Role string `json:"role"`
Status int8 `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ProfileUpdateReq 更新个人资料
type ProfileUpdateReq struct {

12
vo/auth_vo.go

@ -9,7 +9,9 @@ import (
// UserLoginReq 登录请求
type UserLoginReq struct {
Phone string `json:"phone" validate:"required,phone"`
Password string `json:"password" validate:"required"`
Password string `json:"password" validate:"omitempty"`
Captcha string `json:"captcha" validate:"omitempty,len=4"`
SmsCode string `json:"smsCode" validate:"omitempty,len=6"`
}
// RegisterReq 注册请求
@ -18,11 +20,13 @@ type RegisterReq struct {
Phone string `json:"phone" validate:"required,phone"`
Email string `json:"email" validate:"omitempty,email"`
Password string `json:"password" validate:"required,password"`
SmsCode string `json:"smsCode" validate:"required,len=6"`
}
// SmsCodeReq 发送短信验证码请求
type SmsCodeReq struct {
Phone string `json:"phone" validate:"required,phone"`
Scene string `json:"scene" validate:"required,oneof=login register reset"`
}
// ResetPasswordReq 重置密码请求
@ -42,6 +46,12 @@ type LogoutReq struct {
RefreshToken string `json:"refreshToken"`
}
// ChangePasswordReq 登录后修改密码
type ChangePasswordReq struct {
OldPassword string `json:"oldPassword" validate:"required"`
NewPassword string `json:"newPassword" validate:"required,password"`
}
// UserVO 用户信息(脱敏,不含密码)
type UserVO struct {
ID int64 `json:"id"`

1
vo/dock_vo.go

@ -8,7 +8,6 @@ import (
// DockPageReq 机巢分页查询
type DockPageReq struct {
common.Pagination
Status string `json:"status" form:"status"`
RegisterStatus string `json:"registerStatus" form:"registerStatus"`
Keyword string `json:"keyword" form:"keyword"` // 匹配 name / dockId / code
}

1
vo/drone_vo.go

@ -8,7 +8,6 @@ import (
// DronePageReq 无人机分页查询
type DronePageReq struct {
common.Pagination
Status string `json:"status" form:"status"`
Keyword string `json:"keyword" form:"keyword"` // 匹配 droneSn / name / code / model
}

Loading…
Cancel
Save