From fdcff3305883c818fe087b856c6ea6e6adb5e308 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 26 Jan 2026 15:07:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=8D=A1=E6=8E=88=E6=9D=83=E5=92=8C=E6=8E=88=E6=9D=83=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要功能: - 添加企业卡授权/回收接口 (POST /enterprises/:id/allocate-cards, recall-cards) - 添加授权记录管理接口 (GET/PUT /authorizations) - 实现代理用户数据权限过滤(只能查看自己店铺下企业的授权记录) - 添加 GORM callback 支持授权记录表的数据权限过滤 技术改进: - 原生 SQL 查询手动添加数据权限过滤(ListWithJoin, GetByIDWithJoin) - 移除卡授权预检接口(allocate-cards/preview),保留内部方法 - 完善单元测试和集成测试覆盖 --- cmd/api/docs.go | 1 + cmd/gendocs/main.go | 1 + docs/admin-openapi.yaml | 429 +++++++----- docs/proposals/单卡授权企业功能设计.md | 428 ++++++++++++ internal/bootstrap/handlers.go | 1 + internal/bootstrap/services.go | 2 + internal/bootstrap/types.go | 1 + internal/handler/admin/authorization.go | 157 +++++ internal/handler/admin/enterprise_card.go | 20 - internal/model/dto/authorization_dto.go | 48 ++ .../model/enterprise_card_authorization.go | 22 +- internal/routes/admin.go | 3 + internal/routes/authorization.go | 38 ++ internal/routes/enterprise_card.go | 8 - .../enterprise_card/authorization_service.go | 414 ++++++++++++ internal/service/enterprise_card/service.go | 19 +- .../enterprise_card_authorization_store.go | 344 +++++++++- internal/store/postgres/iot_card_store.go | 124 +++- ...terprise_card_authorization_table.down.sql | 8 + ...enterprise_card_authorization_table.up.sql | 52 ++ .../.openspec.yaml | 2 + .../design.md | 123 ++++ .../proposal.md | 57 ++ .../specs/authorization-record/spec.md | 114 ++++ .../specs/data-permission/spec.md | 34 + .../tasks.md | 53 ++ .../.openspec.yaml | 2 + .../enterprise-card-authorization/design.md | 166 +++++ .../enterprise-card-authorization/proposal.md | 29 + .../enterprise-card-authorization/spec.md | 162 +++++ .../specs/iot-card/spec.md | 42 ++ .../enterprise-card-authorization/tasks.md | 63 ++ openspec/specs/authorization-record/spec.md | 112 ++++ openspec/specs/data-permission/spec.md | 12 + pkg/constants/constants.go | 8 + pkg/errors/codes.go | 156 +++-- pkg/gorm/callback.go | 12 + pkg/gorm/callback_test.go | 326 +++++++++ tests/integration/authorization_test.go | 529 +++++++++++++++ ...rise_card_authorization_permission_test.go | 624 ++++++++++++++++++ ...nterprise_card_authorization_store_test.go | 332 ++++++++++ tests/unit/enterprise_card_service_test.go | 2 +- 42 files changed, 4782 insertions(+), 298 deletions(-) create mode 100644 docs/proposals/单卡授权企业功能设计.md create mode 100644 internal/handler/admin/authorization.go create mode 100644 internal/model/dto/authorization_dto.go create mode 100644 internal/routes/authorization.go create mode 100644 internal/service/enterprise_card/authorization_service.go create mode 100644 migrations/000017_create_enterprise_card_authorization_table.down.sql create mode 100644 migrations/000017_create_enterprise_card_authorization_table.up.sql create mode 100644 openspec/changes/archive/2026-01-26-add-authorization-record-management/.openspec.yaml create mode 100644 openspec/changes/archive/2026-01-26-add-authorization-record-management/design.md create mode 100644 openspec/changes/archive/2026-01-26-add-authorization-record-management/proposal.md create mode 100644 openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/authorization-record/spec.md create mode 100644 openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/data-permission/spec.md create mode 100644 openspec/changes/archive/2026-01-26-add-authorization-record-management/tasks.md create mode 100644 openspec/changes/enterprise-card-authorization/.openspec.yaml create mode 100644 openspec/changes/enterprise-card-authorization/design.md create mode 100644 openspec/changes/enterprise-card-authorization/proposal.md create mode 100644 openspec/changes/enterprise-card-authorization/specs/enterprise-card-authorization/spec.md create mode 100644 openspec/changes/enterprise-card-authorization/specs/iot-card/spec.md create mode 100644 openspec/changes/enterprise-card-authorization/tasks.md create mode 100644 openspec/specs/authorization-record/spec.md create mode 100644 tests/integration/authorization_test.go create mode 100644 tests/unit/enterprise_card_authorization_permission_test.go create mode 100644 tests/unit/enterprise_card_authorization_store_test.go diff --git a/cmd/api/docs.go b/cmd/api/docs.go index f6db52e..6ff1dac 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -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), diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 8bda9d0..cc4e05c 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -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), diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index efeb9a0..2b3a6ae 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -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: diff --git a/docs/proposals/单卡授权企业功能设计.md b/docs/proposals/单卡授权企业功能设计.md new file mode 100644 index 0000000..6dff616 --- /dev/null +++ b/docs/proposals/单卡授权企业功能设计.md @@ -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. **测试**时注意验证各种边界情况 \ No newline at end of file diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index ffd0785..eead933 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 13f74fc..c155374 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 1f9a3be..ab50d21 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -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 diff --git a/internal/handler/admin/authorization.go b/internal/handler/admin/authorization.go new file mode 100644 index 0000000..4688068 --- /dev/null +++ b/internal/handler/admin/authorization.go @@ -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) +} diff --git a/internal/handler/admin/enterprise_card.go b/internal/handler/admin/enterprise_card.go index 06c7719..09f4712 100644 --- a/internal/handler/admin/enterprise_card.go +++ b/internal/handler/admin/enterprise_card.go @@ -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) diff --git a/internal/model/dto/authorization_dto.go b/internal/model/dto/authorization_dto.go new file mode 100644 index 0000000..7a85756 --- /dev/null +++ b/internal/model/dto/authorization_dto.go @@ -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字)"` +} diff --git a/internal/model/enterprise_card_authorization.go b/internal/model/enterprise_card_authorization.go index 34a710a..536fed7 100644 --- a/internal/model/enterprise_card_authorization.go +++ b/internal/model/enterprise_card_authorization.go @@ -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 { diff --git a/internal/routes/admin.go b/internal/routes/admin.go index fcfa6e8..d050c08 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -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) } diff --git a/internal/routes/authorization.go b/internal/routes/authorization.go new file mode 100644 index 0000000..b94c739 --- /dev/null +++ b/internal/routes/authorization.go @@ -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, + }) +} diff --git a/internal/routes/enterprise_card.go b/internal/routes/enterprise_card.go index f9d0101..51d5691 100644 --- a/internal/routes/enterprise_card.go +++ b/internal/routes/enterprise_card.go @@ -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{"企业卡授权"}, diff --git a/internal/service/enterprise_card/authorization_service.go b/internal/service/enterprise_card/authorization_service.go new file mode 100644 index 0000000..efed87c --- /dev/null +++ b/internal/service/enterprise_card/authorization_service.go @@ -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) +} diff --git a/internal/service/enterprise_card/service.go b/internal/service/enterprise_card/service.go index 961e49d..1a87ddd 100644 --- a/internal/service/enterprise_card/service.go +++ b/internal/service/enterprise_card/service.go @@ -32,7 +32,7 @@ func New( } } -func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) { +func (s *Service) allocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") @@ -172,7 +172,6 @@ func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, r func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsReq) (*dto.AllocateCardsResp, error) { currentUserID := middleware.GetUserIDFromContext(ctx) - currentShopID := middleware.GetShopIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") } @@ -182,7 +181,7 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在") } - preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs}) + preview, err := s.allocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs}) if err != nil { return nil, err } @@ -224,18 +223,18 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto } now := time.Now() + userType := middleware.GetUserTypeFromContext(ctx) auths := make([]*model.EnterpriseCardAuthorization, 0) for _, cardID := range cardIDsToAllocate { if existingAuths[cardID] { continue } auths = append(auths, &model.EnterpriseCardAuthorization{ - EnterpriseID: enterpriseID, - IotCardID: cardID, - ShopID: currentShopID, - AuthorizedBy: currentUserID, - AuthorizedAt: &now, - Status: 1, + EnterpriseID: enterpriseID, + CardID: cardID, + AuthorizedBy: currentUserID, + AuthorizedAt: now, + AuthorizerType: userType, }) } @@ -409,7 +408,7 @@ func (s *Service) updateCardNetworkStatus(ctx context.Context, enterpriseID, car } auth, err := s.enterpriseCardAuthStore.GetByEnterpriseAndCard(ctx, enterpriseID, cardID) - if err != nil || auth.Status != 1 { + if err != nil || auth.RevokedAt != nil { return errors.New(errors.CodeForbidden, "无权限操作此卡") } diff --git a/internal/store/postgres/enterprise_card_authorization_store.go b/internal/store/postgres/enterprise_card_authorization_store.go index 9b2a61d..50bd1ae 100644 --- a/internal/store/postgres/enterprise_card_authorization_store.go +++ b/internal/store/postgres/enterprise_card_authorization_store.go @@ -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 +} diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 5e49962..7d119ef 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -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 } diff --git a/migrations/000017_create_enterprise_card_authorization_table.down.sql b/migrations/000017_create_enterprise_card_authorization_table.down.sql new file mode 100644 index 0000000..546329e --- /dev/null +++ b/migrations/000017_create_enterprise_card_authorization_table.down.sql @@ -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; \ No newline at end of file diff --git a/migrations/000017_create_enterprise_card_authorization_table.up.sql b/migrations/000017_create_enterprise_card_authorization_table.up.sql new file mode 100644 index 0000000..2f86d4a --- /dev/null +++ b/migrations/000017_create_enterprise_card_authorization_table.up.sql @@ -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); \ No newline at end of file diff --git a/openspec/changes/archive/2026-01-26-add-authorization-record-management/.openspec.yaml b/openspec/changes/archive/2026-01-26-add-authorization-record-management/.openspec.yaml new file mode 100644 index 0000000..e89a784 --- /dev/null +++ b/openspec/changes/archive/2026-01-26-add-authorization-record-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-26 diff --git a/openspec/changes/archive/2026-01-26-add-authorization-record-management/design.md b/openspec/changes/archive/2026-01-26-add-authorization-record-management/design.md new file mode 100644 index 0000000..8f3de17 --- /dev/null +++ b/openspec/changes/archive/2026-01-26-add-authorization-record-management/design.md @@ -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 + +无 diff --git a/openspec/changes/archive/2026-01-26-add-authorization-record-management/proposal.md b/openspec/changes/archive/2026-01-26-add-authorization-record-management/proposal.md new file mode 100644 index 0000000..992f7e0 --- /dev/null +++ b/openspec/changes/archive/2026-01-26-add-authorization-record-management/proposal.md @@ -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)` diff --git a/openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/authorization-record/spec.md b/openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/authorization-record/spec.md new file mode 100644 index 0000000..c4de75c --- /dev/null +++ b/openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/authorization-record/spec.md @@ -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`: 备注 diff --git a/openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/data-permission/spec.md b/openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/data-permission/spec.md new file mode 100644 index 0000000..0a005fc --- /dev/null +++ b/openspec/changes/archive/2026-01-26-add-authorization-record-management/specs/data-permission/spec.md @@ -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** 可查询所有授权记录 diff --git a/openspec/changes/archive/2026-01-26-add-authorization-record-management/tasks.md b/openspec/changes/archive/2026-01-26-add-authorization-record-management/tasks.md new file mode 100644 index 0000000..2c7653d --- /dev/null +++ b/openspec/changes/archive/2026-01-26-add-authorization-record-management/tasks.md @@ -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 验证数据权限:代理用户只能看到自己店铺的数据(集成测试验证通过) diff --git a/openspec/changes/enterprise-card-authorization/.openspec.yaml b/openspec/changes/enterprise-card-authorization/.openspec.yaml new file mode 100644 index 0000000..e89a784 --- /dev/null +++ b/openspec/changes/enterprise-card-authorization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-26 diff --git a/openspec/changes/enterprise-card-authorization/design.md b/openspec/changes/enterprise-card-authorization/design.md new file mode 100644 index 0000000..ddf3d59 --- /dev/null +++ b/openspec/changes/enterprise-card-authorization/design.md @@ -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. **审计日志**:是否需要更详细的授权操作日志? \ No newline at end of file diff --git a/openspec/changes/enterprise-card-authorization/proposal.md b/openspec/changes/enterprise-card-authorization/proposal.md new file mode 100644 index 0000000..cb03c03 --- /dev/null +++ b/openspec/changes/enterprise-card-authorization/proposal.md @@ -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 变更**:修改现有授权接口,移除预检接口,调整权限控制逻辑 +- **权限系统**:需要更新权限中间件,支持基于授权记录的细粒度权限控制 +- **查询逻辑**:企业查询物联网卡时需要额外检查授权记录,并过滤敏感字段 +- **前端影响**:需要调整授权界面,移除预检步骤,更新数据展示逻辑 \ No newline at end of file diff --git a/openspec/changes/enterprise-card-authorization/specs/enterprise-card-authorization/spec.md b/openspec/changes/enterprise-card-authorization/specs/enterprise-card-authorization/spec.md new file mode 100644 index 0000000..41a37f9 --- /dev/null +++ b/openspec/changes/enterprise-card-authorization/specs/enterprise-card-authorization/spec.md @@ -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 错误,如同该卡从未被授权过 \ No newline at end of file diff --git a/openspec/changes/enterprise-card-authorization/specs/iot-card/spec.md b/openspec/changes/enterprise-card-authorization/specs/iot-card/spec.md new file mode 100644 index 0000000..3c54fa5 --- /dev/null +++ b/openspec/changes/enterprise-card-authorization/specs/iot-card/spec.md @@ -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** 系统不返回该卡信息,企业无法再看到该卡 \ No newline at end of file diff --git a/openspec/changes/enterprise-card-authorization/tasks.md b/openspec/changes/enterprise-card-authorization/tasks.md new file mode 100644 index 0000000..423e750 --- /dev/null +++ b/openspec/changes/enterprise-card-authorization/tasks.md @@ -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 文档,确保新接口被正确生成 \ No newline at end of file diff --git a/openspec/specs/authorization-record/spec.md b/openspec/specs/authorization-record/spec.md new file mode 100644 index 0000000..0b483f3 --- /dev/null +++ b/openspec/specs/authorization-record/spec.md @@ -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`: 备注 + diff --git a/openspec/specs/data-permission/spec.md b/openspec/specs/data-permission/spec.md index ae85c29..af9a9da 100644 --- a/openspec/specs/data-permission/spec.md +++ b/openspec/specs/data-permission/spec.md @@ -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 绕过数据权限过滤。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index bc570bb..3847e32 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -128,3 +128,11 @@ const DefaultAdminPassword = "Admin@123456" // DefaultAdminPhone 默认超级管理员手机号 const DefaultAdminPhone = "13800000000" + +// ======== 企业卡授权相关常量 ======== + +// AuthorizerType 授权人类型 +const ( + AuthorizerTypePlatform = UserTypePlatform // 平台用户授权(2) + AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3) +) diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 9a1b6b7..586ad09 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -60,13 +60,19 @@ const ( CodeWalletNotFound = 1053 // 钱包不存在 // IoT 卡相关错误 (1070-1089) - CodeIotCardNotFound = 1070 // IoT 卡不存在 - CodeIotCardBoundToDevice = 1071 // IoT 卡已绑定设备 - CodeIotCardStatusNotAllowed = 1072 // 卡状态不允许此操作 - CodeAssetAllocationRecordNotFound = 1073 // 分配记录不存在 - CodeNotDirectSubordinate = 1074 // 非直属下级店铺 - CodeCannotAllocateToSelf = 1075 // 不能分配给自己 - CodeCannotRecallFromSelf = 1076 // 不能从自己回收 + CodeIotCardNotFound = 1070 // IoT 卡不存在 + CodeIotCardBoundToDevice = 1071 // IoT 卡已绑定设备 + CodeIotCardStatusNotAllowed = 1072 // 卡状态不允许此操作 + CodeAssetAllocationRecordNotFound = 1073 // 分配记录不存在 + CodeNotDirectSubordinate = 1074 // 非直属下级店铺 + CodeCannotAllocateToSelf = 1075 // 不能分配给自己 + CodeCannotRecallFromSelf = 1076 // 不能从自己回收 + CodeCardAlreadyAuthorized = 1077 // 卡已授权给该企业 + CodeCardNotAuthorized = 1078 // 卡未授权给该企业 + CodeCannotAuthorizeOthersCard = 1079 // 不能授权非自己的卡 + CodeCannotRevokeOthersAuthorization = 1080 // 不能回收非自己创建的授权 + CodeCannotAuthorizeBoundCard = 1081 // 不能授权已绑定设备的卡 + CodeCannotAuthorizeToOthersEnterprise = 1082 // 不能授权给非自己的企业 // 对象存储相关错误 (1090-1099) CodeStorageNotConfigured = 1090 // 对象存储服务未配置 @@ -138,6 +144,12 @@ var allErrorCodes = []int{ CodeNotDirectSubordinate, CodeCannotAllocateToSelf, CodeCannotRecallFromSelf, + CodeCardAlreadyAuthorized, + CodeCardNotAuthorized, + CodeCannotAuthorizeOthersCard, + CodeCannotRevokeOthersAuthorization, + CodeCannotAuthorizeBoundCard, + CodeCannotAuthorizeToOthersEnterprise, CodeStorageNotConfigured, CodeStorageUploadFailed, CodeStorageDownloadFailed, @@ -162,68 +174,74 @@ func init() { // errorMessages 错误消息映射表(中文) var errorMessages = map[int]string{ - CodeSuccess: "成功", - CodeInvalidParam: "参数验证失败", - CodeMissingToken: "缺失认证令牌", - CodeInvalidToken: "无效或过期的令牌", - CodeUnauthorized: "未授权访问", - CodeForbidden: "禁止访问", - CodeNotFound: "资源未找到", - CodeConflict: "资源冲突", - CodeTooManyRequests: "请求过多,请稍后重试", - CodeRequestTooLarge: "请求体过大", - CodeAccountNotFound: "账号不存在", - CodeAccountDisabled: "账号已禁用", - CodeAccountDeleted: "账号已删除", - CodeUsernameExists: "用户名已存在", - CodePhoneExists: "手机号已存在", - CodeInvalidPassword: "密码格式不正确", - CodePasswordTooWeak: "密码强度不足", - CodeParentIDRequired: "非 root 用户必须提供上级账号", - CodeInvalidParentID: "上级账号不存在或无效", - CodeCannotModifyParent: "禁止修改上级账号", - CodeCannotModifyUserType: "禁止修改用户类型", - CodeRoleNotFound: "角色不存在", - CodeRoleNameExists: "角色名称已存在", - CodePermissionNotFound: "权限不存在", - CodePermCodeExists: "权限编码已存在", - CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)", - CodeRoleAlreadyAssigned: "角色已分配", - CodePermAlreadyAssigned: "权限已分配", - CodeShopNotFound: "店铺不存在", - CodeShopCodeExists: "店铺编号已存在", - CodeShopLevelExceeded: "店铺层级不能超过 7 级", - CodeEnterpriseNotFound: "企业不存在", - CodeEnterpriseCodeExists: "企业编号已存在", - CodeCustomerNotFound: "个人客户不存在", - CodeCustomerPhoneExists: "个人客户手机号已存在", - CodeInvalidStatus: "状态不允许此操作", - CodeInsufficientBalance: "余额不足", - CodeWithdrawalNotFound: "提现申请不存在", - CodeWalletNotFound: "钱包不存在", - CodeIotCardNotFound: "IoT 卡不存在", - CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作", - CodeIotCardStatusNotAllowed: "卡状态不允许此操作", - CodeAssetAllocationRecordNotFound: "分配记录不存在", - CodeNotDirectSubordinate: "只能操作直属下级店铺", - CodeCannotAllocateToSelf: "不能分配给自己", - CodeCannotRecallFromSelf: "不能从自己回收", - CodeStorageNotConfigured: "对象存储服务未配置", - CodeStorageUploadFailed: "文件上传失败", - CodeStorageDownloadFailed: "文件下载失败", - CodeStorageFileNotFound: "文件不存在", - CodeStorageInvalidPurpose: "不支持的文件用途", - CodeStorageInvalidFileType: "不支持的文件类型", - CodeInvalidCredentials: "用户名或密码错误", - CodeAccountLocked: "账号已锁定", - CodePasswordExpired: "密码已过期", - CodeInvalidOldPassword: "旧密码错误", - CodeInternalError: "内部服务器错误", - CodeDatabaseError: "数据库错误", - CodeRedisError: "缓存服务错误", - CodeServiceUnavailable: "服务暂时不可用", - CodeTimeout: "请求超时", - CodeTaskQueueError: "任务队列错误", + CodeSuccess: "成功", + CodeInvalidParam: "参数验证失败", + CodeMissingToken: "缺失认证令牌", + CodeInvalidToken: "无效或过期的令牌", + CodeUnauthorized: "未授权访问", + CodeForbidden: "禁止访问", + CodeNotFound: "资源未找到", + CodeConflict: "资源冲突", + CodeTooManyRequests: "请求过多,请稍后重试", + CodeRequestTooLarge: "请求体过大", + CodeAccountNotFound: "账号不存在", + CodeAccountDisabled: "账号已禁用", + CodeAccountDeleted: "账号已删除", + CodeUsernameExists: "用户名已存在", + CodePhoneExists: "手机号已存在", + CodeInvalidPassword: "密码格式不正确", + CodePasswordTooWeak: "密码强度不足", + CodeParentIDRequired: "非 root 用户必须提供上级账号", + CodeInvalidParentID: "上级账号不存在或无效", + CodeCannotModifyParent: "禁止修改上级账号", + CodeCannotModifyUserType: "禁止修改用户类型", + CodeRoleNotFound: "角色不存在", + CodeRoleNameExists: "角色名称已存在", + CodePermissionNotFound: "权限不存在", + CodePermCodeExists: "权限编码已存在", + CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)", + CodeRoleAlreadyAssigned: "角色已分配", + CodePermAlreadyAssigned: "权限已分配", + CodeShopNotFound: "店铺不存在", + CodeShopCodeExists: "店铺编号已存在", + CodeShopLevelExceeded: "店铺层级不能超过 7 级", + CodeEnterpriseNotFound: "企业不存在", + CodeEnterpriseCodeExists: "企业编号已存在", + CodeCustomerNotFound: "个人客户不存在", + CodeCustomerPhoneExists: "个人客户手机号已存在", + CodeInvalidStatus: "状态不允许此操作", + CodeInsufficientBalance: "余额不足", + CodeWithdrawalNotFound: "提现申请不存在", + CodeWalletNotFound: "钱包不存在", + CodeIotCardNotFound: "IoT 卡不存在", + CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作", + CodeIotCardStatusNotAllowed: "卡状态不允许此操作", + CodeAssetAllocationRecordNotFound: "分配记录不存在", + CodeNotDirectSubordinate: "只能操作直属下级店铺", + CodeCannotAllocateToSelf: "不能分配给自己", + CodeCannotRecallFromSelf: "不能从自己回收", + CodeCardAlreadyAuthorized: "卡已授权给该企业", + CodeCardNotAuthorized: "卡未授权给该企业", + CodeCannotAuthorizeOthersCard: "不能授权非自己的卡", + CodeCannotRevokeOthersAuthorization: "不能回收非自己创建的授权", + CodeCannotAuthorizeBoundCard: "不能授权已绑定设备的卡", + CodeCannotAuthorizeToOthersEnterprise: "不能授权给非自己的企业", + CodeStorageNotConfigured: "对象存储服务未配置", + CodeStorageUploadFailed: "文件上传失败", + CodeStorageDownloadFailed: "文件下载失败", + CodeStorageFileNotFound: "文件不存在", + CodeStorageInvalidPurpose: "不支持的文件用途", + CodeStorageInvalidFileType: "不支持的文件类型", + CodeInvalidCredentials: "用户名或密码错误", + CodeAccountLocked: "账号已锁定", + CodePasswordExpired: "密码已过期", + CodeInvalidOldPassword: "旧密码错误", + CodeInternalError: "内部服务器错误", + CodeDatabaseError: "数据库错误", + CodeRedisError: "缓存服务错误", + CodeServiceUnavailable: "服务暂时不可用", + CodeTimeout: "请求超时", + CodeTaskQueueError: "任务队列错误", } // GetMessage 获取错误码对应的消息 diff --git a/pkg/gorm/callback.go b/pkg/gorm/callback.go index 5bc9e00..ed357b0 100644 --- a/pkg/gorm/callback.go +++ b/pkg/gorm/callback.go @@ -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 { diff --git a/pkg/gorm/callback_test.go b/pkg/gorm/callback_test.go index 3dc2533..ff0101e 100644 --- a/pkg/gorm/callback_test.go +++ b/pkg/gorm/callback_test.go @@ -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的授权记录") + } +} diff --git a/tests/integration/authorization_test.go b/tests/integration/authorization_test.go new file mode 100644 index 0000000..014822c --- /dev/null +++ b/tests/integration/authorization_test.go @@ -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) + }) +} diff --git a/tests/unit/enterprise_card_authorization_permission_test.go b/tests/unit/enterprise_card_authorization_permission_test.go new file mode 100644 index 0000000..08f4bda --- /dev/null +++ b/tests/unit/enterprise_card_authorization_permission_test.go @@ -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) + }) +} diff --git a/tests/unit/enterprise_card_authorization_store_test.go b/tests/unit/enterprise_card_authorization_store_test.go new file mode 100644 index 0000000..c33bac9 --- /dev/null +++ b/tests/unit/enterprise_card_authorization_store_test.go @@ -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) +} diff --git a/tests/unit/enterprise_card_service_test.go b/tests/unit/enterprise_card_service_test.go index c887049..3e11ae6 100644 --- a/tests/unit/enterprise_card_service_test.go +++ b/tests/unit/enterprise_card_service_test.go @@ -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) })