Compare commits
3 Commits
716e9f5971
...
4b8784d5e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8784d5e7 | |||
| 2413c2fe14 | |||
| e52671eaed |
@@ -4268,6 +4268,30 @@ components:
|
||||
description: 总订单数
|
||||
type: integer
|
||||
type: object
|
||||
DtoGetBatchDownloadURLsRequest:
|
||||
properties:
|
||||
file_keys:
|
||||
description: 文件路径标识列表,最多 50 个
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- file_keys
|
||||
type: object
|
||||
DtoGetBatchDownloadURLsResponse:
|
||||
properties:
|
||||
expires_in:
|
||||
description: URL 有效期(秒)
|
||||
type: integer
|
||||
urls:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: file_key 到预签名下载 URL 的映射
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
DtoGetUploadURLRequest:
|
||||
properties:
|
||||
content_type:
|
||||
@@ -21150,6 +21174,142 @@ paths:
|
||||
summary: 代理商资金概况
|
||||
tags:
|
||||
- 代理商资金管理
|
||||
/api/admin/storage/batch-download-urls:
|
||||
post:
|
||||
description: |-
|
||||
## 文件展示流程
|
||||
|
||||
本接口用于批量获取对象存储文件的预签名下载 URL。由于存储桶为私有访问,文件不能直接通过路径访问,必须通过预签名 URL 才能展示。
|
||||
|
||||
### 为什么需要这个接口
|
||||
|
||||
对象存储使用私有 Bucket,文件路径(file_key)本身无法直接访问。前端持有的 file_key 只是文件标识,需要通过本接口换取带签名的临时访问 URL,才能在 `<img>` 或下载链接中使用。
|
||||
|
||||
### 完整使用流程
|
||||
|
||||
1. **业务接口返回 file_key**(存储在数据库中的文件路径标识)
|
||||
2. **调用本接口** 传入所有需要展示的 file_key 列表
|
||||
3. **使用返回的 URL** 直接赋值给 `<img src>` 或下载链接,图片/文件内容直接从对象存储加载,不经过后端
|
||||
|
||||
### 列表页批量展示示例
|
||||
|
||||
```javascript
|
||||
// 假设列表数据中每条记录有 avatar_key 字段
|
||||
const listData = await api.get('/customers');
|
||||
|
||||
// 收集所有 file_key(过滤掉空值)
|
||||
const fileKeys = listData.map(item => item.avatar_key).filter(Boolean);
|
||||
|
||||
// 一次性换取所有下载 URL
|
||||
const { data } = await api.post('/storage/batch-download-urls', {
|
||||
file_keys: fileKeys
|
||||
});
|
||||
|
||||
// 渲染时通过 map 取对应 URL
|
||||
listData.forEach(item => {
|
||||
item.avatarUrl = data.urls[item.avatar_key] ?? '';
|
||||
});
|
||||
```
|
||||
|
||||
### 单个文件展示示例
|
||||
|
||||
```javascript
|
||||
// 详情页只有一个文件时,也走批量接口(保持一致)
|
||||
const { data } = await api.post('/storage/batch-download-urls', {
|
||||
file_keys: [record.license_key]
|
||||
});
|
||||
|
||||
const licenseUrl = data.urls[record.license_key];
|
||||
```
|
||||
|
||||
### 前端缓存建议
|
||||
|
||||
预签名 URL 有效期 **24 小时**,建议在内存中缓存,避免同一页面重复请求:
|
||||
|
||||
```javascript
|
||||
const urlCache = new Map(); // key: file_key, value: { url, expireAt }
|
||||
|
||||
async function getFileUrl(fileKey) {
|
||||
const cached = urlCache.get(fileKey);
|
||||
if (cached && cached.expireAt > Date.now()) {
|
||||
return cached.url;
|
||||
}
|
||||
const { data } = await api.post('/storage/batch-download-urls', {
|
||||
file_keys: [fileKey]
|
||||
});
|
||||
const url = data.urls[fileKey];
|
||||
urlCache.set(fileKey, { url, expireAt: Date.now() + data.expires_in * 1000 * 0.9 });
|
||||
return url;
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 单次请求最多 **50 个** file_key
|
||||
- 预签名 URL 有效期 **24 小时**,过期后需重新获取
|
||||
- file_key 不存在时对应的 URL 生成会失败,整个请求返回错误
|
||||
- 图片/文件内容直接从对象存储加载到浏览器,**不经过后端**,不消耗服务器带宽
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoGetBatchDownloadURLsRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoGetBatchDownloadURLsResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 批量获取文件下载预签名 URL
|
||||
tags:
|
||||
- 对象存储
|
||||
/api/admin/storage/upload-url:
|
||||
post:
|
||||
description: |-
|
||||
|
||||
@@ -19,6 +19,42 @@ func NewStorageHandler(service *storage.Service) *StorageHandler {
|
||||
return &StorageHandler{service: service}
|
||||
}
|
||||
|
||||
// GetBatchDownloadURLs 批量获取文件下载预签名 URL
|
||||
// POST /api/admin/storage/batch-download-urls
|
||||
func (h *StorageHandler) GetBatchDownloadURLs(c *fiber.Ctx) error {
|
||||
if h.service == nil {
|
||||
return errors.New(errors.CodeInternalError, "对象存储服务未配置")
|
||||
}
|
||||
|
||||
var req dto.GetBatchDownloadURLsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
urls := make(map[string]string, len(req.FileKeys))
|
||||
expiresIn := 0
|
||||
|
||||
for _, fileKey := range req.FileKeys {
|
||||
result, err := h.service.GetDownloadURL(c.UserContext(), fileKey)
|
||||
if err != nil {
|
||||
logger.GetAppLogger().Error("获取下载URL失败",
|
||||
zap.String("file_key", fileKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeInternalError, "获取下载URL失败")
|
||||
}
|
||||
urls[fileKey] = result.URL
|
||||
expiresIn = result.ExpiresIn
|
||||
}
|
||||
|
||||
return response.Success(c, dto.GetBatchDownloadURLsResponse{
|
||||
URLs: urls,
|
||||
ExpiresIn: expiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUploadURL 获取文件上传预签名 URL
|
||||
// POST /api/admin/storage/upload-url
|
||||
func (h *StorageHandler) GetUploadURL(c *fiber.Ctx) error {
|
||||
if h.service == nil {
|
||||
return errors.New(errors.CodeInternalError, "对象存储服务未配置")
|
||||
|
||||
@@ -11,3 +11,14 @@ type GetUploadURLResponse struct {
|
||||
FileKey string `json:"file_key" description:"文件路径标识,上传成功后用于调用业务接口"`
|
||||
ExpiresIn int `json:"expires_in" description:"URL 有效期(秒)"`
|
||||
}
|
||||
|
||||
// GetBatchDownloadURLsRequest 批量获取文件下载预签名 URL 请求
|
||||
type GetBatchDownloadURLsRequest struct {
|
||||
FileKeys []string `json:"file_keys" validate:"required,min=1,max=50,dive,required" required:"true" minLength:"1" description:"文件路径标识列表,最多 50 个"`
|
||||
}
|
||||
|
||||
// GetBatchDownloadURLsResponse 批量获取文件下载预签名 URL 响应
|
||||
type GetBatchDownloadURLsResponse struct {
|
||||
URLs map[string]string `json:"urls" description:"file_key 到预签名下载 URL 的映射"`
|
||||
ExpiresIn int `json:"expires_in" description:"URL 有效期(秒)"`
|
||||
}
|
||||
|
||||
@@ -12,6 +12,86 @@ func registerStorageRoutes(router fiber.Router, handler *admin.StorageHandler, d
|
||||
storage := router.Group("/storage")
|
||||
groupPath := basePath + "/storage"
|
||||
|
||||
Register(storage, doc, groupPath, "POST", "/batch-download-urls", handler.GetBatchDownloadURLs, RouteSpec{
|
||||
Summary: "批量获取文件下载预签名 URL",
|
||||
Description: `## 文件展示流程
|
||||
|
||||
本接口用于批量获取对象存储文件的预签名下载 URL。由于存储桶为私有访问,文件不能直接通过路径访问,必须通过预签名 URL 才能展示。
|
||||
|
||||
### 为什么需要这个接口
|
||||
|
||||
对象存储使用私有 Bucket,文件路径(file_key)本身无法直接访问。前端持有的 file_key 只是文件标识,需要通过本接口换取带签名的临时访问 URL,才能在 ` + "`" + `<img>` + "`" + ` 或下载链接中使用。
|
||||
|
||||
### 完整使用流程
|
||||
|
||||
1. **业务接口返回 file_key**(存储在数据库中的文件路径标识)
|
||||
2. **调用本接口** 传入所有需要展示的 file_key 列表
|
||||
3. **使用返回的 URL** 直接赋值给 ` + "`" + `<img src>` + "`" + ` 或下载链接,图片/文件内容直接从对象存储加载,不经过后端
|
||||
|
||||
### 列表页批量展示示例
|
||||
|
||||
` + "```" + `javascript
|
||||
// 假设列表数据中每条记录有 avatar_key 字段
|
||||
const listData = await api.get('/customers');
|
||||
|
||||
// 收集所有 file_key(过滤掉空值)
|
||||
const fileKeys = listData.map(item => item.avatar_key).filter(Boolean);
|
||||
|
||||
// 一次性换取所有下载 URL
|
||||
const { data } = await api.post('/storage/batch-download-urls', {
|
||||
file_keys: fileKeys
|
||||
});
|
||||
|
||||
// 渲染时通过 map 取对应 URL
|
||||
listData.forEach(item => {
|
||||
item.avatarUrl = data.urls[item.avatar_key] ?? '';
|
||||
});
|
||||
` + "```" + `
|
||||
|
||||
### 单个文件展示示例
|
||||
|
||||
` + "```" + `javascript
|
||||
// 详情页只有一个文件时,也走批量接口(保持一致)
|
||||
const { data } = await api.post('/storage/batch-download-urls', {
|
||||
file_keys: [record.license_key]
|
||||
});
|
||||
|
||||
const licenseUrl = data.urls[record.license_key];
|
||||
` + "```" + `
|
||||
|
||||
### 前端缓存建议
|
||||
|
||||
预签名 URL 有效期 **24 小时**,建议在内存中缓存,避免同一页面重复请求:
|
||||
|
||||
` + "```" + `javascript
|
||||
const urlCache = new Map(); // key: file_key, value: { url, expireAt }
|
||||
|
||||
async function getFileUrl(fileKey) {
|
||||
const cached = urlCache.get(fileKey);
|
||||
if (cached && cached.expireAt > Date.now()) {
|
||||
return cached.url;
|
||||
}
|
||||
const { data } = await api.post('/storage/batch-download-urls', {
|
||||
file_keys: [fileKey]
|
||||
});
|
||||
const url = data.urls[fileKey];
|
||||
urlCache.set(fileKey, { url, expireAt: Date.now() + data.expires_in * 1000 * 0.9 });
|
||||
return url;
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 单次请求最多 **50 个** file_key
|
||||
- 预签名 URL 有效期 **24 小时**,过期后需重新获取
|
||||
- file_key 不存在时对应的 URL 生成会失败,整个请求返回错误
|
||||
- 图片/文件内容直接从对象存储加载到浏览器,**不经过后端**,不消耗服务器带宽`,
|
||||
Tags: []string{"对象存储"},
|
||||
Input: new(dto.GetBatchDownloadURLsRequest),
|
||||
Output: new(dto.GetBatchDownloadURLsResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(storage, doc, groupPath, "POST", "/upload-url", handler.GetUploadURL, RouteSpec{
|
||||
Summary: "获取文件上传预签名 URL",
|
||||
Description: `## 文件上传流程
|
||||
|
||||
@@ -970,3 +970,86 @@ func (h *RealnameActivationHandler) HandleRealnameActivation(ctx context.Context
|
||||
- ✅ 性能指标和错误码定义
|
||||
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||||
- ✅ 测试场景矩阵和实现参考
|
||||
|
||||
---
|
||||
|
||||
## 变更历史:fix-realname-activation-logic
|
||||
|
||||
### MODIFIED: 支持未实名状态购买套餐
|
||||
|
||||
系统 SHALL 允许后台管理端为未实名的载体(设备/卡)购买套餐,套餐状态为"待生效"(status=0)。对于 `expiry_base=from_activation` 的套餐,系统 SHALL 在创建 `PackageUsage` 时检查载体当前实名状态:若已实名则直接激活;若未实名则标记 `pending_realname_activation=true` 等待实名触发。
|
||||
|
||||
**实名状态判定规则**:
|
||||
- `iot_card` 载体:查询该卡的 `real_name_status` 字段
|
||||
- `device` 载体:通过 `tb_device_sim_binding`(`bind_status=1`)查询是否存在任意已实名(`real_name_status=1`)的绑定卡;存在任意一张即视为设备已实名
|
||||
|
||||
#### Scenario: 后台为未实名设备购买 from_activation 套餐——待生效
|
||||
- **GIVEN** 设备 ID=2,绑定的所有卡 `real_name_status=0`(均未实名)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该设备购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=0`,`pending_realname_activation=true`,`activated_at=NULL`,`expires_at=NULL`
|
||||
|
||||
#### Scenario: 后台为已有实名卡的设备购买 from_activation 套餐——直接激活并触发复机
|
||||
- **GIVEN** 设备 ID=2,至少一张绑定卡 `real_name_status=1`(已实名),且设备下有卡因无套餐停机(`stop_reason=no_package`)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该设备购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=1`,`pending_realname_activation=false`,`activated_at=购买时间`,`expires_at` 按套餐配置计算
|
||||
- **AND** `tryResumeAfterPayment` 检测到 `activatedCount=1`,异步调用 `ResumeCardIfStopped("device", 2)`
|
||||
- **AND** 设备下已实名且因 `no_package` 停机的卡自动开机
|
||||
|
||||
#### Scenario: 后台为已实名单卡购买 from_activation 套餐——直接激活
|
||||
- **GIVEN** IoT 卡 ID=10,`real_name_status=1`(已实名)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该卡购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=1`,`pending_realname_activation=false`,`activated_at=购买时间`,`expires_at` 按套餐配置计算
|
||||
|
||||
#### Scenario: 后台为未实名单卡购买 from_activation 套餐——待生效
|
||||
- **GIVEN** IoT 卡 ID=10,`real_name_status=0`(未实名)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该卡购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=0`,`pending_realname_activation=true`,`activated_at=NULL`,`expires_at=NULL`
|
||||
|
||||
#### Scenario: from_purchase 套餐不检查实名状态——始终直接激活
|
||||
- **GIVEN** 设备 ID=2,所有绑定卡均未实名(`real_name_status=0`)
|
||||
- **AND** 套餐 `expiry_base=from_purchase`
|
||||
- **WHEN** 管理员通过后台为该设备购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=1`,`pending_realname_activation=false`,立即生效
|
||||
|
||||
#### Scenario: 客户端购买套餐——必须先实名(不受本次变更影响)
|
||||
- **GIVEN** 设备 ID=2,购买者为个人客户(C 端)
|
||||
- **WHEN** 客户通过 H5/小程序购买套餐
|
||||
- **THEN** 系统前置检查已实名,行为不变
|
||||
|
||||
---
|
||||
|
||||
### ADDED: 卡首次实名时同时触发所属设备的设备级套餐激活
|
||||
|
||||
当轮询系统检测到某张 IoT 卡的实名状态从 0 变为 1(首次实名),系统 SHALL 除提交该卡的 `TaskTypePackageFirstActivation` 任务外,额外查询该卡是否属于某个设备(通过 `tb_device_sim_binding`),若存在有效绑定则同时提交以该设备为载体(`carrier_type=device`)的 `TaskTypePackageFirstActivation` 任务。
|
||||
|
||||
**设计约束**:
|
||||
- 若卡未绑定任何设备(`GetActiveBindingByCardID` 返回 `ErrRecordNotFound`)则静默跳过,不报错
|
||||
- 提交任务失败记录 Warn 日志,不阻塞卡级激活流程
|
||||
- 任务幂等:`ActivateByRealname(ctx, "device", deviceID)` 若查不到 `pending_realname_activation=true` 的套餐则直接返回 nil
|
||||
|
||||
#### Scenario: 设备下某张卡首次实名——同时触发设备级套餐激活与复机
|
||||
- **GIVEN** IoT 卡 ID=4,绑定于设备 ID=2(`tb_device_sim_binding` 中存在有效绑定)
|
||||
- **AND** 设备 ID=2 存在 `status=0, pending_realname_activation=true` 的设备级 PackageUsage
|
||||
- **AND** 设备下有卡因 `not_realname` 停机(`stop_reason=not_realname`)
|
||||
- **WHEN** 轮询系统检测到卡 ID=4 实名状态 0→1
|
||||
- **THEN** 系统提交两个 Asynq 任务:
|
||||
1. `carrier_type=iot_card, carrier_id=4`(卡级激活)
|
||||
2. `carrier_type=device, carrier_id=2`(设备级激活)
|
||||
- **AND** 设备级 PackageUsage 最终 `status=1, pending_realname_activation=false, activated_at` 已设置
|
||||
- **AND** `ActivateByRealname` 激活成功后异步调用 `ResumeCardIfStopped("device", 2)`,设备下已实名的停机卡自动开机
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)首次实名——仅触发卡级激活
|
||||
- **GIVEN** IoT 卡 ID=10,未绑定任何设备(`tb_device_sim_binding` 中无有效记录)
|
||||
- **WHEN** 轮询系统检测到卡 ID=10 实名状态 0→1
|
||||
- **THEN** 系统仅提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=10`)
|
||||
- **AND** 不报错,不记录 Error 日志
|
||||
|
||||
#### Scenario: 设备下某张卡实名但设备无待激活套餐——任务执行幂等返回
|
||||
- **GIVEN** IoT 卡 ID=4,绑定于设备 ID=2
|
||||
- **AND** 设备 ID=2 无 `pending_realname_activation=true` 的套餐
|
||||
- **WHEN** 轮询系统检测到卡 ID=4 实名状态 0→1,并提交设备级激活任务
|
||||
- **THEN** 任务 Worker 执行 `ActivateByRealname(ctx, "device", 2)`,查询结果为空,直接返回 nil(无错误)
|
||||
|
||||
@@ -347,3 +347,75 @@ func NewPollingProtectHandler(
|
||||
#### Scenario: verboseLog 关闭时保护期检查不输出额外日志
|
||||
- **WHEN** `verboseLog = false`
|
||||
- **THEN** 不输出 verbose 日志
|
||||
|
||||
---
|
||||
|
||||
## 变更历史:fix-realname-activation-logic
|
||||
|
||||
### MODIFIED: polling_realname_handler.go——实名数据采集
|
||||
|
||||
**文件**:`internal/task/polling_realname_handler.go`(< 220行)
|
||||
|
||||
**构造函数依赖**(通过构造函数注入,禁止全局变量):
|
||||
```go
|
||||
func NewPollingRealnameHandler(
|
||||
base *PollingBase,
|
||||
gateway *gateway.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
asynqClient *asynq.Client,
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore, // 新增:用于查询卡所属设备
|
||||
) *PollingRealnameHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `triggerFirstRealnameActivation(ctx, cardID)`:提交卡级首次实名激活任务(已有,不变)
|
||||
- `triggerDeviceRealnameActivation(ctx, cardID)`:**新增**,查询卡所属设备并提交设备级激活任务
|
||||
|
||||
**处理流程(0→1 路径新增步骤)**:
|
||||
1. 调 `base.acquireConcurrency("realname")`,获取失败则 requeue 后返回
|
||||
2. 调 `base.getCardWithCache(ctx, cardID)` 获取卡信息
|
||||
3. 调 Gateway 查询实名状态
|
||||
4. 写 Store(更新 `real_name_status`、`last_real_name_check_at`,首次实名时写 `first_realname_at`)
|
||||
5. 若实名状态变为已实名(0→1):
|
||||
a. 调 `triggerFirstRealnameActivation(ctx, cardID)` — 提交卡级激活任务
|
||||
b. **调 `triggerDeviceRealnameActivation(ctx, cardID)` — 提交设备级激活任务(新增)**
|
||||
c. 调 `stopResumeService.EvaluateAndAct(ctx, freshCard)` — 触发复机判断
|
||||
6. 调 `base.requeueCard(ctx, cardID, "realname")` 重新入队
|
||||
7. 调 `base.releaseConcurrency("realname")`
|
||||
|
||||
#### Scenario: 实名状态由未实名变为已实名——同时触发卡级和设备级激活
|
||||
- **GIVEN** cardID=4,`real_name_status=0`,该卡通过 `tb_device_sim_binding` 绑定于设备 ID=2
|
||||
- **WHEN** Gateway 返回实名已完成,Handler 检测到 0→1 变化
|
||||
- **THEN** Store 更新 `real_name_status=1, first_realname_at=NOW()`
|
||||
- **AND** 提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=4`)
|
||||
- **AND** 提交设备级 Asynq 任务(`carrier_type=device, carrier_id=2`)
|
||||
- **AND** 调用 `EvaluateAndAct` 触发复机评估
|
||||
|
||||
#### Scenario: 独立卡实名——仅触发卡级激活
|
||||
- **GIVEN** cardID=10,`real_name_status=0`,未绑定任何设备
|
||||
- **WHEN** Gateway 返回实名已完成,Handler 检测到 0→1 变化
|
||||
- **THEN** 提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=10`)
|
||||
- **AND** `triggerDeviceRealnameActivation` 调用 `GetActiveBindingByCardID` 返回 not found,静默跳过,不报错
|
||||
|
||||
#### Scenario: 实名状态未变化——不触发任何激活
|
||||
- **GIVEN** cardID=200,`real_name_status=1`(已实名),Gateway 返回仍已实名
|
||||
- **WHEN** Handler 执行
|
||||
- **THEN** Store 不执行实名更新;不提交任何激活任务;按正常间隔 requeue
|
||||
|
||||
---
|
||||
|
||||
### ADDED: PollingRealnameHandler 注入 DeviceSimBindingStore
|
||||
|
||||
`PollingRealnameHandler` 结构体 SHALL 包含 `deviceSimBindingStore *postgres.DeviceSimBindingStore` 字段,由 `NewPollingRealnameHandler` 构造函数注入。`pkg/queue/handler.go` 的 `registerPollingHandlers` SHALL 将已可用的 `h.workerResult.Stores.DeviceSimBinding` 作为最后一个参数传入。
|
||||
|
||||
#### Scenario: Worker 启动时 DeviceSimBindingStore 正确注入
|
||||
- **WHEN** Worker 进程启动,调用 `registerPollingHandlers()`
|
||||
- **THEN** `NewPollingRealnameHandler` 接收 6 个参数,第 6 个为 `h.workerResult.Stores.DeviceSimBinding`
|
||||
- **AND** handler 的 `deviceSimBindingStore` 字段非 nil
|
||||
|
||||
#### Scenario: DeviceSimBindingStore 为 nil 时 triggerDeviceRealnameActivation 静默跳过
|
||||
- **GIVEN** handler 的 `deviceSimBindingStore` 为 nil(测试或未注入场景)
|
||||
- **WHEN** 调用 `triggerDeviceRealnameActivation`
|
||||
- **THEN** 函数立即返回,不 panic,不报错
|
||||
|
||||
Reference in New Issue
Block a user