Files
junhong_cmp_fiber/docs/proposals/单卡授权企业功能设计.md
huang fdcff33058
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m9s
feat: 实现企业卡授权和授权记录管理功能
主要功能:
- 添加企业卡授权/回收接口 (POST /enterprises/:id/allocate-cards, recall-cards)
- 添加授权记录管理接口 (GET/PUT /authorizations)
- 实现代理用户数据权限过滤(只能查看自己店铺下企业的授权记录)
- 添加 GORM callback 支持授权记录表的数据权限过滤

技术改进:
- 原生 SQL 查询手动添加数据权限过滤(ListWithJoin, GetByIDWithJoin)
- 移除卡授权预检接口(allocate-cards/preview),保留内部方法
- 完善单元测试和集成测试覆盖
2026-01-26 15:07:03 +08:00

13 KiB
Raw Permalink Blame History

单卡授权企业功能设计

1. 需求背景与目标

1.1 背景

当前系统中的企业卡授权功能是假实现,需要改造成真实的单卡授权功能。该功能与现有的单卡分配功能类似,但核心区别在于:

  • 分配:转移卡的所有权,卡的 shop_id 会变更
  • 授权:仅授予使用权,卡的所有权(shop_id)保持不变

1.2 目标

  • 实现真实的单卡授权功能,支持代理和平台向企业授权单卡使用权
  • 保证授权后的权限控制,企业只能查看和操作被授权的卡片
  • 建立完整的授权记录和追踪机制

2. 功能设计

2.1 授权流程

graph TD
    A[发起授权请求] --> B{校验授权主体}
    B -->|代理| C[校验是否为自己的企业]
    B -->|平台| D[校验数据权限]
    C --> E{校验卡片状态}
    D --> E
    E -->|已绑定设备| F[提示走设备授权]
    E -->|已被授权| G[提示需先回收]
    E -->|可授权| H{校验卡片归属}
    H -->|平台卡| I[直接授权]
    H -->|代理卡| J{校验企业归属}
    J -->|属于该代理| K[创建授权记录]
    J -->|不属于| L[拒绝授权]
    I --> K
    K --> M[返回成功]

2.2 校验规则

2.2.1 授权主体校验

  • 代理
    • 只能授权自己拥有的卡(shop_id 等于代理ID
    • 只能授权给自己名下的企业
  • 平台
    • 可以授权平台拥有的卡给任意企业
    • 可以授权代理拥有的卡,但只能授权给该代理名下的企业

2.2.2 卡片状态校验

  • 已绑定设备的卡不能授权(device_id 不为空)
  • 已被授权的卡必须先回收才能重新授权
  • 卡片状态必须为"已分销"

2.2.3 企业归属校验

  • 授权代理卡时,目标企业必须属于该代理

2.3 数据变更

授权成功后:

  • iot_card 表:不做任何变更shop_id、状态等保持不变)
  • enterprise_card_authorization 表:创建授权记录
  • 不记录asset_allocation_record

回收授权后:

  • 软删除 enterprise_card_authorization 表中的授权记录
  • 企业立即失去该卡的查看和操作权限

3. 接口定义

3.1 批量授权接口

路径POST /api/v1/iot-card/authorize-to-enterprise

请求参数

type AuthorizeCardsToEnterpriseRequest struct {
    CardIDs       []int64 `json:"card_ids" validate:"required,min=1,max=1000"`
    EnterpriseID  int64   `json:"enterprise_id" validate:"required"`
    Remark        string  `json:"remark" validate:"max=200"`
}

响应参数

type AuthorizeCardsToEnterpriseResponse struct {
    SuccessCount int                           `json:"success_count"`
    FailCount    int                           `json:"fail_count"`
    Details      []AuthorizeResultDetail       `json:"details"`
}

type AuthorizeResultDetail struct {
    CardID  int64  `json:"card_id"`
    Success bool   `json:"success"`
    Message string `json:"message,omitempty"`
}

3.2 回收授权接口

路径POST /api/v1/iot-card/revoke-enterprise-authorization

请求参数

type RevokeEnterpriseAuthorizationRequest struct {
    CardIDs []int64 `json:"card_ids" validate:"required,min=1,max=1000"`
}

响应参数

type RevokeEnterpriseAuthorizationResponse struct {
    SuccessCount int                        `json:"success_count"`
    FailCount    int                        `json:"fail_count"`
    Details      []RevokeResultDetail       `json:"details"`
}

3.3 企业卡列表接口调整

现有接口GET /api/v1/enterprise/cards

调整内容

  1. 查询逻辑需要关联 enterprise_card_authorization
  2. 返回授权相关信息(授权人、授权时间)
  3. 屏蔽敏感信息(成本价、分销价、上游供应商)

响应参数调整

type EnterpriseCardDetail struct {
    // 基础信息
    ID          int64  `json:"id"`
    ICCID       string `json:"iccid"`
    IMSI        string `json:"imsi"`
    Status      string `json:"status"`
    // ... 其他基础字段
    
    // 授权信息
    IsAuthorized      bool      `json:"is_authorized"`
    AuthorizedBy      string    `json:"authorized_by,omitempty"`
    AuthorizedByID    int64     `json:"authorized_by_id,omitempty"`
    AuthorizedAt      time.Time `json:"authorized_at,omitempty"`
    
    // 屏蔽字段(不返回)
    // CostPrice        float64
    // DistributionPrice float64
    // UpstreamSupplier  string
}

4. 数据库设计

4.1 EnterpriseCardAuthorization 表结构

-- 表已存在,确认字段是否满足需求
CREATE TABLE IF NOT EXISTS enterprise_card_authorization (
    id BIGINT PRIMARY KEY,
    enterprise_id BIGINT NOT NULL,          -- 被授权的企业ID
    card_id BIGINT NOT NULL,                -- 物联网卡ID
    authorized_by BIGINT NOT NULL,          -- 授权人IDshop_id
    authorized_by_type VARCHAR(20) NOT NULL,-- 授权人类型platform/agent
    remark VARCHAR(200),                    -- 备注
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    deleted_at TIMESTAMP                    -- 软删除
);

-- 索引设计
CREATE UNIQUE INDEX idx_card_enterprise_active ON enterprise_card_authorization(card_id, enterprise_id) 
WHERE deleted_at IS NULL;

CREATE INDEX idx_enterprise_cards ON enterprise_card_authorization(enterprise_id, deleted_at);
CREATE INDEX idx_card_authorization ON enterprise_card_authorization(card_id, deleted_at);

5. 实现计划

5.1 需要修改的文件列表

  1. Service 层

    • internal/service/iot_card/service.go - 新增授权相关方法
    • internal/service/enterprise/service.go - 修改卡列表查询逻辑
  2. Store 层

    • internal/store/postgres/enterprise_card_authorization_store.go - 授权记录存储
    • internal/store/postgres/iot_card_store.go - 增加授权校验查询
  3. Handler 层

    • internal/handler/admin/iot_card_handler.go - 新增授权接口
    • internal/handler/enterprise/card_handler.go - 修改列表接口
  4. Model 层

    • internal/model/enterprise_card_authorization.go - 确认模型定义
    • internal/model/dto/iot_card_dto.go - 新增请求/响应 DTO
  5. 路由注册

    • internal/bootstrap/routes/admin_routes.go - 注册授权接口
    • cmd/api/docs.gocmd/gendocs/main.go - 更新文档生成器

5.2 核心逻辑伪代码

5.2.1 授权服务实现

func (s *IotCardService) AuthorizeCardsToEnterprise(ctx context.Context, req *dto.AuthorizeCardsToEnterpriseRequest) (*dto.AuthorizeCardsToEnterpriseResponse, error) {
    // 1. 获取当前用户信息
    userInfo := GetUserFromContext(ctx)
    
    // 2. 校验企业归属
    if userInfo.ShopType == "agent" {
        enterprise, err := s.enterpriseStore.GetByID(ctx, req.EnterpriseID)
        if err != nil || enterprise.ShopID != userInfo.ShopID {
            return nil, errors.ErrNoPermission
        }
    }
    
    // 3. 批量处理卡片
    var results []dto.AuthorizeResultDetail
    successCount := 0
    
    for _, cardID := range req.CardIDs {
        result := s.authorizeSingleCard(ctx, cardID, req.EnterpriseID, userInfo)
        results = append(results, result)
        if result.Success {
            successCount++
        }
    }
    
    return &dto.AuthorizeCardsToEnterpriseResponse{
        SuccessCount: successCount,
        FailCount:    len(req.CardIDs) - successCount,
        Details:      results,
    }, nil
}

func (s *IotCardService) authorizeSingleCard(ctx context.Context, cardID, enterpriseID int64, userInfo *UserInfo) dto.AuthorizeResultDetail {
    // 1. 获取卡信息
    card, err := s.cardStore.GetByID(ctx, cardID)
    if err != nil {
        return dto.AuthorizeResultDetail{
            CardID:  cardID,
            Success: false,
            Message: "卡片不存在",
        }
    }
    
    // 2. 校验卡片状态
    if card.DeviceID != nil {
        return dto.AuthorizeResultDetail{
            CardID:  cardID,
            Success: false,
            Message: "已绑定设备的卡片请走设备授权流程",
        }
    }
    
    // 3. 检查是否已被授权
    existing, _ := s.authStore.GetActiveAuthorization(ctx, cardID)
    if existing != nil {
        return dto.AuthorizeResultDetail{
            CardID:  cardID,
            Success: false,
            Message: "卡片已被授权,请先回收",
        }
    }
    
    // 4. 校验权限
    if userInfo.ShopType == "agent" && card.ShopID != userInfo.ShopID {
        return dto.AuthorizeResultDetail{
            CardID:  cardID,
            Success: false,
            Message: "无权授权该卡片",
        }
    }
    
    if userInfo.ShopType == "platform" && card.ShopID != constants.PlatformShopID {
        // 平台授权代理卡,需要校验企业归属
        enterprise, _ := s.enterpriseStore.GetByID(ctx, enterpriseID)
        if enterprise.ShopID != card.ShopID {
            return dto.AuthorizeResultDetail{
                CardID:  cardID,
                Success: false,
                Message: "只能将代理的卡授权给该代理的企业",
            }
        }
    }
    
    // 5. 创建授权记录
    auth := &model.EnterpriseCardAuthorization{
        EnterpriseID:     enterpriseID,
        CardID:           cardID,
        AuthorizedBy:     userInfo.ShopID,
        AuthorizedByType: userInfo.ShopType,
        Remark:           req.Remark,
    }
    
    err = s.authStore.Create(ctx, auth)
    if err != nil {
        return dto.AuthorizeResultDetail{
            CardID:  cardID,
            Success: false,
            Message: "创建授权记录失败",
        }
    }
    
    return dto.AuthorizeResultDetail{
        CardID:  cardID,
        Success: true,
    }
}

5.3 企业侧权限控制

5.3.1 卡片查询权限

func (s *EnterpriseCardStore) GetCardsByEnterpriseID(ctx context.Context, enterpriseID int64, filter *CardFilter) ([]*model.IotCard, error) {
    query := `
        SELECT DISTINCT c.* 
        FROM iot_card c
        INNER JOIN enterprise_card_authorization eca 
            ON c.id = eca.card_id 
            AND eca.enterprise_id = $1 
            AND eca.deleted_at IS NULL
        WHERE 1=1
    `
    // ... 其他筛选条件
}

5.3.2 操作权限控制

func (s *EnterpriseService) CanOperateCard(ctx context.Context, enterpriseID, cardID int64) bool {
    // 检查是否有授权记录
    auth, err := s.authStore.GetActiveAuthorization(ctx, cardID)
    if err != nil || auth == nil || auth.EnterpriseID != enterpriseID {
        return false
    }
    return true
}

5.3.3 信息展示控制

func (h *EnterpriseCardHandler) transformCardForEnterprise(card *model.IotCard, auth *model.EnterpriseCardAuthorization) *dto.EnterpriseCardDetail {
    return &dto.EnterpriseCardDetail{
        // 基础信息
        ID:     card.ID,
        ICCID:  card.ICCID,
        IMSI:   card.IMSI,
        Status: card.Status,
        
        // 授权信息
        IsAuthorized:   true,
        AuthorizedBy:   auth.AuthorizedByName,
        AuthorizedByID: auth.AuthorizedBy,
        AuthorizedAt:   auth.CreatedAt,
        
        // 以下字段不返回
        // CostPrice: nil,
        // DistributionPrice: nil,
        // UpstreamSupplier: nil,
    }
}

6. 测试要点

6.1 授权功能测试

  • 权限测试

    • 代理只能授权自己的卡给自己的企业
    • 平台可以授权任意卡,但代理卡只能授权给对应代理的企业
    • 普通用户无授权权限
  • 状态校验测试

    • 已绑定设备的卡不能授权
    • 已授权的卡不能重复授权
    • 非"已分销"状态的卡不能授权
  • 批量操作测试

    • 部分成功场景处理
    • 超过1000张卡的限制

6.2 回收功能测试

  • 回收后企业立即失去权限
  • 回收记录正确(软删除)
  • 批量回收的事务处理

6.3 企业权限测试

  • 企业只能看到被授权的卡
  • 企业看不到成本价、分销价等敏感信息
  • 企业可以执行停机/复机操作
  • 企业不能修改卡信息

6.4 性能测试

  • 批量授权1000张卡的性能
  • 企业卡列表查询性能(关联授权表)

7. 风险评估与注意事项

7.1 数据一致性风险

  • 风险:授权记录与实际权限不一致
  • 措施
    • 使用唯一索引防止重复授权
    • 所有查询基于授权表,不依赖缓存

7.2 权限泄露风险

  • 风险:企业看到不该看的信息
  • 措施
    • DTO 层严格控制字段返回
    • Service 层增加权限校验中间件

7.3 性能风险

  • 风险:授权表数据量大导致查询慢
  • 措施
    • 合理设计索引
    • 考虑分页查询
    • 监控慢查询

7.4 业务风险

  • 风险:误操作导致大量卡片授权错误
  • 措施
    • 增加操作日志
    • 关键操作二次确认
    • 提供批量回收功能

7.5 注意事项

  1. 不要修改现有的分配功能逻辑
  2. 确保授权和分配功能相互独立
  3. 严格区分授权(使用权)和分配(所有权)
  4. 保持 shop_id 不变是授权的核心特征
  5. 测试时注意验证各种边界情况