Compare commits

2 Commits

Author SHA1 Message Date
Break
46ede81aef 修复佣金错误计算的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m15s
2026-06-03 10:25:49 +08:00
Break
36e68e6672 提案 2026-06-02 16:56:18 +08:00
8 changed files with 303 additions and 8 deletions

View File

@@ -228,7 +228,7 @@ default:
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md)
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md)
- **批量同步**:卡状态、实名状态、流量使用情况
- **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)

View File

@@ -141,13 +141,17 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取销售店铺失败")
}
// 卖家利润 = 实际收款 - 成本价,必须用 ActualPaidAmount 而非 TotalAmount
// TotalAmount 在 fix-order-price-semantics 后始终存零售价,代理自购时会错误产生佣金
var sellerProfit int64
if order.ActualPaidAmount != nil {
sellerProfit = *order.ActualPaidAmount - order.SellerCostPrice
} else {
sellerProfit = order.TotalAmount - order.SellerCostPrice
sellerProfit, shouldCalculateSellerProfit := calculateSellerProfit(order)
if !shouldCalculateSellerProfit {
s.logger.Warn("订单缺少实际支付金额,跳过终端销售代理成本价差佣金计算",
zap.Uint("order_id", order.ID),
zap.String("source", order.Source),
zap.String("payment_method", order.PaymentMethod),
zap.String("buyer_type", order.BuyerType),
zap.String("purchase_role", order.PurchaseRole),
zap.Int64("total_amount", order.TotalAmount),
zap.Int64("seller_cost_price", order.SellerCostPrice),
)
}
if sellerProfit > 0 {
records = append(records, &model.CommissionRecord{
@@ -232,6 +236,28 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
return records, nil
}
// calculateSellerProfit 计算终端销售代理的成本价差收益。
// 代理钱包/代购链路必须以 actual_paid_amount 为准,禁止回退到 total_amount
// 否则在 total_amount 已改成零售价后会再次产生“建议售价-成本价”的错误佣金。
func calculateSellerProfit(order *model.Order) (int64, bool) {
if order == nil {
return 0, false
}
if order.ActualPaidAmount != nil {
return *order.ActualPaidAmount - order.SellerCostPrice, true
}
if order.BuyerType == model.BuyerTypeAgent ||
order.PaymentMethod == model.PaymentMethodWallet ||
order.IsPurchaseOnBehalf ||
order.Source == constants.OrderSourceAdmin {
return 0, false
}
return order.TotalAmount - order.SellerCostPrice, true
}
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
return nil

View File

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

View File

@@ -0,0 +1,67 @@
## Context
现有代理开放接口(`internal/service/agent_open_api/service.go`)已实现卡维度的流量查询、状态查询、实名查询和钱包购买。设备维度的 Gateway 操作(切网、重启、恢复出厂)已在 `internal/service/device/gateway_service.go` 中实现,设备级套餐流量查询通过 `PackageUsageStore.ListByCarrier(ctx, "device", deviceID, ...)` 支持。
本次变更在现有 `agent_open_api` service 中扩展设备维度能力,复用已有的 device service 和 package usage store不引入新的数据模型或迁移。
## Goals / Non-Goals
**Goals:**
- 新增 4 个代理开放接口:设备流量查询、切网、重启、恢复出厂
- 复用现有 device service 的 Gateway 调用逻辑,不重复实现
- 权限校验与卡接口保持一致(`shop_id IN SubordinateShopIDs`
- 设备标识符支持虚拟号和 IMEI
**Non-Goals:**
- 不新增数据库表或迁移
- 不修改现有卡接口
- 不支持 SN 作为设备标识符(开放接口只暴露虚拟号/IMEI
- 不支持批量设备操作
## Decisions
### 决策一agent_open_api service 注入 device.Service 依赖
**选择**:在 `agent_open_api.Service` 结构体中新增 `deviceService *device.Service` 字段,通过构造函数注入。
**理由**device service 已封装了 Gateway 调用、审计日志和设备查询逻辑,直接复用避免重复实现。相比新建独立函数,注入 service 更符合项目分层规范。
**替代方案**:在 agent_open_api service 中直接注入 deviceStore 和 gatewayClient自行实现权限校验和 Gateway 调用。缺点是重复了 device service 中已有的审计日志和错误处理逻辑。
### 决策二:设备权限校验方式
**选择**:查询设备后,手动检查 `device.ShopID != nil && SubordinateShopIDs 包含 *device.ShopID`
**理由**`ApplyShopFilter` 作用于 GORM query而 device service 的 `GetByIdentifier` 内部已有自己的查询逻辑。为避免侵入 device service在 agent_open_api service 层做显式权限校验,与卡接口的 `resolveOpenAPICard` 模式一致。
**实现**:新增 `resolveOpenAPIDevice(ctx, deviceNo)` 私有方法,复用 `device.Service.GetDeviceByIdentifier`,然后校验 shop_id。
### 决策三:设备流量查询复用 PackageUsageStore
**选择**:直接在 `agent_open_api.Service` 中调用已注入的 `packageUsageStore.ListByCarrier(ctx, "device", device.ID, &status)`
**理由**`packageUsageStore` 已在 agent_open_api service 中注入(用于卡流量查询),`ListByCarrier` 支持 `"device"` 类型,无需额外改动 store 层。
### 决策四:切网接口调用 device.Service.GatewaySwitchCard
**选择**agent_open_api service 调用 `deviceService.GatewaySwitchCard(ctx, identifier, &dto.SwitchCardRequest{ICCID: req.ICCID})`
**理由**`GatewaySwitchCard` 已包含审计日志记录,开放接口复用可保证操作可追溯。
**注意**`GatewaySwitchCard` 内部通过 `getGatewayDevice` 再次查询设备,存在两次查询。考虑到操作频率低,接受此开销,不做优化。
## Risks / Trade-offs
- **[风险] device service 审计日志中的操作者信息**`GatewaySwitchCard` 等方法通过 `middleware.GetUserIDFromContext` 获取操作者 ID。开放接口认证中间件已将代理账号 ID 写入 `ContextKeyUserID`,审计日志可正确记录操作者。→ 无需额外处理。
- **[风险] 设备标识符两次查询**`resolveOpenAPIDevice` 查一次,`GatewaySwitchCard` 内部再查一次。→ 接受,操作类接口频率低,影响可忽略。
- **[Trade-off] 不支持 SN**admin 接口支持虚拟号/IMEI/SN开放接口只支持虚拟号/IMEI。→ 简化外部接口SN 是内部管理标识,不适合对外暴露。
## Migration Plan
无数据库变更,无需迁移。直接部署新版本即可。
## Open Questions
(无)

View File

@@ -0,0 +1,29 @@
## Why
现有代理开放接口仅支持单卡维度的查询和操作,无法满足代理对设备维度的管理需求。代理需要通过开放接口查询设备套餐内流量、对多卡设备执行切网,以及对设备执行重启和恢复出厂操作。
## What Changes
- **新增** `GET /api/open/v1/devices/traffic` — 查询设备套餐内流量,返回结构与 `/cards/traffic` 一致
- **新增** `POST /api/open/v1/devices/switch-card` — 切网(多卡设备切换到指定 ICCID
- **新增** `POST /api/open/v1/devices/reboot` — 重启设备
- **新增** `POST /api/open/v1/devices/reset` — 恢复出厂设置
## Capabilities
### New Capabilities
- `open-api-device-traffic`: 代理开放接口设备套餐内流量查询,按设备标识符(虚拟号/IMEI查询设备级 PackageUsage返回生效/待生效套餐流量信息
- `open-api-device-operations`: 代理开放接口设备操作包括切网switch-card、重启reboot、恢复出厂reset复用 device service 现有 Gateway 调用,增加代理权限校验
### Modified Capabilities
(无现有 spec 级别的需求变更)
## Impact
- `internal/handler/openapi/handler.go` — 新增 4 个 Handler 方法
- `internal/routes/open.go` — 注册 4 条新路由
- `internal/service/agent_open_api/service.go` — 新增设备流量查询和设备操作业务逻辑,注入 device service 依赖
- `cmd/api/docs.go``cmd/gendocs/main.go` — 同步更新文档生成器
- 权限校验:`device.ShopID IN 代理管辖店铺`,与现有卡权限逻辑一致

View File

@@ -0,0 +1,87 @@
# open-api-device-operations Specification
## ADDED Requirements
### Requirement: 设备切网开放接口
系统 SHALL 提供 `POST /api/open/v1/devices/switch-card` 接口,允许代理对多卡设备执行切网操作(切换到指定 ICCID。请求 body MUST 包含 `device_no`(虚拟号或 IMEI`iccid`(目标卡 ICCID
系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内,否则返回 `CodeForbidden`。权限校验通过后,系统 MUST 复用 `device.Service.GatewaySwitchCard` 执行切网,底层调用 Gateway 接口。
响应 data MUST 为空对象(操作成功即可)。
#### Scenario: 切网成功
- **WHEN** 代理对管辖范围内的多卡设备传入有效目标 ICCID 执行切网
- **THEN** 系统调用 Gateway 切换设备到目标 ICCID返回成功
#### Scenario: 无权限设备切网
- **WHEN** 代理对不在自己管辖店铺范围内的设备执行切网
- **THEN** 系统返回 `CodeForbidden`,不调用 Gateway
#### Scenario: 设备标识符不存在
- **WHEN** 代理传入的 `device_no` 无法解析为任何设备
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"
---
### Requirement: 设备重启开放接口
系统 SHALL 提供 `POST /api/open/v1/devices/reboot` 接口,允许代理对设备执行重启操作。请求 body MUST 包含 `device_no`(虚拟号或 IMEI
系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内,否则返回 `CodeForbidden`。权限校验通过后,系统 MUST 复用 `device.Service.GatewayRebootDevice` 执行重启。
响应 data MUST 为空对象。
#### Scenario: 重启成功
- **WHEN** 代理对管辖范围内的设备执行重启
- **THEN** 系统调用 Gateway 重启设备,返回成功
#### Scenario: 无权限设备重启
- **WHEN** 代理对不在自己管辖店铺范围内的设备执行重启
- **THEN** 系统返回 `CodeForbidden`,不调用 Gateway
---
### Requirement: 设备恢复出厂开放接口
系统 SHALL 提供 `POST /api/open/v1/devices/reset` 接口,允许代理对设备执行恢复出厂设置操作。请求 body MUST 包含 `device_no`(虚拟号或 IMEI
系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内,否则返回 `CodeForbidden`。权限校验通过后,系统 MUST 复用 `device.Service.GatewayResetDevice` 执行恢复出厂。
响应 data MUST 为空对象。
#### Scenario: 恢复出厂成功
- **WHEN** 代理对管辖范围内的设备执行恢复出厂
- **THEN** 系统调用 Gateway 恢复设备出厂设置,返回成功
#### Scenario: 无权限设备恢复出厂
- **WHEN** 代理对不在自己管辖店铺范围内的设备执行恢复出厂
- **THEN** 系统返回 `CodeForbidden`,不调用 Gateway
---
### Requirement: 设备操作开放接口权限校验
系统 SHALL 对所有设备操作开放接口switch-card、reboot、reset统一执行权限校验。校验逻辑 MUST 为:通过设备标识符(虚拟号或 IMEI查询设备若设备不存在或 `device.ShopID` 不在当前代理的 `SubordinateShopIDs` 中,则返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在",不区分"不存在"与"无权限"以防止信息泄露。
#### Scenario: 设备属于代理管辖店铺
- **WHEN** 设备的 `shop_id` 在代理的 `SubordinateShopIDs`
- **THEN** 系统允许执行操作
#### Scenario: 设备不属于代理管辖店铺
- **WHEN** 设备的 `shop_id` 不在代理的 `SubordinateShopIDs`
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"
#### Scenario: 设备 shop_id 为 NULL平台库存
- **WHEN** 设备的 `shop_id` 为 NULL平台库存设备
- **THEN** 系统返回 `CodeForbidden`,代理无权操作平台库存设备

View File

@@ -0,0 +1,53 @@
# open-api-device-traffic Specification
## ADDED Requirements
### Requirement: 设备套餐内流量查询开放接口
系统 SHALL 提供 `GET /api/open/v1/devices/traffic` 接口,按设备标识符查询设备级套餐内流量。请求参数 `device_no` MUST 支持虚拟号或 IMEI 中任一种可解析为设备的标识。系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内(`device.ShopID IN SubordinateShopIDs`),否则返回 `CodeForbidden`
接口 MUST 只返回 `usage_type='device'` 的套餐使用记录,不返回单卡套餐信息。
响应 data MUST 包含:
- `device_no`:请求传入的设备标识符
- `active_total_flow_mb`:当前生效套餐总流量汇总
- `active_used_flow_mb`:当前生效套餐已用流量汇总
- `active_remaining_flow_mb`:当前生效套餐剩余流量汇总
- `active_expires_at`:当前生效套餐中最晚过期时间
- `active_packages`:当前生效套餐列表
- `pending_packages`:待生效套餐列表
流量口径 MUST 与 `/cards/traffic` 保持一致:
- 总流量使用 `data_limit_mb`
- 已用流量使用 `min(data_usage_mb * display_gain_ratio_snapshot, data_limit_mb)`
- 剩余流量使用 `max(data_limit_mb - used_flow_mb, 0)`
- 响应 MUST NOT 包含虚流量、停机阈值等内部字段
`active_packages` 每项 MUST 包含:`package_code``total_flow_mb``used_flow_mb``remaining_flow_mb``expires_at``start_at``package_name``series_name``package_type``package_type_name`
`pending_packages` 每项 MUST 包含:`package_code``total_flow_mb``package_name``series_name``package_type``package_type_name``valid_days``priority`
#### Scenario: 查询有生效套餐的设备流量
- **WHEN** 代理查询自己管辖范围内的设备,且该设备存在生效中的设备级套餐
- **THEN** 系统返回生效套餐列表、总流量、已用流量、剩余流量和最晚过期时间
#### Scenario: 查询待生效套餐
- **WHEN** 代理查询设备流量,且该设备存在待生效的设备级套餐
- **THEN** 系统在 `pending_packages` 中返回套餐编码、真总流量、套餐名称、系列名称、套餐类型、有效天数和生效优先级
#### Scenario: 查询无套餐的设备
- **WHEN** 代理查询有权限但当前无生效或待生效设备级套餐的设备
- **THEN** 系统返回空套餐列表,流量数值为 0
#### Scenario: 无权限设备
- **WHEN** 代理查询不在自己管辖店铺范围内的设备
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"
#### Scenario: 设备标识符不存在
- **WHEN** 代理传入的 `device_no` 无法解析为任何设备
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"

View File

@@ -0,0 +1,31 @@
## 1. DTO 定义
- [x] 1.1 在 `internal/model/dto/` 新增设备开放接口请求/响应 DTO`AgentOpenAPIDeviceQueryRequest`(含 `device_no` 字段)、`AgentOpenAPIDeviceSwitchCardRequest`(含 `device_no``iccid` 字段)、`AgentOpenAPIDeviceOperationRequest`(含 `device_no` 字段,用于 reboot/reset`AgentOpenAPIDeviceTrafficResponse`(结构与 `AgentOpenAPICardTrafficResponse` 一致,`card_no` 换成 `device_no`
## 2. Service 层
- [x] 2.1 在 `agent_open_api.Service` 结构体中新增 `deviceService *device.Service` 字段,更新 `New()` 构造函数签名,在 `internal/bootstrap/services.go``agentOpenAPISvc.New(...)` 调用处传入 `s.Device`
- [x] 2.2 在 `agent_open_api` service 中新增私有方法 `resolveOpenAPIDevice(ctx, deviceNo)`:调用 `deviceService.GetDeviceByIdentifier`,校验 `device.ShopID IN SubordinateShopIDs`,不存在或无权限统一返回 `CodeForbidden`
- [x] 2.3 实现 `GetDeviceTraffic(ctx, req)`:调用 `resolveOpenAPIDevice` 获取设备,通过 `packageUsageStore.ListByCarrier(ctx, "device", device.ID, &activeStatus)``ListByCarrier(ctx, "device", device.ID, &pendingStatus)` 查询套餐,复用 `loadUsagePackageContext``buildTrafficItem`,组装 `AgentOpenAPIDeviceTrafficResponse`
- [x] 2.4 实现 `SwitchDeviceCard(ctx, req)`:调用 `resolveOpenAPIDevice` 校验权限,再调用 `deviceService.GatewaySwitchCard(ctx, req.DeviceNo, &dto.SwitchCardRequest{ICCID: req.ICCID})`
- [x] 2.5 实现 `RebootDevice(ctx, req)`:调用 `resolveOpenAPIDevice` 校验权限,再调用 `deviceService.GatewayRebootDevice(ctx, req.DeviceNo)`
- [x] 2.6 实现 `ResetDevice(ctx, req)`:调用 `resolveOpenAPIDevice` 校验权限,再调用 `deviceService.GatewayResetDevice(ctx, req.DeviceNo)`
## 3. Handler 层
- [x] 3.1 在 `internal/handler/openapi/handler.go` 新增 `GetDeviceTraffic` Handler 方法GETQueryParser 解析 `AgentOpenAPIDeviceQueryRequest`
- [x] 3.2 新增 `SwitchDeviceCard` Handler 方法POSTBodyParser 解析 `AgentOpenAPIDeviceSwitchCardRequest`
- [x] 3.3 新增 `RebootDevice` Handler 方法POSTBodyParser 解析 `AgentOpenAPIDeviceOperationRequest`
- [x] 3.4 新增 `ResetDevice` Handler 方法POSTBodyParser 解析 `AgentOpenAPIDeviceOperationRequest`
## 4. 路由注册
- [x] 4.1 在 `internal/routes/open.go``RegisterOpenAPIRoutes` 中注册 4 条新路由:`GET /devices/traffic``POST /devices/switch-card``POST /devices/reboot``POST /devices/reset`,补充 Summary、Description含 authDescription、Input/Output、Tags、Auth、SecurityScheme
## 5. 文档生成器更新
- [x] 5.1 在 `cmd/api/docs.go``cmd/gendocs/main.go``bootstrap.Handlers{}` 初始化中,将 `AgentOpenAPI` 字段更新为 `openapiHandler.NewHandler(nil, nil)`(或对应的空值初始化),确保新增的 4 个 Handler 方法被文档生成器扫描到
## 6. 编译验证
- [x] 6.1 运行 `go build ./...` 确认无编译错误