diff --git a/cmd/api/docs.go b/cmd/api/docs.go index f51356b..569212a 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -21,6 +21,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { app := fiber.New() // 3. 创建所有 Handler(使用 nil 依赖,因为只需要路由结构) + // 新增 Handler 必须注册到 openapi.BuildDocHandlers,代理开放接口也从该入口进入文档生成器。 handlers := openapi.BuildDocHandlers() // 4. 注册所有路由到文档生成器 diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index ba9feca..69914a9 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -30,6 +30,7 @@ func generateAdminDocs(outputPath string) error { app := fiber.New() // 3. 创建所有 Handler(使用 nil 依赖,因为只需要路由结构) + // 新增 Handler 必须注册到 openapi.BuildDocHandlers,代理开放接口也从该入口进入文档生成器。 handlers := openapi.BuildDocHandlers() // 4. 注册所有路由到文档生成器 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 5ad14f8..f79ccef 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -5,6 +5,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/handler/app" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" "github.com/break/junhong_cmp_fiber/internal/handler/callback" + openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" @@ -142,5 +143,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { Refund: admin.NewRefundHandler(svc.Refund), ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger), SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword), + AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate), } } diff --git a/internal/bootstrap/middlewares.go b/internal/bootstrap/middlewares.go index ea324d3..8a1ae26 100644 --- a/internal/bootstrap/middlewares.go +++ b/internal/bootstrap/middlewares.go @@ -32,9 +32,16 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares { // 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID) adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop) + agentOpenAPIAuthMiddleware := middleware.NewAgentOpenAPIAuthMiddleware(middleware.AgentOpenAPIAuthConfig{ + AccountStore: stores.Account, + ShopStore: stores.Shop, + Redis: deps.Redis, + }) + return &Middlewares{ - PersonalAuth: personalAuthMiddleware, - AdminAuth: adminAuthMiddleware, + PersonalAuth: personalAuthMiddleware, + AdminAuth: adminAuthMiddleware, + AgentOpenAPIAuth: agentOpenAPIAuthMiddleware, } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index e0c947a..22322b9 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -8,6 +8,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/polling" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit" + agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api" assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" @@ -107,6 +108,7 @@ type services struct { Refund *refundSvc.Service TrafficQuery *trafficSvc.QueryService OperationPassword *operationPasswordSvc.Service + AgentOpenAPI *agentOpenAPISvc.Service } func initServices(s *stores, deps *Dependencies) *services { @@ -185,6 +187,11 @@ func initServices(s *stores, deps *Dependencies) *services { assetAudit, ) operationPassword := operationPasswordSvc.New(deps.Redis) + shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger) + packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation) + orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone) + assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder) + agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet) return &services{ Account: account, @@ -209,7 +216,7 @@ func initServices(s *stores, deps *Dependencies) *services { ), Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet), Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger), - ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger), + ShopCommission: shopCommission, CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest), CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting), CommissionCalculation: commissionCalculationSvc.New( @@ -242,7 +249,7 @@ func initServices(s *stores, deps *Dependencies) *services { AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), Carrier: carrierSvc.New(s.Carrier), PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package), - Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation), + Package: packageService, PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger), PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger), ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop), @@ -250,7 +257,7 @@ func initServices(s *stores, deps *Dependencies) *services { ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger), CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), PurchaseValidation: purchaseValidation, - Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone), + Order: orderService, Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger), Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger), PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger), @@ -259,7 +266,7 @@ func initServices(s *stores, deps *Dependencies) *services { PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger), PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger), PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger), - Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder), + Asset: assetService, AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit), AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction), StopResumeService: stopResumeService, @@ -279,6 +286,7 @@ func initServices(s *stores, deps *Dependencies) *services { PackageActivation: packageActivation, TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), OperationPassword: operationPassword, + AgentOpenAPI: agentOpenAPI, Refund: refundSvc.New( deps.DB, s.RefundRequest, diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index ed88224..75d870e 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -5,6 +5,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/handler/app" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" "github.com/break/junhong_cmp_fiber/internal/handler/callback" + openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi" "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/gofiber/fiber/v2" ) @@ -64,12 +65,14 @@ type Handlers struct { Refund *admin.RefundHandler ClientWechat *app.ClientWechatHandler SuperAdmin *admin.SuperAdminHandler + AgentOpenAPI *openapiHandler.Handler } // Middlewares 封装所有中间件 // 用于路由注册 type Middlewares struct { - PersonalAuth *middleware.PersonalAuthMiddleware - AdminAuth func(*fiber.Ctx) error + PersonalAuth *middleware.PersonalAuthMiddleware + AdminAuth func(*fiber.Ctx) error + AgentOpenAPIAuth func(*fiber.Ctx) error // TODO: 新增 Middleware 在此添加字段 } diff --git a/internal/gateway/card_status.go b/internal/gateway/card_status.go new file mode 100644 index 0000000..80ef2e0 --- /dev/null +++ b/internal/gateway/card_status.go @@ -0,0 +1,32 @@ +package gateway + +import ( + "strings" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// ParseCardNetworkStatus 将 Gateway 卡状态转换为系统网络状态。 +// 只有网关明确返回“正常”才视为开机;“准备”或“待激活”不能按正常卡展示。 +func ParseCardNetworkStatus(cardStatus, extend string) (int, bool) { + status := strings.TrimSpace(cardStatus) + ext := strings.TrimSpace(extend) + + switch status { + case constants.GatewayCardStatusNormal: + return constants.NetworkStatusOnline, true + case constants.GatewayCardStatusStopped, constants.GatewayCardStatusReady: + return constants.NetworkStatusOffline, true + } + + if ext == constants.GatewayCardExtendPendingActivation { + return constants.NetworkStatusOffline, true + } + + return constants.NetworkStatusOffline, false +} + +// IsGatewayCardStopped 判断 Gateway 是否明确返回停机状态。 +func IsGatewayCardStopped(cardStatus string) bool { + return strings.TrimSpace(cardStatus) == constants.GatewayCardStatusStopped +} diff --git a/internal/gateway/models.go b/internal/gateway/models.go index b9e6638..7fe2982 100644 --- a/internal/gateway/models.go +++ b/internal/gateway/models.go @@ -46,6 +46,37 @@ func (i *FlexInt) UnmarshalJSON(data []byte) error { return nil } +// FlexFloat 灵活浮点类型 +// Gateway 部分流量字段会在 number / string 之间漂移,此类型统一解析为 float64。 +type FlexFloat float64 + +// UnmarshalJSON 实现 json.Unmarshaler 接口 +// 支持:0.00, "0.00", null +func (f *FlexFloat) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *f = 0 + return nil + } + + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + *f = 0 + return nil + } + raw = strings.TrimSpace(unquoted) + } + + n, err := strconv.ParseFloat(raw, 64) + if err != nil { + *f = 0 + return nil + } + *f = FlexFloat(n) + return nil +} + // FlexString 灵活字符串类型 // Gateway 部分字段会在 string / number / bool 之间漂移,此类型统一转为字符串避免解析失败。 type FlexString string @@ -91,7 +122,7 @@ type CardStatusReq struct { // CardStatusResp 是查询流量卡状态的响应 type CardStatusResp struct { ICCID string `json:"iccid" description:"ICCID"` - CardStatus string `json:"cardStatus" description:"卡状态(准备、正常、停机)"` + CardStatus string `json:"cardStatus" description:"卡状态(准备、正常、停机;仅正常表示开机)"` Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"` } @@ -102,9 +133,9 @@ type FlowQueryReq struct { // FlowUsageResp 是查询流量使用的响应 type FlowUsageResp struct { - ICCID string `json:"iccid" description:"ICCID"` - Used float64 `json:"used" description:"当月已用流量(MB)"` - Unit string `json:"unit" description:"流量单位(MB)"` + ICCID string `json:"iccid" description:"ICCID"` + Used FlexFloat `json:"used" description:"当月已用流量(MB,兼容数字或字符串)"` + Unit string `json:"unit" description:"流量单位(MB)"` } // CardOperationReq 是停机/复机请求 diff --git a/internal/handler/openapi/handler.go b/internal/handler/openapi/handler.go new file mode 100644 index 0000000..759d275 --- /dev/null +++ b/internal/handler/openapi/handler.go @@ -0,0 +1,153 @@ +package openapi + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + agentOpenAPIService "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// Handler 代理开放接口处理器 +type Handler struct { + service *agentOpenAPIService.Service + validator *validator.Validate +} + +// NewHandler 创建代理开放接口处理器 +func NewHandler(service *agentOpenAPIService.Service, validator *validator.Validate) *Handler { + return &Handler{ + service: service, + validator: validator, + } +} + +// GetCardTraffic 查询单卡流量 +// GET /api/open/v1/cards/traffic +func (h *Handler) GetCardTraffic(c *fiber.Ctx) error { + var req dto.AgentOpenAPICardQueryRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + + resp, err := h.service.GetCardTraffic(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, resp) +} + +// GetCardStatus 查询单卡状态 +// GET /api/open/v1/cards/status +func (h *Handler) GetCardStatus(c *fiber.Ctx) error { + var req dto.AgentOpenAPICardQueryRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + + resp, err := h.service.GetCardStatus(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, resp) +} + +// GetRealnameStatus 查询单卡实名状态 +// GET /api/open/v1/cards/realname-status +func (h *Handler) GetRealnameStatus(c *fiber.Ctx) error { + var req dto.AgentOpenAPICardQueryRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + + resp, err := h.service.GetRealnameStatus(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, resp) +} + +// ListPackages 查询代理可购买套餐列表 +// GET /api/open/v1/packages +func (h *Handler) ListPackages(c *fiber.Ctx) error { + var req dto.AgentOpenAPIPackageListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + + items, total, page, pageSize, err := h.service.ListPackages(c.UserContext(), &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, items, total, page, pageSize) +} + +// CreateWalletPackageOrders 使用预充值钱包购买套餐 +// POST /api/open/v1/wallet/package-orders +func (h *Handler) CreateWalletPackageOrders(c *fiber.Ctx) error { + var req dto.AgentOpenAPIWalletPackageOrderRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + + resp, err := h.service.CreateWalletPackageOrders(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, resp) +} + +// GetWalletBalance 查询预充值钱包余额 +// GET /api/open/v1/wallet/balance +func (h *Handler) GetWalletBalance(c *fiber.Ctx) error { + resp, err := h.service.GetWalletBalance(c.UserContext()) + if err != nil { + return err + } + return response.Success(c, resp) +} + +// ListWalletTransactions 查询预充值钱包流水 +// GET /api/open/v1/wallet/transactions +func (h *Handler) ListWalletTransactions(c *fiber.Ctx) error { + var req dto.AgentOpenAPIWalletTransactionListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validate(&req); err != nil { + return err + } + + result, err := h.service.ListWalletTransactions(c.UserContext(), &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) +} + +func (h *Handler) validate(req any) error { + if h.validator == nil { + return nil + } + if err := h.validator.Struct(req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + return nil +} diff --git a/internal/middleware/agent_open_api_auth.go b/internal/middleware/agent_open_api_auth.go new file mode 100644 index 0000000..8d3213a --- /dev/null +++ b/internal/middleware/agent_open_api_auth.go @@ -0,0 +1,249 @@ +package middleware + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// AgentOpenAPIAuthConfig 代理开放接口认证中间件配置 +type AgentOpenAPIAuthConfig struct { + AccountStore *postgres.AccountStore + ShopStore *postgres.ShopStore + Redis *redis.Client +} + +// NewAgentOpenAPIAuthMiddleware 创建代理开放接口认证中间件 +func NewAgentOpenAPIAuthMiddleware(config AgentOpenAPIAuthConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + if c.Method() == fiber.MethodOptions { + return c.SendStatus(fiber.StatusNoContent) + } + + credentials := extractAgentOpenAPICredentials(c) + if err := credentials.validateRequired(); err != nil { + return err + } + + account, err := authenticateAgentAccountForOpenAPI(c.UserContext(), config, credentials) + if err != nil { + return err + } + + if err := validateAgentOpenAPITimestamp(credentials.Timestamp, time.Now()); err != nil { + return err + } + + if err := reserveAgentOpenAPINonce(c.UserContext(), config.Redis, credentials.Account, credentials.Nonce); err != nil { + return err + } + + payload := buildAgentOpenAPISignPayload(c, credentials) + if err := verifyAgentOpenAPISign(payload, credentials.Password, credentials.Sign); err != nil { + return err + } + + if err := injectAgentOpenAPIContext(c, config.ShopStore, account); err != nil { + return err + } + + return c.Next() + } +} + +type agentOpenAPICredentials struct { + Account string + Password string + Timestamp string + Nonce string + Sign string +} + +func extractAgentOpenAPICredentials(c *fiber.Ctx) agentOpenAPICredentials { + return agentOpenAPICredentials{ + Account: strings.TrimSpace(c.Get(constants.AgentOpenAPIHeaderAccount)), + Password: c.Get(constants.AgentOpenAPIHeaderPassword), + Timestamp: strings.TrimSpace(c.Get(constants.AgentOpenAPIHeaderTimestamp)), + Nonce: strings.TrimSpace(c.Get(constants.AgentOpenAPIHeaderNonce)), + Sign: strings.TrimSpace(c.Get(constants.AgentOpenAPIHeaderSign)), + } +} + +func (c agentOpenAPICredentials) validateRequired() error { + if c.Account == "" || c.Password == "" || c.Timestamp == "" || c.Nonce == "" || c.Sign == "" { + return errors.New(errors.CodeOpenAPIAuthFailed) + } + return nil +} + +func authenticateAgentAccountForOpenAPI(ctx context.Context, config AgentOpenAPIAuthConfig, credentials agentOpenAPICredentials) (*model.Account, error) { + if config.AccountStore == nil { + return nil, errors.New(errors.CodeInternalError, "开放接口认证依赖未配置") + } + + account, err := config.AccountStore.GetByUsernameOrPhone(ctx, credentials.Account) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeOpenAPIAuthFailed) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询开放接口账号失败") + } + + if account.UserType != constants.UserTypeAgent || account.ShopID == nil || *account.ShopID == 0 { + return nil, errors.New(errors.CodeForbidden, "开放接口仅允许代理账号调用") + } + if account.Status != constants.StatusEnabled { + return nil, errors.New(errors.CodeOpenAPIAuthFailed) + } + if config.ShopStore == nil { + return nil, errors.New(errors.CodeInternalError, "开放接口店铺认证依赖未配置") + } + shop, err := config.ShopStore.GetByID(ctx, *account.ShopID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeOpenAPIAuthFailed) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询开放接口代理店铺失败") + } + if shop.Status != constants.StatusEnabled { + return nil, errors.New(errors.CodeOpenAPIAuthFailed) + } + if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)); err != nil { + return nil, errors.New(errors.CodeOpenAPIAuthFailed) + } + + return account, nil +} + +func validateAgentOpenAPITimestamp(raw string, now time.Time) error { + requestTime, err := parseAgentOpenAPITimestamp(raw) + if err != nil { + return errors.New(errors.CodeOpenAPITimestampInvalid) + } + diff := now.Sub(requestTime) + if diff < 0 { + diff = -diff + } + if diff > constants.AgentOpenAPISignatureWindow { + return errors.New(errors.CodeOpenAPITimestampInvalid) + } + return nil +} + +func parseAgentOpenAPITimestamp(raw string) (time.Time, error) { + if ts, err := strconv.ParseInt(raw, 10, 64); err == nil { + if len(raw) == 13 { + return time.UnixMilli(ts), nil + } + return time.Unix(ts, 0), nil + } + return time.Parse(time.RFC3339, raw) +} + +func reserveAgentOpenAPINonce(ctx context.Context, redisClient *redis.Client, account, nonce string) error { + if redisClient == nil { + return errors.New(errors.CodeInternalError, "Redis 未配置") + } + + key := constants.RedisAgentOpenAPINonceKey(account, nonce) + ok, err := redisClient.SetNX(ctx, key, "1", constants.AgentOpenAPISignatureWindow).Result() + if err != nil { + return errors.Wrap(errors.CodeRedisError, err, "记录开放接口 nonce 失败") + } + if !ok { + return errors.New(errors.CodeOpenAPINonceReplay) + } + return nil +} + +func buildAgentOpenAPISignPayload(c *fiber.Ctx, credentials agentOpenAPICredentials) string { + bodyHash := sha256.Sum256(c.Body()) + parts := []string{ + strings.ToUpper(c.Method()), + c.Path(), + canonicalAgentOpenAPIQuery(string(c.Request().URI().QueryString())), + hex.EncodeToString(bodyHash[:]), + credentials.Timestamp, + credentials.Nonce, + credentials.Account, + } + return strings.Join(parts, "\n") +} + +func canonicalAgentOpenAPIQuery(rawQuery string) string { + values, err := url.ParseQuery(rawQuery) + if err != nil || len(values) == 0 { + return "" + } + for key := range values { + if strings.EqualFold(key, "sign") { + delete(values, key) + } + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + sort.Strings(values[key]) + } + sort.Strings(keys) + + pairs := make([]string, 0, len(keys)) + for _, key := range keys { + for _, value := range values[key] { + pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value)) + } + } + return strings.Join(pairs, "&") +} + +func verifyAgentOpenAPISign(payload, password, providedSign string) error { + mac := hmac.New(sha256.New, []byte(password)) + _, _ = mac.Write([]byte(payload)) + expectedSign := hex.EncodeToString(mac.Sum(nil)) + if subtle.ConstantTimeCompare([]byte(expectedSign), []byte(strings.ToLower(providedSign))) != 1 { + return errors.New(errors.CodeOpenAPISignInvalid) + } + return nil +} + +func injectAgentOpenAPIContext(c *fiber.Ctx, shopStore *postgres.ShopStore, account *model.Account) error { + shopID := uint(0) + if account.ShopID != nil { + shopID = *account.ShopID + } + shopIDs := []uint{shopID} + if shopStore != nil && shopID > 0 { + var err error + shopIDs, err = shopStore.GetSubordinateShopIDs(c.UserContext(), shopID) + if err != nil || len(shopIDs) == 0 { + shopIDs = []uint{shopID} + } + } + + info := &pkgmiddleware.UserContextInfo{ + UserID: account.ID, + UserType: constants.UserTypeAgent, + Username: account.Username, + ShopID: shopID, + SubordinateShopIDs: shopIDs, + } + pkgmiddleware.SetUserToFiberContext(c, info) + return nil +} diff --git a/internal/model/dto/agent_open_api_dto.go b/internal/model/dto/agent_open_api_dto.go new file mode 100644 index 0000000..5221c09 --- /dev/null +++ b/internal/model/dto/agent_open_api_dto.go @@ -0,0 +1,119 @@ +package dto + +import "time" + +// AgentOpenAPICardQueryRequest 开放接口单卡查询请求 +type AgentOpenAPICardQueryRequest struct { + CardNo string `json:"card_no" query:"card_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"卡标识(支持 ICCID、虚拟号、MSISDN)"` +} + +// AgentOpenAPIPackageTrafficItem 开放接口套餐流量项 +type AgentOpenAPIPackageTrafficItem struct { + PackageCode string `json:"package_code" description:"套餐编码"` + PackageName string `json:"package_name" description:"套餐名称"` + SeriesName string `json:"series_name" description:"套餐系列名称"` + PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"` + PackageTypeName string `json:"package_type_name" description:"套餐类型名称(中文)"` + TotalFlowMB int64 `json:"total_flow_mb" description:"套餐总流量(MB)"` + UsedFlowMB int64 `json:"used_flow_mb,omitempty" description:"套餐已用流量(MB,仅生效套餐返回)"` + RemainingFlowMB int64 `json:"remaining_flow_mb,omitempty" description:"套餐剩余流量(MB,仅生效套餐返回)"` + StartAt *time.Time `json:"start_at,omitempty" description:"套餐生效时间"` + ExpiresAt *time.Time `json:"expires_at,omitempty" description:"套餐过期时间"` + ValidDays int `json:"valid_days,omitempty" description:"套餐有效天数(待生效套餐返回)"` + Priority int `json:"priority,omitempty" description:"生效优先级(数字越小优先级越高)"` +} + +// AgentOpenAPICardTrafficResponse 开放接口卡流量响应 +type AgentOpenAPICardTrafficResponse struct { + CardNo string `json:"card_no" description:"卡标识"` + ActiveRemainingFlowMB int64 `json:"active_remaining_flow_mb" description:"当前生效套餐剩余流量(MB)"` + ActiveTotalFlowMB int64 `json:"active_total_flow_mb" description:"当前生效套餐总流量(MB)"` + ActiveUsedFlowMB int64 `json:"active_used_flow_mb" description:"当前生效套餐已用流量(MB)"` + ActivePackages []AgentOpenAPIPackageTrafficItem `json:"active_packages" description:"当前生效套餐列表"` + PendingPackages []AgentOpenAPIPackageTrafficItem `json:"pending_packages" description:"待生效套餐列表"` + ActiveExpiresAt *time.Time `json:"active_expires_at,omitempty" description:"当前生效套餐最晚过期时间"` +} + +// AgentOpenAPICardStatusResponse 开放接口卡状态响应 +type AgentOpenAPICardStatusResponse struct { + CardNo string `json:"card_no" description:"卡标识"` + CardStatus string `json:"card_status" description:"卡状态 (normal:正常, stopped:停机)"` + CardStatusName string `json:"card_status_name" description:"卡状态名称(中文)"` + StopReason string `json:"stop_reason" description:"停机原因"` + StopReasonName string `json:"stop_reason_name" description:"停机原因名称(中文)"` +} + +// AgentOpenAPIRealnameStatusResponse 开放接口实名状态响应 +type AgentOpenAPIRealnameStatusResponse struct { + CardNo string `json:"card_no" description:"卡标识"` + IsRealnamed bool `json:"is_realnamed" description:"是否已实名"` +} + +// AgentOpenAPIPackageListRequest 开放接口套餐列表请求 +type AgentOpenAPIPackageListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + PackageType *string `json:"package_type" query:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"` + SeriesID *uint `json:"series_id" query:"series_id" validate:"omitempty" description:"套餐系列ID"` + PackageName *string `json:"package_name" query:"package_name" validate:"omitempty,max=255" maxLength:"255" description:"套餐名称(模糊搜索)"` +} + +// AgentOpenAPIPackageItem 开放接口套餐列表项 +type AgentOpenAPIPackageItem struct { + PackageID uint `json:"package_id" description:"套餐ID"` + PackageCode string `json:"package_code" description:"套餐编码"` + PackageName string `json:"package_name" description:"套餐名称"` + SeriesName string `json:"series_name" description:"套餐系列名称"` + PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"` + PackageTypeName string `json:"package_type_name" description:"套餐类型名称(中文)"` + CostPrice int64 `json:"cost_price" description:"成本价(分)"` + RetailPrice int64 `json:"retail_price" description:"生效零售价(分)"` + TotalFlowMB int64 `json:"total_flow_mb" description:"总流量(MB)"` + ValidDays int `json:"valid_days" description:"有效天数"` +} + +// AgentOpenAPIWalletBalanceResponse 开放接口预充值钱包余额响应 +type AgentOpenAPIWalletBalanceResponse struct { + Balance int64 `json:"balance" description:"钱包余额(分)"` + FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"` + AvailableBalance int64 `json:"available_balance" description:"可用余额(分)"` + Currency string `json:"currency" description:"币种"` +} + +// AgentOpenAPIWalletTransactionListRequest 开放接口预充值钱包流水请求 +type AgentOpenAPIWalletTransactionListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + TransactionType string `json:"transaction_type" query:"transaction_type" validate:"omitempty,max=50" maxLength:"50" description:"交易类型过滤(recharge:充值入账, deduct:套餐扣款, refund:退款)"` + StartDate string `json:"start_date" query:"start_date" validate:"omitempty" description:"开始日期(YYYY-MM-DD)"` + EndDate string `json:"end_date" query:"end_date" validate:"omitempty" description:"结束日期(YYYY-MM-DD)"` +} + +// AgentOpenAPIWalletPackageOrderRequest 开放接口钱包套餐购买请求 +type AgentOpenAPIWalletPackageOrderRequest struct { + CardNos []string `json:"card_nos" validate:"required,min=1,max=100,dive,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"卡标识列表(支持 ICCID、虚拟号、MSISDN)"` + PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码(一次只能购买一个套餐)"` +} + +// AgentOpenAPIWalletPackageOrderItem 开放接口钱包套餐购买成功项 +type AgentOpenAPIWalletPackageOrderItem struct { + CardNo string `json:"card_no" description:"卡标识"` + OrderNo string `json:"order_no" description:"订单号"` +} + +// AgentOpenAPIWalletPackageOrderFailure 开放接口钱包套餐购买失败项 +type AgentOpenAPIWalletPackageOrderFailure struct { + CardNo string `json:"card_no" description:"卡标识"` + Code int `json:"code" description:"错误码"` + Message string `json:"message" description:"失败原因"` +} + +// AgentOpenAPIWalletPackageOrderResponse 开放接口钱包套餐购买响应 +type AgentOpenAPIWalletPackageOrderResponse struct { + BatchNo string `json:"batch_no" description:"批次号"` + PackageCode string `json:"package_code" description:"套餐编码"` + SuccessCount int `json:"success_count" description:"成功数量"` + FailedCount int `json:"failed_count" description:"失败数量"` + Orders []AgentOpenAPIWalletPackageOrderItem `json:"orders" description:"成功订单列表"` + FailedCards []AgentOpenAPIWalletPackageOrderFailure `json:"failed_cards" description:"失败卡列表"` +} diff --git a/internal/routes/open.go b/internal/routes/open.go new file mode 100644 index 0000000..b7577c8 --- /dev/null +++ b/internal/routes/open.go @@ -0,0 +1,71 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// RegisterOpenAPIRoutes 注册代理开放接口路由 +func RegisterOpenAPIRoutes(router fiber.Router, handler *openapiHandler.Handler, doc *openapi.Generator, basePath string) { + const tag = "代理开放接口" + const authDescription = "认证 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。签名算法为 hex(HMAC-SHA256(password, sign_payload))。所有启用代理账号默认可调用,不需要开放接口账号授权表。" + + Register(router, doc, basePath, "GET", "/cards/traffic", handler.GetCardTraffic, RouteSpec{ + Summary: "查询单卡流量", + Description: authDescription + "\n\n按 card_no 查询代理权限范围内单卡的当前生效套餐、待生效套餐和真实业务口径流量。不返回虚流量、倍率、停机阈值等内部字段。", + Input: new(dto.AgentOpenAPICardQueryRequest), + Output: new(dto.AgentOpenAPICardTrafficResponse), + Tags: []string{tag}, + Auth: true, + }) + Register(router, doc, basePath, "GET", "/cards/status", handler.GetCardStatus, RouteSpec{ + Summary: "查询单卡状态", + Description: authDescription + "\n\n按 card_no 查询代理权限范围内单卡状态,network_status=1 返回正常,network_status=0 返回停机和停机原因。", + Input: new(dto.AgentOpenAPICardQueryRequest), + Output: new(dto.AgentOpenAPICardStatusResponse), + Tags: []string{tag}, + Auth: true, + }) + Register(router, doc, basePath, "GET", "/cards/realname-status", handler.GetRealnameStatus, RouteSpec{ + Summary: "查询单卡实名状态", + Description: authDescription + "\n\n按 card_no 查询代理权限范围内单卡实名状态。", + Input: new(dto.AgentOpenAPICardQueryRequest), + Output: new(dto.AgentOpenAPIRealnameStatusResponse), + Tags: []string{tag}, + Auth: true, + }) + Register(router, doc, basePath, "GET", "/packages", handler.ListPackages, RouteSpec{ + Summary: "查询代理套餐列表", + Description: authDescription + "\n\n分页查询当前代理已分配、启用、上架且非赠送的可购买套餐,返回代理生效零售价。", + Input: new(dto.AgentOpenAPIPackageListRequest), + Output: new(dto.AgentOpenAPIPackageItem), + Tags: []string{tag}, + Auth: true, + }) + Register(router, doc, basePath, "POST", "/wallet/package-orders", handler.CreateWalletPackageOrders, RouteSpec{ + Summary: "钱包套餐购买", + Description: authDescription + "\n\n使用代理预充值主钱包为多张卡购买同一个套餐编码。每张卡独立下单,允许部分成功。", + Input: new(dto.AgentOpenAPIWalletPackageOrderRequest), + Output: new(dto.AgentOpenAPIWalletPackageOrderResponse), + Tags: []string{tag}, + Auth: true, + }) + Register(router, doc, basePath, "GET", "/wallet/balance", handler.GetWalletBalance, RouteSpec{ + Summary: "查询预充值钱包余额", + Description: authDescription + "\n\n查询当前代理预充值主钱包余额。主钱包不存在时返回 0。", + Output: new(dto.AgentOpenAPIWalletBalanceResponse), + Tags: []string{tag}, + Auth: true, + }) + Register(router, doc, basePath, "GET", "/wallet/transactions", handler.ListWalletTransactions, RouteSpec{ + Summary: "查询预充值钱包流水", + Description: authDescription + "\n\n分页查询当前代理预充值主钱包流水,字段与后台主钱包流水接口保持一致。", + Input: new(dto.AgentOpenAPIWalletTransactionListRequest), + Output: new(dto.MainWalletTransactionItem), + Tags: []string{tag}, + Auth: true, + }) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 853460c..3b67a60 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -32,7 +32,16 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew personalGroup := app.Group("/api/c/v1") RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth) - // 5. 支付回调路由 (挂载在 /api/callback,无需认证) + // 5. 代理开放接口路由 (挂载在 /api/open/v1) + if handlers.AgentOpenAPI != nil { + openGroup := app.Group("/api/open/v1") + if middlewares.AgentOpenAPIAuth != nil { + openGroup = app.Group("/api/open/v1", middlewares.AgentOpenAPIAuth) + } + RegisterOpenAPIRoutes(openGroup, handlers.AgentOpenAPI, doc, "/api/open/v1") + } + + // 6. 支付回调路由 (挂载在 /api/callback,无需认证) if handlers.PaymentCallback != nil { callbackGroup := app.Group("/api/callback") registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback") diff --git a/internal/service/agent_open_api/service.go b/internal/service/agent_open_api/service.go new file mode 100644 index 0000000..6cdbcb0 --- /dev/null +++ b/internal/service/agent_open_api/service.go @@ -0,0 +1,489 @@ +package agent_open_api + +import ( + "context" + "fmt" + "math/rand" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" + orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order" + packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package" + shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "gorm.io/gorm" +) + +// Service 代理开放接口业务编排服务 +type Service struct { + assetService *assetSvc.Service + packageService *packageSvc.Service + orderService *orderSvc.Service + shopCommissionService *shopCommissionSvc.Service + iotCardStore *postgres.IotCardStore + packageUsageStore *postgres.PackageUsageStore + packageStore *postgres.PackageStore + packageSeriesStore *postgres.PackageSeriesStore + agentWalletStore *postgres.AgentWalletStore +} + +// New 创建代理开放接口业务编排服务 +func New( + assetService *assetSvc.Service, + packageService *packageSvc.Service, + orderService *orderSvc.Service, + shopCommissionService *shopCommissionSvc.Service, + iotCardStore *postgres.IotCardStore, + packageUsageStore *postgres.PackageUsageStore, + packageStore *postgres.PackageStore, + packageSeriesStore *postgres.PackageSeriesStore, + agentWalletStore *postgres.AgentWalletStore, +) *Service { + return &Service{ + assetService: assetService, + packageService: packageService, + orderService: orderService, + shopCommissionService: shopCommissionService, + iotCardStore: iotCardStore, + packageUsageStore: packageUsageStore, + packageStore: packageStore, + packageSeriesStore: packageSeriesStore, + agentWalletStore: agentWalletStore, + } +} + +// GetCardTraffic 查询开放接口单卡流量 +func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQueryRequest) (*dto.AgentOpenAPICardTrafficResponse, error) { + card, err := s.resolveOpenAPICard(ctx, req.CardNo) + if err != nil { + return nil, err + } + + activeStatus := constants.PackageUsageStatusActive + pendingStatus := constants.PackageUsageStatusPending + activeUsages, err := s.packageUsageStore.ListByCarrier(ctx, "iot_card", card.ID, &activeStatus) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询生效套餐失败") + } + pendingUsages, err := s.packageUsageStore.ListByCarrier(ctx, "iot_card", card.ID, &pendingStatus) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询待生效套餐失败") + } + + packageMap, seriesMap, err := s.loadUsagePackageContext(ctx, append(activeUsages, pendingUsages...)) + if err != nil { + return nil, err + } + + resp := &dto.AgentOpenAPICardTrafficResponse{ + CardNo: req.CardNo, + ActivePackages: make([]dto.AgentOpenAPIPackageTrafficItem, 0, len(activeUsages)), + PendingPackages: make([]dto.AgentOpenAPIPackageTrafficItem, 0, len(pendingUsages)), + } + for _, usage := range activeUsages { + item := s.buildTrafficItem(usage, packageMap, seriesMap, true) + resp.ActivePackages = append(resp.ActivePackages, item) + resp.ActiveTotalFlowMB += item.TotalFlowMB + resp.ActiveUsedFlowMB += item.UsedFlowMB + resp.ActiveRemainingFlowMB += item.RemainingFlowMB + if item.ExpiresAt != nil && (resp.ActiveExpiresAt == nil || item.ExpiresAt.After(*resp.ActiveExpiresAt)) { + resp.ActiveExpiresAt = item.ExpiresAt + } + } + for _, usage := range pendingUsages { + resp.PendingPackages = append(resp.PendingPackages, s.buildTrafficItem(usage, packageMap, seriesMap, false)) + } + + return resp, nil +} + +// GetCardStatus 查询开放接口单卡状态 +func (s *Service) GetCardStatus(ctx context.Context, req *dto.AgentOpenAPICardQueryRequest) (*dto.AgentOpenAPICardStatusResponse, error) { + card, err := s.resolveOpenAPICard(ctx, req.CardNo) + if err != nil { + return nil, err + } + + cardStatus := constants.AgentOpenAPICardStatusStopped + stopReason := card.StopReason + if card.NetworkStatus == constants.NetworkStatusOnline { + cardStatus = constants.AgentOpenAPICardStatusNormal + stopReason = "" + } + + return &dto.AgentOpenAPICardStatusResponse{ + CardNo: req.CardNo, + CardStatus: cardStatus, + CardStatusName: constants.AgentOpenAPICardStatusName(cardStatus), + StopReason: stopReason, + StopReasonName: constants.AgentOpenAPIStopReasonName(stopReason), + }, nil +} + +// GetRealnameStatus 查询开放接口单卡实名状态 +func (s *Service) GetRealnameStatus(ctx context.Context, req *dto.AgentOpenAPICardQueryRequest) (*dto.AgentOpenAPIRealnameStatusResponse, error) { + card, err := s.resolveOpenAPICard(ctx, req.CardNo) + if err != nil { + return nil, err + } + + return &dto.AgentOpenAPIRealnameStatusResponse{ + CardNo: req.CardNo, + IsRealnamed: card.RealNameStatus == constants.RealNameStatusVerified, + }, nil +} + +// ListPackages 查询开放接口代理可购买套餐列表 +func (s *Service) ListPackages(ctx context.Context, req *dto.AgentOpenAPIPackageListRequest) ([]*dto.AgentOpenAPIPackageItem, int64, int, int, error) { + status := constants.StatusEnabled + shelfStatus := constants.ShelfStatusOn + packageReq := &dto.PackageListRequest{ + Page: req.Page, + PageSize: req.PageSize, + PackageName: req.PackageName, + SeriesID: req.SeriesID, + Status: &status, + ShelfStatus: &shelfStatus, + PackageType: req.PackageType, + } + packages, total, err := s.packageService.List(ctx, packageReq) + if err != nil { + return nil, 0, 0, 0, err + } + + items := make([]*dto.AgentOpenAPIPackageItem, 0, len(packages)) + for _, pkg := range packages { + items = append(items, &dto.AgentOpenAPIPackageItem{ + PackageID: pkg.ID, + PackageCode: pkg.PackageCode, + PackageName: pkg.PackageName, + SeriesName: stringValue(pkg.SeriesName), + PackageType: pkg.PackageType, + PackageTypeName: constants.AgentOpenAPIPackageTypeName(pkg.PackageType), + CostPrice: pkg.CostPrice, + RetailPrice: int64Value(pkg.EffectiveRetailPrice), + TotalFlowMB: pkg.RealDataMB, + ValidDays: packageValidDays(pkg.DurationDays, pkg.DurationMonths), + }) + } + + page, pageSize := normalizePage(req.Page, req.PageSize) + return items, total, page, pageSize, nil +} + +// GetWalletBalance 查询开放接口预充值主钱包余额 +func (s *Service) GetWalletBalance(ctx context.Context) (*dto.AgentOpenAPIWalletBalanceResponse, error) { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + + wallet, err := s.agentWalletStore.GetMainWallet(ctx, shopID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return &dto.AgentOpenAPIWalletBalanceResponse{ + Balance: 0, + FrozenBalance: 0, + AvailableBalance: 0, + Currency: constants.AgentOpenAPIWalletCurrencyCNY, + }, nil + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询主钱包余额失败") + } + + return &dto.AgentOpenAPIWalletBalanceResponse{ + Balance: wallet.Balance, + FrozenBalance: wallet.FrozenBalance, + AvailableBalance: wallet.GetAvailableBalance(), + Currency: walletCurrency(wallet.Currency), + }, nil +} + +// ListWalletTransactions 查询开放接口预充值主钱包流水 +func (s *Service) ListWalletTransactions(ctx context.Context, req *dto.AgentOpenAPIWalletTransactionListRequest) (*dto.MainWalletTransactionListResponse, error) { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + + return s.shopCommissionService.ListMainWalletTransactions(ctx, shopID, &dto.MainWalletTransactionListRequest{ + Page: req.Page, + PageSize: req.PageSize, + TransactionType: req.TransactionType, + StartDate: req.StartDate, + EndDate: req.EndDate, + }) +} + +// CreateWalletPackageOrders 使用代理预充值钱包按卡购买套餐 +func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentOpenAPIWalletPackageOrderRequest) (*dto.AgentOpenAPIWalletPackageOrderResponse, error) { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + + pkg, err := s.packageStore.GetByCode(ctx, strings.TrimSpace(req.PackageCode)) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "套餐不存在") + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐失败") + } + + resp := &dto.AgentOpenAPIWalletPackageOrderResponse{ + BatchNo: generateAgentOpenAPIBatchNo(), + PackageCode: pkg.PackageCode, + Orders: []dto.AgentOpenAPIWalletPackageOrderItem{}, + FailedCards: []dto.AgentOpenAPIWalletPackageOrderFailure{}, + } + + for _, rawCardNo := range req.CardNos { + cardNo := strings.TrimSpace(rawCardNo) + if cardNo == "" { + resp.FailedCards = append(resp.FailedCards, buildFailedCard(rawCardNo, errors.New(errors.CodeInvalidParam, "卡标识不能为空"))) + continue + } + card, err := s.resolveOpenAPICard(ctx, cardNo) + if err != nil { + resp.FailedCards = append(resp.FailedCards, buildFailedCard(cardNo, err)) + continue + } + + orderResp, err := s.orderService.CreateAdminOrder(ctx, &dto.CreateAdminOrderRequest{ + Identifier: card.ICCID, + PackageIDs: []uint{pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, + }, model.BuyerTypeAgent, shopID) + if err != nil { + resp.FailedCards = append(resp.FailedCards, buildFailedCard(cardNo, err)) + continue + } + + resp.Orders = append(resp.Orders, dto.AgentOpenAPIWalletPackageOrderItem{ + CardNo: cardNo, + OrderNo: orderResp.OrderNo, + }) + } + + resp.SuccessCount = len(resp.Orders) + resp.FailedCount = len(resp.FailedCards) + return resp, nil +} + +// resolveOpenAPICard 将开放接口卡标识解析为当前代理可见的 IoT 卡 +func (s *Service) resolveOpenAPICard(ctx context.Context, cardNo string) (*model.IotCard, error) { + identifier := strings.TrimSpace(cardNo) + if identifier == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + if s.assetService == nil || s.iotCardStore == nil { + return nil, errors.New(errors.CodeInternalError, "开放接口资产解析依赖未配置") + } + + resolved, err := s.assetService.Resolve(ctx, identifier) + if err != nil { + if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeNotFound { + return s.resolveOpenAPICardByIdentifier(ctx, identifier) + } + return nil, err + } + if resolved.AssetType != "card" { + return nil, errors.New(errors.CodeInvalidParam, "开放接口只支持单卡标识") + } + + card, err := s.iotCardStore.GetByID(ctx, resolved.AssetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") + } + return card, nil +} + +// resolveOpenAPICardByIdentifier 兜底支持 ICCID 双列、虚拟号和 MSISDN 精确解析 +func (s *Service) resolveOpenAPICardByIdentifier(ctx context.Context, identifier string) (*model.IotCard, error) { + card, err := s.iotCardStore.GetByIdentifier(ctx, identifier) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") + } + return card, nil +} + +// loadUsagePackageContext 批量加载套餐和系列信息,避免单卡流量响应出现 N+1 查询 +func (s *Service) loadUsagePackageContext(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, map[uint]string, error) { + packageIDs := make([]uint, 0) + packageIDSeen := make(map[uint]struct{}) + for _, usage := range usages { + if usage.PackageID == 0 { + continue + } + if _, ok := packageIDSeen[usage.PackageID]; !ok { + packageIDSeen[usage.PackageID] = struct{}{} + packageIDs = append(packageIDs, usage.PackageID) + } + } + + packages, err := s.packageStore.GetByIDsUnscoped(ctx, packageIDs) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "批量查询套餐失败") + } + packageMap := make(map[uint]*model.Package, len(packages)) + seriesIDSeen := make(map[uint]struct{}) + seriesIDs := make([]uint, 0) + for _, pkg := range packages { + packageMap[pkg.ID] = pkg + if pkg.SeriesID > 0 { + if _, ok := seriesIDSeen[pkg.SeriesID]; !ok { + seriesIDSeen[pkg.SeriesID] = struct{}{} + seriesIDs = append(seriesIDs, pkg.SeriesID) + } + } + } + + seriesList, err := s.packageSeriesStore.GetByIDs(ctx, seriesIDs) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "批量查询套餐系列失败") + } + seriesMap := make(map[uint]string, len(seriesList)) + for _, series := range seriesList { + seriesMap[series.ID] = series.SeriesName + } + + return packageMap, seriesMap, nil +} + +// buildTrafficItem 按开放接口口径构造套餐流量项,主动隐藏虚流量内部字段 +func (s *Service) buildTrafficItem(usage *model.PackageUsage, packageMap map[uint]*model.Package, seriesMap map[uint]string, active bool) dto.AgentOpenAPIPackageTrafficItem { + pkg := packageMap[usage.PackageID] + packageCode := "" + packageName := usage.PackageName + packageType := "" + validDays := 0 + seriesName := "" + if pkg != nil { + packageCode = pkg.PackageCode + if packageName == "" { + packageName = pkg.PackageName + } + packageType = pkg.PackageType + validDays = packageValidDays(nil, pkg.DurationMonths) + if pkg.DurationDays > 0 { + validDays = pkg.DurationDays + } + seriesName = seriesMap[pkg.SeriesID] + } + + usedFlow := int64(0) + remainingFlow := int64(0) + if active { + usedFlow = visibleUsedFlowMB(usage) + remainingFlow = usage.DataLimitMB - usedFlow + if remainingFlow < 0 { + remainingFlow = 0 + } + } + + return dto.AgentOpenAPIPackageTrafficItem{ + PackageCode: packageCode, + PackageName: packageName, + SeriesName: seriesName, + PackageType: packageType, + PackageTypeName: constants.AgentOpenAPIPackageTypeName(packageType), + TotalFlowMB: usage.DataLimitMB, + UsedFlowMB: usedFlow, + RemainingFlowMB: remainingFlow, + StartAt: usage.ActivatedAt, + ExpiresAt: usage.ExpiresAt, + ValidDays: validDays, + Priority: usage.Priority, + } +} + +// visibleUsedFlowMB 计算对代理展示的已用流量,并限制不超过真实总量 +func visibleUsedFlowMB(usage *model.PackageUsage) int64 { + ratio := usage.DisplayGainRatioSnapshot + if ratio <= 0 { + ratio = 1 + } + used := int64(float64(usage.DataUsageMB) * ratio) + if used > usage.DataLimitMB { + return usage.DataLimitMB + } + if used < 0 { + return 0 + } + return used +} + +// packageValidDays 将套餐有效期统一折算为开放接口展示天数 +func packageValidDays(durationDays *int, durationMonths int) int { + if durationDays != nil && *durationDays > 0 { + return *durationDays + } + if durationMonths > 0 { + return durationMonths * 30 + } + return 0 +} + +func normalizePage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + return page, pageSize +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func int64Value(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func walletCurrency(currency string) string { + if currency == "" { + return constants.AgentOpenAPIWalletCurrencyCNY + } + return currency +} + +func buildFailedCard(cardNo string, err error) dto.AgentOpenAPIWalletPackageOrderFailure { + code := errors.CodeInternalError + message := errors.GetMessage(code, "zh-CN") + if appErr, ok := err.(*errors.AppError); ok { + code = appErr.Code + message = appErr.Message + } + return dto.AgentOpenAPIWalletPackageOrderFailure{ + CardNo: cardNo, + Code: code, + Message: message, + } +} + +func generateAgentOpenAPIBatchNo() string { + return fmt.Sprintf("%s%s%06d", constants.AgentOpenAPIBatchNoPrefix, time.Now().Format("20060102150405"), rand.Intn(1000000)) +} diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 43cd6b9..9044d95 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -1241,8 +1241,15 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) if err != nil { s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err)) } else { - networkStatus := parseNetworkStatus(statusResp.CardStatus) - updates["network_status"] = networkStatus + networkStatus, ok := gateway.ParseCardNetworkStatus(statusResp.CardStatus, statusResp.Extend) + if !ok { + s.logger.Warn("刷新卡数据:未知 Gateway 卡状态", + zap.String("iccid", iccid), + zap.String("card_status", statusResp.CardStatus), + zap.String("extend", statusResp.Extend)) + } else { + updates["network_status"] = networkStatus + } } // 2. 查询实名状态 @@ -1267,7 +1274,7 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) if err != nil { s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err)) } else { - flowIncrementMB = s.calculateRefreshFlowUpdates(card, flowResp.Used, syncTime, updates) + flowIncrementMB = s.calculateRefreshFlowUpdates(card, float64(flowResp.Used), syncTime, updates) } } @@ -1385,15 +1392,6 @@ func isRefreshResetWindow(now time.Time, resetDay int) bool { return today == prevDay } -// parseNetworkStatus 将网关返回的卡状态字符串转换为 network_status 数值 -// 停机→0,其他(准备/正常)→1 -func parseNetworkStatus(cardStatus string) int { - if cardStatus == "停机" { - return 0 - } - return 1 -} - // parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值 // true=已实名(1),false=未实名(0) func parseGatewayRealnameStatus(realStatus bool) int { diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 87e77b6..27bcac3 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -2064,6 +2064,12 @@ func (s *Service) resolveAssetByIdentifier(ctx context.Context, identifier strin } } } + if card, cardErr := s.iotCardStore.GetByIdentifier(ctx, identifier); cardErr == nil && card != nil { + return card, nil, nil + } + if device, deviceErr := s.deviceStore.GetByIdentifier(ctx, identifier); deviceErr == nil && device != nil { + return nil, device, nil + } return nil, nil, errors.New(errors.CodeNotFound, "未找到对应资产,请使用 ICCID 或虚拟号") } diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 58705b2..2f91b49 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -90,6 +90,24 @@ func (s *IotCardStore) GetByICCID(ctx context.Context, iccid string) (*model.Iot return &card, err } +// GetByIdentifier 通过 ICCID、虚拟号或 MSISDN 精确查询 IoT 卡 +func (s *IotCardStore) GetByIdentifier(ctx context.Context, identifier string) (*model.IotCard, error) { + var card model.IotCard + query := s.db.WithContext(ctx).Where( + "virtual_no = ? OR iccid = ? OR msisdn = ? OR iccid_19 = ? OR iccid_20 = ?", + identifier, + identifier, + identifier, + identifier, + identifier, + ) + query = middleware.ApplyShopFilter(ctx, query) + if err := query.First(&card).Error; err != nil { + return nil, err + } + return &card, nil +} + func (s *IotCardStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.IotCard, error) { if len(ids) == 0 { return []*model.IotCard{}, nil diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index 66ba229..40da970 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -81,7 +81,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, t *asynq.Task) erro h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) } - gatewayFlowMB = result.Used + gatewayFlowMB = float64(result.Used) } else { gatewayFlowMB = card.CurrentMonthUsageMB } diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index a992984..ad19d0d 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -14,7 +14,7 @@ import ( ) // PollingCardStatusHandler 卡开停机状态轮询任务处理器 -// 职责:调 Gateway 查卡状态(准备/正常/停机)→ 映射为 network_status → 写 DB → 触发停复机评估 +// 职责:调 Gateway 查卡状态(正常/停机/准备)→ 映射为 network_status → 写 DB → 触发停复机评估 // 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct type PollingCardStatusHandler struct { base *PollingBase @@ -63,7 +63,8 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) er return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } - var newNetworkStatus int + newNetworkStatus := card.NetworkStatus + gatewayCardStatus := "" if h.gateway != nil { result, gwErr := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) if gwErr != nil { @@ -72,22 +73,26 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) er h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } - // "停机" → 0(停机),"准备"/"正常" → 1(开机) - if result.CardStatus == "停机" { - newNetworkStatus = constants.NetworkStatusOffline + gatewayCardStatus = result.CardStatus + parsedStatus, ok := gateway.ParseCardNetworkStatus(result.CardStatus, result.Extend) + if !ok { + h.base.logger.Warn("卡状态轮询:未知 Gateway 卡状态", + zap.Uint("card_id", cardID), + zap.String("iccid", card.ICCID), + zap.String("card_status", result.CardStatus), + zap.String("extend", result.Extend)) } else { - newNetworkStatus = constants.NetworkStatusOnline + newNetworkStatus = parsedStatus } if h.base.verboseLog { h.base.logger.Info("卡状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.String("card_status", result.CardStatus), + zap.String("extend", result.Extend), zap.Int("new_network_status", newNetworkStatus), zap.Bool("changed", newNetworkStatus != card.NetworkStatus)) } - } else { - newNetworkStatus = card.NetworkStatus } statusChanged := newNetworkStatus != card.NetworkStatus @@ -98,7 +103,9 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) er if statusChanged { fields["network_status"] = newNetworkStatus // 网关侧停机事件:补写 stop_reason,便于状态追踪和区分手动停机 - if newNetworkStatus == constants.NetworkStatusOffline && card.StopReason == "" { + if newNetworkStatus == constants.NetworkStatusOffline && + card.StopReason == "" && + gateway.IsGatewayCardStopped(gatewayCardStatus) { fields["stop_reason"] = constants.StopReasonCarrierStopped } } diff --git a/pkg/constants/agent_open_api.go b/pkg/constants/agent_open_api.go new file mode 100644 index 0000000..246247e --- /dev/null +++ b/pkg/constants/agent_open_api.go @@ -0,0 +1,73 @@ +package constants + +import "time" + +// 代理开放接口请求头名称 +const ( + AgentOpenAPIHeaderAccount = "X-Agent-Account" // 开放接口账号请求头 + AgentOpenAPIHeaderPassword = "X-Agent-Password" // 开放接口密码请求头 + AgentOpenAPIHeaderTimestamp = "X-Agent-Timestamp" // 开放接口时间戳请求头 + AgentOpenAPIHeaderNonce = "X-Agent-Nonce" // 开放接口随机串请求头 + AgentOpenAPIHeaderSign = "X-Agent-Sign" // 开放接口签名请求头 +) + +// 代理开放接口签名配置 +const ( + AgentOpenAPISignatureWindow = 5 * time.Minute // 签名允许时间窗 + AgentOpenAPIBatchNoPrefix = "AOP" // 开放接口批次号前缀 +) + +// 代理开放接口卡状态 +const ( + AgentOpenAPICardStatusNormal = "normal" // 正常 + AgentOpenAPICardStatusStopped = "stopped" // 停机 + AgentOpenAPIWalletCurrencyCNY = "CNY" // 人民币币种 +) + +// AgentOpenAPICardStatusName 返回开放接口卡状态名称 +func AgentOpenAPICardStatusName(status string) string { + switch status { + case AgentOpenAPICardStatusNormal: + return "正常" + case AgentOpenAPICardStatusStopped: + return "停机" + default: + return "未知" + } +} + +// AgentOpenAPIPackageTypeName 返回开放接口套餐类型名称 +func AgentOpenAPIPackageTypeName(packageType string) string { + switch packageType { + case PackageTypeFormal: + return "正式套餐" + case PackageTypeAddon: + return "附加套餐" + default: + return "未知" + } +} + +// AgentOpenAPIStopReasonName 返回开放接口停机原因名称 +func AgentOpenAPIStopReasonName(reason string) string { + switch reason { + case StopReasonTrafficExhausted: + return "流量耗尽" + case StopReasonManual: + return "手动停机" + case StopReasonArrears: + return "欠费" + case StopReasonProtectPeriod: + return "保护期自动停机" + case StopReasonNoPackage: + return "无有效套餐" + case StopReasonNotRealname: + return "未完成实名认证" + case StopReasonCarrierStopped: + return "运营商侧停机" + case "": + return "" + default: + return "未知" + } +} diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index e1573f4..0c47a35 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -57,6 +57,14 @@ const ( NetworkStatusOnline = 1 // 开机 ) +// Gateway 卡状态 +const ( + GatewayCardStatusReady = "准备" // 网关卡状态:准备 + GatewayCardStatusNormal = "正常" // 网关卡状态:正常 + GatewayCardStatusStopped = "停机" // 网关卡状态:停机 + GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活 +) + // IoT 卡停机原因 const ( StopReasonTrafficExhausted = "traffic_exhausted" // 流量耗尽 diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index dfa70d3..3bae739 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -13,6 +13,13 @@ func RedisAuthTokenKey(token string) string { return fmt.Sprintf("auth:token:%s", token) } +// RedisAgentOpenAPINonceKey 生成代理开放接口 nonce 防重放 Redis 键 +// 用途:记录账号在签名时间窗内已使用的随机串,防止重复请求 +// 过期时间:与开放接口签名时间窗一致 +func RedisAgentOpenAPINonceKey(account, nonce string) string { + return fmt.Sprintf("agent_open_api:nonce:%s:%s", account, nonce) +} + // RedisRefreshTokenKey 生成刷新令牌的 Redis 键 // 用途:存储用户 refresh token 信息 // 过期时间:7 天(可配置) diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index d4a232d..ef570fa 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -48,6 +48,8 @@ const ( CodeWechatUserInfoFailed = 1045 // 获取微信用户信息失败 CodeWechatPayFailed = 1046 // 微信支付发起失败 CodeWechatCallbackInvalid = 1047 // 微信回调签名验证失败 + CodeOpenAPIAuthFailed = 1048 // 开放接口认证失败 + CodeOpenAPISignInvalid = 1049 // 开放接口签名无效 // 组织相关错误 (1030-1049) CodeShopNotFound = 1030 // 店铺不存在 @@ -152,6 +154,8 @@ const ( CodeNeedRealname = 1187 // 该套餐需实名认证后购买 CodeOpenIDNotFound = 1188 // 未找到微信授权信息,请先完成授权 CodeRealnameNotAvailable = 1189 // 请先完成充值或购买套餐后再进行实名认证 + CodeOpenAPITimestampInvalid = 1190 // 开放接口时间戳无效 + CodeOpenAPINonceReplay = 1191 // 开放接口 nonce 重放 // 换货相关错误 (1200-1209) CodeExchangeOrderNotFound = 1200 // 换货单不存在 @@ -219,6 +223,8 @@ var allErrorCodes = []int{ CodeWechatUserInfoFailed, CodeWechatPayFailed, CodeWechatCallbackInvalid, + CodeOpenAPIAuthFailed, + CodeOpenAPISignInvalid, CodeInvalidStatus, CodeInsufficientBalance, CodeWithdrawalNotFound, @@ -289,6 +295,8 @@ var allErrorCodes = []int{ CodeNeedRealname, CodeOpenIDNotFound, CodeRealnameNotAvailable, + CodeOpenAPITimestampInvalid, + CodeOpenAPINonceReplay, CodeExchangeOrderNotFound, CodeExchangeInProgress, CodeExchangeStatusInvalid, @@ -436,6 +444,10 @@ var errorMessages = map[int]string{ CodeWechatUserInfoFailed: "获取微信用户信息失败", CodeWechatPayFailed: "微信支付发起失败", CodeWechatCallbackInvalid: "微信回调验证失败", + CodeOpenAPIAuthFailed: "开放接口认证失败", + CodeOpenAPISignInvalid: "开放接口签名无效", + CodeOpenAPITimestampInvalid: "开放接口时间戳无效", + CodeOpenAPINonceReplay: "重复请求,请勿重放", CodeInternalError: "内部服务器错误", CodeDatabaseError: "数据库错误", CodeRedisError: "缓存服务错误", @@ -464,7 +476,7 @@ func GetHTTPStatus(code int) int { return 200 // OK case CodeInvalidParam, CodeRequestTooLarge: return 400 // Bad Request - case CodeMissingToken, CodeInvalidToken, CodeUnauthorized: + case CodeMissingToken, CodeInvalidToken, CodeUnauthorized, CodeOpenAPIAuthFailed, CodeOpenAPISignInvalid, CodeOpenAPITimestampInvalid: return 401 // Unauthorized case CodeForbidden: return 403 // Forbidden @@ -478,7 +490,8 @@ func GetHTTPStatus(code int) int { CodeShopCodeExists, CodeEnterpriseCodeExists, CodeCustomerPhoneExists, - CodeCarrierCodeExists: + CodeCarrierCodeExists, + CodeOpenAPINonceReplay: return 409 // Conflict case CodeTooManyRequests: return 429 // Too Many Requests diff --git a/pkg/logger/middleware.go b/pkg/logger/middleware.go index 442a24d..ac96788 100644 --- a/pkg/logger/middleware.go +++ b/pkg/logger/middleware.go @@ -2,9 +2,12 @@ package logger import ( "context" + "net/url" + "strings" "time" "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" "go.uber.org/zap" ) @@ -28,6 +31,89 @@ func truncateBody(body []byte, maxSize int) string { return string(body[:maxSize]) + "... (truncated)" } +// maskSensitiveValue 按字段名判断并脱敏访问日志中的敏感值 +func maskSensitiveValue(key, value string) string { + if value == "" { + return value + } + normalized := strings.ToLower(key) + if strings.Contains(normalized, "password") || + strings.Contains(normalized, "sign") || + strings.Contains(normalized, "nonce") || + strings.Contains(normalized, "token") || + strings.Contains(normalized, "secret") { + return "***" + } + return value +} + +// sanitizeQuery 脱敏 query 中的密码、签名、nonce、token 等字段 +func sanitizeQuery(rawQuery string) string { + if rawQuery == "" { + return "" + } + values, err := url.ParseQuery(rawQuery) + if err != nil { + return rawQuery + } + for key, items := range values { + for index, item := range items { + items[index] = maskSensitiveValue(key, item) + } + values[key] = items + } + return values.Encode() +} + +// sanitizeBody 脱敏 JSON 请求体后再写入访问日志 +func sanitizeBody(rawBody []byte) string { + if len(rawBody) == 0 { + return "" + } + var payload any + if err := sonic.Unmarshal(rawBody, &payload); err != nil { + return truncateBody(rawBody, MaxBodyLogSize) + } + sanitizeJSONValue(payload) + data, err := sonic.Marshal(payload) + if err != nil { + return truncateBody(rawBody, MaxBodyLogSize) + } + return truncateBody(data, MaxBodyLogSize) +} + +// sanitizeJSONValue 递归脱敏 JSON 对象中的敏感字段 +func sanitizeJSONValue(value any) { + switch typed := value.(type) { + case map[string]any: + for key, item := range typed { + if masked, ok := item.(string); ok { + typed[key] = maskSensitiveValue(key, masked) + continue + } + if shouldMaskField(key) { + typed[key] = "***" + continue + } + sanitizeJSONValue(item) + } + case []any: + for _, item := range typed { + sanitizeJSONValue(item) + } + } +} + +// shouldMaskField 判断字段名是否属于访问日志敏感字段 +func shouldMaskField(key string) bool { + normalized := strings.ToLower(key) + return strings.Contains(normalized, "password") || + strings.Contains(normalized, "sign") || + strings.Contains(normalized, "nonce") || + strings.Contains(normalized, "token") || + strings.Contains(normalized, "secret") +} + // Middleware 创建 Fiber 日志中间件 // 记录所有 HTTP 请求到访问日志(包括请求和响应 body) func Middleware() fiber.Handler { @@ -50,10 +136,10 @@ func Middleware() fiber.Handler { c.SetUserContext(ctx) // 获取请求 body(在 c.Next() 之前读取) - requestBody := truncateBody(c.Body(), MaxBodyLogSize) + requestBody := sanitizeBody(c.Body()) // 获取 query 参数 - queryParams := string(c.Request().URI().QueryString()) + queryParams := sanitizeQuery(string(c.Request().URI().QueryString())) // 处理请求 err := c.Next() diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 0a3245f..7ccdb42 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -6,6 +6,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/handler/app" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" "github.com/break/junhong_cmp_fiber/internal/handler/callback" + openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi" ) // BuildDocHandlers 构造文档生成用的 handlers(所有依赖传 nil) @@ -65,5 +66,6 @@ func BuildDocHandlers() *bootstrap.Handlers { Refund: admin.NewRefundHandler(nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), + AgentOpenAPI: openapiHandler.NewHandler(nil, nil), } }