From ee70de986eaafeb0132b1cbdb34950109d8baede Mon Sep 17 00:00:00 2001 From: liuhaodong Date: Fri, 21 Aug 2026 14:45:22 +0800 Subject: [PATCH] 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. --- DESIGN.md | 15 +- cache/cache_key.go | 10 + casbin.model | 14 - common/busi_error.go | 18 +- common/casbin.go | 54 ---- common/config.go | 9 +- config.yaml | 12 +- go.mod | 31 +-- go.sum | 253 ------------------ handler/account_handler.go | 165 ++++++------ handler/account_security_invoice_handler.go | 139 ++++++++++ handler/auth_handler.go | 48 ++-- handler/video_handler.go | 5 +- main.go | 4 +- middleware/auth.go | 34 +-- model/billing.go | 4 +- model/dock.go | 2 - model/invoice.go | 35 +++ model/user.go | 23 +- model/user_session.go | 19 ++ route/route.go | 16 +- service/account_resource_service.go | 68 +++++ service/account_service.go | 25 +- service/billing_service.go | 98 ++----- service/captcha_service.go | 120 +++++++++ service/casbin_bootstrap.go | 90 ------- service/dock_service.go | 11 +- service/drone_service.go | 9 - service/heartbeat.go | 23 +- service/invoice_service.go | 130 +++++++++ service/operation_log_service.go | 2 +- service/system_service.go | 6 - service/user_service.go | 282 +++++++++++++++----- service/video_service.go | 18 +- sql/001_schema.sql | 5 +- sql/003_seed.sql | 5 +- sql/006_remove_device_status.sql | 26 ++ sql/007_remove_casbin_rule.sql | 2 + sql/008_billing_indexes.sql | 9 + sql/009_account_security_invoice.sql | 49 ++++ token/token.go | 36 +-- vo/account_extra_vo.go | 52 ++++ vo/account_vo.go | 18 +- vo/auth_vo.go | 12 +- vo/dock_vo.go | 1 - vo/drone_vo.go | 1 - 46 files changed, 1164 insertions(+), 844 deletions(-) delete mode 100644 casbin.model delete mode 100644 common/casbin.go create mode 100644 handler/account_security_invoice_handler.go create mode 100644 model/invoice.go create mode 100644 model/user_session.go create mode 100644 service/account_resource_service.go create mode 100644 service/captcha_service.go delete mode 100644 service/casbin_bootstrap.go create mode 100644 service/invoice_service.go create mode 100644 sql/006_remove_device_status.sql create mode 100644 sql/007_remove_casbin_rule.sql create mode 100644 sql/008_billing_indexes.sql create mode 100644 sql/009_account_security_invoice.sql create mode 100644 vo/account_extra_vo.go diff --git a/DESIGN.md b/DESIGN.md index 78daf36..472f706 100644 --- a/DESIGN.md +++ b/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 引擎 + 告警生命周期** | diff --git a/cache/cache_key.go b/cache/cache_key.go index 9e97cd9..e5e97b8 100644 --- a/cache/cache_key.go +++ b/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) } diff --git a/casbin.model b/casbin.model deleted file mode 100644 index 907f24e..0000000 --- a/casbin.model +++ /dev/null @@ -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 == "*") diff --git a/common/busi_error.go b/common/busi_error.go index d9d786e..5a7e367 100644 --- a/common/busi_error.go +++ b/common/busi_error.go @@ -63,10 +63,11 @@ const ( LiveLeaseNotFound = 57006 // 计费 58xxx - TrafficNotEnough = 58001 - OrderPaid = 58002 - SimCardNotFound = 58003 - OrderNotFound = 58004 + TrafficNotEnough = 58001 + OrderPaid = 58002 + SimCardNotFound = 58003 + OrderNotFound = 58004 + CarrierUnavailable = 58005 ) // 预定义的业务错误 @@ -113,10 +114,11 @@ var ( ErrVideoNotFound = &BusiError{Code: VideoNotFound, Msg: "视频不存在"} ErrVideoUploading = &BusiError{Code: VideoUploading, Msg: "视频上传中"} - ErrTrafficNotEnough = &BusiError{Code: TrafficNotEnough, Msg: "流量不足,请充值"} - ErrOrderPaid = &BusiError{Code: OrderPaid, Msg: "该订单已支付"} - ErrSimCardNotFound = &BusiError{Code: SimCardNotFound, Msg: "SIM卡不存在"} - ErrOrderNotFound = &BusiError{Code: OrderNotFound, Msg: "订单不存在"} + ErrTrafficNotEnough = &BusiError{Code: TrafficNotEnough, Msg: "流量不足,请充值"} + ErrOrderPaid = &BusiError{Code: OrderPaid, Msg: "该订单已支付"} + ErrSimCardNotFound = &BusiError{Code: SimCardNotFound, Msg: "SIM卡不存在"} + ErrOrderNotFound = &BusiError{Code: OrderNotFound, Msg: "订单不存在"} + ErrCarrierUnavailable = &BusiError{Code: CarrierUnavailable, Msg: "运营商通道未接入"} ) // NewBusiError 创建自定义业务错误 diff --git a/common/casbin.go b/common/casbin.go deleted file mode 100644 index 2bb28ee..0000000 --- a/common/casbin.go +++ /dev/null @@ -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 -} diff --git a/common/config.go b/common/config.go index 012cbb3..6808f9e 100644 --- a/common/config.go +++ b/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()) } diff --git a/config.yaml b/config.yaml index ffed137..b20c8ae 100644 --- a/config.yaml +++ b/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 diff --git a/go.mod b/go.mod index 2503cd1..ffde7d1 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 533b71a..67625be 100644 --- a/go.sum +++ b/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= diff --git a/handler/account_handler.go b/handler/account_handler.go index 5887df5..f055a9d 100644 --- a/handler/account_handler.go +++ b/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 - } - if req.PageSize <= 0 { - req.PageSize = 10 + data, e := service.DefaultAccountService.GetUsagePage(common.GetUserId(c), &req) + if e != nil { + common.FailWithBusiError(c, e) + return } - page, busiErr := service.DefaultAccountService.GetUsagePage(common.GetUserId(c), &req) - if busiErr != nil { - common.FailWithBusiError(c, busiErr) + common.OKWithData(c, data) +} +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 + } } diff --git a/handler/account_security_invoice_handler.go b/handler/account_security_invoice_handler.go new file mode 100644 index 0000000..d8ec89f --- /dev/null +++ b/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) +} diff --git a/handler/auth_handler.go b/handler/auth_handler.go index 581d7ce..d2f9769 100644 --- a/handler/auth_handler.go +++ b/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 } diff --git a/handler/video_handler.go b/handler/video_handler.go index c33dce4..5a0e516 100644 --- a/handler/video_handler.go +++ b/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) diff --git a/main.go b/main.go index cd7d519..4e6e6be 100644 --- a/main.go +++ b/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() diff --git a/middleware/auth.go b/middleware/auth.go index 654e902..909fb9f 100644 --- a/middleware/auth.go +++ b/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() diff --git a/model/billing.go b/model/billing.go index a6f1d35..de1c602 100644 --- a/model/billing.go +++ b/model/billing.go @@ -45,7 +45,7 @@ type SimCard struct { PlanGb int `gorm:"column:plan_gb;type:INT" json:"planGb"` UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4);default:0" json:"usedGb"` ExpiredAt *time.Time `gorm:"column:expired_at;type:DATE" json:"expiredAt"` - Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` // active / expired + Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` // active / expired CarrierStatus string `gorm:"column:carrier_status;type:VARCHAR(16);default:normal" json:"carrierStatus"` // normal / suspended / arrears / cancelled LastSyncAt *time.Time `gorm:"column:last_sync_at" json:"lastSyncAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` @@ -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"` diff --git a/model/dock.go b/model/dock.go index afe8120..602993d 100644 --- a/model/dock.go +++ b/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"` diff --git a/model/invoice.go b/model/invoice.go new file mode 100644 index 0000000..6cb4ac3 --- /dev/null +++ b/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" } diff --git a/model/user.go b/model/user.go index f54dbe5..d3bd231 100644 --- a/model/user.go +++ b/model/user.go @@ -4,17 +4,18 @@ import "time" // User 用户表 type User struct { - ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` - Name string `gorm:"column:name;type:VARCHAR(32);not null" json:"name"` - Phone string `gorm:"column:phone;type:VARCHAR(32);not null;uniqueIndex:idx_phone" json:"phone"` - Email string `gorm:"column:email;type:VARCHAR(64)" json:"email"` - Password string `gorm:"column:password;type:VARCHAR(64);not null" json:"-"` - Role string `gorm:"column:role;type:VARCHAR(16);default:user" json:"role"` - 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"` - CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` - UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + Name string `gorm:"column:name;type:VARCHAR(32);not null" json:"name"` + Phone string `gorm:"column:phone;type:VARCHAR(32);not null;uniqueIndex:idx_phone" json:"phone"` + Email string `gorm:"column:email;type:VARCHAR(64)" json:"email"` + Password string `gorm:"column:password;type:VARCHAR(64);not null" json:"-"` + Role string `gorm:"column:role;type:VARCHAR(16);default:user" json:"role"` + 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"` } func (User) TableName() string { diff --git a/model/user_session.go b/model/user_session.go new file mode 100644 index 0000000..1813f6f --- /dev/null +++ b/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" } diff --git a/route/route.go b/route/route.go index c9335f8..dd9d428 100644 --- a/route/route.go +++ b/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) } // 机巢 & 无人机 diff --git a/service/account_resource_service.go b/service/account_resource_service.go new file mode 100644 index 0000000..5f28f96 --- /dev/null +++ b/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 +} diff --git a/service/account_service.go b/service/account_service.go index f0780f0..d3991ed 100644 --- a/service/account_service.go +++ b/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 查询云媒体流量余额 diff --git a/service/billing_service.go b/service/billing_service.go index b1d0f52..3fbe351 100644 --- a/service/billing_service.go +++ b/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,11 +158,8 @@ 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 { - return err - } - return common.DB.Model(&model.User{}).Where("id = ?", userID). - UpdateColumn("traffic_balance", gorm.Expr("traffic_balance + ?", bytes)).Error + _, err := common.IncrBy(key, bytes) + return err } // GetUsagePage 流量消费流水分页 @@ -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 - } - if order.PayStatus == "paid" { - return nil, 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.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { + return err + } + if order.PayStatus == "paid" { + return common.ErrOrderPaid + } + bytes := int64(order.AmountGb) * gbBytes + 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) diff --git a/service/captcha_service.go b/service/captcha_service.go new file mode 100644 index 0000000..169f097 --- /dev/null +++ b/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 } diff --git a/service/casbin_bootstrap.go b/service/casbin_bootstrap.go deleted file mode 100644 index 6284a17..0000000 --- a/service/casbin_bootstrap.go +++ /dev/null @@ -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) - } - } -} diff --git a/service/dock_service.go b/service/dock_service.go index b1c160a..6420a0a 100644 --- a/service/dock_service.go +++ b/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 } diff --git a/service/drone_service.go b/service/drone_service.go index c51255b..ddccea1 100644 --- a/service/drone_service.go +++ b/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(), } diff --git a/service/heartbeat.go b/service/heartbeat.go index 78531c6..7c1404e 100644 --- a/service/heartbeat.go +++ b/service/heartbeat.go @@ -6,7 +6,6 @@ import ( "laic-backend/cache" "laic-backend/common" "laic-backend/logger" - "laic-backend/model" ) func heartbeatTimeout() uint { @@ -57,29 +56,11 @@ 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}) - } + 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}) - } + broadcast("drone.status", map[string]any{"droneSn": droneSN, "online": false}) } func heartbeatScanInterval() time.Duration { diff --git a/service/invoice_service.go b/service/invoice_service.go new file mode 100644 index 0000000..80c9a0c --- /dev/null +++ b/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 +} diff --git a/service/operation_log_service.go b/service/operation_log_service.go index f971247..aab5f45 100644 --- a/service/operation_log_service.go +++ b/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) } } diff --git a/service/system_service.go b/service/system_service.go index 5a5b662..de29656 100644 --- a/service/system_service.go +++ b/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 } diff --git a/service/user_service.go b/service/user_service.go index b20b150..8e76c41 100644 --- a/service/user_service.go +++ b/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) + _ = common.Delete(cache.UserTokenKeyOf(userID, session.AccessTokenID)) + return s.issueForSession(&user, &session, userAgent, ip) +} - return s.buildLoginResp(&user) +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 != "" { + _ = common.Delete(cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken))) + } + return nil + } + return s.revokeSession(userID, sessionID) } -// 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) - if refreshToken != "" { - refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken)) - _ = common.Delete(refreshKey) +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 } -// SendSmsCode 生成并"发送"短信验证码(暂存 Redis,短信通道未接入前记日志) -func (s *UserService) SendSmsCode(phone string) (string, *common.BusiError) { +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 +} + +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 + } + 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(refreshKey, refreshToken, token.RefreshExpireSeconds()); err != nil { + 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 } diff --git a/service/video_service.go b/service/video_service.go index 2249935..9c39ca1 100644 --- a/service/video_service.go +++ b/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 diff --git a/sql/001_schema.sql b/sql/001_schema.sql index 4d246c0..3abfe3f 100644 --- a/sql/001_schema.sql +++ b/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, -- 平台充值单号,超时重试去重 diff --git a/sql/003_seed.sql b/sql/003_seed.sql index 31de681..f757b98 100644 --- a/sql/003_seed.sql +++ b/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 保护。 \ No newline at end of file diff --git a/sql/006_remove_device_status.sql b/sql/006_remove_device_status.sql new file mode 100644 index 0000000..38fd9d2 --- /dev/null +++ b/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 ; diff --git a/sql/007_remove_casbin_rule.sql b/sql/007_remove_casbin_rule.sql new file mode 100644 index 0000000..3276a82 --- /dev/null +++ b/sql/007_remove_casbin_rule.sql @@ -0,0 +1,2 @@ +-- Casbin 已移除,策略表不再使用。 +DROP TABLE IF EXISTS `casbin_rule`; diff --git a/sql/008_billing_indexes.sql b/sql/008_billing_indexes.sql new file mode 100644 index 0000000..72c7db9 --- /dev/null +++ b/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); diff --git a/sql/009_account_security_invoice.sql b/sql/009_account_security_invoice.sql new file mode 100644 index 0000000..51ad3a1 --- /dev/null +++ b/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); diff --git a/token/token.go b/token/token.go index 379411b..35ed8e4 100644 --- a/token/token.go +++ b/token/token.go @@ -11,8 +11,8 @@ import ( var ( jwtSecret []byte - accessExpireH = 7 * 24 // 默认 7 天 - refreshExpireH = 30 * 24 // 默认 30 天 + accessExpireH = 7 * 24 // 默认 7 天 + refreshExpireH = 30 * 24 // 默认 30 天 ) // Init 初始化 JWT 密钥与有效期(main 启动时从配置加载) @@ -28,25 +28,28 @@ func Init(secret string, accessH, refreshH int) { // Claims access token 载荷 type Claims struct { - UserID int64 `json:"user_id"` - Username string `json:"username"` - Roles []string `json:"roles"` + 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"` + 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, + 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), @@ -59,7 +62,8 @@ func GenerateToken(userID int64, username string, roles []string) (string, strin } refreshClaims := RefreshClaims{ - UserID: userID, + 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) { diff --git a/vo/account_extra_vo.go b/vo/account_extra_vo.go new file mode 100644 index 0000000..0272a29 --- /dev/null +++ b/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"` +} diff --git a/vo/account_vo.go b/vo/account_vo.go index 5e6bd43..2f816e8 100644 --- a/vo/account_vo.go +++ b/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 { diff --git a/vo/auth_vo.go b/vo/auth_vo.go index d9c158e..25e63e0 100644 --- a/vo/auth_vo.go +++ b/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"` diff --git a/vo/dock_vo.go b/vo/dock_vo.go index e8e993e..834819c 100644 --- a/vo/dock_vo.go +++ b/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 } diff --git a/vo/drone_vo.go b/vo/drone_vo.go index 54954f8..b651e43 100644 --- a/vo/drone_vo.go +++ b/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 }