企业授权增强与资产列表扩展
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m9s

- 企业卡授权唯一约束:新增 DB 迁移(000154),卡级部分唯一索引防止同一张卡被多个企业同时持有,Service 层新增跨企业冲突检测
- 单卡列表新增 network_status 过滤参数
- 单卡/设备列表新增 asset_status、asset_status_name、generation 响应字段
- 单卡/设备列表新增企业维度过滤(authorized_enterprise_id、is_authorized_to_enterprise)及响应中企业授权信息(批量加载,无 N+1)
- 主钱包流水/退款列表新增 asset_identifier 精确过滤参数
- 企业卡授权/收回接口升级为三模式(list/range/filter),企业设备授权/收回升级为双模式(list/filter)
- 升级 sonic v1.14.2 → v1.15.2 以兼容 Go 1.26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:23:03 +09:00
parent cf36f1447f
commit 3b7b856e48
37 changed files with 1260 additions and 84 deletions

3
.gitignore vendored
View File

@@ -107,3 +107,6 @@ docs/admin-openapi.yaml
.omx/state/sessions/omx-1777517489966-oo3g37/notify-hook-state.json
.omx/state/tmux-extended-keys/private-tmp-tmux-501-default.json
.aider*
# /teach skill 的个人学习工作区,不进入项目提交历史
.claude/teach-workspace/

View File

@@ -0,0 +1,143 @@
Status: ready-for-agent
# PRD企业授权增强 & 资产列表扩展
## Problem Statement
平台运营人员在管理企业授权和查看资产状态时面临以下痛点:
1. **企业维度检索缺失**:卡列表和设备列表无法按企业维度过滤,也无法在列表中直接看到某张卡/设备被授权给了哪个企业,需要跨页面跳转查询。
2. **企业授权操作繁琐**:授权和收回接口只接受精确 ICCID/虚拟号列表,但前端列表有分页,量大时需一个个选,无法按批次号、号段等条件一次性批量操作。
3. **换货历史不可见**:经历过换货的旧卡/设备,在列表和详情中没有任何标记,运营无法判断当前资产是否为"换货遗留"状态或曾经历过换货后被重新激活(转新)的资产。
4. **主钱包流水和退款列表检索维度不足**无法直接通过资产标识ICCID 或虚拟号)快速定位某个资产相关的流水和退款记录。
5. **卡网络状态过滤缺失**standalone 卡列表无法按网络状态(开机/停机)过滤,批量运维操作不便。
## Solution
对六个业务场景进行针对性扩展:
1. 在 standalone 卡列表和设备列表中新增企业维度过滤及企业信息返回字段
2. 将企业授权/收回接口升级为支持 list/range/filter 三(两)模式选资产
3. 在 standalone 卡列表和设备列表响应中暴露换货业务状态和资产世代编号
4. 主钱包流水列表新增资产标识精确检索
5. 退款列表新增资产标识精确检索
6. standalone 卡列表新增网络状态过滤
同时修复卡企业授权表的数据一致性问题(补唯一约束,与设备授权保持一致)。
## User Stories
1. 作为平台运营,我希望在 standalone 卡列表中输入企业 ID 进行过滤,以便快速找到已授权给该企业的所有卡
2. 作为平台运营,我希望在 standalone 卡列表中使用"是否授权企业"开关过滤,以便快速区分已授权和未授权的卡库存
3. 作为平台运营,我希望在 standalone 卡列表中直接看到每张卡被授权给哪个企业企业ID和名称以便不用跳转到企业详情页
4. 作为平台运营,我希望在设备列表中输入企业 ID 进行过滤,以便快速找到已授权给该企业的所有设备
5. 作为平台运营,我希望在设备列表中使用"是否授权企业"开关过滤,以便区分已授权和未授权的设备
6. 作为平台运营,我希望在设备列表中直接看到每台设备被授权给哪个企业,以便快速掌握设备归属
7. 作为平台运营,我希望在授权卡给企业时能通过 ICCID 号段范围一次性选取,以便高效授权大批量卡
8. 作为平台运营,我希望在授权卡给企业时能通过批次号、运营商等筛选条件选取,以便按业务维度批量授权
9. 作为平台运营,我希望在收回企业卡授权时同样支持 list/range/filter 三种模式,以便与授权操作保持一致的操作体验
10. 作为平台运营,我希望在授权设备给企业时能通过虚拟号模糊搜索和批次号筛选一次性选取,以便批量操作设备
11. 作为平台运营,我希望在收回企业设备授权时支持 list/filter 两种模式,以便批量收回
12. 作为平台运营,我希望在 standalone 卡列表中看到每张卡的业务状态(`asset_status`),以便识别已换货但尚未转新的"死档"卡
13. 作为平台运营,我希望在 standalone 卡列表中看到每张卡的世代编号(`generation`),以便识别曾换货后转新重入库的卡
14. 作为平台运营,我希望在设备列表中看到每台设备的业务状态和世代编号,以便同样掌握设备的换货历史
15. 作为平台运营,我希望在主钱包流水列表中输入 ICCID 或虚拟号精确检索,以便快速定位某资产的所有充值/扣款记录
16. 作为平台运营,我希望在退款列表中输入 ICCID 或虚拟号精确检索,以便快速找到某资产相关的所有退款申请
17. 作为平台运营,我希望在 standalone 卡列表中按网络状态(开机/停机)过滤,以便批量处理特定网络状态的卡
## Implementation Decisions
### 数据一致性修复(前置)
- **卡企业授权唯一约束**:在 `tb_enterprise_card_authorization` 表上补充部分唯一索引,约束同一张卡在同一时间只能有一条有效授权记录(`WHERE revoked_at IS NULL AND deleted_at IS NULL`),与设备授权表的 `uq_active_device_auth` 约束保持一致
- **service 层校验补充**:卡授权 service 在执行授权前,需增加"卡是否已授权给其他企业"的校验,目前只校验同一企业重复授权
### 需求1standalone 卡列表 + 设备列表企业字段
**Request 新增过滤条件**`ListStandaloneIotCardRequest``ListDeviceRequest`
- `authorized_enterprise_id *uint`按企业ID过滤只匹配当前有效授权`revoked_at IS NULL`
- `is_authorized_to_enterprise *bool`true=已授权给某企业false=未授权任何企业
**Response 新增字段**`StandaloneIotCardResponse``DeviceResponse`
- `authorized_enterprise_id *uint`:未授权时为 null
- `authorized_enterprise_name string`:未授权时为空字符串
Store 层需通过 JOIN 或子查询 `tb_enterprise_card_authorization` / `tb_enterprise_device_authorization` 表获取企业ID再批量查企业名称
### 需求2企业授权/收回接口升级为多模式
参照 `AllocateStandaloneCardsRequest` / `RecallStandaloneCardsRequest``selection_type` 三模式设计:
**allocate-cards / recall-cards**三模式list / range / filter
- `list``ICCIDs []string`,精确 ICCID 列表
- `range``ICCIDStart string` + `ICCIDEnd string`,号段范围
- `filter`:筛选条件
- allocate-cards filter 字段:`ICCID`(模糊)、`BatchNo``CarrierID``ShopID`/`ShopIDs`
- recall-cards filter 字段:`ICCID`(模糊)、`BatchNo``CarrierID`
**allocate-devices / recall-devices**两模式list / filter不支持 range
- `list``DeviceNos []string`,设备虚拟号列表
- `filter`:筛选条件
- allocate-devices filter 字段:`VirtualNo`(模糊)、`BatchNo``ShopID`
- recall-devices filter 字段:`VirtualNo`(模糊)、`BatchNo`
原有接口的请求结构需要破坏性变更DTO 重构),需与前端联调确认过渡方案
### 需求3换货状态字段
**`StandaloneIotCardResponse``DeviceResponse` 新增字段**
- `asset_status int`业务状态1=在库, 2=已销售, 3=已换货, 4=已停用)
- `asset_status_name string`:对应中文名称
- `generation int`资产世代编号初始值1每次换货+转新后+1
语义约定:
- `asset_status = 3`:该资产已换货且尚未执行"旧资产转新",是永久性死档标记
- `generation > 1`:该资产历史上曾执行过换货+转新,目前仍在流通
### 需求4主钱包流水资产标识检索
`MainWalletTransactionListRequest` 新增:
- `AssetIdentifier string`:精确匹配 `asset_identifier` 快照字段ICCID 或虚拟号),空字符串时不过滤
### 需求5退款列表资产标识检索
`RefundListRequest` 新增:
- `AssetIdentifier string`:精确匹配 `asset_identifier` 快照字段ICCID 或虚拟号),空字符串时不过滤
### 需求6standalone 卡列表网络状态过滤
`ListStandaloneIotCardRequest` 新增:
- `NetworkStatus *int`网络状态过滤0=停机, 1=开机),不传则不过滤
Store 层 `applyStandaloneFilters` 函数中补充 `network_status = ?` 条件
## Testing Decisions
本项目禁止自动化测试,验证方式为:
- **数据库验证**:通过 PostgreSQL MCP 工具验证授权表唯一约束是否生效、字段值是否正确写入
- **接口验证**:通过 curl/Postman 对各接口进行手动联调,重点覆盖以下场景:
- 企业 ID 过滤确认只返回有效授权revoked_at IS NULL的卡/设备
- 是否授权企业过滤true/false 结果集互补且无交集
- 授权时尝试将同一张卡授权给第二个企业,确认被拒绝
- allocate-cards 三种 selection_type 均能正确选取目标卡
- allocate-devices 两种 selection_type 均能正确选取目标设备
- asset_status=3 的卡在列表中可见且字段正确
- generation 字段在换货+转新后正确递增
- 主钱包流水 asset_identifier 精确匹配不漏不多
- 退款 asset_identifier 精确匹配不漏不多
- 网络状态过滤 0/1 结果集互补
## Out of Scope
- 企业卡/设备授权列表接口本身的改造(本次只改授权/收回操作接口)
- `AllocateCardsPreviewReq`(预览接口)是否同步升级为多模式(待后续评估)
- standalone 卡列表和设备列表的导出功能是否同步支持新增过滤条件
- 换货历史的完整时间线展示(仅暴露字段,不新增详情接口)
- `generation` 字段的过滤能力(暂只返回,不支持作为检索条件)
## Further Notes
- 需求1 的企业名称需要 N+1 防护:通过授权查询批量拿到 enterprise_id 后,用 IN 一次性查企业名称,不能逐条查
- 需求2 的 filter 模式对于 allocate-cards 场景卡的范围天然受操作者权限约束代理用户只能授权自己店铺的卡Store 层已有 `middleware.ApplyShopFilter` 处理filter 模式需要同样受此约束
- 卡唯一约束迁移执行前,需确认现有数据中是否存在一张卡同时有多条 `revoked_at IS NULL` 的记录,若有需先清理

View File

@@ -0,0 +1,28 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
修复卡企业授权的数据一致性问题,使其与设备授权保持一致:一张卡在同一时间只能授权给一个企业。
分两步:
**第一步:数据库迁移**
`tb_enterprise_card_authorization` 表上新增部分唯一索引,约束同一张卡不能同时存在两条有效授权记录(`revoked_at IS NULL AND deleted_at IS NULL`)。迁移执行前需先查询是否存在脏数据(同一 card_id 有多条 revoked_at IS NULL 的记录),若有需在迁移脚本中先行清理。
**第二步service 层校验补充**
卡授权 service`BatchAuthorize`)在执行授权前,增加"目标卡是否已授权给其他企业"的校验。目前代码只跳过已授权给同一企业的卡,不阻止授权给第二个企业。新逻辑:若目标卡已存在有效授权(`revoked_at IS NULL`)且授权对象不是当前企业,则将该卡加入失败列表并给出明确错误原因(如"卡已授权给其他企业,请先收回")。
## Acceptance criteria
- [x] `tb_enterprise_card_authorization` 表存在部分唯一索引,约束 `(card_id) WHERE revoked_at IS NULL AND deleted_at IS NULL`
- [x] 尝试将同一张卡授权给第二个企业时,接口返回失败,错误信息明确说明"已授权给其他企业"
- [x] 已撤回revoked_at 有值)的历史记录不受唯一约束影响,可正常查询
- [x] 同一张卡授权给同一企业时仍返回"已授权给该企业"的原有错误,行为不变
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,22 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/iot-cards/standalone` 接口的查询条件中新增网络状态过滤。
Request DTO 新增可选字段 `network_status *int`0=停机1=开机不传时不过滤。Store 层 `applyStandaloneFilters` 函数补充对应的 `network_status = ?` WHERE 条件。
## Acceptance criteria
- [x] 传入 `network_status=0` 时只返回停机的卡
- [x] 传入 `network_status=1` 时只返回开机的卡
- [x] 不传 `network_status` 时返回全部(与改动前行为一致)
- [x] 网络状态过滤可与现有其他过滤条件ICCID、运营商等叠加使用
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,34 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
在 standalone 卡列表响应和设备列表响应中暴露换货相关字段,使运营人员能直接在列表中识别资产的换货历史。
**`StandaloneIotCardResponse` 新增三个字段:**
- `asset_status int`业务状态1=在库, 2=已销售, 3=已换货, 4=已停用)
- `asset_status_name string`:对应中文名称
- `generation int`资产世代编号初始值1每次换货+旧资产转新后 +1
**`DeviceResponse` 同步新增相同三个字段。**
语义约定(写入注释和 description
- `asset_status = 3`:该资产已换货且尚未执行"旧资产转新",不再流通
- `generation > 1`:该资产历史上曾执行过换货后转新,目前仍在使用
Service 层组装响应时直接从 Model 取值,`asset_status_name` 通过 constants 中的方法转换。
## Acceptance criteria
- [x] `/api/admin/iot-cards/standalone` 响应中每条记录包含 `asset_status``asset_status_name``generation` 三个字段
- [x] `/api/admin/devices` 响应中每条记录包含相同三个字段
- [x] 已换货未转新的卡/设备返回 `asset_status=3``asset_status_name="已换货"`
- [x] 经过换货转新重入库的卡/设备返回 `generation=2`(或更高)且 `asset_status=1`
- [x] 普通未经历换货的资产返回 `generation=1`
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,23 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/shops/{shop_id}/main-wallet/transactions` 接口的查询条件中新增资产标识精确检索。
`MainWalletTransactionListRequest` 新增可选字段 `asset_identifier string`,非空时对 `asset_identifier` 列做精确匹配(`= ?`,不做模糊匹配)。`asset_identifier` 字段存储的是下单时资产的标识符快照ICCID 或虚拟号),空字符串时不过滤。
## Acceptance criteria
- [x] 传入有效的 ICCID 时只返回该卡相关的主钱包流水
- [x] 传入有效的设备虚拟号时只返回该设备相关的主钱包流水
- [x] 传入不存在的标识符时返回空列表total=0不报错
- [x] 不传 `asset_identifier` 时行为与改动前完全一致
- [x] 可与 `transaction_type``start_date``end_date` 等现有条件叠加使用
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,23 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/refunds` 接口的查询条件中新增资产标识精确检索。
`RefundListRequest` 新增可选字段 `asset_identifier string`,非空时对退款记录的 `asset_identifier` 列做精确匹配(`= ?`。该字段存储的是下单时资产的标识符快照ICCID 或虚拟号),空字符串时不过滤。
## Acceptance criteria
- [x] 传入有效的 ICCID 时只返回该卡相关的退款申请
- [x] 传入有效的设备虚拟号时只返回该设备相关的退款申请
- [x] 传入不存在的标识符时返回空列表total=0不报错
- [x] 不传 `asset_identifier` 时行为与改动前完全一致
- [x] 可与 `status``order_id``shop_id` 等现有条件叠加使用
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,33 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/iot-cards/standalone` 接口中新增企业维度过滤条件,并在响应中返回当前有效授权的企业信息。
**Request 新增过滤条件(`ListStandaloneIotCardRequest`**
- `authorized_enterprise_id *uint`按企业ID过滤只匹配 `tb_enterprise_card_authorization``revoked_at IS NULL` 的有效授权
- `is_authorized_to_enterprise *bool`true=只返回当前已授权给某企业的卡false=只返回未授权任何企业的卡
**Response 新增字段(`StandaloneIotCardResponse`**
- `authorized_enterprise_id *uint`当前有效授权的企业ID未授权时为 null
- `authorized_enterprise_name string`:对应企业名称,未授权时为空字符串
Store 层在 `applyStandaloneFilters` 中增加对 `authorized_enterprise_id``is_authorized_to_enterprise` 的处理,通过子查询 `tb_enterprise_card_authorization``revoked_at IS NULL AND deleted_at IS NULL`实现过滤。响应组装时批量查询企业名称IN 查询),不逐条查询。
## Acceptance criteria
- [x] 传入 `authorized_enterprise_id` 时只返回当前有效授权给该企业的卡
- [x] 传入 `is_authorized_to_enterprise=true` 时只返回已授权给某企业的卡
- [x] 传入 `is_authorized_to_enterprise=false` 时只返回未授权任何企业的卡
- [x] 已撤回的历史授权不影响过滤结果(已撤回视为未授权)
- [x] 响应中每张卡包含 `authorized_enterprise_id``authorized_enterprise_name`,未授权卡对应字段为 null/空字符串
- [x] 企业名称通过批量查询获取,不触发 N+1 查询
- [x] 新增过滤条件可与现有条件ICCID、运营商、店铺等叠加使用
## Blocked by
- `issues/01-card-enterprise-auth-unique-constraint.md`

View File

@@ -0,0 +1,32 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/devices` 接口中新增企业维度过滤条件,并在响应中返回当前有效授权的企业信息。
**Request 新增过滤条件(`ListDeviceRequest`**
- `authorized_enterprise_id *uint`按企业ID过滤只匹配 `tb_enterprise_device_authorization``revoked_at IS NULL` 的有效授权
- `is_authorized_to_enterprise *bool`true=只返回当前已授权给某企业的设备false=只返回未授权任何企业的设备
**Response 新增字段(`DeviceResponse`**
- `authorized_enterprise_id *uint`当前有效授权的企业ID未授权时为 null
- `authorized_enterprise_name string`:对应企业名称,未授权时为空字符串
Store 层通过子查询或 JOIN `tb_enterprise_device_authorization` 实现过滤。响应组装时批量查询企业名称IN 查询),不逐条查询。
## Acceptance criteria
- [x] 传入 `authorized_enterprise_id` 时只返回当前有效授权给该企业的设备
- [x] 传入 `is_authorized_to_enterprise=true` 时只返回已授权给某企业的设备
- [x] 传入 `is_authorized_to_enterprise=false` 时只返回未授权任何企业的设备
- [x] 已撤回的历史授权不影响过滤结果(已撤回视为未授权)
- [x] 响应中每台设备包含 `authorized_enterprise_id``authorized_enterprise_name`,未授权设备对应字段为 null/空字符串
- [x] 企业名称通过批量查询获取,不触发 N+1 查询
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,42 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
将企业卡授权和收回接口升级为支持 list/range/filter 三种模式批量选取卡,替代原来只接受精确 ICCID 列表的方式。
**涉及接口:**
- `POST /api/admin/enterprises/{id}/allocate-cards`
- `POST /api/admin/enterprises/{id}/recall-cards`
**`AllocateCardsReq` 重构为:**
- `selection_type string`(必填,`list``range``filter`
- list 模式:`iccids []string`ICCID 列表最多1000个
- range 模式:`iccid_start string` + `iccid_end string`(号段范围)
- filter 模式过滤字段:`iccid string`(模糊)、`batch_no string``carrier_id *uint``shop_id *uint``shop_ids []uint`
- `remark string`(备注,所有模式均可选)
**`RecallCardsReq` 重构为:**
- `selection_type string`(必填,`list``range``filter`
- list 模式:`iccids []string`
- range 模式:`iccid_start string` + `iccid_end string`
- filter 模式过滤字段:`iccid string`(模糊)、`batch_no string``carrier_id *uint`
filter 和 range 模式下allocate 操作的候选卡集受操作者权限约束(代理用户只能授权自己店铺的卡),与 `applyStandaloneFilters` 中的 `ApplyShopFilter` 逻辑保持一致。
## Acceptance criteria
- [x] `allocate-cards` 接口接受 `selection_type=list` + `iccids`,行为与改动前一致
- [x] `allocate-cards` 接口接受 `selection_type=range` + 号段,批量授权号段内所有匹配的卡
- [x] `allocate-cards` 接口接受 `selection_type=filter` + 过滤条件,批量授权所有匹配的卡
- [x] `recall-cards` 接口同样支持三种模式,分别正确收回对应卡的企业授权
- [x] filter/range 模式下代理用户只能操作自己店铺的卡,超出范围的卡进入失败列表
- [x] 任何模式下尝试授权"已授权给其他企业"的卡,该卡进入失败列表并附带原因
- [x] 响应中 `success_count``fail_count``failed_items` 准确反映实际执行结果
## Blocked by
- `issues/01-card-enterprise-auth-unique-constraint.md`

View File

@@ -0,0 +1,38 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
将企业设备授权和收回接口升级为支持 list/filter 两种模式批量选取设备,替代原来只接受精确设备号列表的方式。
**涉及接口:**
- `POST /api/admin/enterprises/{id}/allocate-devices`
- `POST /api/admin/enterprises/{id}/recall-devices`
**`AllocateDevicesReq` 重构为:**
- `selection_type string`(必填,`list``filter`
- list 模式:`device_nos []string`(设备虚拟号列表)
- filter 模式过滤字段:`virtual_no string`(模糊)、`batch_no string``shop_id *uint`
**`RecallDevicesReq` 重构为:**
- `selection_type string`(必填,`list``filter`
- list 模式:`device_nos []string`
- filter 模式过滤字段:`virtual_no string`(模糊)、`batch_no string`
filter 模式下allocate 操作的候选设备集受操作者权限约束代理用户只能授权自己店铺的设备。filter 模式命中的设备数量无硬性上限,但单次事务处理建议分批,超大批次需记录日志。
## Acceptance criteria
- [x] `allocate-devices` 接口接受 `selection_type=list` + `device_nos` 列表,行为与改动前一致
- [x] `allocate-devices` 接口接受 `selection_type=filter` + 过滤条件,批量授权所有匹配设备
- [x] `recall-devices` 接口接受 `selection_type=list` + `device_nos` 列表,行为与改动前一致
- [x] `recall-devices` 接口接受 `selection_type=filter` + 过滤条件,批量收回所有匹配设备
- [x] filter 模式下代理用户只能操作自己店铺的设备,超出范围的设备进入失败列表
- [x] 响应中 `success_count``fail_count``failed_items` 准确反映实际执行结果
## Blocked by
None - can start immediately

19
CONTEXT.md Normal file
View File

@@ -0,0 +1,19 @@
# 领域术语表
## 资产Asset
**IoT 卡 / IotCard**:物联网流量卡,以 ICCID 为唯一标识。分为独立卡(`is_standalone=true`,未绑定设备)和设备卡(绑定在设备上)。
**设备 / Device**硬件设备以虚拟号VirtualNo为业务标识可插卡使用。
**资产世代 / Generation**`generation` 字段记录卡/设备经历"换货+旧资产转新"操作的次数。初始值为 1每完成一次换货且旧资产执行"转新"后加 1。`generation > 1` 说明该资产曾作为旧资产经历过换货并被重新投入使用。
**资产业务状态 / AssetStatus**`asset_status` 字段表示资产在 CMP 内部的业务生命周期1=在库, 2=已销售, 3=已换货, 4=已停用)。换货完成时旧资产置为 3执行"旧资产转新"后重置为 1同时 generation+1。
## 企业授权Enterprise Authorization
**卡企业授权**:将独立卡授权给企业使用的操作。**业务约束:一张卡在同一时间只能授权给一个企业**(与设备授权保持一致,数据库通过部分唯一索引强制约束)。
**有效授权**`revoked_at IS NULL AND deleted_at IS NULL` 的授权记录。撤回授权后(`revoked_at` 有值)视为历史记录,不计入当前授权关系。
**设备企业授权**:将设备(含其绑定卡)授权给企业使用的操作。同一台设备同一时间只能授权给一个企业(`uq_active_device_auth` 唯一约束)。

View File

@@ -523,17 +523,54 @@ components:
type: object
DtoAllocateCardsReq:
properties:
batch_no:
description: '[filter模式] 批次号'
maxLength: 100
type: string
carrier_id:
description: '[filter模式] 运营商ID'
minimum: 0
nullable: true
type: integer
iccid:
description: '[filter模式] ICCID模糊匹配'
maxLength: 20
type: string
iccid_end:
description: '[range模式] 号段结束ICCID'
maxLength: 20
type: string
iccid_start:
description: '[range模式] 号段起始ICCID'
maxLength: 20
type: string
iccids:
description: 需要授权的 ICCID 列表
description: '[list模式] ICCID列表最多1000个'
items:
type: string
nullable: true
type: array
remark:
description: 授权备注
description: 授权备注(所有模式均可选)
maxLength: 500
type: string
selection_type:
description: 选取模式 (list:指定ICCID列表, range:号段范围, filter:按条件过滤)
type: string
shop_id:
description: '[filter模式] 店铺ID'
minimum: 0
nullable: true
type: integer
shop_ids:
description: '[filter模式] 店铺ID列表'
items:
minimum: 0
type: integer
nullable: true
type: array
required:
- iccids
- selection_type
type: object
DtoAllocateCardsResp:
properties:
@@ -552,15 +589,34 @@ components:
type: object
DtoAllocateDevicesReq:
properties:
batch_no:
description: '[filter模式] 批次号'
maxLength: 100
type: string
device_nos:
description: 设备号列表最多100个
description: '[list模式] 设备号列表最多100个'
items:
type: string
nullable: true
type: array
remark:
description: 授权备注
description: 授权备注(所有模式均可选)
maxLength: 500
type: string
selection_type:
description: 选取模式 (list:指定设备号列表, filter:按条件过滤)
type: string
shop_id:
description: '[filter模式] 店铺ID'
minimum: 0
nullable: true
type: integer
virtual_no:
description: '[filter模式] 设备虚拟号(模糊匹配)'
maxLength: 100
type: string
required:
- selection_type
type: object
DtoAllocateDevicesRequest:
properties:
@@ -3262,7 +3318,7 @@ components:
description: 导出筛选参数(JSON对象可选)
type: object
scene:
description: 导出场景 (device:设备, iot_card:IoT卡)
description: 导出场景 (device:设备, iot_card:IoT卡, order:订单)
type: string
required:
- scene
@@ -4617,6 +4673,20 @@ components:
activation_status_name:
description: 激活状态名称(中文)
type: string
asset_status:
description: 业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)
type: integer
asset_status_name:
description: 业务状态名称(中文)
type: string
authorized_enterprise_id:
description: 当前有效授权的企业ID未授权时为 null
minimum: 0
nullable: true
type: integer
authorized_enterprise_name:
description: 当前有效授权的企业名称,未授权时为空字符串
type: string
batch_no:
description: 批次号
type: string
@@ -4636,6 +4706,9 @@ components:
device_type:
description: 设备类型
type: string
generation:
description: 资产世代编号初始值1每次换货转新后+1
type: integer
id:
description: 设备ID
minimum: 0
@@ -5180,7 +5253,7 @@ components:
description: 任务进度(0-100)
type: integer
scene:
description: 导出场景 (device:设备, iot_card:IoT卡)
description: 导出场景 (device:设备, iot_card:IoT卡, order:订单)
type: string
started_at:
description: 开始处理时间
@@ -5260,7 +5333,7 @@ components:
description: 任务进度(0-100)
type: integer
scene:
description: 导出场景 (device:设备, iot_card:IoT卡)
description: 导出场景 (device:设备, iot_card:IoT卡, order:订单)
type: string
started_at:
description: 开始处理时间
@@ -6980,14 +7053,38 @@ components:
type: object
DtoRecallCardsReq:
properties:
batch_no:
description: '[filter模式] 批次号'
maxLength: 100
type: string
carrier_id:
description: '[filter模式] 运营商ID'
minimum: 0
nullable: true
type: integer
iccid:
description: '[filter模式] ICCID模糊匹配'
maxLength: 20
type: string
iccid_end:
description: '[range模式] 号段结束ICCID'
maxLength: 20
type: string
iccid_start:
description: '[range模式] 号段起始ICCID'
maxLength: 20
type: string
iccids:
description: 需要回收授权的 ICCID 列表
description: '[list模式] 需要回收授权的ICCID列表'
items:
type: string
nullable: true
type: array
selection_type:
description: 选取模式 (list:指定ICCID列表, range:号段范围, filter:按条件过滤)
type: string
required:
- iccids
- selection_type
type: object
DtoRecallCardsResp:
properties:
@@ -7012,12 +7109,25 @@ components:
type: object
DtoRecallDevicesReq:
properties:
batch_no:
description: '[filter模式] 批次号'
maxLength: 100
type: string
device_nos:
description: 设备号列表最多100个
description: '[list模式] 设备号列表最多100个'
items:
type: string
nullable: true
type: array
selection_type:
description: 选取模式 (list:指定设备号列表, filter:按条件过滤)
type: string
virtual_no:
description: '[filter模式] 设备虚拟号(模糊匹配)'
maxLength: 100
type: string
required:
- selection_type
type: object
DtoRecallDevicesRequest:
properties:
@@ -7999,6 +8109,20 @@ components:
activation_status_name:
description: 激活状态名称(中文)
type: string
asset_status:
description: 业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)
type: integer
asset_status_name:
description: 业务状态名称(中文)
type: string
authorized_enterprise_id:
description: 当前有效授权的企业ID未授权时为 null
minimum: 0
nullable: true
type: integer
authorized_enterprise_name:
description: 当前有效授权的企业名称,未授权时为空字符串
type: string
batch_no:
description: 批次号
type: string
@@ -8042,6 +8166,9 @@ components:
gateway_extend:
description: Gateway 卡状态扩展字段,原样返回上游 extend用于展示运营商侧实际停机原因
type: string
generation:
description: 资产世代编号初始值1每次换货转新后+1
type: integer
iccid:
description: ICCID
type: string
@@ -13689,6 +13816,21 @@ paths:
description: 关键字搜索匹配虚拟号或IMEI(模糊查询与virtual_no/imei独立)
maxLength: 100
type: string
- description: 按有效授权企业ID过滤只返回当前授权给该企业的设备
in: query
name: authorized_enterprise_id
schema:
description: 按有效授权企业ID过滤只返回当前授权给该企业的设备
minimum: 0
nullable: true
type: integer
- description: 企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)
in: query
name: is_authorized_to_enterprise
schema:
description: 企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)
nullable: true
type: boolean
responses:
"200":
content:
@@ -16225,11 +16367,11 @@ paths:
maximum: 100
minimum: 1
type: integer
- description: 导出场景 (device:设备, iot_card:IoT卡)
- description: 导出场景 (device:设备, iot_card:IoT卡, order:订单)
in: query
name: scene
schema:
description: 导出场景 (device:设备, iot_card:IoT卡)
description: 导出场景 (device:设备, iot_card:IoT卡, order:订单)
type: string
- description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)
in: query
@@ -16313,7 +16455,7 @@ paths:
tags:
- 导出任务
post:
description: 创建统一导出任务,支持场景 scene=device/iot_card 和格式 format=xlsx/csv。
description: 创建统一导出任务,支持场景 scene=device/iot_card/order 和格式 format=xlsx/csv。
requestBody:
content:
application/json:
@@ -17078,6 +17220,30 @@ paths:
description: 关键字搜索匹配ICCID或卡虚拟号(模糊查询与iccid/virtual_no独立)
maxLength: 100
type: string
- description: 网络状态 (0:停机, 1:开机)
in: query
name: network_status
schema:
description: 网络状态 (0:停机, 1:开机)
maximum: 1
minimum: 0
nullable: true
type: integer
- description: 按有效授权企业ID过滤只返回当前授权给该企业的卡
in: query
name: authorized_enterprise_id
schema:
description: 按有效授权企业ID过滤只返回当前授权给该企业的卡
minimum: 0
nullable: true
type: integer
- description: 企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)
in: query
name: is_authorized_to_enterprise
schema:
description: 企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)
nullable: true
type: boolean
responses:
"200":
content:
@@ -20655,6 +20821,13 @@ paths:
minimum: 0
nullable: true
type: integer
- description: 资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)
in: query
name: asset_identifier
schema:
description: 资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)
maxLength: 100
type: string
responses:
"200":
content:
@@ -22740,6 +22913,13 @@ paths:
schema:
description: 结束日期YYYY-MM-DD
type: string
- description: 资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)
in: query
name: asset_identifier
schema:
description: 资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)
maxLength: 100
type: string
- description: 店铺ID
in: path
name: shop_id

8
go.mod
View File

@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
github.com/aws/aws-sdk-go v1.55.5
github.com/bytedance/sonic v1.14.2
github.com/bytedance/sonic v1.15.2
github.com/go-playground/validator/v10 v10.28.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/gofiber/storage/redis/v3 v3.4.1
@@ -14,6 +14,7 @@ require (
github.com/hibiken/asynq v0.25.1
github.com/jackc/pgx/v5 v5.7.6
github.com/redis/go-redis/v9 v9.17.3
github.com/smartwalle/alipay/v3 v3.2.29
github.com/spf13/viper v1.21.0
github.com/swaggest/openapi-go v0.2.60
github.com/valyala/fasthttp v1.66.0
@@ -24,6 +25,7 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
gorm.io/driver/mysql v1.5.6
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -35,7 +37,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -69,7 +71,6 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/smartwalle/alipay/v3 v3.2.29 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.1.0 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
@@ -98,5 +99,4 @@ require (
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)

4
go.sum
View File

@@ -28,8 +28,12 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=

View File

@@ -172,6 +172,8 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
iotCard.SetEnterpriseCardAuthStore(s.EnterpriseCardAuthorization)
iotCard.SetEnterpriseStore(s.Enterprise)
iotCard.SetRedisClient(deps.Redis)
device := deviceSvc.New(
deps.DB,
@@ -187,6 +189,8 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.GatewayClient,
s.AssetIdentifier,
assetAudit,
s.EnterpriseDeviceAuthorization,
s.Enterprise,
)
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)

View File

@@ -20,6 +20,8 @@ type ListDeviceRequest struct {
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐true:有生效中主套餐, false:无生效中主套餐)"`
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索匹配虚拟号或IMEI(模糊查询与virtual_no/imei独立)"`
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤只返回当前授权给该企业的设备"`
IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"`
}
type DeviceResponse struct {
@@ -52,6 +54,13 @@ type DeviceResponse struct {
SoftwareVersion string `json:"software_version" description:"固件版本号"`
SwitchMode string `json:"switch_mode" description:"切卡模式0=自动1=手动"`
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at" description:"最后 sync-info 同步时间"`
// asset_status=3 表示该资产已换货且尚未执行"旧资产转新",不再流通
// generation>1 表示该资产历史上曾执行过换货后转新,目前仍在使用
AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"`
AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"`
Generation int `json:"generation" description:"资产世代编号初始值1每次换货转新后+1"`
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID未授权时为 null"`
AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"`
}
type ListDeviceResponse struct {

View File

@@ -34,8 +34,16 @@ type AllocateCardsPreviewResp struct {
type AllocateCardsReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
ICCIDs []string `json:"iccids" validate:"required,min=1,max=1000,dive,required" required:"true" description:"需要授权的 ICCID 列表"`
Remark string `json:"remark" validate:"max=500" description:"授权备注"`
SelectionType string `json:"selection_type" validate:"required,oneof=list range filter" required:"true" description:"选取模式 (list:指定ICCID列表, range:号段范围, filter:按条件过滤)"`
ICCIDs []string `json:"iccids" validate:"omitempty,max=1000,dive,required" description:"[list模式] ICCID列表最多1000个"`
ICCIDStart string `json:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"[range模式] 号段起始ICCID"`
ICCIDEnd string `json:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"[range模式] 号段结束ICCID"`
ICCID string `json:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"[filter模式] ICCID模糊匹配"`
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"[filter模式] 批次号"`
CarrierID *uint `json:"carrier_id" description:"[filter模式] 运营商ID"`
ShopID *uint `json:"shop_id" description:"[filter模式] 店铺ID"`
ShopIDs []uint `json:"shop_ids" description:"[filter模式] 店铺ID列表"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"授权备注(所有模式均可选)"`
}
type AllocateCardsResp struct {
@@ -46,7 +54,13 @@ type AllocateCardsResp struct {
type RecallCardsReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
ICCIDs []string `json:"iccids" validate:"required,min=1,max=1000,dive,required" required:"true" description:"需要回收授权的 ICCID 列表"`
SelectionType string `json:"selection_type" validate:"required,oneof=list range filter" required:"true" description:"选取模式 (list:指定ICCID列表, range:号段范围, filter:按条件过滤)"`
ICCIDs []string `json:"iccids" validate:"omitempty,max=1000,dive,required" description:"[list模式] 需要回收授权的ICCID列表"`
ICCIDStart string `json:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"[range模式] 号段起始ICCID"`
ICCIDEnd string `json:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"[range模式] 号段结束ICCID"`
ICCID string `json:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"[filter模式] ICCID模糊匹配"`
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"[filter模式] 批次号"`
CarrierID *uint `json:"carrier_id" description:"[filter模式] 运营商ID"`
}
type RecalledDevice struct {

View File

@@ -2,10 +2,15 @@ package dto
import "time"
// AllocateDevicesReq 企业授权设备请求,支持 list/filter 两种模式
type AllocateDevicesReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
DeviceNos []string `json:"device_nos" validate:"required,min=1,max=100" description:"设备号列表最多100个"`
Remark string `json:"remark" validate:"max=500" description:"授权备注"`
SelectionType string `json:"selection_type" validate:"required,oneof=list filter" required:"true" description:"选取模式 (list:指定设备号列表, filter:按条件过滤)"`
DeviceNos []string `json:"device_nos" validate:"omitempty,max=100" description:"[list模式] 设备号列表最多100个"`
VirtualNo string `json:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"[filter模式] 设备虚拟号(模糊匹配)"`
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"[filter模式] 批次号"`
ShopID *uint `json:"shop_id" description:"[filter模式] 店铺ID"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"授权备注(所有模式均可选)"`
}
type AllocateDevicesResp struct {
@@ -26,9 +31,13 @@ type AuthorizedDeviceItem struct {
CardCount int `json:"card_count" description:"绑定卡数量"`
}
// RecallDevicesReq 企业收回设备授权请求,支持 list/filter 两种模式
type RecallDevicesReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
DeviceNos []string `json:"device_nos" validate:"required,min=1,max=100" description:"设备号列表最多100个"`
SelectionType string `json:"selection_type" validate:"required,oneof=list filter" required:"true" description:"选取模式 (list:指定设备号列表, filter:按条件过滤)"`
DeviceNos []string `json:"device_nos" validate:"omitempty,max=100" description:"[list模式] 设备号列表最多100个"`
VirtualNo string `json:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"[filter模式] 设备虚拟号(模糊匹配)"`
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"[filter模式] 批次号"`
}
type RecallDevicesResp struct {

View File

@@ -23,6 +23,9 @@ type ListStandaloneIotCardRequest struct {
ICCIDEnd string `json:"iccid_end" query:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"ICCID结束号"`
CarrierName string `json:"carrier_name" query:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称(模糊查询)"`
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索匹配ICCID或卡虚拟号(模糊查询与iccid/virtual_no独立)"`
NetworkStatus *int `json:"network_status" query:"network_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"网络状态 (0:停机, 1:开机)"`
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤只返回当前授权给该企业的卡"`
IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"`
}
type StandaloneIotCardResponse struct {
@@ -62,6 +65,13 @@ type StandaloneIotCardResponse struct {
SeriesName string `json:"series_name" description:"套餐系列名称"`
DeviceVirtualNo string `json:"device_virtual_no" description:"绑定设备的虚拟号(未绑定时为空字符串)"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
// asset_status=3 表示该资产已换货且尚未执行"旧资产转新",不再流通
// generation>1 表示该资产历史上曾执行过换货后转新,目前仍在使用
AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"`
AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"`
Generation int `json:"generation" description:"资产世代编号初始值1每次换货转新后+1"`
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID未授权时为 null"`
AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}

View File

@@ -51,6 +51,7 @@ type RefundListRequest struct {
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
OrderID *uint `json:"order_id" query:"order_id" validate:"omitempty" description:"关联订单ID"`
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)"`
}
// RefundResponse 退款申请详情响应

View File

@@ -50,6 +50,7 @@ type MainWalletTransactionListRequest struct {
TransactionType string `json:"transaction_type" query:"transaction_type" validate:"omitempty,max=50" maxLength:"50" description:"交易类型过滤recharge:充值入账, deduct:套餐扣款, refund:退款)"`
StartDate string `json:"start_date" query:"start_date" validate:"omitempty" description:"开始日期YYYY-MM-DD"`
EndDate string `json:"end_date" query:"end_date" validate:"omitempty" description:"结束日期YYYY-MM-DD"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)"`
}
// MainWalletTransactionItem 主钱包流水记录项

View File

@@ -33,6 +33,8 @@ type Service struct {
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
}
func New(
@@ -49,6 +51,8 @@ func New(
gatewayClient *gateway.Client,
assetIdentifierStore *postgres.AssetIdentifierStore,
assetAuditService AssetAuditService,
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
enterpriseStore *postgres.EnterpriseStore,
) *Service {
return &Service{
db: db,
@@ -64,6 +68,8 @@ func New(
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService,
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore,
}
}
@@ -133,6 +139,12 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
if req.HasActivePackage != nil {
filters["has_active_package"] = *req.HasActivePackage
}
if req.AuthorizedEnterpriseID != nil {
filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID
}
if req.IsAuthorizedToEnterprise != nil {
filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise
}
devices, total, err := s.deviceStore.List(ctx, opts, filters)
if err != nil {
@@ -151,9 +163,31 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
return nil, err
}
// 批量查询有效企业授权(避免 N+1 查询)
deviceEnterpriseMap, err := s.enterpriseDeviceAuthStore.GetActiveAuthEnterpriseByDeviceIDs(ctx, deviceIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备企业授权失败")
}
enterpriseIDs := make([]uint, 0)
seen := make(map[uint]struct{})
for _, eid := range deviceEnterpriseMap {
if _, ok := seen[eid]; !ok {
seen[eid] = struct{}{}
enterpriseIDs = append(enterpriseIDs, eid)
}
}
enterpriseNameMap, err := s.enterpriseStore.GetNameMapByIDs(ctx, enterpriseIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询企业名称失败")
}
list := make([]*dto.DeviceResponse, 0, len(devices))
for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses)
if eid, ok := deviceEnterpriseMap[device.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]
}
list = append(list, item)
}
@@ -941,6 +975,9 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string
SoftwareVersion: device.SoftwareVersion,
SwitchMode: device.SwitchMode,
LastGatewaySyncAt: device.LastGatewaySyncAt,
AssetStatus: device.AssetStatus,
AssetStatusName: constants.GetAssetStatusName(device.AssetStatus),
Generation: device.Generation,
}
if device.ShopID != nil && *device.ShopID > 0 {

View File

@@ -112,6 +112,92 @@ func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, r
return resp, nil
}
// resolveICCIDsForAllocate 根据选取模式解析待授权的 ICCID 列表
func (s *Service) resolveICCIDsForAllocate(ctx context.Context, req *dto.AllocateCardsReq) ([]string, error) {
switch req.SelectionType {
case "list":
return req.ICCIDs, nil
case "range":
cards, err := s.iotCardStore.GetStandaloneByICCIDRangeForAuth(ctx, req.ICCIDStart, req.ICCIDEnd)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按号段查询卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
case "filter":
filters := map[string]any{}
if req.ICCID != "" {
filters["iccid"] = req.ICCID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if len(req.ShopIDs) > 0 {
filters["shop_ids"] = req.ShopIDs
}
cards, err := s.iotCardStore.GetStandaloneByAuthFilters(ctx, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选取模式")
}
}
// resolveICCIDsForRecall 根据选取模式解析待回收的 ICCID 列表
func (s *Service) resolveICCIDsForRecall(ctx context.Context, enterpriseID uint, req *dto.RecallCardsReq) ([]string, error) {
switch req.SelectionType {
case "list":
return req.ICCIDs, nil
case "range":
cards, err := s.iotCardStore.GetAuthorizedStandaloneByICCIDRange(ctx, enterpriseID, req.ICCIDStart, req.ICCIDEnd)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按号段查询已授权卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
case "filter":
filters := map[string]any{}
if req.ICCID != "" {
filters["iccid"] = req.ICCID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
cards, err := s.iotCardStore.GetAuthorizedStandaloneByFilters(ctx, enterpriseID, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询已授权卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选取模式")
}
}
func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsReq) (*dto.AllocateCardsResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
@@ -123,7 +209,12 @@ 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})
iccids, err := s.resolveICCIDsForAllocate(ctx, req)
if err != nil {
return nil, err
}
preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: iccids})
if err != nil {
return nil, err
}
@@ -133,9 +224,31 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
FailCount: len(preview.FailedItems),
}
cardIDsToAllocate := make([]uint, 0)
// 构建 cardID -> ICCID 映射,用于组装失败原因
cardIDToICCID := make(map[uint]string, len(preview.StandaloneCards))
allCandidateIDs := make([]uint, 0, len(preview.StandaloneCards))
for _, card := range preview.StandaloneCards {
cardIDsToAllocate = append(cardIDsToAllocate, card.IotCardID)
cardIDToICCID[card.IotCardID] = card.ICCID
allCandidateIDs = append(allCandidateIDs, card.IotCardID)
}
// 检测已被其他企业授权的卡,阻止重复授权
conflictAuths, err := s.enterpriseCardAuthStore.GetConflictingAuthsByCardIDs(ctx, enterpriseID, allCandidateIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询冲突授权失败")
}
cardIDsToAllocate := make([]uint, 0, len(allCandidateIDs))
for _, cardID := range allCandidateIDs {
if _, conflict := conflictAuths[cardID]; conflict {
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
ICCID: cardIDToICCID[cardID],
Reason: "卡已授权给其他企业,请先收回",
})
resp.FailCount++
continue
}
cardIDsToAllocate = append(cardIDsToAllocate, cardID)
}
existingAuths, err := s.enterpriseCardAuthStore.GetActiveAuthsByCardIDs(ctx, enterpriseID, cardIDsToAllocate)
@@ -181,7 +294,12 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
iotCardPtrs, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
iccids, err := s.resolveICCIDsForRecall(ctx, enterpriseID, req)
if err != nil {
return nil, err
}
iotCardPtrs, err := s.iotCardStore.GetByICCIDs(ctx, iccids)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
}
@@ -206,7 +324,7 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
}
cardIDsToRecall := make([]uint, 0)
for _, iccid := range req.ICCIDs {
for _, iccid := range iccids {
card, exists := cardMap[iccid]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{

View File

@@ -46,7 +46,7 @@ func New(
}
}
// AllocateDevices 授权设备给企业
// AllocateDevices 授权设备给企业,支持 list/filter 两种模式
func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *dto.AllocateDevicesReq) (*dto.AllocateDevicesResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
@@ -59,9 +59,15 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForAllocate(ctx, req)
if err != nil {
return nil, err
}
// 查询所有设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -88,8 +94,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
}
devicesToAllocate := make([]*model.Device, 0)
seenDeviceNos := make(map[string]struct{}, len(req.DeviceNos))
for _, deviceNo := range req.DeviceNos {
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
for _, deviceNo := range deviceNos {
if _, exists := seenDeviceNos[deviceNo]; exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
@@ -267,7 +273,62 @@ func isUniqueConstraintViolation(err error, constraintName string) bool {
return false
}
// RecallDevices 撤销设备授权
// resolveDeviceNosForAllocate 根据选取模式解析授权候选设备号列表
// list 模式:直接使用请求中的 device_nos
// filter 模式:按过滤条件从 store 查询,代理用户的店铺限制由 ApplyShopFilter 自动应用
func (s *Service) resolveDeviceNosForAllocate(ctx context.Context, req *dto.AllocateDevicesReq) ([]string, error) {
if req.SelectionType == "list" {
return req.DeviceNos, nil
}
filters := make(map[string]any)
if req.VirtualNo != "" {
filters["virtual_no"] = req.VirtualNo
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.ShopID != nil {
filters["shop_id"] = req.ShopID
}
devices, err := s.deviceStore.GetByFilters(ctx, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询设备失败")
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
nos = append(nos, d.VirtualNo)
}
return nos, nil
}
// resolveDeviceNosForRecall 根据选取模式解析收回候选设备号列表
// filter 模式仅返回当前企业有效授权的设备(通过子查询限定)
func (s *Service) resolveDeviceNosForRecall(ctx context.Context, enterpriseID uint, req *dto.RecallDevicesReq) ([]string, error) {
if req.SelectionType == "list" {
return req.DeviceNos, nil
}
// filter 模式:只查询已授权给该企业的设备
filters := make(map[string]any)
if req.VirtualNo != "" {
filters["virtual_no"] = req.VirtualNo
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
// 仅返回有效授权给该企业的设备
filters["authorized_enterprise_id"] = enterpriseID
devices, err := s.deviceStore.GetByFilters(ctx, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询设备失败")
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
nos = append(nos, d.VirtualNo)
}
return nos, nil
}
// RecallDevices 撤销设备授权,支持 list/filter 两种模式
func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto.RecallDevicesReq) (*dto.RecallDevicesResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
@@ -280,9 +341,15 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForRecall(ctx, enterpriseID, req)
if err != nil {
return nil, err
}
// 查询设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -304,7 +371,7 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
}
deviceAuthsToRevoke := make([]uint, 0)
for _, deviceNo := range req.DeviceNos {
for _, deviceNo := range deviceNos {
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{

View File

@@ -69,6 +69,8 @@ type Service struct {
redis *redis.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
}
func New(
@@ -107,6 +109,16 @@ func (s *Service) SetAssetIdentifierStore(store *postgres.AssetIdentifierStore)
s.assetIdentifierStore = store
}
// SetEnterpriseCardAuthStore 注入企业卡授权 store用于列表响应回填企业信息
func (s *Service) SetEnterpriseCardAuthStore(store *postgres.EnterpriseCardAuthorizationStore) {
s.enterpriseCardAuthStore = store
}
// SetEnterpriseStore 注入企业 store用于批量加载企业名称
func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) {
s.enterpriseStore = store
}
// SetDataDeductor 设置流量扣减回调
// 在应用启动时由 bootstrap 调用,注入套餐扣减服务
func (s *Service) SetDataDeductor(deductor DataDeductor) {
@@ -229,6 +241,15 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
if req.Keyword != "" {
filters["keyword"] = req.Keyword
}
if req.NetworkStatus != nil {
filters["network_status"] = *req.NetworkStatus
}
if req.AuthorizedEnterpriseID != nil {
filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID
}
if req.IsAuthorizedToEnterprise != nil {
filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise
}
// 代理用户注入 subordinate_shop_ids让 Store 层走并行查询路径
// 避免 PG 对 shop_id IN (...) + ORDER BY 选择全表扫描
@@ -260,9 +281,45 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
//TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的
seriesMap := s.loadSeriesNames(ctx, cards)
// 批量加载企业授权信息
cardAuthMap := make(map[uint]uint)
enterpriseNameMap := make(map[uint]string)
if s.enterpriseCardAuthStore != nil && len(cards) > 0 {
cardIDs := make([]uint, 0, len(cards))
for _, card := range cards {
cardIDs = append(cardIDs, card.ID)
}
authMap, err := s.enterpriseCardAuthStore.GetActiveAuthEnterpriseByCardIDs(ctx, cardIDs)
if err != nil {
s.logger.Error("批量加载卡企业授权失败", zap.Error(err))
} else {
cardAuthMap = authMap
}
if s.enterpriseStore != nil && len(cardAuthMap) > 0 {
eIDs := make([]uint, 0, len(cardAuthMap))
seen := make(map[uint]struct{})
for _, eid := range cardAuthMap {
if _, ok := seen[eid]; !ok {
seen[eid] = struct{}{}
eIDs = append(eIDs, eid)
}
}
nameMap, err := s.enterpriseStore.GetNameMapByIDs(ctx, eIDs)
if err != nil {
s.logger.Error("批量加载企业名称失败", zap.Error(err))
} else {
enterpriseNameMap = nameMap
}
}
}
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
for _, card := range cards {
item := s.toStandaloneResponse(card, shopMap, seriesMap)
if eid, ok := cardAuthMap[card.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]
}
list = append(list, item)
}
@@ -411,6 +468,9 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str
EnablePolling: card.EnablePolling,
SeriesID: card.SeriesID,
DeviceVirtualNo: card.DeviceVirtualNo,
AssetStatus: card.AssetStatus,
AssetStatusName: constants.GetAssetStatusName(card.AssetStatus),
Generation: card.Generation,
CreatedAt: card.CreatedAt,
UpdatedAt: card.UpdatedAt,
}

View File

@@ -163,6 +163,7 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
Status: req.Status,
OrderID: req.OrderID,
ShopID: req.ShopID,
AssetIdentifier: req.AssetIdentifier,
}
requests, total, err := s.refundStore.List(ctx, opts, filters)

View File

@@ -709,6 +709,7 @@ func (s *Service) ListMainWalletTransactions(ctx context.Context, shopID uint, r
TransactionType: req.TransactionType,
StartDate: req.StartDate,
EndDate: req.EndDate,
AssetIdentifier: req.AssetIdentifier,
}
offset := (page - 1) * pageSize

View File

@@ -30,6 +30,8 @@ type AgentWalletTransactionListFilters struct {
StartDate string
// EndDate 结束日期,格式 YYYY-MM-DD
EndDate string
// AssetIdentifier 资产标识精确匹配ICCID 或设备虚拟号),空字符串表示不过滤
AssetIdentifier string
}
// CreateWithTx 创建代理钱包交易记录(带事务)
@@ -95,5 +97,8 @@ func applyWalletTransactionFilters(query *gorm.DB, filters *AgentWalletTransacti
if filters.EndDate != "" {
query = query.Where("created_at <= ?", filters.EndDate+" 23:59:59")
}
if filters.AssetIdentifier != "" {
query = query.Where("asset_identifier = ?", filters.AssetIdentifier)
}
return query
}

View File

@@ -99,7 +99,7 @@ func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filter
query := s.db.WithContext(ctx).Model(&model.Device{})
// 应用数据权限过滤NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
query = s.applyDeviceFilters(query, filters)
query = s.applyDeviceFilters(ctx, query, filters)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
@@ -127,7 +127,7 @@ func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filter
return devices, total, nil
}
func (s *DeviceStore) applyDeviceFilters(query *gorm.DB, filters map[string]any) *gorm.DB {
func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, filters map[string]any) *gorm.DB {
if virtualNo, ok := filters["virtual_no"].(string); ok && virtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+virtualNo+"%")
}
@@ -186,6 +186,23 @@ func (s *DeviceStore) applyDeviceFilters(query *gorm.DB, filters map[string]any)
if hasActive, ok := filters["has_active_package"].(bool); ok {
query = s.applyHasActivePackageFilter(query, hasActive)
}
if enterpriseID, ok := filters["authorized_enterprise_id"].(uint); ok && enterpriseID > 0 {
// 只返回当前有效授权给该企业的设备
query = query.Where("id IN (?)",
s.db.WithContext(ctx).Table("tb_enterprise_device_authorization").
Select("device_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID))
}
if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok {
subQ := s.db.WithContext(ctx).Table("tb_enterprise_device_authorization").
Select("device_id").
Where("revoked_at IS NULL AND deleted_at IS NULL")
if isAuthorized {
query = query.Where("id IN (?)", subQ)
} else {
query = query.Where("id NOT IN (?)", subQ)
}
}
return query
}
@@ -241,7 +258,7 @@ func (s *DeviceStore) GetByFilters(ctx context.Context, filters map[string]any)
var devices []*model.Device
query := s.db.WithContext(ctx).Model(&model.Device{})
query = middleware.ApplyShopFilter(ctx, query)
query = s.applyDeviceFilters(query, filters)
query = s.applyDeviceFilters(ctx, query, filters)
if err := query.Find(&devices).Error; err != nil {
return nil, err
}

View File

@@ -180,6 +180,53 @@ func (s *EnterpriseCardAuthorizationStore) GetActiveAuthsByCardIDs(ctx context.C
return result, nil
}
// GetConflictingAuthsByCardIDs 查询与指定企业冲突的有效授权(已被其他企业授权的卡)
// 不受数据权限过滤,专用于授权前的冲突检测
func (s *EnterpriseCardAuthorizationStore) GetConflictingAuthsByCardIDs(ctx context.Context, enterpriseID uint, cardIDs []uint) (map[uint]uint, error) {
if len(cardIDs) == 0 {
return make(map[uint]uint), nil
}
var auths []struct {
CardID uint
EnterpriseID uint
}
err := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Select("card_id, enterprise_id").
Where("card_id IN ? AND enterprise_id != ? AND revoked_at IS NULL AND deleted_at IS NULL", cardIDs, enterpriseID).
Scan(&auths).Error
if err != nil {
return nil, err
}
result := make(map[uint]uint, len(auths))
for _, a := range auths {
result[a.CardID] = a.EnterpriseID
}
return result, nil
}
// GetActiveAuthEnterpriseByCardIDs 获取卡ID列表的有效授权企业映射cardID -> enterpriseID不受数据权限过滤
func (s *EnterpriseCardAuthorizationStore) GetActiveAuthEnterpriseByCardIDs(ctx context.Context, cardIDs []uint) (map[uint]uint, error) {
if len(cardIDs) == 0 {
return make(map[uint]uint), nil
}
var auths []struct {
CardID uint
EnterpriseID uint
}
err := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Select("card_id, enterprise_id").
Where("card_id IN ? AND revoked_at IS NULL AND deleted_at IS NULL", cardIDs).
Scan(&auths).Error
if err != nil {
return nil, err
}
result := make(map[uint]uint, len(auths))
for _, a := range auths {
result[a.CardID] = a.EnterpriseID
}
return result, nil
}
// BatchUpdateStatus 批量更新授权状态(回收授权:设置 revoked_at
func (s *EnterpriseCardAuthorizationStore) BatchUpdateStatus(ctx context.Context, enterpriseID uint, cardIDs []uint, status int) error {
if len(cardIDs) == 0 {

View File

@@ -155,3 +155,27 @@ func (s *EnterpriseStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.En
}
return enterprises, nil
}
// GetNameMapByIDs 批量查询企业名称映射(不受数据权限过滤)
// 专用于列表响应中回填企业名称,企业 ID 来自已过滤的授权记录,无需二次权限限制
func (s *EnterpriseStore) GetNameMapByIDs(ctx context.Context, ids []uint) (map[uint]string, error) {
if len(ids) == 0 {
return make(map[uint]string), nil
}
var rows []struct {
ID uint
EnterpriseName string
}
err := s.db.WithContext(ctx).Model(&model.Enterprise{}).
Select("id, enterprise_name").
Where("id IN ? AND deleted_at IS NULL", ids).
Scan(&rows).Error
if err != nil {
return nil, err
}
result := make(map[uint]string, len(rows))
for _, r := range rows {
result[r.ID] = r.EnterpriseName
}
return result, nil
}

View File

@@ -899,6 +899,26 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D
if carrierName, ok := filters["carrier_name"].(string); ok && carrierName != "" {
query = query.Where("carrier_name LIKE ?", "%"+carrierName+"%")
}
if networkStatus, ok := filters["network_status"].(int); ok {
query = query.Where("network_status = ?", networkStatus)
}
if enterpriseID, ok := filters["authorized_enterprise_id"].(uint); ok && enterpriseID > 0 {
// 只返回有效授权给该企业的卡
query = query.Where("id IN (?)",
s.db.WithContext(ctx).Table("tb_enterprise_card_authorization").
Select("card_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID))
}
if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok {
subQ := s.db.WithContext(ctx).Table("tb_enterprise_card_authorization").
Select("card_id").
Where("revoked_at IS NULL AND deleted_at IS NULL")
if isAuthorized {
query = query.Where("id IN (?)", subQ)
} else {
query = query.Where("id NOT IN (?)", subQ)
}
}
// has_active_package 过滤:是否存在生效中的主套餐记录
if hasActivePackage, ok := filters["has_active_package"].(bool); ok {
subQuery := s.db.WithContext(ctx).Table("tb_package_usage pu").Select("1").
@@ -1064,6 +1084,86 @@ func (s *IotCardStore) GetBySeriesBindingFilters(ctx context.Context, filters ma
return cards, nil
}
// GetStandaloneByICCIDRangeForAuth 按号段范围查询单卡(企业授权用),应用代理数据权限过滤但不限制店铺
func (s *IotCardStore) GetStandaloneByICCIDRangeForAuth(ctx context.Context, iccidStart, iccidEnd string) ([]*model.IotCard, error) {
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("is_standalone = true AND iccid >= ? AND iccid <= ?", iccidStart, iccidEnd)
query = middleware.ApplyShopFilter(ctx, query)
var cards []*model.IotCard
if err := query.Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}
// GetStandaloneByAuthFilters 按条件查询单卡(企业授权用),应用代理数据权限过滤
func (s *IotCardStore) GetStandaloneByAuthFilters(ctx context.Context, filters map[string]any) ([]*model.IotCard, error) {
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("is_standalone = true")
query = middleware.ApplyShopFilter(ctx, query)
if iccid, ok := filters["iccid"].(string); ok && iccid != "" {
query = query.Where("iccid LIKE ?", "%"+iccid+"%")
}
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
query = query.Where("batch_no = ?", batchNo)
}
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 shopIDs, ok := filters["shop_ids"].([]uint); ok && len(shopIDs) > 0 {
query = query.Where("shop_id IN ?", shopIDs)
}
var cards []*model.IotCard
if err := query.Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}
// GetAuthorizedStandaloneByICCIDRange 按号段范围查询已授权给指定企业的单卡(企业回收用)
func (s *IotCardStore) GetAuthorizedStandaloneByICCIDRange(ctx context.Context, enterpriseID uint, iccidStart, iccidEnd string) ([]*model.IotCard, error) {
subQ := s.db.WithContext(ctx).Table("tb_enterprise_card_authorization").
Select("card_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID)
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("is_standalone = true AND iccid >= ? AND iccid <= ? AND id IN (?)", iccidStart, iccidEnd, subQ)
var cards []*model.IotCard
if err := query.Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}
// GetAuthorizedStandaloneByFilters 按条件查询已授权给指定企业的单卡(企业回收用)
func (s *IotCardStore) GetAuthorizedStandaloneByFilters(ctx context.Context, enterpriseID uint, filters map[string]any) ([]*model.IotCard, error) {
subQ := s.db.WithContext(ctx).Table("tb_enterprise_card_authorization").
Select("card_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID)
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("is_standalone = true AND id IN (?)", subQ)
if iccid, ok := filters["iccid"].(string); ok && iccid != "" {
query = query.Where("iccid LIKE ?", "%"+iccid+"%")
}
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
query = query.Where("batch_no = ?", batchNo)
}
if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 {
query = query.Where("carrier_id = ?", carrierID)
}
var cards []*model.IotCard
if err := query.Find(&cards).Error; err != nil {
return nil, err
}
return cards, nil
}
func (s *IotCardStore) BatchUpdateShopIDAndStatus(ctx context.Context, cardIDs []uint, shopID *uint, status int) error {
if len(cardIDs) == 0 {
return nil

View File

@@ -15,6 +15,7 @@ type RefundListFilters struct {
Status *int `json:"status"`
OrderID *uint `json:"order_id"`
ShopID *uint `json:"shop_id"`
AssetIdentifier string `json:"asset_identifier"`
}
// RefundStore 退款申请数据访问层
@@ -81,6 +82,9 @@ func (s *RefundStore) List(ctx context.Context, opts *store.QueryOptions, filter
if filters.ShopID != nil {
query = query.Where("tb_refund_request.shop_id = ?", *filters.ShopID)
}
if filters.AssetIdentifier != "" {
query = query.Where("tb_refund_request.asset_identifier = ?", filters.AssetIdentifier)
}
}
if err := query.Count(&total).Error; err != nil {

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_enterprise_card_auth_card_active;

View File

@@ -0,0 +1,6 @@
-- 为卡企业授权表添加部分唯一索引
-- 约束同一张卡在同一时间只能存在一条有效授权revoked_at IS NULL AND deleted_at IS NULL
-- 已撤回或已删除的历史记录不受此约束影响
CREATE UNIQUE INDEX idx_enterprise_card_auth_card_active
ON tb_enterprise_card_authorization(card_id)
WHERE revoked_at IS NULL AND deleted_at IS NULL;

View File

@@ -9,3 +9,19 @@ const (
AssetStatusExchanged = 3 // 已换货
AssetStatusDeactivated = 4 // 已停用
)
// GetAssetStatusName 返回资产业务状态对应的中文名称
func GetAssetStatusName(status int) string {
switch status {
case AssetStatusInStock:
return "在库"
case AssetStatusSold:
return "已销售"
case AssetStatusExchanged:
return "已换货"
case AssetStatusDeactivated:
return "已停用"
default:
return ""
}
}