开放接口,修复上游同步不对的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s

This commit is contained in:
2026-05-11 11:20:48 +08:00
parent a9eaf1d697
commit 98ff88d5c3
25 changed files with 1432 additions and 39 deletions

View File

@@ -21,6 +21,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
app := fiber.New()
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
// 4. 注册所有路由到文档生成器

View File

@@ -30,6 +30,7 @@ func generateAdminDocs(outputPath string) error {
app := fiber.New()
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
// 4. 注册所有路由到文档生成器

View File

@@ -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),
}
}

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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 在此添加字段
}

View File

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

View File

@@ -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 是停机/复机请求

View File

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

View File

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

View File

@@ -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:"失败卡列表"`
}

71
internal/routes/open.go Normal file
View File

@@ -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 = "认证 HeaderX-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,
})
}

View File

@@ -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")

View File

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

View File

@@ -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 {

View File

@@ -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 或虚拟号")
}

View File

@@ -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

View File

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

View File

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

View File

@@ -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 "未知"
}
}

View File

@@ -57,6 +57,14 @@ const (
NetworkStatusOnline = 1 // 开机
)
// Gateway 卡状态
const (
GatewayCardStatusReady = "准备" // 网关卡状态:准备
GatewayCardStatusNormal = "正常" // 网关卡状态:正常
GatewayCardStatusStopped = "停机" // 网关卡状态:停机
GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活
)
// IoT 卡停机原因
const (
StopReasonTrafficExhausted = "traffic_exhausted" // 流量耗尽

View File

@@ -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 天(可配置)

View File

@@ -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

View File

@@ -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()

View File

@@ -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),
}
}