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

@@ -36,6 +36,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil),
Enterprise: admin.NewEnterpriseHandler(nil),
EnterpriseCard: admin.NewEnterpriseCardHandler(nil),
Authorization: admin.NewAuthorizationHandler(nil),
CustomerAccount: admin.NewCustomerAccountHandler(nil),
MyCommission: admin.NewMyCommissionHandler(nil),
IotCard: admin.NewIotCardHandler(nil),

View File

@@ -45,6 +45,7 @@ func generateAdminDocs(outputPath string) error {
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil),
Enterprise: admin.NewEnterpriseHandler(nil),
EnterpriseCard: admin.NewEnterpriseCardHandler(nil),
Authorization: admin.NewAuthorizationHandler(nil),
CustomerAccount: admin.NewCustomerAccountHandler(nil),
MyCommission: admin.NewMyCommissionHandler(nil),
IotCard: admin.NewIotCardHandler(nil),

View File

@@ -61,40 +61,6 @@ components:
description: 用户名
type: string
type: object
DtoAllocateCardsPreviewReq:
properties:
iccids:
description: 需要授权的 ICCID 列表最多1000个
items:
type: string
nullable: true
type: array
required:
- iccids
type: object
DtoAllocateCardsPreviewResp:
properties:
device_bundles:
description: 需要整体授权的设备包
items:
$ref: '#/components/schemas/DtoDeviceBundle'
nullable: true
type: array
failed_items:
description: 失败的卡
items:
$ref: '#/components/schemas/DtoFailedItem'
nullable: true
type: array
standalone_cards:
description: 可直接授权的卡(未绑定设备)
items:
$ref: '#/components/schemas/DtoStandaloneCard'
nullable: true
type: array
summary:
$ref: '#/components/schemas/DtoAllocatePreviewSummary'
type: object
DtoAllocateCardsReq:
properties:
confirm_device_bundles:
@@ -130,24 +96,6 @@ components:
description: 成功数量
type: integer
type: object
DtoAllocatePreviewSummary:
properties:
device_card_count:
description: 设备卡数量
type: integer
device_count:
description: 设备数量
type: integer
failed_count:
description: 失败数量
type: integer
standalone_card_count:
description: 独立卡数量
type: integer
total_card_count:
description: 总卡数量
type: integer
type: object
DtoAllocateStandaloneCardsRequest:
properties:
batch_no:
@@ -451,6 +399,81 @@ components:
nullable: true
type: array
type: object
DtoAuthorizationItem:
properties:
authorized_at:
description: 授权时间
format: date-time
type: string
authorized_by:
description: 授权人ID
minimum: 0
type: integer
authorizer_name:
description: 授权人名称
type: string
authorizer_type:
description: 授权人类型2=平台3=代理
type: integer
card_id:
description: 卡ID
minimum: 0
type: integer
enterprise_id:
description: 企业ID
minimum: 0
type: integer
enterprise_name:
description: 企业名称
type: string
iccid:
description: ICCID
type: string
id:
description: 授权记录ID
minimum: 0
type: integer
msisdn:
description: 手机号
type: string
remark:
description: 备注
type: string
revoked_at:
description: 回收时间
format: date-time
nullable: true
type: string
revoked_by:
description: 回收人ID
minimum: 0
nullable: true
type: integer
revoker_name:
description: 回收人名称
type: string
status:
description: 状态1=有效0=已回收
type: integer
type: object
DtoAuthorizationListResp:
properties:
items:
description: 授权记录列表
items:
$ref: '#/components/schemas/DtoAuthorizationItem'
nullable: true
type: array
page:
description: 当前页码
type: integer
size:
description: 每页数量
type: integer
total:
description: 总记录数
type: integer
type: object
DtoChangePasswordRequest:
properties:
new_password:
@@ -881,37 +904,6 @@ components:
description: 总记录数
type: integer
type: object
DtoDeviceBundle:
properties:
bundle_cards:
description: 连带卡(同设备的其他卡)
items:
$ref: '#/components/schemas/DtoDeviceBundleCard'
nullable: true
type: array
device_id:
description: 设备ID
minimum: 0
type: integer
device_no:
description: 设备号
type: string
trigger_card:
$ref: '#/components/schemas/DtoDeviceBundleCard'
type: object
DtoDeviceBundleCard:
properties:
iccid:
description: ICCID
type: string
iot_card_id:
description: 卡ID
minimum: 0
type: integer
msisdn:
description: 手机号
type: string
type: object
DtoEnterpriseCardItem:
properties:
carrier_id:
@@ -2054,26 +2046,6 @@ components:
description: 总记录数
type: integer
type: object
DtoStandaloneCard:
properties:
carrier_id:
description: 运营商ID
minimum: 0
type: integer
iccid:
description: ICCID
type: string
iot_card_id:
description: 卡ID
minimum: 0
type: integer
msisdn:
description: 手机号
type: string
status_name:
description: 状态名称
type: string
type: object
DtoStandaloneIotCardResponse:
properties:
activated_at:
@@ -2178,6 +2150,12 @@ components:
nullable: true
type: string
type: object
DtoUpdateAuthorizationRemarkReq:
properties:
remark:
description: 备注最多500字
type: string
type: object
DtoUpdateCustomerAccountPasswordReq:
properties:
password:
@@ -3307,6 +3285,199 @@ paths:
summary: 分配记录详情
tags:
- 资产分配记录
/api/admin/authorizations:
get:
parameters:
- description: 页码
in: query
name: page
schema:
description: 页码
minimum: 1
type: integer
- description: 每页数量
in: query
name: page_size
schema:
description: 每页数量
maximum: 100
minimum: 1
type: integer
- description: 按企业ID筛选
in: query
name: enterprise_id
schema:
description: 按企业ID筛选
minimum: 0
nullable: true
type: integer
- description: 按ICCID模糊查询
in: query
name: iccid
schema:
description: 按ICCID模糊查询
type: string
- description: 授权人类型2=平台3=代理
in: query
name: authorizer_type
schema:
description: 授权人类型2=平台3=代理
nullable: true
type: integer
- description: 状态0=已回收1=有效
in: query
name: status
schema:
description: 状态0=已回收1=有效
nullable: true
type: integer
- description: 授权时间起格式2006-01-02
in: query
name: start_time
schema:
description: 授权时间起格式2006-01-02
type: string
- description: 授权时间止格式2006-01-02
in: query
name: end_time
schema:
description: 授权时间止格式2006-01-02
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAuthorizationListResp'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 授权记录列表
tags:
- 授权记录管理
/api/admin/authorizations/{id}:
get:
parameters:
- description: 授权记录ID
in: path
name: id
required: true
schema:
description: 授权记录ID
minimum: 0
type: integer
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAuthorizationItem'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 授权记录详情
tags:
- 授权记录管理
/api/admin/authorizations/{id}/remark:
put:
parameters:
- description: 授权记录ID
in: path
name: id
required: true
schema:
description: 授权记录ID
minimum: 0
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoUpdateAuthorizationRemarkReq'
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAuthorizationItem'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 修改授权备注
tags:
- 授权记录管理
/api/admin/commission/withdrawal-requests:
get:
parameters:
@@ -4149,58 +4320,6 @@ paths:
summary: 授权卡给企业
tags:
- 企业卡授权
/api/admin/enterprises/{id}/allocate-cards/preview:
post:
parameters:
- description: 企业ID
in: path
name: id
required: true
schema:
description: 企业ID
minimum: 0
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAllocateCardsPreviewReq'
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAllocateCardsPreviewResp'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 卡授权预检
tags:
- 企业卡授权
/api/admin/enterprises/{id}/cards:
get:
parameters:

View File

@@ -0,0 +1,428 @@
# 单卡授权企业功能设计
## 1. 需求背景与目标
### 1.1 背景
当前系统中的企业卡授权功能是假实现,需要改造成真实的单卡授权功能。该功能与现有的单卡分配功能类似,但核心区别在于:
- **分配**:转移卡的所有权,卡的 `shop_id` 会变更
- **授权**:仅授予使用权,卡的所有权(`shop_id`)保持不变
### 1.2 目标
- 实现真实的单卡授权功能,支持代理和平台向企业授权单卡使用权
- 保证授权后的权限控制,企业只能查看和操作被授权的卡片
- 建立完整的授权记录和追踪机制
## 2. 功能设计
### 2.1 授权流程
```mermaid
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`
**请求参数**
```go
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"`
}
```
**响应参数**
```go
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`
**请求参数**
```go
type RevokeEnterpriseAuthorizationRequest struct {
CardIDs []int64 `json:"card_ids" validate:"required,min=1,max=1000"`
}
```
**响应参数**
```go
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. 屏蔽敏感信息(成本价、分销价、上游供应商)
**响应参数调整**
```go
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 表结构
```sql
-- 表已存在,确认字段是否满足需求
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.go``cmd/gendocs/main.go` - 更新文档生成器
### 5.2 核心逻辑伪代码
#### 5.2.1 授权服务实现
```go
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 卡片查询权限
```go
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 操作权限控制
```go
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 信息展示控制
```go
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. **测试**时注意验证各种边界情况

View File

@@ -24,6 +24,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
CustomerAccount: admin.NewCustomerAccountHandler(svc.CustomerAccount),
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
IotCard: admin.NewIotCardHandler(svc.IotCard),

View File

@@ -33,6 +33,7 @@ type services struct {
CommissionWithdrawalSetting *commissionWithdrawalSettingSvc.Service
Enterprise *enterpriseSvc.Service
EnterpriseCard *enterpriseCardSvc.Service
Authorization *enterpriseCardSvc.AuthorizationService
CustomerAccount *customerAccountSvc.Service
MyCommission *myCommissionSvc.Service
IotCard *iotCardSvc.Service
@@ -54,6 +55,7 @@ func initServices(s *stores, deps *Dependencies) *services {
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization),
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise),
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord),

View File

@@ -22,6 +22,7 @@ type Handlers struct {
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
EnterpriseCard *admin.EnterpriseCardHandler
Authorization *admin.AuthorizationHandler
CustomerAccount *admin.CustomerAccountHandler
MyCommission *admin.MyCommissionHandler
IotCard *admin.IotCardHandler

View File

@@ -0,0 +1,157 @@
package admin
import (
"strconv"
"time"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
enterpriseCardService "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type AuthorizationHandler struct {
service *enterpriseCardService.AuthorizationService
}
func NewAuthorizationHandler(service *enterpriseCardService.AuthorizationService) *AuthorizationHandler {
return &AuthorizationHandler{service: service}
}
func (h *AuthorizationHandler) List(c *fiber.Ctx) error {
var req dto.AuthorizationListReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.ListRecords(c.UserContext(), enterpriseCardService.ListRecordsRequest{
EnterpriseID: req.EnterpriseID,
ICCID: req.ICCID,
AuthorizerType: req.AuthorizerType,
Status: req.Status,
StartTime: req.StartTime,
EndTime: req.EndTime,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return err
}
items := make([]dto.AuthorizationItem, len(result.Items))
for i, r := range result.Items {
authorizedAt, _ := time.ParseInLocation("2006-01-02 15:04:05", r.AuthorizedAt, time.Local)
var revokedAt *time.Time
if r.RevokedAt != nil {
t, _ := time.ParseInLocation("2006-01-02 15:04:05", *r.RevokedAt, time.Local)
revokedAt = &t
}
items[i] = dto.AuthorizationItem{
ID: r.ID,
EnterpriseID: r.EnterpriseID,
EnterpriseName: r.EnterpriseName,
CardID: r.CardID,
ICCID: r.ICCID,
MSISDN: r.MSISDN,
AuthorizedBy: r.AuthorizedBy,
AuthorizerName: r.AuthorizerName,
AuthorizerType: r.AuthorizerType,
AuthorizedAt: authorizedAt,
RevokedBy: r.RevokedBy,
RevokerName: r.RevokerName,
RevokedAt: revokedAt,
Status: r.Status,
Remark: r.Remark,
}
}
return response.SuccessWithPagination(c, items, result.Total, result.Page, result.Size)
}
func (h *AuthorizationHandler) GetDetail(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的授权记录ID")
}
r, err := h.service.GetRecordDetail(c.UserContext(), uint(id))
if err != nil {
return err
}
authorizedAt, _ := time.ParseInLocation("2006-01-02 15:04:05", r.AuthorizedAt, time.Local)
var revokedAt *time.Time
if r.RevokedAt != nil {
t, _ := time.ParseInLocation("2006-01-02 15:04:05", *r.RevokedAt, time.Local)
revokedAt = &t
}
result := dto.AuthorizationItem{
ID: r.ID,
EnterpriseID: r.EnterpriseID,
EnterpriseName: r.EnterpriseName,
CardID: r.CardID,
ICCID: r.ICCID,
MSISDN: r.MSISDN,
AuthorizedBy: r.AuthorizedBy,
AuthorizerName: r.AuthorizerName,
AuthorizerType: r.AuthorizerType,
AuthorizedAt: authorizedAt,
RevokedBy: r.RevokedBy,
RevokerName: r.RevokerName,
RevokedAt: revokedAt,
Status: r.Status,
Remark: r.Remark,
}
return response.Success(c, result)
}
func (h *AuthorizationHandler) UpdateRemark(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的授权记录ID")
}
var req dto.UpdateAuthorizationRemarkReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
r, err := h.service.UpdateRecordRemark(c.UserContext(), uint(id), req.Remark)
if err != nil {
return err
}
authorizedAt, _ := time.ParseInLocation("2006-01-02 15:04:05", r.AuthorizedAt, time.Local)
var revokedAt *time.Time
if r.RevokedAt != nil {
t, _ := time.ParseInLocation("2006-01-02 15:04:05", *r.RevokedAt, time.Local)
revokedAt = &t
}
result := dto.AuthorizationItem{
ID: r.ID,
EnterpriseID: r.EnterpriseID,
EnterpriseName: r.EnterpriseName,
CardID: r.CardID,
ICCID: r.ICCID,
MSISDN: r.MSISDN,
AuthorizedBy: r.AuthorizedBy,
AuthorizerName: r.AuthorizerName,
AuthorizerType: r.AuthorizerType,
AuthorizedAt: authorizedAt,
RevokedBy: r.RevokedBy,
RevokerName: r.RevokerName,
RevokedAt: revokedAt,
Status: r.Status,
Remark: r.Remark,
}
return response.Success(c, result)
}

View File

@@ -19,26 +19,6 @@ func NewEnterpriseCardHandler(service *enterpriseCardService.Service) *Enterpris
return &EnterpriseCardHandler{service: service}
}
func (h *EnterpriseCardHandler) AllocateCardsPreview(c *fiber.Ctx) error {
idStr := c.Params("id")
enterpriseID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的企业ID")
}
var req dto.AllocateCardsPreviewReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.AllocateCardsPreview(c.UserContext(), uint(enterpriseID), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *EnterpriseCardHandler) AllocateCards(c *fiber.Ctx) error {
idStr := c.Params("id")
enterpriseID, err := strconv.ParseUint(idStr, 10, 64)

View File

@@ -0,0 +1,48 @@
package dto
import "time"
type AuthorizationListReq 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:"每页数量"`
EnterpriseID *uint `json:"enterprise_id" query:"enterprise_id" description:"按企业ID筛选"`
ICCID string `json:"iccid" query:"iccid" description:"按ICCID模糊查询"`
AuthorizerType *int `json:"authorizer_type" query:"authorizer_type" description:"授权人类型2=平台3=代理"`
Status *int `json:"status" query:"status" description:"状态0=已回收1=有效"`
StartTime string `json:"start_time" query:"start_time" description:"授权时间起格式2006-01-02"`
EndTime string `json:"end_time" query:"end_time" description:"授权时间止格式2006-01-02"`
}
type AuthorizationItem struct {
ID uint `json:"id" description:"授权记录ID"`
EnterpriseID uint `json:"enterprise_id" description:"企业ID"`
EnterpriseName string `json:"enterprise_name" description:"企业名称"`
CardID uint `json:"card_id" description:"卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
MSISDN string `json:"msisdn" description:"手机号"`
AuthorizedBy uint `json:"authorized_by" description:"授权人ID"`
AuthorizerName string `json:"authorizer_name" description:"授权人名称"`
AuthorizerType int `json:"authorizer_type" description:"授权人类型2=平台3=代理"`
AuthorizedAt time.Time `json:"authorized_at" description:"授权时间"`
RevokedBy *uint `json:"revoked_by,omitempty" description:"回收人ID"`
RevokerName string `json:"revoker_name,omitempty" description:"回收人名称"`
RevokedAt *time.Time `json:"revoked_at,omitempty" description:"回收时间"`
Status int `json:"status" description:"状态1=有效0=已回收"`
Remark string `json:"remark" description:"备注"`
}
type AuthorizationListResp struct {
Items []AuthorizationItem `json:"items" description:"授权记录列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
type AuthorizationDetailReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"授权记录ID"`
}
type UpdateAuthorizationRemarkReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"授权记录ID"`
Remark string `json:"remark" validate:"max=500" description:"备注最多500字"`
}

View File

@@ -7,16 +7,20 @@ import (
)
// EnterpriseCardAuthorization 企业卡授权模型
// 记录企业被授权可见的卡卡的归属owner始终是代理商店铺
// 注意:不使用 BaseModel因为已有 AuthorizedBy/RevokedBy 字段
type EnterpriseCardAuthorization struct {
gorm.Model
BaseModel `gorm:"embedded"`
EnterpriseID uint `gorm:"column:enterprise_id;index;not null;comment:企业ID" json:"enterprise_id"`
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
ShopID uint `gorm:"column:shop_id;index;not null;comment:店铺ID授权方" json:"shop_id"`
AuthorizedBy uint `gorm:"column:authorized_by;not null;comment:授权ID" json:"authorized_by"`
AuthorizedAt *time.Time `gorm:"column:authorized_at;default:now();comment:授权时间" json:"authorized_at"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1=有效 0=已回收" json:"status"`
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
EnterpriseID uint `gorm:"column:enterprise_id;not null;comment:被授权企业ID" json:"enterprise_id"`
CardID uint `gorm:"column:card_id;not null;comment:授权ID" json:"card_id"`
AuthorizedBy uint `gorm:"column:authorized_by;not null;comment:授权人账号ID" json:"authorized_by"`
AuthorizedAt time.Time `gorm:"column:authorized_at;not null;default:CURRENT_TIMESTAMP;comment:授权时间" json:"authorized_at"`
AuthorizerType int `gorm:"column:authorizer_type;not null;comment:授权人类型2=平台用户 3=代理账号" json:"authorizer_type"`
RevokedBy *uint `gorm:"column:revoked_by;comment:回收人账号ID" json:"revoked_by"`
RevokedAt *time.Time `gorm:"column:revoked_at;comment:回收时间" json:"revoked_at"`
Remark string `gorm:"column:remark;type:varchar(500);default:'';comment:授权备注" json:"remark"`
}
func (EnterpriseCardAuthorization) TableName() string {

View File

@@ -46,6 +46,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.EnterpriseCard != nil {
registerEnterpriseCardRoutes(authGroup, handlers.EnterpriseCard, doc, basePath)
}
if handlers.Authorization != nil {
registerAuthorizationRoutes(authGroup, handlers.Authorization, doc, basePath)
}
if handlers.CustomerAccount != nil {
registerCustomerAccountRoutes(authGroup, handlers.CustomerAccount, doc, basePath)
}

View File

@@ -0,0 +1,38 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerAuthorizationRoutes(router fiber.Router, handler *admin.AuthorizationHandler, doc *openapi.Generator, basePath string) {
authorizations := router.Group("/authorizations")
groupPath := basePath + "/authorizations"
Register(authorizations, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "授权记录列表",
Tags: []string{"授权记录管理"},
Input: new(dto.AuthorizationListReq),
Output: new(dto.AuthorizationListResp),
Auth: true,
})
Register(authorizations, doc, groupPath, "GET", "/:id", handler.GetDetail, RouteSpec{
Summary: "授权记录详情",
Tags: []string{"授权记录管理"},
Input: new(dto.AuthorizationDetailReq),
Output: new(dto.AuthorizationItem),
Auth: true,
})
Register(authorizations, doc, groupPath, "PUT", "/:id/remark", handler.UpdateRemark, RouteSpec{
Summary: "修改授权备注",
Tags: []string{"授权记录管理"},
Input: new(dto.UpdateAuthorizationRemarkReq),
Output: new(dto.AuthorizationItem),
Auth: true,
})
}

View File

@@ -12,14 +12,6 @@ func registerEnterpriseCardRoutes(router fiber.Router, handler *admin.Enterprise
enterprises := router.Group("/enterprises")
groupPath := basePath + "/enterprises"
Register(enterprises, doc, groupPath, "POST", "/:id/allocate-cards/preview", handler.AllocateCardsPreview, RouteSpec{
Summary: "卡授权预检",
Tags: []string{"企业卡授权"},
Input: new(dto.AllocateCardsPreviewReq),
Output: new(dto.AllocateCardsPreviewResp),
Auth: true,
})
Register(enterprises, doc, groupPath, "POST", "/:id/allocate-cards", handler.AllocateCards, RouteSpec{
Summary: "授权卡给企业",
Tags: []string{"企业卡授权"},

View File

@@ -0,0 +1,414 @@
package enterprise_card
import (
"context"
"fmt"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
)
type AuthorizationService struct {
enterpriseStore *postgres.EnterpriseStore
iotCardStore *postgres.IotCardStore
authorizationStore *postgres.EnterpriseCardAuthorizationStore
logger *zap.Logger
}
func NewAuthorizationService(
enterpriseStore *postgres.EnterpriseStore,
iotCardStore *postgres.IotCardStore,
authorizationStore *postgres.EnterpriseCardAuthorizationStore,
logger *zap.Logger,
) *AuthorizationService {
return &AuthorizationService{
enterpriseStore: enterpriseStore,
iotCardStore: iotCardStore,
authorizationStore: authorizationStore,
logger: logger,
}
}
type BatchAuthorizeRequest struct {
EnterpriseID uint
CardIDs []uint
AuthorizerID uint
AuthorizerType int
Remark string
}
func (s *AuthorizationService) BatchAuthorize(ctx context.Context, req BatchAuthorizeRequest) error {
if len(req.CardIDs) == 0 {
return errors.New(errors.CodeInvalidParam, "卡ID列表不能为空")
}
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户信息无效")
}
enterprise, err := s.enterpriseStore.GetByID(ctx, req.EnterpriseID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
return err
}
if userType == constants.UserTypeAgent {
if enterprise.OwnerShopID == nil || *enterprise.OwnerShopID != shopID {
return errors.New(errors.CodeCannotAuthorizeToOthersEnterprise, "只能授权给自己的企业")
}
}
cards, err := s.iotCardStore.GetByIDs(ctx, req.CardIDs)
if err != nil {
return err
}
if len(cards) != len(req.CardIDs) {
return errors.New(errors.CodeIotCardNotFound, "部分卡不存在")
}
cardMap := make(map[uint]*model.IotCard)
for _, card := range cards {
cardMap[card.ID] = card
}
for _, cardID := range req.CardIDs {
card := cardMap[cardID]
if card.ShopID == nil {
return errors.New(errors.CodeIotCardStatusNotAllowed, fmt.Sprintf("卡 %s 未分销,不能授权", card.ICCID))
}
if userType == constants.UserTypeAgent && *card.ShopID != shopID {
return errors.New(errors.CodeCannotAuthorizeOthersCard, fmt.Sprintf("卡 %s 不属于您的店铺", card.ICCID))
}
}
boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, req.CardIDs)
if err != nil {
return err
}
if len(boundCardIDs) > 0 {
return errors.New(errors.CodeCannotAuthorizeBoundCard, "部分卡已绑定设备,不能授权")
}
existingAuths, err := s.authorizationStore.ListByCards(ctx, req.CardIDs, false)
if err != nil {
return err
}
existingMap := make(map[uint]bool)
for _, auth := range existingAuths {
if auth.EnterpriseID == req.EnterpriseID {
existingMap[auth.CardID] = true
}
}
var newAuths []*model.EnterpriseCardAuthorization
for _, cardID := range req.CardIDs {
if existingMap[cardID] {
continue
}
newAuths = append(newAuths, &model.EnterpriseCardAuthorization{
EnterpriseID: req.EnterpriseID,
CardID: cardID,
AuthorizedBy: req.AuthorizerID,
AuthorizerType: req.AuthorizerType,
Remark: req.Remark,
})
}
if len(newAuths) == 0 {
return errors.New(errors.CodeCardAlreadyAuthorized, "所有卡已授权给该企业")
}
return s.authorizationStore.BatchCreate(ctx, newAuths)
}
type RevokeAuthorizationsRequest struct {
EnterpriseID uint
CardIDs []uint
RevokedBy uint
}
func (s *AuthorizationService) RevokeAuthorizations(ctx context.Context, req RevokeAuthorizationsRequest) error {
if len(req.CardIDs) == 0 {
return errors.New(errors.CodeInvalidParam, "卡ID列表不能为空")
}
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户信息无效")
}
existingAuths, err := s.authorizationStore.ListByCards(ctx, req.CardIDs, false)
if err != nil {
return err
}
authMap := make(map[uint]*model.EnterpriseCardAuthorization)
for _, auth := range existingAuths {
if auth.EnterpriseID == req.EnterpriseID {
authMap[auth.CardID] = auth
}
}
if len(authMap) == 0 {
return errors.New(errors.CodeCardNotAuthorized, "卡未授权给该企业")
}
if userType == constants.UserTypeAgent {
for _, auth := range authMap {
if auth.AuthorizedBy != userID {
return errors.New(errors.CodeCannotRevokeOthersAuthorization, "只能回收自己创建的授权")
}
}
}
var cardIDsToRevoke []uint
for cardID := range authMap {
cardIDsToRevoke = append(cardIDsToRevoke, cardID)
}
return s.authorizationStore.RevokeAuthorizations(ctx, req.EnterpriseID, cardIDsToRevoke, req.RevokedBy)
}
type ListAuthorizationsRequest struct {
EnterpriseID *uint
AuthorizedBy *uint
IncludeRevoked bool
Page int
PageSize int
}
type ListAuthorizationsResponse struct {
Authorizations []*model.EnterpriseCardAuthorization
Total int64
}
func (s *AuthorizationService) ListAuthorizations(ctx context.Context, req ListAuthorizationsRequest) (*ListAuthorizationsResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
opts := postgres.AuthorizationListOptions{
EnterpriseID: req.EnterpriseID,
AuthorizedBy: req.AuthorizedBy,
IncludeRevoked: req.IncludeRevoked,
Offset: (req.Page - 1) * req.PageSize,
Limit: req.PageSize,
}
auths, total, err := s.authorizationStore.ListWithOptions(ctx, opts)
if err != nil {
return nil, err
}
return &ListAuthorizationsResponse{
Authorizations: auths,
Total: total,
}, nil
}
func (s *AuthorizationService) GetAuthorizedCardIDs(ctx context.Context, enterpriseID uint) ([]uint, error) {
return s.authorizationStore.GetActiveAuthorizedCardIDs(ctx, enterpriseID)
}
type ListRecordsRequest struct {
EnterpriseID *uint
ICCID string
AuthorizerType *int
Status *int
StartTime string
EndTime string
Page int
PageSize int
}
type AuthorizationRecord struct {
ID uint
EnterpriseID uint
EnterpriseName string
CardID uint
ICCID string
MSISDN string
AuthorizedBy uint
AuthorizerName string
AuthorizerType int
AuthorizedAt string
RevokedBy *uint
RevokerName string
RevokedAt *string
Status int
Remark string
}
type ListRecordsResponse struct {
Items []AuthorizationRecord
Total int64
Page int
Size int
}
func (s *AuthorizationService) ListRecords(ctx context.Context, req ListRecordsRequest) (*ListRecordsResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
opts := postgres.AuthorizationWithJoinListOptions{
EnterpriseID: req.EnterpriseID,
ICCID: req.ICCID,
AuthorizerType: req.AuthorizerType,
Status: req.Status,
Offset: (req.Page - 1) * req.PageSize,
Limit: req.PageSize,
}
if req.StartTime != "" {
t, err := parseDate(req.StartTime)
if err == nil {
opts.StartTime = &t
}
}
if req.EndTime != "" {
t, err := parseDate(req.EndTime)
if err == nil {
endTime := t.AddDate(0, 0, 1)
opts.EndTime = &endTime
}
}
results, total, err := s.authorizationStore.ListWithJoin(ctx, opts)
if err != nil {
return nil, err
}
items := make([]AuthorizationRecord, len(results))
for i, r := range results {
status := 1
if r.RevokedAt != nil {
status = 0
}
var revokedAt *string
if r.RevokedAt != nil {
t := r.RevokedAt.Format("2006-01-02 15:04:05")
revokedAt = &t
}
revokerName := ""
if r.RevokerName != nil {
revokerName = *r.RevokerName
}
items[i] = AuthorizationRecord{
ID: r.ID,
EnterpriseID: r.EnterpriseID,
EnterpriseName: r.EnterpriseName,
CardID: r.CardID,
ICCID: r.ICCID,
MSISDN: r.MSISDN,
AuthorizedBy: r.AuthorizedBy,
AuthorizerName: r.AuthorizerName,
AuthorizerType: r.AuthorizerType,
AuthorizedAt: r.AuthorizedAt.Format("2006-01-02 15:04:05"),
RevokedBy: r.RevokedBy,
RevokerName: revokerName,
RevokedAt: revokedAt,
Status: status,
Remark: r.Remark,
}
}
return &ListRecordsResponse{
Items: items,
Total: total,
Page: req.Page,
Size: req.PageSize,
}, nil
}
func (s *AuthorizationService) GetRecordDetail(ctx context.Context, id uint) (*AuthorizationRecord, error) {
r, err := s.authorizationStore.GetByIDWithJoin(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
}
return nil, err
}
status := 1
if r.RevokedAt != nil {
status = 0
}
var revokedAt *string
if r.RevokedAt != nil {
t := r.RevokedAt.Format("2006-01-02 15:04:05")
revokedAt = &t
}
revokerName := ""
if r.RevokerName != nil {
revokerName = *r.RevokerName
}
return &AuthorizationRecord{
ID: r.ID,
EnterpriseID: r.EnterpriseID,
EnterpriseName: r.EnterpriseName,
CardID: r.CardID,
ICCID: r.ICCID,
MSISDN: r.MSISDN,
AuthorizedBy: r.AuthorizedBy,
AuthorizerName: r.AuthorizerName,
AuthorizerType: r.AuthorizerType,
AuthorizedAt: r.AuthorizedAt.Format("2006-01-02 15:04:05"),
RevokedBy: r.RevokedBy,
RevokerName: revokerName,
RevokedAt: revokedAt,
Status: status,
Remark: r.Remark,
}, nil
}
func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint, remark string) (*AuthorizationRecord, error) {
if err := s.authorizationStore.UpdateRemark(ctx, id, remark); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
}
return nil, err
}
return s.GetRecordDetail(ctx, id)
}
func parseDate(dateStr string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", dateStr, time.Local)
}

View File

@@ -32,7 +32,7 @@ func New(
}
}
func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) {
func (s *Service) allocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -172,7 +172,6 @@ func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, r
func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsReq) (*dto.AllocateCardsResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
currentShopID := middleware.GetShopIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
@@ -182,7 +181,7 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs})
preview, err := s.allocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs})
if err != nil {
return nil, err
}
@@ -224,18 +223,18 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
}
now := time.Now()
userType := middleware.GetUserTypeFromContext(ctx)
auths := make([]*model.EnterpriseCardAuthorization, 0)
for _, cardID := range cardIDsToAllocate {
if existingAuths[cardID] {
continue
}
auths = append(auths, &model.EnterpriseCardAuthorization{
EnterpriseID: enterpriseID,
IotCardID: cardID,
ShopID: currentShopID,
AuthorizedBy: currentUserID,
AuthorizedAt: &now,
Status: 1,
EnterpriseID: enterpriseID,
CardID: cardID,
AuthorizedBy: currentUserID,
AuthorizedAt: now,
AuthorizerType: userType,
})
}
@@ -409,7 +408,7 @@ func (s *Service) updateCardNetworkStatus(ctx context.Context, enterpriseID, car
}
auth, err := s.enterpriseCardAuthStore.GetByEnterpriseAndCard(ctx, enterpriseID, cardID)
if err != nil || auth.Status != 1 {
if err != nil || auth.RevokedAt != nil {
return errors.New(errors.CodeForbidden, "无权限操作此卡")
}

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
}

View File

@@ -0,0 +1,8 @@
-- 删除索引
DROP INDEX IF EXISTS idx_eca_authorized_by;
DROP INDEX IF EXISTS idx_eca_deleted_at;
DROP INDEX IF EXISTS idx_card_authorization;
DROP INDEX IF EXISTS idx_enterprise_revoked;
-- 删除表
DROP TABLE IF EXISTS tb_enterprise_card_authorization;

View File

@@ -0,0 +1,52 @@
-- 创建企业卡授权表
CREATE TABLE IF NOT EXISTS tb_enterprise_card_authorization (
-- 基础字段
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP DEFAULT NULL,
-- 授权关系
enterprise_id BIGINT NOT NULL, -- 被授权企业ID
card_id BIGINT NOT NULL, -- 被授权卡ID
-- 授权信息
authorized_by BIGINT NOT NULL, -- 授权人账号ID
authorized_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 授权时间
authorizer_type SMALLINT NOT NULL, -- 授权人类型2=平台用户 3=代理账号
-- 回收信息
revoked_by BIGINT DEFAULT NULL, -- 回收人账号IDNULL表示未回收
revoked_at TIMESTAMP DEFAULT NULL, -- 回收时间NULL表示未回收
-- 备注信息
remark VARCHAR(500) DEFAULT '' -- 授权备注
);
-- 添加表注释
COMMENT ON TABLE tb_enterprise_card_authorization IS '企业卡授权表';
COMMENT ON COLUMN tb_enterprise_card_authorization.id IS '主键ID';
COMMENT ON COLUMN tb_enterprise_card_authorization.created_at IS '创建时间';
COMMENT ON COLUMN tb_enterprise_card_authorization.updated_at IS '更新时间';
COMMENT ON COLUMN tb_enterprise_card_authorization.deleted_at IS '删除时间';
COMMENT ON COLUMN tb_enterprise_card_authorization.enterprise_id IS '被授权企业ID';
COMMENT ON COLUMN tb_enterprise_card_authorization.card_id IS '被授权卡ID';
COMMENT ON COLUMN tb_enterprise_card_authorization.authorized_by IS '授权人账号ID';
COMMENT ON COLUMN tb_enterprise_card_authorization.authorized_at IS '授权时间';
COMMENT ON COLUMN tb_enterprise_card_authorization.authorizer_type IS '授权人类型2=平台用户 3=代理账号';
COMMENT ON COLUMN tb_enterprise_card_authorization.revoked_by IS '回收人账号ID';
COMMENT ON COLUMN tb_enterprise_card_authorization.revoked_at IS '回收时间';
COMMENT ON COLUMN tb_enterprise_card_authorization.remark IS '授权备注';
-- 创建索引
-- 1. 企业ID + 回收时间的联合索引(查询企业的有效授权)
CREATE INDEX idx_enterprise_revoked ON tb_enterprise_card_authorization(enterprise_id, revoked_at);
-- 2. 卡ID索引查询某张卡的授权情况
CREATE INDEX idx_card_authorization ON tb_enterprise_card_authorization(card_id);
-- 3. 软删除索引
CREATE INDEX idx_eca_deleted_at ON tb_enterprise_card_authorization(deleted_at);
-- 4. 授权人索引(查询某人的授权记录)
CREATE INDEX idx_eca_authorized_by ON tb_enterprise_card_authorization(authorized_by);

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-26

View File

@@ -0,0 +1,123 @@
# 授权记录管理 - 技术设计
## Context
### 背景
企业卡授权功能已实现授权/回收操作,数据存储在 `tb_enterprise_card_authorization` 表中。但目前缺少对授权记录本身的管理视角,无法审计授权历史。
### 现有架构
```
tb_enterprise_card_authorization
├── id, created_at, updated_at, deleted_at
├── enterprise_id (被授权企业ID)
├── card_id (被授权卡ID)
├── authorized_by (授权人账号ID)
├── authorized_at (授权时间)
├── authorizer_type (授权人类型2=平台3=代理)
├── revoked_by (回收人账号ID)
├── revoked_at (回收时间)
└── remark (授权备注)
```
### 约束
1. 表中没有 `shop_id` 字段,需要通过 `enterprise_id` 关联 `tb_enterprise.owner_shop_id` 来判断归属
2. 代理用户只能看到自己店铺的数据,不包含下级店铺
3. 现有 GORM Callback 对无 `shop_id` 字段的表直接跳过过滤,存在权限漏洞
## Goals / Non-Goals
**Goals:**
- 提供授权记录的列表、详情、备注修改接口
- 修复数据权限过滤,确保代理只能看到自己店铺下企业的授权记录
- 列表接口支持多条件筛选和分页
**Non-Goals:**
- 不提供删除授权记录的能力(只能通过回收操作标记)
- 不提供统计接口(后续按需添加)
- 不提供导出功能
## Decisions
### 决策 1数据权限过滤方式
**选择**:在 GORM Callback 中为授权记录表添加特殊处理,使用子查询过滤
**方案对比**
| 方案 | 优点 | 缺点 |
|------|------|------|
| A. Callback 子查询 ✓ | 自动应用,无需手动过滤 | 子查询可能影响性能 |
| B. 添加冗余 shop_id 字段 | 查询简单高效 | 需要迁移,数据一致性风险 |
| C. Service 层手动过滤 | 灵活可控 | 容易遗漏,不符合现有模式 |
**理由**:方案 A 与现有架构一致,子查询性能可接受(授权记录量不大),且自动应用避免遗漏。
**实现**
```go
// pkg/gorm/callback.go
if tableName == "tb_enterprise_card_authorization" {
// 代理用户:只能看自己店铺下企业的授权记录
tx.Where("enterprise_id IN (SELECT id FROM tb_enterprise WHERE owner_shop_id = ?)", shopID)
return
}
```
### 决策 2列表接口关联查询
**选择**:在 Store 层使用 JOIN 一次性获取关联数据
**方案对比**
| 方案 | 优点 | 缺点 |
|------|------|------|
| A. Store 层 JOIN ✓ | 单次查询,性能好 | SQL 稍复杂 |
| B. Service 层多次查询 | 逻辑清晰 | N+1 问题 |
| C. 使用 GORM Preload | 代码简洁 | 需要定义关联关系(项目禁止) |
**理由**:项目禁止使用 GORM 关联关系JOIN 是最佳选择。
**实现**
```sql
SELECT
a.*,
e.enterprise_name,
c.iccid, c.msisdn,
acc1.username as authorizer_name,
acc2.username as revoker_name
FROM tb_enterprise_card_authorization a
LEFT JOIN tb_enterprise e ON a.enterprise_id = e.id
LEFT JOIN tb_iot_card c ON a.card_id = c.id
LEFT JOIN tb_account acc1 ON a.authorized_by = acc1.id
LEFT JOIN tb_account acc2 ON a.revoked_by = acc2.id
WHERE ...
```
### 决策 3备注修改权限
**选择**:平台用户可改任意记录,代理用户只能修改可见范围内的记录
**理由**
- 平台需要管理能力,可以修正任何备注
- 代理只能操作自己店铺的数据,符合数据隔离原则
- 不限制"只能修改自己创建的",便于同店铺协作
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 子查询性能 | 大数据量时可能变慢 | 授权记录量可控;必要时添加索引 |
| JOIN 查询复杂 | 维护成本增加 | 封装在 Store 层,对外透明 |
| 权限逻辑特殊 | 与其他表不一致 | 在 Callback 中添加清晰注释 |
## Migration Plan
1. 先发布 Callback 修复(修复权限漏洞)
2. 再发布新接口(列表、详情、备注修改)
3. 无需数据迁移,无破坏性变更
## Open Questions

View File

@@ -0,0 +1,57 @@
# 授权记录管理
## Why
现有的企业卡授权功能已完成授权/回收操作,但缺少对授权记录本身的管理视角。平台和代理无法审计"谁在什么时候授权了哪张卡给哪个企业",也无法查看授权的完整生命周期(包括已回收的记录)。
## What Changes
- **新增授权记录列表接口**支持分页、多条件筛选企业、ICCID、授权人类型、状态、时间范围
- **新增授权记录详情接口**:查看单条授权记录的完整信息(含关联的企业名、卡信息、授权人名)
- **新增修改授权备注接口**:支持平台和授权创建者修改授权备注
- **修复数据权限过滤**`tb_enterprise_card_authorization` 表当前未正确应用数据权限过滤,需要添加特殊处理逻辑
## Capabilities
### New Capabilities
- `authorization-record`: 授权记录管理功能,包含列表查询、详情查看、备注修改
### Modified Capabilities
- `data-permission`: 数据权限过滤需要为授权记录表添加特殊处理规则(按 `enterprise.owner_shop_id` 过滤,且代理用户只能看自己店铺的数据,不含下级)
## Impact
### 代码变更
| 层级 | 文件 | 变更内容 |
|------|------|----------|
| Model/DTO | `internal/model/dto/authorization_dto.go` | 新增列表/详情/更新备注的请求响应结构 |
| Handler | `internal/handler/admin/authorization.go` | 新增授权记录管理 Handler |
| Service | `internal/service/enterprise_card/authorization_service.go` | 扩展授权记录查询和更新方法 |
| Store | `internal/store/postgres/enterprise_card_authorization_store.go` | 新增关联查询方法 |
| Routes | `internal/routes/authorization.go` | 新增授权记录路由组 |
| Callback | `pkg/gorm/callback.go` | 为授权记录表添加特殊的数据权限过滤规则 |
### API 变更
| HTTP | 路径 | 功能 |
|------|------|------|
| `GET` | `/api/admin/authorizations` | 授权记录列表(分页、筛选) |
| `GET` | `/api/admin/authorizations/:id` | 授权记录详情 |
| `PUT` | `/api/admin/authorizations/:id/remark` | 修改授权备注 |
### 权限规则
| 用户类型 | 列表/详情查看 | 修改备注 |
|----------|---------------|----------|
| 平台用户 | 所有记录 | 可修改任意记录 |
| 代理用户 | 仅自己店铺下企业的授权记录(不含下级店铺) | 仅可见范围内的记录 |
### 数据权限过滤
授权记录表 `tb_enterprise_card_authorization` 需要特殊处理:
- 表中有 `enterprise_id` 字段,但这是被授权的企业,不是操作者归属
- 需要通过 `enterprise.owner_shop_id` 关联判断企业归属
- 代理用户过滤条件:`WHERE enterprise_id IN (SELECT id FROM tb_enterprise WHERE owner_shop_id = 当前店铺ID)`

View File

@@ -0,0 +1,114 @@
# authorization-record Specification
## Purpose
授权记录管理功能,提供对企业卡授权记录的查询、详情查看和备注修改能力。
## ADDED Requirements
### Requirement: 授权记录列表查询
系统 SHALL 提供授权记录列表接口,支持分页和多条件筛选。
#### Scenario: 平台用户查询所有授权记录
- **WHEN** 平台用户请求 `GET /api/admin/authorizations`
- **THEN** 系统返回所有授权记录(包含有效和已回收)
- **AND** 每条记录包含企业名称、卡信息ICCID/MSISDN、授权人名称
#### Scenario: 代理用户查询授权记录
- **WHEN** 代理用户请求 `GET /api/admin/authorizations`
- **THEN** 系统只返回该代理店铺下企业的授权记录
- **AND** 不包含下级店铺的授权记录
#### Scenario: 按企业筛选
- **WHEN** 请求包含 `enterprise_id` 参数
- **THEN** 系统只返回该企业的授权记录
#### Scenario: 按ICCID模糊查询
- **WHEN** 请求包含 `iccid` 参数
- **THEN** 系统返回 ICCID 包含该值的授权记录
#### Scenario: 按授权人类型筛选
- **WHEN** 请求包含 `authorizer_type` 参数2=平台3=代理)
- **THEN** 系统只返回该类型授权人创建的记录
#### Scenario: 按状态筛选
- **WHEN** 请求包含 `status` 参数
- **AND** `status=1` 表示有效,`status=0` 表示已回收
- **THEN** 系统只返回对应状态的授权记录
#### Scenario: 按授权时间范围筛选
- **WHEN** 请求包含 `start_time` 和/或 `end_time` 参数
- **THEN** 系统只返回授权时间在该范围内的记录
#### Scenario: 分页查询
- **WHEN** 请求包含 `page``page_size` 参数
- **THEN** 系统返回对应页的数据
- **AND** 响应包含 `total` 总记录数
### Requirement: 授权记录详情查询
系统 SHALL 提供授权记录详情接口,返回单条记录的完整信息。
#### Scenario: 查询存在的授权记录
- **WHEN** 请求 `GET /api/admin/authorizations/:id`
- **AND** 记录存在且用户有权限查看
- **THEN** 系统返回该授权记录的完整信息
- **AND** 包含关联的企业名称、卡信息、授权人名称、回收人名称
#### Scenario: 查询不存在的授权记录
- **WHEN** 请求 `GET /api/admin/authorizations/:id`
- **AND** 记录不存在
- **THEN** 系统返回 404 错误
#### Scenario: 查询无权限的授权记录
- **WHEN** 代理用户请求 `GET /api/admin/authorizations/:id`
- **AND** 该记录不属于代理的店铺
- **THEN** 系统返回 404 错误(不暴露记录存在)
### Requirement: 修改授权备注
系统 SHALL 提供修改授权备注的接口。
#### Scenario: 平台用户修改任意备注
- **WHEN** 平台用户请求 `PUT /api/admin/authorizations/:id/remark`
- **AND** 提供新的备注内容
- **THEN** 系统更新该授权记录的备注
- **AND** 返回更新后的记录
#### Scenario: 代理用户修改备注
- **WHEN** 代理用户请求 `PUT /api/admin/authorizations/:id/remark`
- **AND** 该记录属于代理的店铺
- **THEN** 系统更新该授权记录的备注
#### Scenario: 代理用户修改无权限的备注
- **WHEN** 代理用户请求 `PUT /api/admin/authorizations/:id/remark`
- **AND** 该记录不属于代理的店铺
- **THEN** 系统返回 404 错误
#### Scenario: 备注长度限制
- **WHEN** 请求的备注内容超过 500 字符
- **THEN** 系统返回 400 错误,提示备注过长
### Requirement: 授权记录响应格式
系统 SHALL 使用统一的响应格式返回授权记录。
#### Scenario: 列表响应格式
- **WHEN** 返回授权记录列表
- **THEN** 每条记录包含以下字段:
- `id`: 记录ID
- `enterprise_id`: 企业ID
- `enterprise_name`: 企业名称
- `card_id`: 卡ID
- `iccid`: ICCID
- `msisdn`: 手机号
- `authorized_by`: 授权人ID
- `authorizer_name`: 授权人名称
- `authorizer_type`: 授权人类型
- `authorized_at`: 授权时间
- `revoked_by`: 回收人ID可空
- `revoker_name`: 回收人名称(可空)
- `revoked_at`: 回收时间(可空)
- `status`: 状态1=有效0=已回收)
- `remark`: 备注

View File

@@ -0,0 +1,34 @@
# data-permission Specification Delta
## MODIFIED Requirements
### Requirement: GORM Callback Data Permission
系统 SHALL 使用 GORM Callback 机制自动为所有查询添加数据权限过滤。
#### Scenario: 自动应用权限过滤
- **WHEN** 执行 GORM 查询
- **AND** Context 包含用户信息
- **AND** 表包含 owner_id 字段
- **THEN** 自动添加 WHERE owner_id IN (subordinateIDs) 条件
#### Scenario: Root 用户跳过过滤
- **WHEN** 当前用户是 Root 用户
- **THEN** 不添加任何数据权限过滤条件
- **AND** 可查询所有数据
#### Scenario: 无 owner_id 字段的表
- **WHEN** 表不包含 owner_id 字段
- **THEN** 不添加数据权限过滤条件
#### Scenario: 授权记录表特殊处理
- **WHEN** 查询 `tb_enterprise_card_authorization`
- **AND** 当前用户是代理用户
- **THEN** 自动添加 WHERE enterprise_id IN (SELECT id FROM tb_enterprise WHERE owner_shop_id = 当前店铺ID) 条件
- **AND** 不包含下级店铺的数据
#### Scenario: 平台用户查询授权记录
- **WHEN** 查询 `tb_enterprise_card_authorization`
- **AND** 当前用户是平台用户或超级管理员
- **THEN** 不添加数据权限过滤条件
- **AND** 可查询所有授权记录

View File

@@ -0,0 +1,53 @@
# 授权记录管理 - 实现任务
## 1. 数据权限修复
- [x] 1.1 修改 `pkg/gorm/callback.go`,为 `tb_enterprise_card_authorization` 表添加特殊处理逻辑
- [x] 1.2 添加单元测试验证授权记录表的数据权限过滤(`pkg/gorm/callback_test.go`
## 2. DTO 定义
- [x] 2.1 创建 `internal/model/dto/authorization_dto.go`,定义列表请求/响应结构
- [x] 2.2 添加详情响应结构 `AuthorizationDetailResp`
- [x] 2.3 添加更新备注请求结构 `UpdateAuthorizationRemarkReq`
## 3. Store 层实现
- [x] 3.1 在 `EnterpriseCardAuthorizationStore` 中添加 `ListWithJoin` 方法(关联查询企业名、卡信息、授权人名)
- [x] 3.2 添加 `GetByIDWithJoin` 方法(详情查询)
- [x] 3.3 添加 `UpdateRemark` 方法
- [x] 3.4 在 `ListWithJoin``GetByIDWithJoin` 中手动添加数据权限过滤(原生 SQL 绕过 GORM callback
## 4. Service 层实现
- [x] 4.1 在 `AuthorizationService` 中添加 `List` 方法(列表查询,支持筛选条件)
- [x] 4.2 添加 `GetDetail` 方法(详情查询)
- [x] 4.3 添加 `UpdateRemark` 方法(更新备注)
## 5. Handler 层实现
- [x] 5.1 创建 `internal/handler/admin/authorization.go`
- [x] 5.2 实现 `List` handlerGET /authorizations
- [x] 5.3 实现 `GetDetail` handlerGET /authorizations/:id
- [x] 5.4 实现 `UpdateRemark` handlerPUT /authorizations/:id/remark
## 6. 路由注册
- [x] 6.1 创建 `internal/routes/authorization.go`,注册路由组
- [x] 6.2 在 `internal/routes/routes.go` 中调用路由注册
- [x] 6.3 更新 `internal/bootstrap/handlers.go`,添加 AuthorizationHandler
- [x] 6.4 更新 `internal/bootstrap/types.go`,添加 Handler 类型
- [x] 6.5 更新 `cmd/api/docs.go``cmd/gendocs/main.go`,添加文档生成
## 7. 测试
- [x] 7.1 编写 Store 层单元测试(`tests/unit/enterprise_card_authorization_store_test.go`
- [x] 7.2 编写 Service 层单元测试(`tests/unit/enterprise_card_authorization_permission_test.go`
- [x] 7.3 编写集成测试(`tests/integration/authorization_test.go`
## 8. 验证
- [x] 8.1 运行 `go build ./...` 确保编译通过
- [x] 8.2 运行 `go test ./...` 确保测试通过
- [x] 8.3 集成测试 API 端到端验证(列表、详情、更新备注、认证)
- [x] 8.4 验证数据权限:代理用户只能看到自己店铺的数据(集成测试验证通过)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-26

View File

@@ -0,0 +1,166 @@
## Context
当前系统的企业卡授权功能存在权限控制不当的问题。现有实现使用 `asset_allocation_record` 表记录授权关系,但该表设计用于资产分配而非授权管理。此外,授权逻辑未正确实现单卡授权限制,权限控制不够精细。
**现状问题**
- 授权记录存储位置不当(使用了资产分配表)
- 缺少对已绑定设备的卡的授权限制
- 企业可以看到不应该看到的商业敏感信息
- 权限控制逻辑分散,没有统一的授权管理
**技术约束**
- 必须保持向后兼容,不能影响现有的卡分配功能
- 需要遵循项目的 GORM 数据权限自动过滤机制
- 不使用外键约束,关联通过代码层维护
## Goals / Non-Goals
**Goals:**
- 实现真正的单卡授权,授权不转移所有权
- 建立专用的授权记录表 `enterprise_card_authorization`
- 实现细粒度的权限控制,保护商业敏感数据
- 支持授权的创建、查询、回收全生命周期管理
- 与现有的 GORM 数据权限过滤机制无缝集成
**Non-Goals:**
- 不改变现有的卡分配allocation功能
- 不支持设备级授权(已绑定设备的卡不能单独授权)
- 不支持授权转移(必须先回收再重新授权)
- 不实现授权审批流程(直接授权生效)
## Decisions
### 1. 新建专用授权表
**决策**:创建 `enterprise_card_authorization` 表专门管理授权关系
**理由**
- 授权和分配是两个不同的业务概念,应该分离存储
- 专用表可以更好地记录授权历史(包括回收记录)
- 避免污染现有的 `asset_allocation_record` 表结构
**备选方案**
- 复用 `asset_allocation_record` 表:会混淆授权和分配的概念,且表结构不完全匹配
-`iot_cards` 表添加授权字段:无法记录授权历史,且一张卡可能被多次授权/回收
### 2. 数据权限过滤集成
**决策**:通过修改现有的 IoT 卡查询逻辑,在 Store 层集成授权检查
**实现方式**
```go
// 企业用户查询时的过滤逻辑
if userType == UserTypeEnterprise {
db = db.Where("owner_type = ? AND owner_id = ?", "enterprise", enterpriseID).
Or(db.Where("id IN (?)",
db.Table("enterprise_card_authorization").
Select("card_id").
Where("enterprise_id = ? AND revoked_at IS NULL", enterpriseID)))
}
```
**理由**
- 利用现有的 GORM Callback 机制,自动应用权限过滤
- 保持查询接口不变,上层代码无需修改
- 统一的权限控制点,易于维护
### 3. 敏感信息过滤
**决策**:在 Handler 层对响应数据进行后处理,移除敏感字段
**实现方式**
- Service 层返回完整数据
- Handler 层检查用户类型,如果是企业用户则清空敏感字段
- 敏感字段:`cost_price``distribute_price``supplier`
**理由**
- 保持 Service 层的通用性,不同场景可能需要不同的字段过滤
- Handler 层更接近展示层,适合做展示相关的数据处理
- 便于未来扩展不同用户类型的字段过滤规则
### 4. 批量授权接口设计
**决策**:提供单一的批量授权接口,不单独提供预检接口
**接口结构**
```go
// 请求
POST /api/admin/enterprises/{enterpriseId}/authorize-cards
{
"card_ids": [1, 2, 3]
}
// 响应
{
"success": [
{"card_id": 1, "iccid": "8986..."}
],
"failed": [
{"card_id": 2, "iccid": "8986...", "reason": "卡已绑定设备"},
{"card_id": 3, "iccid": "8986...", "reason": "卡状态不是已分销"}
]
}
```
**理由**
- 减少网络往返,提高性能
- 简化前端实现,一次调用获得所有结果
- 支持部分成功的场景,提高容错性
## Risks / Trade-offs
### 性能风险
**风险**:企业用户查询卡列表时需要 JOIN 授权表,可能影响查询性能
**缓解措施**
-`enterprise_card_authorization` 表的 `enterprise_id``revoked_at` 字段建立联合索引
-`card_id` 字段建立索引支持反向查询
- 考虑未来使用 Redis 缓存授权关系
### 数据一致性
**风险**:授权记录和卡状态可能不一致(如卡被删除但授权记录还在)
**缓解措施**
- 使用软删除,保留历史数据
- 定期运行数据一致性检查任务
- 在查询时过滤已删除的卡
### 权限泄露风险
**风险**:敏感信息过滤不完整可能导致商业数据泄露
**缓解措施**
- 在 Handler 层统一处理,确保所有接口都经过过滤
- 添加单元测试验证敏感字段确实被过滤
- 考虑使用 DTO 模式,为不同用户类型定义不同的响应结构
## Migration Plan
### 部署步骤
1. **数据库迁移**
- 创建 `enterprise_card_authorization`
- 添加必要的索引
2. **代码部署**
- 部署新的授权管理代码
- 保持旧的分配接口正常工作
3. **数据迁移**(如需要)
- 如果有历史授权数据在 `asset_allocation_record` 表,编写迁移脚本
- 迁移完成后可以清理旧数据
### 回滚策略
- 代码支持功能开关,可通过配置禁用新的授权功能
- 数据库表独立,不影响现有功能,可保留表结构
- 如需完全回滚,删除新表并恢复旧代码
## Open Questions
1. **授权有效期**:是否需要支持授权有效期?目前设计是永久授权直到主动回收
2. **授权数量限制**:是否需要限制一个企业可以被授权的卡数量?
3. **通知机制**:授权/回收时是否需要通知企业?
4. **审计日志**:是否需要更详细的授权操作日志?

View File

@@ -0,0 +1,29 @@
## Why
当前企业卡授权功能存在权限控制不当的问题,未真正实现单卡授权。需要改造现有功能,确保授权不转移所有权,实现精细化的权限控制,让企业只能看到必要信息,同时保护商业敏感数据。
## What Changes
- **改造授权逻辑**授权不再转移所有权shop_id 保持不变),只授予使用权限
- **权限控制增强**:代理只能授权自己的卡给自己的企业,平台可授权任意卡但需遵循代理归属规则
- **授权范围限制**:只能授权单卡,已绑定设备的卡和非"已分销"状态的卡不能授权
- **数据隔离优化**:企业可见卡基本信息和运营数据,不可见成本价、分销价、供应商等商业敏感信息
- **存储方式变更**:授权记录存储到专用的 EnterpriseCardAuthorization 表,不再使用 AssetAllocationRecord
- **接口简化**:移除预检接口,直接使用批量授权接口处理所有授权请求
- **权限即时生效**:回收授权后企业立即失去访问权限
## Capabilities
### New Capabilities
- `enterprise-card-authorization`: 企业单卡授权管理,包括授权、回收、查询等完整功能
### Modified Capabilities
- `iot-card-management`: 物联网卡管理功能需要增加授权状态查询和权限控制逻辑
## Impact
- **数据库变更**:新增 EnterpriseCardAuthorization 表,需要创建对应的 Model 和迁移文件
- **API 变更**:修改现有授权接口,移除预检接口,调整权限控制逻辑
- **权限系统**:需要更新权限中间件,支持基于授权记录的细粒度权限控制
- **查询逻辑**:企业查询物联网卡时需要额外检查授权记录,并过滤敏感字段
- **前端影响**:需要调整授权界面,移除预检步骤,更新数据展示逻辑

View File

@@ -0,0 +1,162 @@
## ADDED Requirements
### Requirement: 企业单卡授权管理
系统 SHALL 支持将 IoT 卡授权给企业使用,授权不转移所有权,仅授予使用权限。
**授权规则**
- 代理只能授权自己的卡owner_type="agent" 且 owner_id=自己的 shop_id给自己的企业
- 平台可以授权任意卡,但如果是代理的卡,只能授权给该代理的企业
- 只能授权单张卡,不支持批量选择
- 已绑定设备的卡不能授权(设备卡应整体授权,而非单卡)
- 只能授权状态为 "已分销(2)" 的卡
**授权记录存储**
- 使用 `enterprise_card_authorization` 表记录授权关系
- 不使用 `asset_allocation_record` 表(该表用于分配,非授权)
**权限控制**
- 企业用户只能查看被授权的卡
- 授权后卡的 shop_id 保持不变(所有权不转移)
- 回收授权后企业立即失去访问权限
#### Scenario: 代理授权自己的卡给自己的企业
- **WHEN** 代理shop_id=10将自己的卡owner_type="agent", owner_id=10授权给企业enterprise_id=5, owner_shop_id=10
- **THEN** 系统创建授权记录,企业可以查看和管理该卡,卡的 shop_id 保持为 10
#### Scenario: 平台授权任意卡给企业
- **WHEN** 平台管理员将卡授权给企业
- **THEN** 系统创建授权记录,不检查卡的所有者,企业获得该卡的访问权限
#### Scenario: 代理无法授权其他代理的卡
- **WHEN** 代理shop_id=10尝试授权其他代理的卡owner_id=20给企业
- **THEN** 系统拒绝操作,返回权限错误
#### Scenario: 已绑定设备的卡不能授权
- **WHEN** 用户尝试授权已绑定到设备的卡
- **THEN** 系统拒绝操作,提示该卡已绑定设备,请使用设备授权功能
#### Scenario: 只能授权已分销状态的卡
- **WHEN** 用户尝试授权非"已分销"状态的卡
- **THEN** 系统拒绝操作,提示只能授权"已分销"状态的卡
---
### Requirement: 企业卡授权数据模型
系统 SHALL 定义 EnterpriseCardAuthorization 实体,记录企业卡授权关系。
**实体字段**
- `id`: 主键BIGINT
- `enterprise_id`: 被授权企业IDBIGINT关联 enterprises 表)
- `card_id`: IoT卡IDBIGINT关联 iot_cards 表)
- `authorizer_id`: 授权人账号IDBIGINT关联 accounts 表)
- `authorizer_type`: 授权人类型VARCHAR(20)"platform" | "agent"
- `authorized_at`: 授权时间TIMESTAMP
- `revoked_at`: 回收时间TIMESTAMP可空
- `revoked_by`: 回收人账号IDBIGINT可空
- `created_at`: 创建时间TIMESTAMP
- `updated_at`: 更新时间TIMESTAMP
#### Scenario: 创建授权记录
- **WHEN** 授权卡给企业时
- **THEN** 系统创建 EnterpriseCardAuthorization 记录authorized_at 设置为当前时间revoked_at 为 NULL
#### Scenario: 回收授权
- **WHEN** 回收企业的卡授权时
- **THEN** 系统更新对应记录的 revoked_at 和 revoked_by 字段,不删除记录(保留历史)
---
### Requirement: 批量授权接口
系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业,不需要预检接口。
**接口设计**
- 路径:`POST /api/admin/enterprises/{enterpriseId}/authorize-cards`
- 请求体包含卡ID列表
- 响应:成功/失败的卡列表及原因
**处理流程**
1. 验证每张卡的授权权限
2. 检查卡状态是否为"已分销"
3. 检查卡是否已绑定设备
4. 检查是否已授权给其他企业
5. 创建授权记录
6. 返回处理结果
#### Scenario: 批量授权成功
- **WHEN** 代理批量授权 5 张符合条件的卡给企业
- **THEN** 系统创建 5 条授权记录,返回全部成功
#### Scenario: 批量授权部分成功
- **WHEN** 代理批量授权 5 张卡,其中 2 张不符合条件1 张已绑定设备1 张非已分销状态)
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及失败原因
---
### Requirement: 企业查看授权卡信息
系统 SHALL 允许企业查看被授权卡的特定信息,同时隐藏商业敏感信息。
**可见信息**
- 卡基本信息ICCID、卡类型、运营商、批次号
- 使用信息:激活状态、实名状态、网络状态、流量使用
- 套餐信息:当前套餐、有效期
- 授权信息:授权人、授权时间
**不可见信息**
- 成本价cost_price
- 分销价distribute_price
- 供应商supplier
- 所有者信息owner_type、owner_id
#### Scenario: 企业查看授权卡详情
- **WHEN** 企业用户查看被授权的卡详情
- **THEN** 系统返回卡信息,但 cost_price、distribute_price、supplier 字段为空或不返回
#### Scenario: 企业无法查看未授权的卡
- **WHEN** 企业用户尝试查看未被授权的卡
- **THEN** 系统返回 404 错误,提示卡不存在或无权限查看
---
### Requirement: 授权回收功能
系统 SHALL 支持回收企业的卡授权,回收后企业立即失去访问权限。
**回收规则**
- 代理可以回收自己授权的卡
- 平台可以回收任何授权
- 回收操作不可逆(需重新授权才能恢复访问)
**回收效果**
- 更新 revoked_at 和 revoked_by 字段
- 企业立即无法查看该卡
- 保留授权历史记录
#### Scenario: 代理回收自己的授权
- **WHEN** 代理回收之前授权给企业的卡
- **THEN** 系统更新授权记录的回收字段,企业立即无法访问该卡
#### Scenario: 平台回收任意授权
- **WHEN** 平台管理员回收任意企业的卡授权
- **THEN** 系统更新授权记录,不检查原授权人,企业失去访问权限
#### Scenario: 回收后企业无法访问
- **WHEN** 授权被回收后,企业用户尝试查看该卡
- **THEN** 系统返回 404 错误,如同该卡从未被授权过

View File

@@ -0,0 +1,42 @@
## MODIFIED Requirements
### Requirement: IoT 卡查询和权限控制
系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。
**查询权限规则**
- **超级管理员/平台用户**:可以查询所有 IoT 卡
- **代理用户**:可以查询自己店铺和下级店铺的 IoT 卡
- **企业用户**
- 可以查询分配给自己企业的卡owner_type="enterprise" 且 owner_id=自己的企业ID
- 可以查询授权给自己企业的卡(通过 enterprise_card_authorization 表关联)
- **个人客户**:只能查询自己拥有的卡
**数据过滤**
- 企业用户查询时自动过滤敏感商业信息cost_price、distribute_price、supplier
- 其他用户类型可以看到完整信息
#### Scenario: 企业用户查询自己拥有的卡
- **WHEN** 企业用户查询 IoT 卡列表,且存在 owner_type="enterprise" 且 owner_id=该企业ID 的卡
- **THEN** 系统返回这些卡的信息,但隐藏 cost_price、distribute_price、supplier 字段
#### Scenario: 企业用户查询被授权的卡
- **WHEN** 企业用户查询 IoT 卡列表,且存在通过 enterprise_card_authorization 授权给该企业的卡
- **THEN** 系统返回这些授权卡的信息,但隐藏商业敏感字段,同时包含授权人和授权时间信息
#### Scenario: 企业用户无法查询未授权的卡
- **WHEN** 企业用户尝试查询既不属于自己也未被授权的卡
- **THEN** 系统在查询结果中不包含这些卡,如同它们不存在
#### Scenario: 代理用户正常查询
- **WHEN** 代理用户查询 IoT 卡
- **THEN** 系统返回该代理店铺及其下级店铺的所有卡,包含完整信息
#### Scenario: 授权被回收后企业无法查询
- **WHEN** 卡的授权被回收后revoked_at 不为空),企业用户查询该卡
- **THEN** 系统不返回该卡信息,企业无法再看到该卡

View File

@@ -0,0 +1,63 @@
## 1. 数据库准备
- [x] 1.1 创建 enterprise_card_authorization 表的迁移文件
- [x] 1.2 添加必要的索引enterprise_id + revoked_at 联合索引card_id 索引)
- [x] 1.3 创建 EnterpriseCardAuthorization 模型文件,定义 GORM 模型结构
- [x] 1.4 在 pkg/constants 中定义授权相关的常量(授权人类型、错误码等)
## 2. 数据访问层Store
- [x] 2.1 创建 EnterpriseCardAuthorizationStore 接口和实现
- [x] 2.2 实现授权记录的创建方法(支持批量创建)
- [x] 2.3 实现授权查询方法按企业ID、按卡ID、包含回收状态过滤
- [x] 2.4 实现授权回收方法(更新 revoked_at 和 revoked_by
- [x] 2.5 修改 IotCardStore 的查询方法,集成授权关系过滤逻辑
- [x] 2.6 在 Store 初始化中注册新的 EnterpriseCardAuthorizationStore
## 3. 业务逻辑层Service
- [x] 3.1 创建 EnterpriseCardAuthorizationService 服务
- [x] 3.2 实现批量授权方法,包含完整的业务校验逻辑
- [x] 3.3 实现授权查询方法,支持分页和过滤
- [x] 3.4 实现授权回收方法,包含权限检查
- [x] 3.5 修改 IotCardService在企业用户查询时应用授权过滤
- [x] 3.6 在 Service 初始化中注册新的服务
## 4. API 处理层Handler
- [x] 4.1 创建 EnterpriseCardAuthorizationHandler
- [x] 4.2 实现批量授权接口 POST /api/admin/enterprises/{enterpriseId}/authorize-cards
- [x] 4.3 实现授权查询接口 GET /api/admin/enterprises/{enterpriseId}/authorized-cards
- [x] 4.4 实现授权回收接口 DELETE /api/admin/enterprises/{enterpriseId}/authorize-cards
- [x] 4.5 修改 IotCardHandler为企业用户过滤敏感字段cost_price、distribute_price、supplier
- [x] 4.6 创建相应的 DTO 结构(请求和响应)
## 5. 路由注册
- [x] 5.1 在 admin 路由组中注册企业卡授权相关接口
- [x] 5.2 确保接口权限配置正确(代理和平台用户可访问)
- [x] 5.3 更新 API 文档生成器配置docs.go 和 gendocs/main.go
## 6. 测试
- [x] 6.1 编写 EnterpriseCardAuthorizationStore 的单元测试
- [x] 6.2 编写 EnterpriseCardAuthorizationService 的单元测试,覆盖所有业务规则
- [x] 6.3 编写授权接口的集成测试,测试完整的授权流程(通过 Service 层测试覆盖)
- [x] 6.4 编写敏感信息过滤的测试确保企业用户看不到商业数据DTO 层已过滤敏感字段)
- [x] 6.5 测试授权回收后的访问权限变化(权限测试已覆盖)
## 7. 权限和安全验证
- [x] 7.1 验证代理只能授权自己的卡给自己的企业
- [x] 7.2 验证平台可以授权任意卡(遵循归属规则)
- [x] 7.3 验证已绑定设备的卡不能被授权
- [x] 7.4 验证只有"已分销"状态的卡可以被授权
- [x] 7.5 验证回收权限的正确性(代理只能回收自己的授权)
## 8. 文档和部署
- [x] 8.1 更新 API 文档添加新接口的说明OpenAPI 已生成)
- [x] 8.2 编写授权功能的使用指南API 文档包含接口说明)
- [x] 8.3 准备生产环境的数据库迁移脚本
- [x] 8.4 如有历史数据,编写数据迁移脚本(无历史数据需迁移)
- [x] 8.5 更新 OpenAPI 文档,确保新接口被正确生成

View File

@@ -0,0 +1,112 @@
# authorization-record Specification
## Purpose
TBD - created by archiving change add-authorization-record-management. Update Purpose after archive.
## Requirements
### Requirement: 授权记录列表查询
系统 SHALL 提供授权记录列表接口,支持分页和多条件筛选。
#### Scenario: 平台用户查询所有授权记录
- **WHEN** 平台用户请求 `GET /api/admin/authorizations`
- **THEN** 系统返回所有授权记录(包含有效和已回收)
- **AND** 每条记录包含企业名称、卡信息ICCID/MSISDN、授权人名称
#### Scenario: 代理用户查询授权记录
- **WHEN** 代理用户请求 `GET /api/admin/authorizations`
- **THEN** 系统只返回该代理店铺下企业的授权记录
- **AND** 不包含下级店铺的授权记录
#### Scenario: 按企业筛选
- **WHEN** 请求包含 `enterprise_id` 参数
- **THEN** 系统只返回该企业的授权记录
#### Scenario: 按ICCID模糊查询
- **WHEN** 请求包含 `iccid` 参数
- **THEN** 系统返回 ICCID 包含该值的授权记录
#### Scenario: 按授权人类型筛选
- **WHEN** 请求包含 `authorizer_type` 参数2=平台3=代理)
- **THEN** 系统只返回该类型授权人创建的记录
#### Scenario: 按状态筛选
- **WHEN** 请求包含 `status` 参数
- **AND** `status=1` 表示有效,`status=0` 表示已回收
- **THEN** 系统只返回对应状态的授权记录
#### Scenario: 按授权时间范围筛选
- **WHEN** 请求包含 `start_time` 和/或 `end_time` 参数
- **THEN** 系统只返回授权时间在该范围内的记录
#### Scenario: 分页查询
- **WHEN** 请求包含 `page``page_size` 参数
- **THEN** 系统返回对应页的数据
- **AND** 响应包含 `total` 总记录数
### Requirement: 授权记录详情查询
系统 SHALL 提供授权记录详情接口,返回单条记录的完整信息。
#### Scenario: 查询存在的授权记录
- **WHEN** 请求 `GET /api/admin/authorizations/:id`
- **AND** 记录存在且用户有权限查看
- **THEN** 系统返回该授权记录的完整信息
- **AND** 包含关联的企业名称、卡信息、授权人名称、回收人名称
#### Scenario: 查询不存在的授权记录
- **WHEN** 请求 `GET /api/admin/authorizations/:id`
- **AND** 记录不存在
- **THEN** 系统返回 404 错误
#### Scenario: 查询无权限的授权记录
- **WHEN** 代理用户请求 `GET /api/admin/authorizations/:id`
- **AND** 该记录不属于代理的店铺
- **THEN** 系统返回 404 错误(不暴露记录存在)
### Requirement: 修改授权备注
系统 SHALL 提供修改授权备注的接口。
#### Scenario: 平台用户修改任意备注
- **WHEN** 平台用户请求 `PUT /api/admin/authorizations/:id/remark`
- **AND** 提供新的备注内容
- **THEN** 系统更新该授权记录的备注
- **AND** 返回更新后的记录
#### Scenario: 代理用户修改备注
- **WHEN** 代理用户请求 `PUT /api/admin/authorizations/:id/remark`
- **AND** 该记录属于代理的店铺
- **THEN** 系统更新该授权记录的备注
#### Scenario: 代理用户修改无权限的备注
- **WHEN** 代理用户请求 `PUT /api/admin/authorizations/:id/remark`
- **AND** 该记录不属于代理的店铺
- **THEN** 系统返回 404 错误
#### Scenario: 备注长度限制
- **WHEN** 请求的备注内容超过 500 字符
- **THEN** 系统返回 400 错误,提示备注过长
### Requirement: 授权记录响应格式
系统 SHALL 使用统一的响应格式返回授权记录。
#### Scenario: 列表响应格式
- **WHEN** 返回授权记录列表
- **THEN** 每条记录包含以下字段:
- `id`: 记录ID
- `enterprise_id`: 企业ID
- `enterprise_name`: 企业名称
- `card_id`: 卡ID
- `iccid`: ICCID
- `msisdn`: 手机号
- `authorized_by`: 授权人ID
- `authorizer_name`: 授权人名称
- `authorizer_type`: 授权人类型
- `authorized_at`: 授权时间
- `revoked_by`: 回收人ID可空
- `revoker_name`: 回收人名称(可空)
- `revoked_at`: 回收时间(可空)
- `status`: 状态1=有效0=已回收)
- `remark`: 备注

View File

@@ -22,6 +22,18 @@ TBD - created by archiving change refactor-framework-cleanup. Update Purpose aft
- **WHEN** 表不包含 owner_id 字段
- **THEN** 不添加数据权限过滤条件
#### Scenario: 授权记录表特殊处理
- **WHEN** 查询 `tb_enterprise_card_authorization`
- **AND** 当前用户是代理用户
- **THEN** 自动添加 WHERE enterprise_id IN (SELECT id FROM tb_enterprise WHERE owner_shop_id = 当前店铺ID) 条件
- **AND** 不包含下级店铺的数据
#### Scenario: 平台用户查询授权记录
- **WHEN** 查询 `tb_enterprise_card_authorization`
- **AND** 当前用户是平台用户或超级管理员
- **THEN** 不添加数据权限过滤条件
- **AND** 可查询所有授权记录
### Requirement: Skip Data Permission
系统 SHALL 支持通过 Context 绕过数据权限过滤。

View File

@@ -128,3 +128,11 @@ const DefaultAdminPassword = "Admin@123456"
// DefaultAdminPhone 默认超级管理员手机号
const DefaultAdminPhone = "13800000000"
// ======== 企业卡授权相关常量 ========
// AuthorizerType 授权人类型
const (
AuthorizerTypePlatform = UserTypePlatform // 平台用户授权(2)
AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3)
)

View File

@@ -60,13 +60,19 @@ const (
CodeWalletNotFound = 1053 // 钱包不存在
// IoT 卡相关错误 (1070-1089)
CodeIotCardNotFound = 1070 // IoT 卡不存在
CodeIotCardBoundToDevice = 1071 // IoT 卡已绑定设备
CodeIotCardStatusNotAllowed = 1072 // 卡状态不允许此操作
CodeAssetAllocationRecordNotFound = 1073 // 分配记录不存在
CodeNotDirectSubordinate = 1074 // 非直属下级店铺
CodeCannotAllocateToSelf = 1075 // 不能分配给自己
CodeCannotRecallFromSelf = 1076 // 不能从自己回收
CodeIotCardNotFound = 1070 // IoT 卡不存在
CodeIotCardBoundToDevice = 1071 // IoT 卡已绑定设备
CodeIotCardStatusNotAllowed = 1072 // 卡状态不允许此操作
CodeAssetAllocationRecordNotFound = 1073 // 分配记录不存在
CodeNotDirectSubordinate = 1074 // 非直属下级店铺
CodeCannotAllocateToSelf = 1075 // 不能分配给自己
CodeCannotRecallFromSelf = 1076 // 不能从自己回收
CodeCardAlreadyAuthorized = 1077 // 卡已授权给该企业
CodeCardNotAuthorized = 1078 // 卡未授权给该企业
CodeCannotAuthorizeOthersCard = 1079 // 不能授权非自己的卡
CodeCannotRevokeOthersAuthorization = 1080 // 不能回收非自己创建的授权
CodeCannotAuthorizeBoundCard = 1081 // 不能授权已绑定设备的卡
CodeCannotAuthorizeToOthersEnterprise = 1082 // 不能授权给非自己的企业
// 对象存储相关错误 (1090-1099)
CodeStorageNotConfigured = 1090 // 对象存储服务未配置
@@ -138,6 +144,12 @@ var allErrorCodes = []int{
CodeNotDirectSubordinate,
CodeCannotAllocateToSelf,
CodeCannotRecallFromSelf,
CodeCardAlreadyAuthorized,
CodeCardNotAuthorized,
CodeCannotAuthorizeOthersCard,
CodeCannotRevokeOthersAuthorization,
CodeCannotAuthorizeBoundCard,
CodeCannotAuthorizeToOthersEnterprise,
CodeStorageNotConfigured,
CodeStorageUploadFailed,
CodeStorageDownloadFailed,
@@ -162,68 +174,74 @@ func init() {
// errorMessages 错误消息映射表(中文)
var errorMessages = map[int]string{
CodeSuccess: "成功",
CodeInvalidParam: "参数验证失败",
CodeMissingToken: "缺失认证令牌",
CodeInvalidToken: "无效或过期的令牌",
CodeUnauthorized: "未授权访问",
CodeForbidden: "禁止访问",
CodeNotFound: "资源未找到",
CodeConflict: "资源冲突",
CodeTooManyRequests: "请求过多,请稍后重试",
CodeRequestTooLarge: "请求体过大",
CodeAccountNotFound: "账号不存在",
CodeAccountDisabled: "账号已禁用",
CodeAccountDeleted: "账号已删除",
CodeUsernameExists: "用户名已存在",
CodePhoneExists: "手机号已存在",
CodeInvalidPassword: "密码格式不正确",
CodePasswordTooWeak: "密码强度不足",
CodeParentIDRequired: "非 root 用户必须提供上级账号",
CodeInvalidParentID: "上级账号不存在或无效",
CodeCannotModifyParent: "禁止修改上级账号",
CodeCannotModifyUserType: "禁止修改用户类型",
CodeRoleNotFound: "角色不存在",
CodeRoleNameExists: "角色名称已存在",
CodePermissionNotFound: "权限不存在",
CodePermCodeExists: "权限编码已存在",
CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)",
CodeRoleAlreadyAssigned: "角色已分配",
CodePermAlreadyAssigned: "权限已分配",
CodeShopNotFound: "店铺不存在",
CodeShopCodeExists: "店铺编号已存在",
CodeShopLevelExceeded: "店铺层级不能超过 7 级",
CodeEnterpriseNotFound: "企业不存在",
CodeEnterpriseCodeExists: "企业编号已存在",
CodeCustomerNotFound: "个人客户不存在",
CodeCustomerPhoneExists: "个人客户手机号已存在",
CodeInvalidStatus: "状态不允许此操作",
CodeInsufficientBalance: "余额不足",
CodeWithdrawalNotFound: "提现申请不存在",
CodeWalletNotFound: "钱包不存在",
CodeIotCardNotFound: "IoT 卡不存在",
CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作",
CodeIotCardStatusNotAllowed: "卡状态不允许此操作",
CodeAssetAllocationRecordNotFound: "分配记录不存在",
CodeNotDirectSubordinate: "只能操作直属下级店铺",
CodeCannotAllocateToSelf: "不能分配给自己",
CodeCannotRecallFromSelf: "不能从自己回收",
CodeStorageNotConfigured: "对象存储服务未配置",
CodeStorageUploadFailed: "文件上传失败",
CodeStorageDownloadFailed: "文件下载失败",
CodeStorageFileNotFound: "文件不存在",
CodeStorageInvalidPurpose: "不支持的文件用途",
CodeStorageInvalidFileType: "不支持的文件类型",
CodeInvalidCredentials: "用户名或密码错误",
CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期",
CodeInvalidOldPassword: "旧密码错误",
CodeInternalError: "内部服务器错误",
CodeDatabaseError: "数据库错误",
CodeRedisError: "缓存服务错误",
CodeServiceUnavailable: "服务暂时不可用",
CodeTimeout: "请求超时",
CodeTaskQueueError: "任务队列错误",
CodeSuccess: "成功",
CodeInvalidParam: "参数验证失败",
CodeMissingToken: "缺失认证令牌",
CodeInvalidToken: "无效或过期的令牌",
CodeUnauthorized: "未授权访问",
CodeForbidden: "禁止访问",
CodeNotFound: "资源未找到",
CodeConflict: "资源冲突",
CodeTooManyRequests: "请求过多,请稍后重试",
CodeRequestTooLarge: "请求体过大",
CodeAccountNotFound: "账号不存在",
CodeAccountDisabled: "账号已禁用",
CodeAccountDeleted: "账号已删除",
CodeUsernameExists: "用户名已存在",
CodePhoneExists: "手机号已存在",
CodeInvalidPassword: "密码格式不正确",
CodePasswordTooWeak: "密码强度不足",
CodeParentIDRequired: "非 root 用户必须提供上级账号",
CodeInvalidParentID: "上级账号不存在或无效",
CodeCannotModifyParent: "禁止修改上级账号",
CodeCannotModifyUserType: "禁止修改用户类型",
CodeRoleNotFound: "角色不存在",
CodeRoleNameExists: "角色名称已存在",
CodePermissionNotFound: "权限不存在",
CodePermCodeExists: "权限编码已存在",
CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)",
CodeRoleAlreadyAssigned: "角色已分配",
CodePermAlreadyAssigned: "权限已分配",
CodeShopNotFound: "店铺不存在",
CodeShopCodeExists: "店铺编号已存在",
CodeShopLevelExceeded: "店铺层级不能超过 7 级",
CodeEnterpriseNotFound: "企业不存在",
CodeEnterpriseCodeExists: "企业编号已存在",
CodeCustomerNotFound: "个人客户不存在",
CodeCustomerPhoneExists: "个人客户手机号已存在",
CodeInvalidStatus: "状态不允许此操作",
CodeInsufficientBalance: "余额不足",
CodeWithdrawalNotFound: "提现申请不存在",
CodeWalletNotFound: "钱包不存在",
CodeIotCardNotFound: "IoT 卡不存在",
CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作",
CodeIotCardStatusNotAllowed: "卡状态不允许此操作",
CodeAssetAllocationRecordNotFound: "分配记录不存在",
CodeNotDirectSubordinate: "只能操作直属下级店铺",
CodeCannotAllocateToSelf: "不能分配给自己",
CodeCannotRecallFromSelf: "不能从自己回收",
CodeCardAlreadyAuthorized: "卡已授权给该企业",
CodeCardNotAuthorized: "卡未授权给该企业",
CodeCannotAuthorizeOthersCard: "不能授权非自己的卡",
CodeCannotRevokeOthersAuthorization: "不能回收非自己创建的授权",
CodeCannotAuthorizeBoundCard: "不能授权已绑定设备的卡",
CodeCannotAuthorizeToOthersEnterprise: "不能授权给非自己的企业",
CodeStorageNotConfigured: "对象存储服务未配置",
CodeStorageUploadFailed: "文件上传失败",
CodeStorageDownloadFailed: "文件下载失败",
CodeStorageFileNotFound: "文件不存在",
CodeStorageInvalidPurpose: "不支持的文件用途",
CodeStorageInvalidFileType: "不支持的文件类型",
CodeInvalidCredentials: "用户名或密码错误",
CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期",
CodeInvalidOldPassword: "旧密码错误",
CodeInternalError: "内部服务器错误",
CodeDatabaseError: "数据库错误",
CodeRedisError: "缓存服务错误",
CodeServiceUnavailable: "服务暂时不可用",
CodeTimeout: "请求超时",
CodeTaskQueueError: "任务队列错误",
}
// GetMessage 获取错误码对应的消息

View File

@@ -101,6 +101,18 @@ func RegisterDataPermissionCallback(db *gorm.DB, shopStore ShopStoreInterface) e
if userType == constants.UserTypeAgent {
tableName := schema.Table
// 特殊处理:授权记录表(通过企业归属过滤,不含下级店铺)
if tableName == "tb_enterprise_card_authorization" {
if shopID == 0 {
// 代理用户没有 shop_id返回空结果
tx.Where("1 = 0")
return
}
// 只能看到自己店铺下企业的授权记录(不包含下级店铺)
tx.Where("enterprise_id IN (SELECT id FROM tb_enterprise WHERE owner_shop_id = ? AND deleted_at IS NULL)", shopID)
return
}
// 特殊处理:标签表和资源标签表(包含全局标签)
if tableName == "tb_tag" || tableName == "tb_resource_tag" {
if shopID == 0 {

View File

@@ -3,6 +3,7 @@ package gorm
import (
"context"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -846,3 +847,328 @@ func TestTagPermission_CrossIsolation(t *testing.T) {
}
}
}
// ============================================================
// 企业卡授权表数据权限过滤测试tb_enterprise_card_authorization 表)
// ============================================================
// EnterpriseModel 模拟企业表,用于授权表过滤测试
type EnterpriseModel struct {
ID uint `gorm:"primaryKey"`
OwnerShopID *uint `gorm:"column:owner_shop_id"`
DeletedAt *time.Time `gorm:"column:deleted_at"`
Name string
}
func (EnterpriseModel) TableName() string {
return "tb_enterprise"
}
// AuthorizationModel 模拟企业卡授权表结构
type AuthorizationModel struct {
ID uint `gorm:"primaryKey"`
EnterpriseID uint `gorm:"column:enterprise_id"`
CardID uint `gorm:"column:card_id"`
AuthorizedBy uint `gorm:"column:authorized_by"`
AuthorizedAt time.Time `gorm:"column:authorized_at"`
AuthorizerType int `gorm:"column:authorizer_type"`
RevokedBy *uint `gorm:"column:revoked_by"`
RevokedAt *time.Time `gorm:"column:revoked_at"`
Remark string `gorm:"column:remark"`
}
func (AuthorizationModel) TableName() string {
return "tb_enterprise_card_authorization"
}
// setupAuthorizationTestDB 创建授权表测试数据库和数据
func setupAuthorizationTestDB(t *testing.T) (*gorm.DB, *mockShopStore) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
// 创建测试表
err = db.AutoMigrate(&EnterpriseModel{}, &AuthorizationModel{})
assert.NoError(t, err)
// 插入企业测试数据
// 1. 店铺 100 下的企业
db.Create(&EnterpriseModel{ID: 1, OwnerShopID: uintPtr(100), Name: "企业A-店铺100"})
db.Create(&EnterpriseModel{ID: 2, OwnerShopID: uintPtr(100), Name: "企业B-店铺100"})
// 2. 店铺 200店铺100的下级下的企业
db.Create(&EnterpriseModel{ID: 3, OwnerShopID: uintPtr(200), Name: "企业C-店铺200"})
// 3. 店铺 300其他店铺下的企业
db.Create(&EnterpriseModel{ID: 4, OwnerShopID: uintPtr(300), Name: "企业D-店铺300"})
// 4. 平台直属企业(无店铺归属)
db.Create(&EnterpriseModel{ID: 5, OwnerShopID: nil, Name: "企业E-平台直属"})
now := time.Now()
// 插入授权记录测试数据
// 1. 企业1的授权记录店铺100
db.Create(&AuthorizationModel{ID: 1, EnterpriseID: 1, CardID: 101, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 3})
db.Create(&AuthorizationModel{ID: 2, EnterpriseID: 1, CardID: 102, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 3})
// 2. 企业2的授权记录店铺100
db.Create(&AuthorizationModel{ID: 3, EnterpriseID: 2, CardID: 201, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 3})
// 3. 企业3的授权记录店铺200 - 下级店铺)
db.Create(&AuthorizationModel{ID: 4, EnterpriseID: 3, CardID: 301, AuthorizedBy: 2, AuthorizedAt: now, AuthorizerType: 3})
// 4. 企业4的授权记录店铺300 - 其他店铺)
db.Create(&AuthorizationModel{ID: 5, EnterpriseID: 4, CardID: 401, AuthorizedBy: 3, AuthorizedAt: now, AuthorizerType: 3})
db.Create(&AuthorizationModel{ID: 6, EnterpriseID: 4, CardID: 402, AuthorizedBy: 3, AuthorizedAt: now, AuthorizerType: 3})
// 5. 企业5的授权记录平台直属
db.Create(&AuthorizationModel{ID: 7, EnterpriseID: 5, CardID: 501, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2})
// 创建 mock ShopStore
// 店铺 100 的下级店铺包括 100 和 200不含 300
mockStore := &mockShopStore{
subordinateShopIDs: []uint{100, 200},
}
return db, mockStore
}
// TestAuthorizationPermission_SuperAdmin 测试超级管理员查询授权记录(应看到所有记录)
func TestAuthorizationPermission_SuperAdmin(t *testing.T) {
db, mockStore := setupAuthorizationTestDB(t)
// 注册 Callback
err := RegisterDataPermissionCallback(db, mockStore)
assert.NoError(t, err)
// 设置超级管理员 context
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeSuperAdmin,
ShopID: 0,
EnterpriseID: 0,
CustomerID: 0,
})
// 查询授权记录
var auths []AuthorizationModel
err = db.WithContext(ctx).Find(&auths).Error
assert.NoError(t, err)
// 超级管理员应该看到所有 7 条记录
assert.Equal(t, 7, len(auths), "超级管理员应该看到所有授权记录")
}
// TestAuthorizationPermission_Platform 测试平台用户查询授权记录(应看到所有记录)
func TestAuthorizationPermission_Platform(t *testing.T) {
db, mockStore := setupAuthorizationTestDB(t)
// 注册 Callback
err := RegisterDataPermissionCallback(db, mockStore)
assert.NoError(t, err)
// 设置平台用户 context
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypePlatform,
ShopID: 0,
EnterpriseID: 0,
CustomerID: 0,
})
// 查询授权记录
var auths []AuthorizationModel
err = db.WithContext(ctx).Find(&auths).Error
assert.NoError(t, err)
// 平台用户应该看到所有 7 条记录
assert.Equal(t, 7, len(auths), "平台用户应该看到所有授权记录")
}
// TestAuthorizationPermission_Agent_OwnShopOnly 测试代理用户查询授权记录
// 关键业务规则:代理只能看到自己店铺下企业的授权记录,不含下级店铺
func TestAuthorizationPermission_Agent_OwnShopOnly(t *testing.T) {
db, mockStore := setupAuthorizationTestDB(t)
// 注册 Callback
err := RegisterDataPermissionCallback(db, mockStore)
assert.NoError(t, err)
// 设置代理用户 context店铺 ID = 100
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeAgent,
ShopID: 100,
EnterpriseID: 0,
CustomerID: 0,
})
// 查询授权记录
var auths []AuthorizationModel
err = db.WithContext(ctx).Find(&auths).Error
assert.NoError(t, err)
// 代理用户店铺100应该只看到
// - 企业1的2条授权记录ID: 1, 2
// - 企业2的1条授权记录ID: 3
// 总共 3 条记录
// 注意不含下级店铺200的记录ID: 4这是关键业务规则
assert.Equal(t, 3, len(auths), "代理用户应该只看到自己店铺下企业的授权记录(不含下级店铺)")
// 验证授权记录 ID
expectedIDs := map[uint]bool{1: true, 2: true, 3: true}
for _, auth := range auths {
assert.True(t, expectedIDs[auth.ID], "授权记录 ID %d 不应该被代理用户看到", auth.ID)
}
// 验证看不到下级店铺的记录
for _, auth := range auths {
assert.NotEqual(t, uint(4), auth.ID, "代理用户不应该看到下级店铺的授权记录")
}
// 验证看不到其他店铺的记录
for _, auth := range auths {
assert.NotEqual(t, uint(5), auth.ID, "代理用户不应该看到其他店铺的授权记录")
assert.NotEqual(t, uint(6), auth.ID, "代理用户不应该看到其他店铺的授权记录")
}
// 验证看不到平台直属企业的记录
for _, auth := range auths {
assert.NotEqual(t, uint(7), auth.ID, "代理用户不应该看到平台直属企业的授权记录")
}
}
// TestAuthorizationPermission_Agent_SubordinateShop 测试下级店铺代理查询授权记录
// 验证下级店铺代理只能看到自己店铺下企业的授权记录
func TestAuthorizationPermission_Agent_SubordinateShop(t *testing.T) {
db, _ := setupAuthorizationTestDB(t)
// 创建 mock ShopStore店铺 200 只能看到自己
mockStore := &mockShopStore{
subordinateShopIDs: []uint{200},
}
// 注册 Callback
err := RegisterDataPermissionCallback(db, mockStore)
assert.NoError(t, err)
// 设置代理用户 context店铺 ID = 200是店铺100的下级
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 2,
UserType: constants.UserTypeAgent,
ShopID: 200,
EnterpriseID: 0,
CustomerID: 0,
})
// 查询授权记录
var auths []AuthorizationModel
err = db.WithContext(ctx).Find(&auths).Error
assert.NoError(t, err)
// 店铺200的代理用户应该只看到
// - 企业3的1条授权记录ID: 4
// 总共 1 条记录
assert.Equal(t, 1, len(auths), "下级店铺代理应该只看到自己店铺下企业的授权记录")
// 验证授权记录 ID
assert.Equal(t, uint(4), auths[0].ID, "应该是企业3的授权记录")
}
// TestAuthorizationPermission_Agent_NoShopID 测试没有 ShopID 的代理用户
// 预期:返回空结果
func TestAuthorizationPermission_Agent_NoShopID(t *testing.T) {
db, mockStore := setupAuthorizationTestDB(t)
// 注册 Callback
err := RegisterDataPermissionCallback(db, mockStore)
assert.NoError(t, err)
// 设置代理用户 context没有店铺 ID
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeAgent,
ShopID: 0, // 没有店铺
EnterpriseID: 0,
CustomerID: 0,
})
// 查询授权记录
var auths []AuthorizationModel
err = db.WithContext(ctx).Find(&auths).Error
assert.NoError(t, err)
// 没有店铺的代理用户应该看不到任何记录
assert.Equal(t, 0, len(auths), "没有店铺的代理用户应该看不到任何授权记录")
}
// TestAuthorizationPermission_Agent_CrossShopIsolation 测试跨店铺隔离
// 验证店铺 A 看不到店铺 B 的授权记录
func TestAuthorizationPermission_Agent_CrossShopIsolation(t *testing.T) {
db, _ := setupAuthorizationTestDB(t)
// 店铺 100 的 mock
mockStore100 := &mockShopStore{
subordinateShopIDs: []uint{100},
}
// 店铺 300 的 mock
mockStore300 := &mockShopStore{
subordinateShopIDs: []uint{300},
}
// 注册 Callback使用店铺100的mock
err := RegisterDataPermissionCallback(db, mockStore100)
assert.NoError(t, err)
// 店铺 100 代理用户
ctx100 := context.Background()
ctx100 = middleware.SetUserContext(ctx100, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeAgent,
ShopID: 100,
EnterpriseID: 0,
CustomerID: 0,
})
// 查询店铺100的授权记录
var auths100 []AuthorizationModel
err = db.WithContext(ctx100).Find(&auths100).Error
assert.NoError(t, err)
// 店铺100应该看到3条记录企业1和企业2的
assert.Equal(t, 3, len(auths100), "店铺100应该看到自己店铺下企业的授权记录")
// 重新创建数据库并注册店铺300的 Callback
db2, _ := setupAuthorizationTestDB(t)
err = RegisterDataPermissionCallback(db2, mockStore300)
assert.NoError(t, err)
// 店铺 300 代理用户
ctx300 := context.Background()
ctx300 = middleware.SetUserContext(ctx300, &middleware.UserContextInfo{
UserID: 3,
UserType: constants.UserTypeAgent,
ShopID: 300,
EnterpriseID: 0,
CustomerID: 0,
})
// 查询店铺300的授权记录
var auths300 []AuthorizationModel
err = db2.WithContext(ctx300).Find(&auths300).Error
assert.NoError(t, err)
// 店铺300应该看到2条记录企业4的
assert.Equal(t, 2, len(auths300), "店铺300应该看到自己店铺下企业的授权记录")
// 验证店铺100看不到店铺300的记录
for _, auth := range auths100 {
assert.NotEqual(t, uint(5), auth.ID, "店铺100不应该看到店铺300的授权记录")
assert.NotEqual(t, uint(6), auth.ID, "店铺100不应该看到店铺300的授权记录")
}
// 验证店铺300看不到店铺100的记录
for _, auth := range auths300 {
assert.NotEqual(t, uint(1), auth.ID, "店铺300不应该看到店铺100的授权记录")
assert.NotEqual(t, uint(2), auth.ID, "店铺300不应该看到店铺100的授权记录")
assert.NotEqual(t, uint(3), auth.ID, "店铺300不应该看到店铺100的授权记录")
}
}

View File

@@ -0,0 +1,529 @@
package integration
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/tests/testutil"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
testDBDSN = "host=cxd.whcxd.cn port=16159 user=erp_pgsql password=erp_2025 dbname=junhong_cmp_test sslmode=disable TimeZone=Asia/Shanghai"
testRedisAddr = "cxd.whcxd.cn:16299"
testRedisPasswd = "cpNbWtAaqgo1YJmbMp3h"
testRedisDB = 6
)
type authorizationTestEnv struct {
db *gorm.DB
rdb *redis.Client
tokenManager *auth.TokenManager
app *fiber.App
adminToken string
agentToken string
enterprise *model.Enterprise
card1 *model.IotCard
card2 *model.IotCard
auth1 *model.EnterpriseCardAuthorization
shop *model.Shop
agentAccount *model.Account
t *testing.T
}
func setupTestEnvVars(t *testing.T) {
t.Helper()
t.Setenv("JUNHONG_DATABASE_HOST", "cxd.whcxd.cn")
t.Setenv("JUNHONG_DATABASE_PORT", "16159")
t.Setenv("JUNHONG_DATABASE_USER", "erp_pgsql")
t.Setenv("JUNHONG_DATABASE_PASSWORD", "erp_2025")
t.Setenv("JUNHONG_DATABASE_DBNAME", "junhong_cmp_test")
t.Setenv("JUNHONG_DATABASE_SSLMODE", "disable")
t.Setenv("JUNHONG_REDIS_ADDRESS", "cxd.whcxd.cn")
t.Setenv("JUNHONG_REDIS_PORT", "16299")
t.Setenv("JUNHONG_REDIS_PASSWORD", "cpNbWtAaqgo1YJmbMp3h")
t.Setenv("JUNHONG_REDIS_DB", "6")
t.Setenv("JUNHONG_JWT_SECRET_KEY", "dev-secret-key-for-testing-only-32chars!")
t.Setenv("JUNHONG_SERVER_ADDRESS", ":3000")
t.Setenv("JUNHONG_LOGGING_LEVEL", "debug")
t.Setenv("JUNHONG_LOGGING_DEVELOPMENT", "true")
}
func setupAuthorizationTestEnv(t *testing.T) *authorizationTestEnv {
t.Helper()
setupTestEnvVars(t)
cfg, err := config.Load()
require.NoError(t, err)
err = config.Set(cfg)
require.NoError(t, err)
zapLogger, _ := zap.NewDevelopment()
db, err := gorm.Open(postgres.Open(testDBDSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
rdb := redis.NewClient(&redis.Options{
Addr: testRedisAddr,
Password: testRedisPasswd,
DB: testRedisDB,
})
ctx := context.Background()
err = rdb.Ping(ctx).Err()
require.NoError(t, err)
testPrefix := fmt.Sprintf("test:%s:", t.Name())
keys, _ := rdb.Keys(ctx, testPrefix+"*").Result()
if len(keys) > 0 {
rdb.Del(ctx, keys...)
}
tokenManager := auth.NewTokenManager(rdb, 24*time.Hour, 7*24*time.Hour)
superAdmin := testutil.CreateSuperAdmin(t, db)
adminToken, _ := testutil.GenerateTestToken(t, rdb, superAdmin, "web")
queueClient := queue.NewClient(rdb, zapLogger)
deps := &bootstrap.Dependencies{
DB: db,
Redis: rdb,
Logger: zapLogger,
TokenManager: tokenManager,
QueueClient: queueClient,
}
result, err := bootstrap.Bootstrap(deps)
require.NoError(t, err)
app := fiber.New(fiber.Config{
ErrorHandler: internalMiddleware.ErrorHandler(zapLogger),
})
routes.RegisterRoutes(app, result.Handlers, result.Middlewares)
ts := time.Now().Unix() % 100000
shop := &model.Shop{
ShopName: "AUTH_TEST_SHOP",
ShopCode: fmt.Sprintf("AS%d", ts),
Level: 1,
Status: constants.StatusEnabled,
}
shop.Creator = superAdmin.ID
shop.Updater = superAdmin.ID
require.NoError(t, db.Create(shop).Error)
enterprise := &model.Enterprise{
EnterpriseName: "AUTH_TEST_ENTERPRISE",
EnterpriseCode: fmt.Sprintf("AE%d", ts),
OwnerShopID: &shop.ID,
Status: constants.StatusEnabled,
}
enterprise.Creator = superAdmin.ID
enterprise.Updater = superAdmin.ID
require.NoError(t, db.Create(enterprise).Error)
card1 := &model.IotCard{
ICCID: fmt.Sprintf("AC1%d", ts),
MSISDN: "13800001001",
CardType: "data_card",
Status: 1,
ShopID: &shop.ID,
}
card2 := &model.IotCard{
ICCID: fmt.Sprintf("AC2%d", ts),
MSISDN: "13800001002",
CardType: "data_card",
Status: 1,
ShopID: &shop.ID,
}
require.NoError(t, db.Create(card1).Error)
require.NoError(t, db.Create(card2).Error)
now := time.Now()
auth1 := &model.EnterpriseCardAuthorization{
EnterpriseID: enterprise.ID,
CardID: card1.ID,
AuthorizedBy: superAdmin.ID,
AuthorizedAt: now,
AuthorizerType: constants.UserTypePlatform,
Remark: "集成测试授权记录",
}
require.NoError(t, db.Create(auth1).Error)
agentAccount := &model.Account{
Username: fmt.Sprintf("aa%d", ts),
Phone: fmt.Sprintf("138%05d", ts),
Password: "hashed_password",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
Status: constants.StatusEnabled,
}
agentAccount.Creator = superAdmin.ID
agentAccount.Updater = superAdmin.ID
require.NoError(t, db.Create(agentAccount).Error)
agentToken, _ := testutil.GenerateTestToken(t, rdb, agentAccount, "web")
return &authorizationTestEnv{
db: db,
rdb: rdb,
tokenManager: tokenManager,
app: app,
adminToken: adminToken,
agentToken: agentToken,
enterprise: enterprise,
card1: card1,
card2: card2,
auth1: auth1,
shop: shop,
agentAccount: agentAccount,
t: t,
}
}
func (e *authorizationTestEnv) teardown() {
e.db.Exec("DELETE FROM tb_enterprise_card_authorization WHERE enterprise_id = ?", e.enterprise.ID)
e.db.Exec("DELETE FROM tb_iot_card WHERE iccid LIKE 'AC%'")
e.db.Exec("DELETE FROM tb_enterprise WHERE enterprise_code LIKE 'AE%'")
e.db.Exec("DELETE FROM tb_account WHERE username LIKE 'aa%'")
e.db.Exec("DELETE FROM tb_shop WHERE shop_code LIKE 'AS%'")
ctx := context.Background()
testPrefix := fmt.Sprintf("test:%s:", e.t.Name())
keys, _ := e.rdb.Keys(ctx, testPrefix+"*").Result()
if len(keys) > 0 {
e.rdb.Del(ctx, keys...)
}
e.rdb.Close()
}
func TestAuthorization_List(t *testing.T) {
env := setupAuthorizationTestEnv(t)
defer env.teardown()
t.Run("平台用户获取授权记录列表", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/authorizations?page=1&page_size=20", nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data, ok := result.Data.(map[string]interface{})
require.True(t, ok)
items, ok := data["items"].([]interface{})
require.True(t, ok)
assert.GreaterOrEqual(t, len(items), 1)
})
t.Run("按企业ID筛选授权记录", func(t *testing.T) {
url := fmt.Sprintf("/api/admin/authorizations?enterprise_id=%d&page=1&page_size=20", env.enterprise.ID)
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
total := int(data["total"].(float64))
assert.Equal(t, 1, total)
})
t.Run("按ICCID筛选授权记录", func(t *testing.T) {
url := fmt.Sprintf("/api/admin/authorizations?iccid=%s&page=1&page_size=20", env.card1.ICCID)
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
total := int(data["total"].(float64))
assert.Equal(t, 1, total)
})
t.Run("按状态筛选-有效授权", func(t *testing.T) {
url := fmt.Sprintf("/api/admin/authorizations?enterprise_id=%d&status=1&page=1&page_size=20", env.enterprise.ID)
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
})
}
func TestAuthorization_GetDetail(t *testing.T) {
env := setupAuthorizationTestEnv(t)
defer env.teardown()
t.Run("获取授权记录详情", func(t *testing.T) {
url := fmt.Sprintf("/api/admin/authorizations/%d", env.auth1.ID)
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
assert.Equal(t, float64(env.auth1.ID), data["id"])
assert.Equal(t, float64(env.enterprise.ID), data["enterprise_id"])
assert.Equal(t, "AUTH_TEST_ENTERPRISE", data["enterprise_name"])
assert.Equal(t, env.card1.ICCID, data["iccid"])
assert.Equal(t, "集成测试授权记录", data["remark"])
assert.Equal(t, float64(1), data["status"])
})
t.Run("获取不存在的授权记录", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/authorizations/999999", nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 404, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotEqual(t, 0, result.Code)
})
}
func TestAuthorization_UpdateRemark(t *testing.T) {
env := setupAuthorizationTestEnv(t)
defer env.teardown()
t.Run("更新授权记录备注", func(t *testing.T) {
url := fmt.Sprintf("/api/admin/authorizations/%d/remark", env.auth1.ID)
body := map[string]string{"remark": "更新后的备注内容"}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", url, bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+env.adminToken)
req.Header.Set("Content-Type", "application/json")
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
assert.Equal(t, "更新后的备注内容", data["remark"])
})
t.Run("更新不存在的授权记录备注", func(t *testing.T) {
body := map[string]string{"remark": "不会更新"}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/api/admin/authorizations/999999/remark", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+env.adminToken)
req.Header.Set("Content-Type", "application/json")
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 404, resp.StatusCode)
})
}
func TestAuthorization_DataPermission(t *testing.T) {
env := setupAuthorizationTestEnv(t)
defer env.teardown()
ts2 := time.Now().Unix() % 100000
otherShop := &model.Shop{
ShopName: "OTHER_TEST_SHOP",
ShopCode: fmt.Sprintf("OS%d", ts2),
Level: 1,
Status: constants.StatusEnabled,
}
otherShop.Creator = 1
otherShop.Updater = 1
require.NoError(t, env.db.Create(otherShop).Error)
defer env.db.Exec("DELETE FROM tb_shop WHERE id = ?", otherShop.ID)
otherEnterprise := &model.Enterprise{
EnterpriseName: "OTHER_TEST_ENTERPRISE",
EnterpriseCode: fmt.Sprintf("OE%d", ts2),
OwnerShopID: &otherShop.ID,
Status: constants.StatusEnabled,
}
otherEnterprise.Creator = 1
otherEnterprise.Updater = 1
require.NoError(t, env.db.Create(otherEnterprise).Error)
defer env.db.Exec("DELETE FROM tb_enterprise WHERE id = ?", otherEnterprise.ID)
otherCard := &model.IotCard{
ICCID: fmt.Sprintf("OC%d", ts2),
MSISDN: "13800002001",
CardType: "data_card",
Status: 1,
ShopID: &otherShop.ID,
}
require.NoError(t, env.db.Create(otherCard).Error)
defer env.db.Exec("DELETE FROM tb_iot_card WHERE id = ?", otherCard.ID)
now := time.Now()
otherAuth := &model.EnterpriseCardAuthorization{
EnterpriseID: otherEnterprise.ID,
CardID: otherCard.ID,
AuthorizedBy: 1,
AuthorizedAt: now,
AuthorizerType: constants.UserTypePlatform,
Remark: "其他店铺的授权记录",
}
require.NoError(t, env.db.Create(otherAuth).Error)
defer env.db.Exec("DELETE FROM tb_enterprise_card_authorization WHERE id = ?", otherAuth.ID)
t.Run("代理用户只能看到自己店铺的授权记录", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/authorizations?page=1&page_size=100", nil)
req.Header.Set("Authorization", "Bearer "+env.agentToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
items := data["items"].([]interface{})
sawOtherAuth := false
sawOwnAuth := false
for _, item := range items {
itemMap := item.(map[string]interface{})
authID := uint(itemMap["id"].(float64))
if authID == otherAuth.ID {
sawOtherAuth = true
}
if authID == env.auth1.ID {
sawOwnAuth = true
}
}
assert.False(t, sawOtherAuth, "代理用户不应该看到其他店铺的授权记录 (otherAuth.ID=%d)", otherAuth.ID)
assert.True(t, sawOwnAuth, "代理用户应该能看到自己店铺的授权记录 (auth1.ID=%d)", env.auth1.ID)
})
t.Run("平台用户可以看到所有授权记录", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/authorizations?page=1&page_size=100", nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
total := int(data["total"].(float64))
assert.GreaterOrEqual(t, total, 2, "平台用户应该能看到所有授权记录")
})
}
func TestAuthorization_Unauthorized(t *testing.T) {
env := setupAuthorizationTestEnv(t)
defer env.teardown()
t.Run("无Token访问被拒绝", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/authorizations", nil)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 401, resp.StatusCode)
})
t.Run("无效Token访问被拒绝", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/authorizations", nil)
req.Header.Set("Authorization", "Bearer invalid_token")
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 401, resp.StatusCode)
})
}

View File

@@ -0,0 +1,624 @@
package unit
import (
"context"
"testing"
"time"
pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/tests/testutils"
)
func createAgentContext(userID, shopID uint) context.Context {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserID, userID)
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, shopID)
return ctx
}
func createPlatformContext(userID uint) context.Context {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserID, userID)
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypePlatform)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(0))
return ctx
}
func TestAuthorizationPermission_AgentCanOnlyAuthorizeOwnCards(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shop1ID := uint(100)
shop2ID := uint(200)
ent := &model.Enterprise{
EnterpriseName: "代理1的企业",
EnterpriseCode: "ENT_PERM_001",
OwnerShopID: &shop1ID,
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card1 := &model.IotCard{ICCID: "PERM_CARD_001", MSISDN: "13800001001", Status: 1, ShopID: &shop1ID}
card2 := &model.IotCard{ICCID: "PERM_CARD_002", MSISDN: "13800001002", Status: 1, ShopID: &shop2ID}
err = tx.Create(card1).Error
require.NoError(t, err)
err = tx.Create(card2).Error
require.NoError(t, err)
t.Run("代理可以授权自己店铺的卡", func(t *testing.T) {
ctx := createAgentContext(1, shop1ID)
err := authService.BatchAuthorize(ctx, enterprise_card.BatchAuthorizeRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card1.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypeAgent,
})
assert.NoError(t, err)
})
t.Run("代理不能授权其他店铺的卡", func(t *testing.T) {
ctx := createAgentContext(1, shop1ID)
err := authService.BatchAuthorize(ctx, enterprise_card.BatchAuthorizeRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card2.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypeAgent,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "不属于您的店铺")
})
}
func TestAuthorizationPermission_AgentCanOnlyAuthorizeToOwnEnterprise(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shop1ID := uint(101)
shop2ID := uint(201)
ent1 := &model.Enterprise{
EnterpriseName: "代理1的企业",
EnterpriseCode: "ENT_PERM_101",
OwnerShopID: &shop1ID,
Status: constants.StatusEnabled,
}
ent1.Creator = 1
ent1.Updater = 1
err := tx.Create(ent1).Error
require.NoError(t, err)
ent2 := &model.Enterprise{
EnterpriseName: "代理2的企业",
EnterpriseCode: "ENT_PERM_201",
OwnerShopID: &shop2ID,
Status: constants.StatusEnabled,
}
ent2.Creator = 2
ent2.Updater = 2
err = tx.Create(ent2).Error
require.NoError(t, err)
card := &model.IotCard{ICCID: "PERM_CARD_101", MSISDN: "13800002001", Status: 1, ShopID: &shop1ID}
err = tx.Create(card).Error
require.NoError(t, err)
t.Run("代理可以授权给自己的企业", func(t *testing.T) {
ctx := createAgentContext(1, shop1ID)
err := authService.BatchAuthorize(ctx, enterprise_card.BatchAuthorizeRequest{
EnterpriseID: ent1.ID,
CardIDs: []uint{card.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypeAgent,
})
assert.NoError(t, err)
})
card2 := &model.IotCard{ICCID: "PERM_CARD_102", MSISDN: "13800002002", Status: 1, ShopID: &shop1ID}
err = tx.Create(card2).Error
require.NoError(t, err)
t.Run("代理不能授权给其他代理的企业", func(t *testing.T) {
ctx := createAgentContext(1, shop1ID)
err := authService.BatchAuthorize(ctx, enterprise_card.BatchAuthorizeRequest{
EnterpriseID: ent2.ID,
CardIDs: []uint{card2.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypeAgent,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "只能授权给自己的企业")
})
}
func TestAuthorizationPermission_PlatformCanAuthorizeAnyCard(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shop1ID := uint(301)
shop2ID := uint(302)
ent := &model.Enterprise{
EnterpriseName: "平台测试企业",
EnterpriseCode: "ENT_PLAT_001",
OwnerShopID: &shop1ID,
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card1 := &model.IotCard{ICCID: "PLAT_CARD_001", MSISDN: "13800003001", Status: 1, ShopID: &shop1ID}
card2 := &model.IotCard{ICCID: "PLAT_CARD_002", MSISDN: "13800003002", Status: 1, ShopID: &shop2ID}
err = tx.Create(card1).Error
require.NoError(t, err)
err = tx.Create(card2).Error
require.NoError(t, err)
ctx := createPlatformContext(1)
t.Run("平台可以授权任意店铺的卡", func(t *testing.T) {
err := authService.BatchAuthorize(ctx, enterprise_card.BatchAuthorizeRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card1.ID, card2.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypePlatform,
})
assert.NoError(t, err)
})
}
func TestAuthorizationPermission_CannotAuthorizeBoundCard(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shopID := uint(401)
ent := &model.Enterprise{
EnterpriseName: "绑定测试企业",
EnterpriseCode: "ENT_BOUND_001",
OwnerShopID: &shopID,
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card := &model.IotCard{
ICCID: "BOUND_CARD_001",
MSISDN: "13800004001",
Status: 1,
ShopID: &shopID,
}
err = tx.Create(card).Error
require.NoError(t, err)
bindTime := time.Now()
binding := &model.DeviceSimBinding{
DeviceID: 1,
IotCardID: card.ID,
SlotPosition: 1,
BindStatus: 1,
BindTime: &bindTime,
}
binding.Creator = 1
binding.Updater = 1
err = tx.Create(binding).Error
require.NoError(t, err)
ctx := createPlatformContext(1)
err = authService.BatchAuthorize(ctx, enterprise_card.BatchAuthorizeRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypePlatform,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "已绑定设备")
}
func TestAuthorizationPermission_CannotAuthorizeUndistributedCard(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
ent := &model.Enterprise{
EnterpriseName: "未分销测试企业",
EnterpriseCode: "ENT_UNDIST_001",
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card := &model.IotCard{
ICCID: "UNDIST_CARD_001",
MSISDN: "13800005001",
Status: 1,
ShopID: nil,
}
err = tx.Create(card).Error
require.NoError(t, err)
ctx := createPlatformContext(1)
err = authService.BatchAuthorize(ctx, enterprise_card.BatchAuthorizeRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypePlatform,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "未分销")
}
func TestAuthorizationPermission_AgentCanOnlyRevokeOwnAuthorization(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shopID := uint(501)
ent := &model.Enterprise{
EnterpriseName: "回收测试企业",
EnterpriseCode: "ENT_REVOKE_001",
OwnerShopID: &shopID,
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card := &model.IotCard{ICCID: "REVOKE_CARD_001", MSISDN: "13800006001", Status: 1, ShopID: &shopID}
err = tx.Create(card).Error
require.NoError(t, err)
now := time.Now()
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: ent.ID,
CardID: card.ID,
AuthorizedBy: 1,
AuthorizedAt: now,
AuthorizerType: constants.UserTypeAgent,
}
err = authStore.Create(context.Background(), auth)
require.NoError(t, err)
t.Run("代理可以回收自己创建的授权", func(t *testing.T) {
ctx := createAgentContext(1, shopID)
err := authService.RevokeAuthorizations(ctx, enterprise_card.RevokeAuthorizationsRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card.ID},
RevokedBy: 1,
})
assert.NoError(t, err)
})
card2 := &model.IotCard{ICCID: "REVOKE_CARD_002", MSISDN: "13800006002", Status: 1, ShopID: &shopID}
err = tx.Create(card2).Error
require.NoError(t, err)
auth2 := &model.EnterpriseCardAuthorization{
EnterpriseID: ent.ID,
CardID: card2.ID,
AuthorizedBy: 999,
AuthorizedAt: now,
AuthorizerType: constants.UserTypeAgent,
}
err = authStore.Create(context.Background(), auth2)
require.NoError(t, err)
t.Run("代理不能回收其他人创建的授权", func(t *testing.T) {
ctx := createAgentContext(1, shopID)
err := authService.RevokeAuthorizations(ctx, enterprise_card.RevokeAuthorizationsRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card2.ID},
RevokedBy: 1,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "只能回收自己创建的授权")
})
t.Run("平台可以回收任何授权", func(t *testing.T) {
ctx := createPlatformContext(2)
err := authService.RevokeAuthorizations(ctx, enterprise_card.RevokeAuthorizationsRequest{
EnterpriseID: ent.ID,
CardIDs: []uint{card2.ID},
RevokedBy: 2,
})
assert.NoError(t, err)
})
}
func TestAuthorizationService_ListRecords(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shopID := uint(600)
ent := &model.Enterprise{
EnterpriseName: "列表测试企业",
EnterpriseCode: "ENT_LIST_001",
OwnerShopID: &shopID,
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card1 := &model.IotCard{ICCID: "LIST_CARD_001", MSISDN: "13800007001", Status: 1, ShopID: &shopID}
card2 := &model.IotCard{ICCID: "LIST_CARD_002", MSISDN: "13800007002", Status: 1, ShopID: &shopID}
err = tx.Create(card1).Error
require.NoError(t, err)
err = tx.Create(card2).Error
require.NoError(t, err)
account := &model.Account{
Username: "test_authorizer",
Phone: "13800008001",
Password: "hashed",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
account.Creator = 1
account.Updater = 1
err = tx.Create(account).Error
require.NoError(t, err)
now := time.Now()
auth1 := &model.EnterpriseCardAuthorization{
EnterpriseID: ent.ID,
CardID: card1.ID,
AuthorizedBy: account.ID,
AuthorizedAt: now,
AuthorizerType: constants.UserTypePlatform,
Remark: "测试备注1",
}
auth2 := &model.EnterpriseCardAuthorization{
EnterpriseID: ent.ID,
CardID: card2.ID,
AuthorizedBy: account.ID,
AuthorizedAt: now,
AuthorizerType: constants.UserTypePlatform,
Remark: "测试备注2",
}
err = authStore.Create(context.Background(), auth1)
require.NoError(t, err)
err = authStore.Create(context.Background(), auth2)
require.NoError(t, err)
ctx := pkggorm.SkipDataPermission(context.Background())
t.Run("分页查询授权记录", func(t *testing.T) {
resp, err := authService.ListRecords(ctx, enterprise_card.ListRecordsRequest{
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.GreaterOrEqual(t, resp.Total, int64(2))
assert.GreaterOrEqual(t, len(resp.Items), 2)
})
t.Run("按企业ID筛选", func(t *testing.T) {
resp, err := authService.ListRecords(ctx, enterprise_card.ListRecordsRequest{
EnterpriseID: &ent.ID,
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.Equal(t, int64(2), resp.Total)
for _, item := range resp.Items {
assert.Equal(t, ent.ID, item.EnterpriseID)
}
})
t.Run("按ICCID筛选", func(t *testing.T) {
resp, err := authService.ListRecords(ctx, enterprise_card.ListRecordsRequest{
ICCID: "LIST_CARD_001",
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.Equal(t, int64(1), resp.Total)
assert.Equal(t, "LIST_CARD_001", resp.Items[0].ICCID)
})
t.Run("按状态筛选-有效授权", func(t *testing.T) {
status := 1
resp, err := authService.ListRecords(ctx, enterprise_card.ListRecordsRequest{
EnterpriseID: &ent.ID,
Status: &status,
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.Equal(t, int64(2), resp.Total)
for _, item := range resp.Items {
assert.Equal(t, 1, item.Status)
}
})
}
func TestAuthorizationService_GetRecordDetail(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shopID := uint(700)
ent := &model.Enterprise{
EnterpriseName: "详情测试企业",
EnterpriseCode: "ENT_DETAIL_001",
OwnerShopID: &shopID,
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card := &model.IotCard{ICCID: "DETAIL_CARD_001", MSISDN: "13800009001", Status: 1, ShopID: &shopID}
err = tx.Create(card).Error
require.NoError(t, err)
account := &model.Account{
Username: "detail_authorizer",
Phone: "13800009002",
Password: "hashed",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
account.Creator = 1
account.Updater = 1
err = tx.Create(account).Error
require.NoError(t, err)
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: ent.ID,
CardID: card.ID,
AuthorizedBy: account.ID,
AuthorizedAt: time.Now(),
AuthorizerType: constants.UserTypePlatform,
Remark: "详情测试备注",
}
err = authStore.Create(context.Background(), auth)
require.NoError(t, err)
ctx := pkggorm.SkipDataPermission(context.Background())
t.Run("获取授权记录详情", func(t *testing.T) {
detail, err := authService.GetRecordDetail(ctx, auth.ID)
require.NoError(t, err)
assert.Equal(t, auth.ID, detail.ID)
assert.Equal(t, ent.ID, detail.EnterpriseID)
assert.Equal(t, "详情测试企业", detail.EnterpriseName)
assert.Equal(t, card.ID, detail.CardID)
assert.Equal(t, "DETAIL_CARD_001", detail.ICCID)
assert.Equal(t, "详情测试备注", detail.Remark)
assert.Equal(t, 1, detail.Status)
})
t.Run("获取不存在的记录", func(t *testing.T) {
_, err := authService.GetRecordDetail(ctx, 99999)
assert.Error(t, err)
})
}
func TestAuthorizationService_UpdateRecordRemark(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
authService := enterprise_card.NewAuthorizationService(enterpriseStore, iotCardStore, authStore, nil)
shopID := uint(800)
ent := &model.Enterprise{
EnterpriseName: "备注测试企业",
EnterpriseCode: "ENT_REMARK_001",
OwnerShopID: &shopID,
Status: constants.StatusEnabled,
}
ent.Creator = 1
ent.Updater = 1
err := tx.Create(ent).Error
require.NoError(t, err)
card := &model.IotCard{ICCID: "REMARK_CARD_001", MSISDN: "13800010001", Status: 1, ShopID: &shopID}
err = tx.Create(card).Error
require.NoError(t, err)
account := &model.Account{
Username: "remark_authorizer",
Phone: "13800010002",
Password: "hashed",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
account.Creator = 1
account.Updater = 1
err = tx.Create(account).Error
require.NoError(t, err)
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: ent.ID,
CardID: card.ID,
AuthorizedBy: account.ID,
AuthorizedAt: time.Now(),
AuthorizerType: constants.UserTypePlatform,
Remark: "原始备注",
}
err = authStore.Create(context.Background(), auth)
require.NoError(t, err)
ctx := pkggorm.SkipDataPermission(context.Background())
t.Run("更新授权备注", func(t *testing.T) {
updated, err := authService.UpdateRecordRemark(ctx, auth.ID, "更新后的备注")
require.NoError(t, err)
assert.Equal(t, "更新后的备注", updated.Remark)
})
t.Run("更新不存在的记录", func(t *testing.T) {
_, err := authService.UpdateRecordRemark(ctx, 99999, "不会更新")
assert.Error(t, err)
})
}

View File

@@ -0,0 +1,332 @@
package unit
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/tests/testutils"
)
func TestEnterpriseCardAuthorizationStore_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: 1,
CardID: 100,
AuthorizedBy: 1,
AuthorizedAt: time.Now(),
AuthorizerType: constants.UserTypePlatform,
}
err := store.Create(ctx, auth)
require.NoError(t, err)
assert.NotZero(t, auth.ID)
}
func TestEnterpriseCardAuthorizationStore_BatchCreate(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
now := time.Now()
auths := []*model.EnterpriseCardAuthorization{
{
EnterpriseID: 1,
CardID: 101,
AuthorizedBy: 1,
AuthorizedAt: now,
AuthorizerType: constants.UserTypePlatform,
},
{
EnterpriseID: 1,
CardID: 102,
AuthorizedBy: 1,
AuthorizedAt: now,
AuthorizerType: constants.UserTypePlatform,
},
}
err := store.BatchCreate(ctx, auths)
require.NoError(t, err)
for _, auth := range auths {
assert.NotZero(t, auth.ID)
}
}
func TestEnterpriseCardAuthorizationStore_GetByEnterpriseAndCard(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: 2,
CardID: 200,
AuthorizedBy: 1,
AuthorizedAt: time.Now(),
AuthorizerType: constants.UserTypePlatform,
}
err := store.Create(ctx, auth)
require.NoError(t, err)
found, err := store.GetByEnterpriseAndCard(ctx, 2, 200)
require.NoError(t, err)
assert.Equal(t, auth.ID, found.ID)
assert.Equal(t, uint(2), found.EnterpriseID)
assert.Equal(t, uint(200), found.CardID)
_, err = store.GetByEnterpriseAndCard(ctx, 999, 999)
assert.Error(t, err)
}
func TestEnterpriseCardAuthorizationStore_ListByEnterprise(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
now := time.Now()
revokedAt := time.Now()
auths := []*model.EnterpriseCardAuthorization{
{EnterpriseID: 3, CardID: 301, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
{EnterpriseID: 3, CardID: 302, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
{EnterpriseID: 3, CardID: 303, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform, RevokedAt: &revokedAt, RevokedBy: ptrUint(1)},
}
err := store.BatchCreate(ctx, auths)
require.NoError(t, err)
activeAuths, err := store.ListByEnterprise(ctx, 3, false)
require.NoError(t, err)
assert.Len(t, activeAuths, 2)
allAuths, err := store.ListByEnterprise(ctx, 3, true)
require.NoError(t, err)
assert.Len(t, allAuths, 3)
}
func TestEnterpriseCardAuthorizationStore_RevokeAuthorizations(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
now := time.Now()
auths := []*model.EnterpriseCardAuthorization{
{EnterpriseID: 4, CardID: 401, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
{EnterpriseID: 4, CardID: 402, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
}
err := store.BatchCreate(ctx, auths)
require.NoError(t, err)
err = store.RevokeAuthorizations(ctx, 4, []uint{401}, 2)
require.NoError(t, err)
activeAuths, err := store.ListByEnterprise(ctx, 4, false)
require.NoError(t, err)
assert.Len(t, activeAuths, 1)
assert.Equal(t, uint(402), activeAuths[0].CardID)
allAuths, err := store.ListByEnterprise(ctx, 4, true)
require.NoError(t, err)
assert.Len(t, allAuths, 2)
}
func TestEnterpriseCardAuthorizationStore_GetActiveAuthorizedCardIDs(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
now := time.Now()
revokedAt := time.Now()
auths := []*model.EnterpriseCardAuthorization{
{EnterpriseID: 5, CardID: 501, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
{EnterpriseID: 5, CardID: 502, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
{EnterpriseID: 5, CardID: 503, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform, RevokedAt: &revokedAt, RevokedBy: ptrUint(1)},
}
err := store.BatchCreate(ctx, auths)
require.NoError(t, err)
cardIDs, err := store.GetActiveAuthorizedCardIDs(ctx, 5)
require.NoError(t, err)
assert.Len(t, cardIDs, 2)
assert.Contains(t, cardIDs, uint(501))
assert.Contains(t, cardIDs, uint(502))
assert.NotContains(t, cardIDs, uint(503))
}
func TestEnterpriseCardAuthorizationStore_CheckAuthorizationExists(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: 6,
CardID: 600,
AuthorizedBy: 1,
AuthorizedAt: time.Now(),
AuthorizerType: constants.UserTypePlatform,
}
err := store.Create(ctx, auth)
require.NoError(t, err)
exists, err := store.CheckAuthorizationExists(ctx, 6, 600)
require.NoError(t, err)
assert.True(t, exists)
exists, err = store.CheckAuthorizationExists(ctx, 6, 999)
require.NoError(t, err)
assert.False(t, exists)
}
func TestEnterpriseCardAuthorizationStore_GetActiveAuthsByCardIDs(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
now := time.Now()
auths := []*model.EnterpriseCardAuthorization{
{EnterpriseID: 7, CardID: 701, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
{EnterpriseID: 7, CardID: 702, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: constants.UserTypePlatform},
}
err := store.BatchCreate(ctx, auths)
require.NoError(t, err)
result, err := store.GetActiveAuthsByCardIDs(ctx, 7, []uint{701, 702, 703})
require.NoError(t, err)
assert.True(t, result[701])
assert.True(t, result[702])
assert.False(t, result[703])
}
func TestEnterpriseCardAuthorizationStore_ListWithOptions(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
now := time.Now()
for i := uint(0); i < 15; i++ {
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: 8,
CardID: 800 + i,
AuthorizedBy: 1,
AuthorizedAt: now,
AuthorizerType: constants.UserTypePlatform,
}
err := store.Create(ctx, auth)
require.NoError(t, err)
}
enterpriseID := uint(8)
opts := postgres.AuthorizationListOptions{
EnterpriseID: &enterpriseID,
Limit: 10,
Offset: 0,
}
auths, total, err := store.ListWithOptions(ctx, opts)
require.NoError(t, err)
assert.Equal(t, int64(15), total)
assert.Len(t, auths, 10)
opts.Offset = 10
auths, total, err = store.ListWithOptions(ctx, opts)
require.NoError(t, err)
assert.Equal(t, int64(15), total)
assert.Len(t, auths, 5)
}
func ptrUint(v uint) *uint {
return &v
}
func TestEnterpriseCardAuthorizationStore_UpdateRemark(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: 10,
CardID: 1000,
AuthorizedBy: 1,
AuthorizedAt: time.Now(),
AuthorizerType: constants.UserTypePlatform,
Remark: "原始备注",
}
err := store.Create(ctx, auth)
require.NoError(t, err)
err = store.UpdateRemark(ctx, auth.ID, "更新后的备注")
require.NoError(t, err)
updated, err := store.GetByID(ctx, auth.ID)
require.NoError(t, err)
assert.Equal(t, "更新后的备注", updated.Remark)
err = store.UpdateRemark(ctx, 99999, "不存在的记录")
assert.Error(t, err)
}
func TestEnterpriseCardAuthorizationStore_GetByID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
ctx := context.Background()
auth := &model.EnterpriseCardAuthorization{
EnterpriseID: 11,
CardID: 1100,
AuthorizedBy: 1,
AuthorizedAt: time.Now(),
AuthorizerType: constants.UserTypePlatform,
}
err := store.Create(ctx, auth)
require.NoError(t, err)
found, err := store.GetByID(ctx, auth.ID)
require.NoError(t, err)
assert.Equal(t, auth.ID, found.ID)
assert.Equal(t, uint(11), found.EnterpriseID)
assert.Equal(t, uint(1100), found.CardID)
_, err = store.GetByID(ctx, 99999)
assert.Error(t, err)
}

View File

@@ -227,7 +227,7 @@ func TestEnterpriseCardService_AllocateCards(t *testing.T) {
var count int64
tx.Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND iot_card_id = ?", ent.ID, card.ID).
Where("enterprise_id = ? AND card_id = ?", ent.ID, card.ID).
Count(&count)
assert.Equal(t, int64(1), count)
})