feat: 实现企业卡授权和授权记录管理功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m9s
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:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
428
docs/proposals/单卡授权企业功能设计.md
Normal file
428
docs/proposals/单卡授权企业功能设计.md
Normal 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, -- 授权人ID(shop_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. **测试**时注意验证各种边界情况
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
157
internal/handler/admin/authorization.go
Normal file
157
internal/handler/admin/authorization.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
48
internal/model/dto/authorization_dto.go
Normal file
48
internal/model/dto/authorization_dto.go
Normal 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字)"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
38
internal/routes/authorization.go
Normal file
38
internal/routes/authorization.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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{"企业卡授权"},
|
||||
|
||||
414
internal/service/enterprise_card/authorization_service.go
Normal file
414
internal/service/enterprise_card/authorization_service.go
Normal 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)
|
||||
}
|
||||
@@ -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,6 +223,7 @@ 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] {
|
||||
@@ -231,11 +231,10 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
|
||||
}
|
||||
auths = append(auths, &model.EnterpriseCardAuthorization{
|
||||
EnterpriseID: enterpriseID,
|
||||
IotCardID: cardID,
|
||||
ShopID: currentShopID,
|
||||
CardID: cardID,
|
||||
AuthorizedBy: currentUserID,
|
||||
AuthorizedAt: &now,
|
||||
Status: 1,
|
||||
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, "无权限操作此卡")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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, -- 回收人账号ID(NULL表示未回收)
|
||||
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);
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-26
|
||||
@@ -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
|
||||
|
||||
无
|
||||
@@ -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)`
|
||||
@@ -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`: 备注
|
||||
@@ -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** 可查询所有授权记录
|
||||
@@ -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` handler(GET /authorizations)
|
||||
- [x] 5.3 实现 `GetDetail` handler(GET /authorizations/:id)
|
||||
- [x] 5.4 实现 `UpdateRemark` handler(PUT /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 验证数据权限:代理用户只能看到自己店铺的数据(集成测试验证通过)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-26
|
||||
166
openspec/changes/enterprise-card-authorization/design.md
Normal file
166
openspec/changes/enterprise-card-authorization/design.md
Normal 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. **审计日志**:是否需要更详细的授权操作日志?
|
||||
29
openspec/changes/enterprise-card-authorization/proposal.md
Normal file
29
openspec/changes/enterprise-card-authorization/proposal.md
Normal 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 变更**:修改现有授权接口,移除预检接口,调整权限控制逻辑
|
||||
- **权限系统**:需要更新权限中间件,支持基于授权记录的细粒度权限控制
|
||||
- **查询逻辑**:企业查询物联网卡时需要额外检查授权记录,并过滤敏感字段
|
||||
- **前端影响**:需要调整授权界面,移除预检步骤,更新数据展示逻辑
|
||||
@@ -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`: 被授权企业ID(BIGINT,关联 enterprises 表)
|
||||
- `card_id`: IoT卡ID(BIGINT,关联 iot_cards 表)
|
||||
- `authorizer_id`: 授权人账号ID(BIGINT,关联 accounts 表)
|
||||
- `authorizer_type`: 授权人类型(VARCHAR(20),"platform" | "agent")
|
||||
- `authorized_at`: 授权时间(TIMESTAMP)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `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 错误,如同该卡从未被授权过
|
||||
@@ -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** 系统不返回该卡信息,企业无法再看到该卡
|
||||
63
openspec/changes/enterprise-card-authorization/tasks.md
Normal file
63
openspec/changes/enterprise-card-authorization/tasks.md
Normal 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 文档,确保新接口被正确生成
|
||||
112
openspec/specs/authorization-record/spec.md
Normal file
112
openspec/specs/authorization-record/spec.md
Normal 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`: 备注
|
||||
|
||||
@@ -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 绕过数据权限过滤。
|
||||
|
||||
@@ -128,3 +128,11 @@ const DefaultAdminPassword = "Admin@123456"
|
||||
|
||||
// DefaultAdminPhone 默认超级管理员手机号
|
||||
const DefaultAdminPhone = "13800000000"
|
||||
|
||||
// ======== 企业卡授权相关常量 ========
|
||||
|
||||
// AuthorizerType 授权人类型
|
||||
const (
|
||||
AuthorizerTypePlatform = UserTypePlatform // 平台用户授权(2)
|
||||
AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3)
|
||||
)
|
||||
|
||||
@@ -67,6 +67,12 @@ const (
|
||||
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,
|
||||
@@ -208,6 +220,12 @@ var errorMessages = map[int]string{
|
||||
CodeNotDirectSubordinate: "只能操作直属下级店铺",
|
||||
CodeCannotAllocateToSelf: "不能分配给自己",
|
||||
CodeCannotRecallFromSelf: "不能从自己回收",
|
||||
CodeCardAlreadyAuthorized: "卡已授权给该企业",
|
||||
CodeCardNotAuthorized: "卡未授权给该企业",
|
||||
CodeCannotAuthorizeOthersCard: "不能授权非自己的卡",
|
||||
CodeCannotRevokeOthersAuthorization: "不能回收非自己创建的授权",
|
||||
CodeCannotAuthorizeBoundCard: "不能授权已绑定设备的卡",
|
||||
CodeCannotAuthorizeToOthersEnterprise: "不能授权给非自己的企业",
|
||||
CodeStorageNotConfigured: "对象存储服务未配置",
|
||||
CodeStorageUploadFailed: "文件上传失败",
|
||||
CodeStorageDownloadFailed: "文件下载失败",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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的授权记录")
|
||||
}
|
||||
}
|
||||
|
||||
529
tests/integration/authorization_test.go
Normal file
529
tests/integration/authorization_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
624
tests/unit/enterprise_card_authorization_permission_test.go
Normal file
624
tests/unit/enterprise_card_authorization_permission_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
332
tests/unit/enterprise_card_authorization_store_test.go
Normal file
332
tests/unit/enterprise_card_authorization_store_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user