feat: 实现企业卡授权和授权记录管理功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m9s

主要功能:
- 添加企业卡授权/回收接口 (POST /enterprises/:id/allocate-cards, recall-cards)
- 添加授权记录管理接口 (GET/PUT /authorizations)
- 实现代理用户数据权限过滤(只能查看自己店铺下企业的授权记录)
- 添加 GORM callback 支持授权记录表的数据权限过滤

技术改进:
- 原生 SQL 查询手动添加数据权限过滤(ListWithJoin, GetByIDWithJoin)
- 移除卡授权预检接口(allocate-cards/preview),保留内部方法
- 完善单元测试和集成测试覆盖
This commit is contained in:
2026-01-26 15:07:03 +08:00
parent 45aa7deb87
commit fdcff33058
42 changed files with 4782 additions and 298 deletions

View File

@@ -2,8 +2,12 @@ package postgres
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgGorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -31,25 +35,23 @@ func (s *EnterpriseCardAuthorizationStore) BatchCreate(ctx context.Context, auth
return s.db.WithContext(ctx).CreateInBatches(auths, 100).Error
}
func (s *EnterpriseCardAuthorizationStore) UpdateStatus(ctx context.Context, enterpriseID, cardID uint, status int) error {
return s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND iot_card_id = ?", enterpriseID, cardID).
Update("status", status).Error
}
func (s *EnterpriseCardAuthorizationStore) BatchUpdateStatus(ctx context.Context, enterpriseID uint, cardIDs []uint, status int) error {
func (s *EnterpriseCardAuthorizationStore) RevokeAuthorizations(ctx context.Context, enterpriseID uint, cardIDs []uint, revokedBy uint) error {
if len(cardIDs) == 0 {
return nil
}
now := time.Now()
return s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND iot_card_id IN ?", enterpriseID, cardIDs).
Update("status", status).Error
Where("enterprise_id = ? AND card_id IN ? AND revoked_at IS NULL", enterpriseID, cardIDs).
Updates(map[string]interface{}{
"revoked_by": revokedBy,
"revoked_at": now,
}).Error
}
func (s *EnterpriseCardAuthorizationStore) GetByEnterpriseAndCard(ctx context.Context, enterpriseID, cardID uint) (*model.EnterpriseCardAuthorization, error) {
var auth model.EnterpriseCardAuthorization
err := s.db.WithContext(ctx).
Where("enterprise_id = ? AND iot_card_id = ?", enterpriseID, cardID).
Where("enterprise_id = ? AND card_id = ?", enterpriseID, cardID).
First(&auth).Error
if err != nil {
return nil, err
@@ -57,11 +59,11 @@ func (s *EnterpriseCardAuthorizationStore) GetByEnterpriseAndCard(ctx context.Co
return &auth, nil
}
func (s *EnterpriseCardAuthorizationStore) ListByEnterprise(ctx context.Context, enterpriseID uint, status *int) ([]*model.EnterpriseCardAuthorization, error) {
func (s *EnterpriseCardAuthorizationStore) ListByEnterprise(ctx context.Context, enterpriseID uint, includeRevoked bool) ([]*model.EnterpriseCardAuthorization, error) {
var auths []*model.EnterpriseCardAuthorization
query := s.db.WithContext(ctx).Where("enterprise_id = ?", enterpriseID)
if status != nil {
query = query.Where("status = ?", *status)
if !includeRevoked {
query = query.Where("revoked_at IS NULL")
}
if err := query.Find(&auths).Error; err != nil {
return nil, err
@@ -69,28 +71,326 @@ func (s *EnterpriseCardAuthorizationStore) ListByEnterprise(ctx context.Context,
return auths, nil
}
func (s *EnterpriseCardAuthorizationStore) ListCardIDsByEnterprise(ctx context.Context, enterpriseID uint) ([]uint, error) {
func (s *EnterpriseCardAuthorizationStore) ListByCards(ctx context.Context, cardIDs []uint, includeRevoked bool) ([]*model.EnterpriseCardAuthorization, error) {
if len(cardIDs) == 0 {
return []*model.EnterpriseCardAuthorization{}, nil
}
var auths []*model.EnterpriseCardAuthorization
query := s.db.WithContext(ctx).Where("card_id IN ?", cardIDs)
if !includeRevoked {
query = query.Where("revoked_at IS NULL")
}
if err := query.Find(&auths).Error; err != nil {
return nil, err
}
return auths, nil
}
func (s *EnterpriseCardAuthorizationStore) GetActiveAuthorizedCardIDs(ctx context.Context, enterpriseID uint) ([]uint, error) {
var cardIDs []uint
err := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND status = 1", enterpriseID).
Pluck("iot_card_id", &cardIDs).Error
Where("enterprise_id = ? AND revoked_at IS NULL", enterpriseID).
Pluck("card_id", &cardIDs).Error
return cardIDs, err
}
func (s *EnterpriseCardAuthorizationStore) CheckAuthorizationExists(ctx context.Context, enterpriseID, cardID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND card_id = ? AND revoked_at IS NULL", enterpriseID, cardID).
Count(&count).Error
return count > 0, err
}
type AuthorizationListOptions struct {
EnterpriseID *uint
AuthorizerType *int
AuthorizedBy *uint
IncludeRevoked bool
CardIDs []uint
Offset int
Limit int
}
func (s *EnterpriseCardAuthorizationStore) ListWithOptions(ctx context.Context, opts AuthorizationListOptions) ([]*model.EnterpriseCardAuthorization, int64, error) {
var auths []*model.EnterpriseCardAuthorization
query := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{})
if opts.EnterpriseID != nil {
query = query.Where("enterprise_id = ?", *opts.EnterpriseID)
}
if opts.AuthorizerType != nil {
query = query.Where("authorizer_type = ?", *opts.AuthorizerType)
}
if opts.AuthorizedBy != nil {
query = query.Where("authorized_by = ?", *opts.AuthorizedBy)
}
if !opts.IncludeRevoked {
query = query.Where("revoked_at IS NULL")
}
if len(opts.CardIDs) > 0 {
query = query.Where("card_id IN ?", opts.CardIDs)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts.Limit > 0 {
query = query.Limit(opts.Limit).Offset(opts.Offset)
}
if err := query.Order("id DESC").Find(&auths).Error; err != nil {
return nil, 0, err
}
return auths, total, nil
}
// GetActiveAuthsByCardIDs 获取指定企业和卡ID列表的有效授权记录返回 map[cardID]bool
func (s *EnterpriseCardAuthorizationStore) GetActiveAuthsByCardIDs(ctx context.Context, enterpriseID uint, cardIDs []uint) (map[uint]bool, error) {
if len(cardIDs) == 0 {
return make(map[uint]bool), nil
}
var auths []model.EnterpriseCardAuthorization
err := s.db.WithContext(ctx).
Where("enterprise_id = ? AND iot_card_id IN ? AND status = 1", enterpriseID, cardIDs).
Find(&auths).Error
var authCardIDs []uint
err := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND card_id IN ? AND revoked_at IS NULL", enterpriseID, cardIDs).
Pluck("card_id", &authCardIDs).Error
if err != nil {
return nil, err
}
result := make(map[uint]bool)
for _, auth := range auths {
result[auth.IotCardID] = true
for _, cardID := range authCardIDs {
result[cardID] = true
}
return result, nil
}
// BatchUpdateStatus 批量更新授权状态(回收授权:设置 revoked_at
func (s *EnterpriseCardAuthorizationStore) BatchUpdateStatus(ctx context.Context, enterpriseID uint, cardIDs []uint, status int) error {
if len(cardIDs) == 0 {
return nil
}
// status 0 表示回收(设置 revoked_at
if status == 0 {
now := time.Now()
return s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND card_id IN ? AND revoked_at IS NULL", enterpriseID, cardIDs).
Update("revoked_at", now).Error
}
// 其他状态暂不处理
return nil
}
// ListCardIDsByEnterprise 获取企业的有效授权卡ID列表
func (s *EnterpriseCardAuthorizationStore) ListCardIDsByEnterprise(ctx context.Context, enterpriseID uint) ([]uint, error) {
var cardIDs []uint
err := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND revoked_at IS NULL", enterpriseID).
Pluck("card_id", &cardIDs).Error
return cardIDs, err
}
type AuthorizationWithJoinListOptions struct {
EnterpriseID *uint
ICCID string
AuthorizerType *int
Status *int
StartTime *time.Time
EndTime *time.Time
Offset int
Limit int
}
type AuthorizationWithJoin struct {
ID uint `gorm:"column:id"`
EnterpriseID uint `gorm:"column:enterprise_id"`
EnterpriseName string `gorm:"column:enterprise_name"`
CardID uint `gorm:"column:card_id"`
ICCID string `gorm:"column:iccid"`
MSISDN string `gorm:"column:msisdn"`
AuthorizedBy uint `gorm:"column:authorized_by"`
AuthorizerName string `gorm:"column:authorizer_name"`
AuthorizerType int `gorm:"column:authorizer_type"`
AuthorizedAt time.Time `gorm:"column:authorized_at"`
RevokedBy *uint `gorm:"column:revoked_by"`
RevokerName *string `gorm:"column:revoker_name"`
RevokedAt *time.Time `gorm:"column:revoked_at"`
Remark string `gorm:"column:remark"`
}
func (s *EnterpriseCardAuthorizationStore) ListWithJoin(ctx context.Context, opts AuthorizationWithJoinListOptions) ([]AuthorizationWithJoin, int64, error) {
baseQuery := `
FROM tb_enterprise_card_authorization a
LEFT JOIN tb_enterprise e ON a.enterprise_id = e.id AND e.deleted_at IS NULL
LEFT JOIN tb_iot_card c ON a.card_id = c.id AND c.deleted_at IS NULL
LEFT JOIN tb_account acc1 ON a.authorized_by = acc1.id AND acc1.deleted_at IS NULL
LEFT JOIN tb_account acc2 ON a.revoked_by = acc2.id AND acc2.deleted_at IS NULL
WHERE a.deleted_at IS NULL
`
args := []interface{}{}
// 数据权限过滤(原生 SQL 需要手动处理)
// 检查是否跳过数据权限过滤
if skip, ok := ctx.Value(pkgGorm.SkipDataPermissionKey).(bool); !ok || !skip {
userType := middleware.GetUserTypeFromContext(ctx)
// 超级管理员和平台用户跳过过滤
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
if userType == constants.UserTypeAgent {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
// 代理用户没有 shop_id返回空结果
return []AuthorizationWithJoin{}, 0, nil
}
// 只能看到自己店铺下企业的授权记录(不包含下级店铺)
baseQuery += " AND a.enterprise_id IN (SELECT id FROM tb_enterprise WHERE owner_shop_id = ? AND deleted_at IS NULL)"
args = append(args, shopID)
} else if userType == constants.UserTypeEnterprise {
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
if enterpriseID == 0 {
return []AuthorizationWithJoin{}, 0, nil
}
baseQuery += " AND a.enterprise_id = ?"
args = append(args, enterpriseID)
} else {
// 其他用户类型(个人客户等)不应访问授权记录
return []AuthorizationWithJoin{}, 0, nil
}
}
}
if opts.EnterpriseID != nil {
baseQuery += " AND a.enterprise_id = ?"
args = append(args, *opts.EnterpriseID)
}
if opts.ICCID != "" {
baseQuery += " AND c.iccid LIKE ?"
args = append(args, "%"+opts.ICCID+"%")
}
if opts.AuthorizerType != nil {
baseQuery += " AND a.authorizer_type = ?"
args = append(args, *opts.AuthorizerType)
}
if opts.Status != nil {
if *opts.Status == 1 {
baseQuery += " AND a.revoked_at IS NULL"
} else {
baseQuery += " AND a.revoked_at IS NOT NULL"
}
}
if opts.StartTime != nil {
baseQuery += " AND a.authorized_at >= ?"
args = append(args, *opts.StartTime)
}
if opts.EndTime != nil {
baseQuery += " AND a.authorized_at < ?"
args = append(args, *opts.EndTime)
}
var total int64
countSQL := "SELECT COUNT(*) " + baseQuery
if err := s.db.WithContext(ctx).Raw(countSQL, args...).Scan(&total).Error; err != nil {
return nil, 0, err
}
selectSQL := `
SELECT
a.id, a.enterprise_id, COALESCE(e.enterprise_name, '') as enterprise_name,
a.card_id, COALESCE(c.iccid, '') as iccid, COALESCE(c.msisdn, '') as msisdn,
a.authorized_by, COALESCE(acc1.username, '') as authorizer_name,
a.authorizer_type, a.authorized_at,
a.revoked_by, acc2.username as revoker_name, a.revoked_at,
COALESCE(a.remark, '') as remark
` + baseQuery + " ORDER BY a.id DESC"
if opts.Limit > 0 {
selectSQL += " LIMIT ? OFFSET ?"
args = append(args, opts.Limit, opts.Offset)
}
var results []AuthorizationWithJoin
if err := s.db.WithContext(ctx).Raw(selectSQL, args...).Scan(&results).Error; err != nil {
return nil, 0, err
}
return results, total, nil
}
func (s *EnterpriseCardAuthorizationStore) GetByIDWithJoin(ctx context.Context, id uint) (*AuthorizationWithJoin, error) {
baseSQL := `
SELECT
a.id, a.enterprise_id, COALESCE(e.enterprise_name, '') as enterprise_name,
a.card_id, COALESCE(c.iccid, '') as iccid, COALESCE(c.msisdn, '') as msisdn,
a.authorized_by, COALESCE(acc1.username, '') as authorizer_name,
a.authorizer_type, a.authorized_at,
a.revoked_by, acc2.username as revoker_name, a.revoked_at,
COALESCE(a.remark, '') as remark
FROM tb_enterprise_card_authorization a
LEFT JOIN tb_enterprise e ON a.enterprise_id = e.id AND e.deleted_at IS NULL
LEFT JOIN tb_iot_card c ON a.card_id = c.id AND c.deleted_at IS NULL
LEFT JOIN tb_account acc1 ON a.authorized_by = acc1.id AND acc1.deleted_at IS NULL
LEFT JOIN tb_account acc2 ON a.revoked_by = acc2.id AND acc2.deleted_at IS NULL
WHERE a.id = ? AND a.deleted_at IS NULL
`
args := []interface{}{id}
// 数据权限过滤(原生 SQL 需要手动处理)
if skip, ok := ctx.Value(pkgGorm.SkipDataPermissionKey).(bool); !ok || !skip {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
if userType == constants.UserTypeAgent {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return nil, gorm.ErrRecordNotFound
}
baseSQL += " AND a.enterprise_id IN (SELECT id FROM tb_enterprise WHERE owner_shop_id = ? AND deleted_at IS NULL)"
args = append(args, shopID)
} else if userType == constants.UserTypeEnterprise {
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
if enterpriseID == 0 {
return nil, gorm.ErrRecordNotFound
}
baseSQL += " AND a.enterprise_id = ?"
args = append(args, enterpriseID)
} else {
return nil, gorm.ErrRecordNotFound
}
}
}
var result AuthorizationWithJoin
if err := s.db.WithContext(ctx).Raw(baseSQL, args...).Scan(&result).Error; err != nil {
return nil, err
}
if result.ID == 0 {
return nil, gorm.ErrRecordNotFound
}
return &result, nil
}
func (s *EnterpriseCardAuthorizationStore) UpdateRemark(ctx context.Context, id uint, remark string) error {
result := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("id = ?", id).
Update("remark", remark)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *EnterpriseCardAuthorizationStore) GetByID(ctx context.Context, id uint) (*model.EnterpriseCardAuthorization, error) {
var auth model.EnterpriseCardAuthorization
err := s.db.WithContext(ctx).Where("id = ?", id).First(&auth).Error
if err != nil {
return nil, err
}
return &auth, nil
}

View File

@@ -49,6 +49,17 @@ func (s *IotCardStore) GetByICCID(ctx context.Context, iccid string) (*model.Iot
return &card, nil
}
func (s *IotCardStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.IotCard, error) {
if len(ids) == 0 {
return []*model.IotCard{}, nil
}
var cards []*model.IotCard
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}
func (s *IotCardStore) ExistsByICCID(ctx context.Context, iccid string) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).Where("iccid = ?", iccid).Count(&count).Error; err != nil {
@@ -84,6 +95,89 @@ func (s *IotCardStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.IotCard{}, id).Error
}
func (s *IotCardStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.IotCard, int64, error) {
var cards []*model.IotCard
var total int64
query := s.db.WithContext(ctx).Model(&model.IotCard{})
// 企业用户特殊处理:只能看到授权给自己的卡
if enterpriseID, ok := filters["authorized_enterprise_id"].(uint); ok && enterpriseID > 0 {
query = query.Where("id IN (?)",
s.db.Table("tb_enterprise_card_authorization").
Select("card_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID))
}
// 基础过滤条件
if status, ok := filters["status"].(int); ok && status > 0 {
query = query.Where("status = ?", status)
}
if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 {
query = query.Where("carrier_id = ?", carrierID)
}
if shopID, ok := filters["shop_id"].(uint); ok && shopID > 0 {
query = query.Where("shop_id = ?", shopID)
}
if iccid, ok := filters["iccid"].(string); ok && iccid != "" {
query = query.Where("iccid LIKE ?", "%"+iccid+"%")
}
if msisdn, ok := filters["msisdn"].(string); ok && msisdn != "" {
query = query.Where("msisdn LIKE ?", "%"+msisdn+"%")
}
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
query = query.Where("batch_no = ?", batchNo)
}
if packageID, ok := filters["package_id"].(uint); ok && packageID > 0 {
query = query.Where("id IN (?)",
s.db.Table("tb_package_usage").
Select("iot_card_id").
Where("package_id = ? AND deleted_at IS NULL", packageID))
}
if isDistributed, ok := filters["is_distributed"].(bool); ok {
if isDistributed {
query = query.Where("shop_id IS NOT NULL")
} else {
query = query.Where("shop_id IS NULL")
}
}
if iccidStart, ok := filters["iccid_start"].(string); ok && iccidStart != "" {
query = query.Where("iccid >= ?", iccidStart)
}
if iccidEnd, ok := filters["iccid_end"].(string); ok && iccidEnd != "" {
query = query.Where("iccid <= ?", iccidEnd)
}
// 统计总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页处理
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
// 排序
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
// 查询结果
if err := query.Find(&cards).Error; err != nil {
return nil, 0, err
}
return cards, total, nil
}
func (s *IotCardStore) ListStandalone(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.IotCard, int64, error) {
var cards []*model.IotCard
var total int64
@@ -251,10 +345,32 @@ func (s *IotCardStore) GetBoundCardIDs(ctx context.Context, cardIDs []uint) ([]u
if len(cardIDs) == 0 {
return nil, nil
}
var boundCardIDs []uint
var boundIDs []uint
err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("iot_card_id").
Where("iot_card_id IN ? AND bind_status = ?", cardIDs, 1).
Pluck("iot_card_id", &boundCardIDs).Error
return boundCardIDs, err
Pluck("iot_card_id", &boundIDs).Error
return boundIDs, err
}
func (s *IotCardStore) GetByIDsWithEnterpriseFilter(ctx context.Context, cardIDs []uint, enterpriseID *uint) ([]*model.IotCard, error) {
if len(cardIDs) == 0 {
return []*model.IotCard{}, nil
}
query := s.db.WithContext(ctx).Model(&model.IotCard{})
if enterpriseID != nil && *enterpriseID > 0 {
query = query.Where("id IN (?) AND id IN (?)",
cardIDs,
s.db.Table("tb_enterprise_card_authorization").
Select("card_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", *enterpriseID))
} else {
query = query.Where("id IN ?", cardIDs)
}
var cards []*model.IotCard
if err := query.Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}