From 3b7b856e480d38de631ce51bd0546014b604087c Mon Sep 17 00:00:00 2001 From: break Date: Wed, 17 Jun 2026 16:23:03 +0900 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E6=8E=88=E6=9D=83=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E4=B8=8E=E8=B5=84=E4=BA=A7=E5=88=97=E8=A1=A8=E6=89=A9?= =?UTF-8?q?=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 企业卡授权唯一约束:新增 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 --- .gitignore | 3 + .../PRD.md | 143 ++++++++++++ ...-card-enterprise-auth-unique-constraint.md | 28 +++ ...2-standalone-card-network-status-filter.md | 22 ++ .../issues/03-asset-exchange-status-fields.md | 34 +++ .../04-main-wallet-asset-identifier-filter.md | 23 ++ .../05-refund-asset-identifier-filter.md | 23 ++ ...-standalone-card-list-enterprise-filter.md | 33 +++ .../07-device-list-enterprise-filter.md | 32 +++ .../08-enterprise-card-auth-multi-mode.md | 42 ++++ .../09-enterprise-device-auth-multi-mode.md | 38 ++++ CONTEXT.md | 19 ++ docs/admin-openapi.yaml | 208 ++++++++++++++++-- go.mod | 8 +- go.sum | 4 + internal/bootstrap/services.go | 4 + internal/model/dto/device_dto.go | 13 +- .../dto/enterprise_card_authorization_dto.go | 24 +- .../enterprise_device_authorization_dto.go | 19 +- internal/model/dto/iot_card_dto.go | 14 +- internal/model/dto/refund_dto.go | 11 +- internal/model/dto/shop_commission_dto.go | 1 + internal/service/device/service.go | 89 +++++--- internal/service/enterprise_card/service.go | 128 ++++++++++- internal/service/enterprise_device/service.go | 81 ++++++- internal/service/iot_card/service.go | 60 +++++ internal/service/refund/service.go | 7 +- internal/service/shop_commission/service.go | 1 + .../agent_wallet_transaction_store.go | 5 + internal/store/postgres/device_store.go | 23 +- .../enterprise_card_authorization_store.go | 47 ++++ internal/store/postgres/enterprise_store.go | 24 ++ internal/store/postgres/iot_card_store.go | 100 +++++++++ internal/store/postgres/refund_store.go | 10 +- ...prise_card_auth_unique_constraint.down.sql | 1 + ...erprise_card_auth_unique_constraint.up.sql | 6 + pkg/constants/asset_status.go | 16 ++ 37 files changed, 1260 insertions(+), 84 deletions(-) create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/PRD.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/01-card-enterprise-auth-unique-constraint.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/02-standalone-card-network-status-filter.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/03-asset-exchange-status-fields.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/04-main-wallet-asset-identifier-filter.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/05-refund-asset-identifier-filter.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/06-standalone-card-list-enterprise-filter.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/07-device-list-enterprise-filter.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/08-enterprise-card-auth-multi-mode.md create mode 100644 .scratch/enterprise-auth-and-asset-enhancements/issues/09-enterprise-device-auth-multi-mode.md create mode 100644 CONTEXT.md create mode 100644 migrations/000154_enterprise_card_auth_unique_constraint.down.sql create mode 100644 migrations/000154_enterprise_card_auth_unique_constraint.up.sql diff --git a/.gitignore b/.gitignore index 6e68cd6..93405bb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.scratch/enterprise-auth-and-asset-enhancements/PRD.md b/.scratch/enterprise-auth-and-asset-enhancements/PRD.md new file mode 100644 index 0000000..339eaa2 --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/PRD.md @@ -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 在执行授权前,需增加"卡是否已授权给其他企业"的校验,目前只校验同一企业重复授权 + +### 需求1:standalone 卡列表 + 设备列表企业字段 + +**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 或虚拟号),空字符串时不过滤 + +### 需求6:standalone 卡列表网络状态过滤 + +`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` 的记录,若有需先清理 diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/01-card-enterprise-auth-unique-constraint.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/01-card-enterprise-auth-unique-constraint.md new file mode 100644 index 0000000..c550736 --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/01-card-enterprise-auth-unique-constraint.md @@ -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 diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/02-standalone-card-network-status-filter.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/02-standalone-card-network-status-filter.md new file mode 100644 index 0000000..ad0e5e8 --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/02-standalone-card-network-status-filter.md @@ -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 diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/03-asset-exchange-status-fields.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/03-asset-exchange-status-fields.md new file mode 100644 index 0000000..7f91158 --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/03-asset-exchange-status-fields.md @@ -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 diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/04-main-wallet-asset-identifier-filter.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/04-main-wallet-asset-identifier-filter.md new file mode 100644 index 0000000..229ba8c --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/04-main-wallet-asset-identifier-filter.md @@ -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 diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/05-refund-asset-identifier-filter.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/05-refund-asset-identifier-filter.md new file mode 100644 index 0000000..ff754ba --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/05-refund-asset-identifier-filter.md @@ -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 diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/06-standalone-card-list-enterprise-filter.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/06-standalone-card-list-enterprise-filter.md new file mode 100644 index 0000000..4c8186f --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/06-standalone-card-list-enterprise-filter.md @@ -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` diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/07-device-list-enterprise-filter.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/07-device-list-enterprise-filter.md new file mode 100644 index 0000000..8d397f2 --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/07-device-list-enterprise-filter.md @@ -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 diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/08-enterprise-card-auth-multi-mode.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/08-enterprise-card-auth-multi-mode.md new file mode 100644 index 0000000..c67d338 --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/08-enterprise-card-auth-multi-mode.md @@ -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` diff --git a/.scratch/enterprise-auth-and-asset-enhancements/issues/09-enterprise-device-auth-multi-mode.md b/.scratch/enterprise-auth-and-asset-enhancements/issues/09-enterprise-device-auth-multi-mode.md new file mode 100644 index 0000000..85d58ab --- /dev/null +++ b/.scratch/enterprise-auth-and-asset-enhancements/issues/09-enterprise-device-auth-multi-mode.md @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8799c64 --- /dev/null +++ b/CONTEXT.md @@ -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` 唯一约束)。 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index df2d4d6..977d951 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -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 diff --git a/go.mod b/go.mod index 3e90e04..f58dc27 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 4bb1395..79d9f97 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 14c3c8e..523ea18 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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) diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 1c3ccd3..1029835 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -18,8 +18,10 @@ type ListDeviceRequest struct { Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"` CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` 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独立)"` + 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 { diff --git a/internal/model/dto/enterprise_card_authorization_dto.go b/internal/model/dto/enterprise_card_authorization_dto.go index ee6f634..c654126 100644 --- a/internal/model/dto/enterprise_card_authorization_dto.go +++ b/internal/model/dto/enterprise_card_authorization_dto.go @@ -33,9 +33,17 @@ 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:"授权备注"` + ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"` + 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 { @@ -45,8 +53,14 @@ 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 列表"` + ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"` + 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 { diff --git a/internal/model/dto/enterprise_device_authorization_dto.go b/internal/model/dto/enterprise_device_authorization_dto.go index c7d7f9a..50c2a04 100644 --- a/internal/model/dto/enterprise_device_authorization_dto.go +++ b/internal/model/dto/enterprise_device_authorization_dto.go @@ -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:"授权备注"` + ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"` + 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个)"` + ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"` + 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 { diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index da23a32..d26d9d0 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -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,8 +65,15 @@ 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:先充值/购买后实名)"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` - UpdatedAt time.Time `json:"updated_at" description:"更新时间"` + // 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:"更新时间"` } type ListStandaloneIotCardResponse struct { diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index 411a48c..ef0b48c 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -46,11 +46,12 @@ type ReturnRefundRequest struct { // RefundListRequest 退款申请列表查询请求 type RefundListRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` - 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"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` + 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 退款申请详情响应 diff --git a/internal/model/dto/shop_commission_dto.go b/internal/model/dto/shop_commission_dto.go index a44710f..20ba0ca 100644 --- a/internal/model/dto/shop_commission_dto.go +++ b/internal/model/dto/shop_commission_dto.go @@ -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 主钱包流水记录项 diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 387cfea..38ea715 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -20,19 +20,21 @@ import ( ) type Service struct { - db *gorm.DB - redis *redis.Client - deviceStore *postgres.DeviceStore - deviceSimBindingStore *postgres.DeviceSimBindingStore - iotCardStore *postgres.IotCardStore - shopStore *postgres.ShopStore - assetAllocationRecordStore *postgres.AssetAllocationRecordStore - shopPackageAllocationStore *postgres.ShopPackageAllocationStore - shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore - packageSeriesStore *postgres.PackageSeriesStore - gatewayClient *gateway.Client - assetIdentifierStore *postgres.AssetIdentifierStore - assetAuditService AssetAuditService + db *gorm.DB + redis *redis.Client + deviceStore *postgres.DeviceStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + iotCardStore *postgres.IotCardStore + shopStore *postgres.ShopStore + assetAllocationRecordStore *postgres.AssetAllocationRecordStore + shopPackageAllocationStore *postgres.ShopPackageAllocationStore + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + packageSeriesStore *postgres.PackageSeriesStore + gatewayClient *gateway.Client + assetIdentifierStore *postgres.AssetIdentifierStore + assetAuditService AssetAuditService + enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore + enterpriseStore *postgres.EnterpriseStore } func New( @@ -49,21 +51,25 @@ func New( gatewayClient *gateway.Client, assetIdentifierStore *postgres.AssetIdentifierStore, assetAuditService AssetAuditService, + enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore, + enterpriseStore *postgres.EnterpriseStore, ) *Service { return &Service{ - db: db, - redis: rds, - deviceStore: deviceStore, - deviceSimBindingStore: deviceSimBindingStore, - iotCardStore: iotCardStore, - shopStore: shopStore, - assetAllocationRecordStore: assetAllocationRecordStore, - shopPackageAllocationStore: shopPackageAllocationStore, - shopSeriesAllocationStore: shopSeriesAllocationStore, - packageSeriesStore: packageSeriesStore, - gatewayClient: gatewayClient, - assetIdentifierStore: assetIdentifierStore, - assetAuditService: assetAuditService, + db: db, + redis: rds, + deviceStore: deviceStore, + deviceSimBindingStore: deviceSimBindingStore, + iotCardStore: iotCardStore, + shopStore: shopStore, + assetAllocationRecordStore: assetAllocationRecordStore, + shopPackageAllocationStore: shopPackageAllocationStore, + shopSeriesAllocationStore: shopSeriesAllocationStore, + packageSeriesStore: packageSeriesStore, + 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 { diff --git a/internal/service/enterprise_card/service.go b/internal/service/enterprise_card/service.go index d09792d..b8d3f29 100644 --- a/internal/service/enterprise_card/service.go +++ b/internal/service/enterprise_card/service.go @@ -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{ diff --git a/internal/service/enterprise_device/service.go b/internal/service/enterprise_device/service.go index 60bf62d..efe454c 100644 --- a/internal/service/enterprise_device/service.go +++ b/internal/service/enterprise_device/service.go @@ -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{ diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index f5c3c0d..afe7428 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -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, } diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 1fa9c97..829372a 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -160,9 +160,10 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re } filters := &postgres.RefundListFilters{ - Status: req.Status, - OrderID: req.OrderID, - ShopID: req.ShopID, + Status: req.Status, + OrderID: req.OrderID, + ShopID: req.ShopID, + AssetIdentifier: req.AssetIdentifier, } requests, total, err := s.refundStore.List(ctx, opts, filters) diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index abba4d5..4cd2126 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -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 diff --git a/internal/store/postgres/agent_wallet_transaction_store.go b/internal/store/postgres/agent_wallet_transaction_store.go index 29b69c2..1b522ee 100644 --- a/internal/store/postgres/agent_wallet_transaction_store.go +++ b/internal/store/postgres/agent_wallet_transaction_store.go @@ -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 } diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 3653af7..94c72bf 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -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 } diff --git a/internal/store/postgres/enterprise_card_authorization_store.go b/internal/store/postgres/enterprise_card_authorization_store.go index 61bc5c9..1a88beb 100644 --- a/internal/store/postgres/enterprise_card_authorization_store.go +++ b/internal/store/postgres/enterprise_card_authorization_store.go @@ -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 { diff --git a/internal/store/postgres/enterprise_store.go b/internal/store/postgres/enterprise_store.go index 6380454..de48973 100644 --- a/internal/store/postgres/enterprise_store.go +++ b/internal/store/postgres/enterprise_store.go @@ -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 +} diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index c2b4d35..6250200 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -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 diff --git a/internal/store/postgres/refund_store.go b/internal/store/postgres/refund_store.go index 41932e3..153b227 100644 --- a/internal/store/postgres/refund_store.go +++ b/internal/store/postgres/refund_store.go @@ -12,9 +12,10 @@ import ( // RefundListFilters 退款列表筛选条件 type RefundListFilters struct { - Status *int `json:"status"` - OrderID *uint `json:"order_id"` - ShopID *uint `json:"shop_id"` + 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 { diff --git a/migrations/000154_enterprise_card_auth_unique_constraint.down.sql b/migrations/000154_enterprise_card_auth_unique_constraint.down.sql new file mode 100644 index 0000000..210b247 --- /dev/null +++ b/migrations/000154_enterprise_card_auth_unique_constraint.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_enterprise_card_auth_card_active; diff --git a/migrations/000154_enterprise_card_auth_unique_constraint.up.sql b/migrations/000154_enterprise_card_auth_unique_constraint.up.sql new file mode 100644 index 0000000..807f39d --- /dev/null +++ b/migrations/000154_enterprise_card_auth_unique_constraint.up.sql @@ -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; diff --git a/pkg/constants/asset_status.go b/pkg/constants/asset_status.go index 8393bd0..3b473e0 100644 --- a/pkg/constants/asset_status.go +++ b/pkg/constants/asset_status.go @@ -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 "" + } +}