From 1d6c212d04a58c37d3d0b7a24e498c1287e1a658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Wed, 14 Oct 2026 18:00:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC1=E6=89=B9=20M5=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20IoT=20=E5=8D=A1=E5=AF=BC=E5=87=BA=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E4=BD=BF=E7=94=A8/=E5=89=A9=E4=BD=99=E6=B5=81=E9=87=8F(GB)?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新任务在「套餐到期时间」后追加「使用流量(GB)」「剩余流量(GB)」,同行 MB ÷ 1024 两位小数 - 冻结表头按末列识别三版尾列,历史任务行宽不变 - OpenSpec 补充提案、设计、spec 与任务 3.2/5.6 Co-Authored-By: Claude Opus 5.5 --- internal/exporter/iot_card_scene.go | 37 +++++++++++++++---- .../design.md | 1 + .../proposal.md | 3 +- .../specs/export-task/spec.md | 20 ++++++++++ .../tasks.md | 3 ++ 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index e2269bcd..61f7606a 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -39,8 +39,8 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { - // 「关联手机号」「套餐到期时间」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 - return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader}, nil + // 「关联手机号」「套餐到期时间」「使用流量(GB)」「剩余流量(GB)」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 + return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 @@ -81,7 +81,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if err != nil { return nil, err } - includeExpiry := len(params.ResolvedHeaders) == 0 || iotCardHeadersIncludeFinalExpiry(params.ResolvedHeaders) + includeExpiry, includeGB := iotCardExportTailColumns(params.ResolvedHeaders) var finalExpiries map[uint]string if includeExpiry { finalExpiries, err = s.loadFinalExpiries(ctx, cardIDs) @@ -92,6 +92,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs rows := make([][]string, 0, len(items)) for _, item := range items { + remainingMB := remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB) row := []string{ item.ICCID, item.MSISDN, @@ -104,12 +105,15 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs constants.GetNetworkStatusName(item.NetworkStatus), item.PackageName, strconv.FormatInt(item.DataUsageMB, 10), - strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10), + strconv.FormatInt(remainingMB, 10), associatedPhones[item.ID], } if includeExpiry { row = append(row, finalExpiries[item.ID]) } + if includeGB { + row = append(row, formatMB(float64(item.DataUsageMB)/deviceExportMBPerGB), formatMB(float64(remainingMB)/deviceExportMBPerGB)) + } rows = append(rows, row) } return rows, nil @@ -131,9 +135,20 @@ func (s *IotCardDataSource) loadFinalExpiries(ctx context.Context, cardIDs []uin return result, nil } -// iotCardHeadersIncludeFinalExpiry 判断冻结表头是否为带“套餐到期时间”末列的新表头。 -func iotCardHeadersIncludeFinalExpiry(headers []string) bool { - return len(headers) > 0 && headers[len(headers)-1] == iotCardExportFinalExpiryHeader +// iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「剩余流量(GB)」输出到期列与 GB 列, +// 末列「套餐到期时间」只输出到期列,更早的表头两者都不输出;无冻结表头按当前版本输出。 +func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB bool) { + if len(headers) == 0 { + return true, true + } + switch headers[len(headers)-1] { + case iotCardExportRemainingGBHeader: + return true, true + case iotCardExportFinalExpiryHeader: + return true, false + default: + return false, false + } } // baseQuery 构造卡导出的基础查询。 @@ -269,9 +284,15 @@ func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, pa // iotCardExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。 const iotCardExportAssociatedPhoneHeader = "关联手机号" -// iotCardExportFinalExpiryHeader 是「套餐到期时间」列的表头,固定位于导出最后一列。 +// iotCardExportFinalExpiryHeader 是「套餐到期时间」列的表头,位于两列 GB 流量之前。 const iotCardExportFinalExpiryHeader = "套餐到期时间" +// iotCardExportUsageGBHeader、iotCardExportRemainingGBHeader 是 GB 流量列的表头,固定位于导出最后两列。 +const ( + iotCardExportUsageGBHeader = "使用流量(GB)" + iotCardExportRemainingGBHeader = "剩余流量(GB)" +) + type iotCardExportRow struct { ID uint `gorm:"column:id"` ICCID string `gorm:"column:iccid"` diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/design.md b/openspec/changes/fix-august-iteration-export-and-password-hints/design.md index 3a42ab14..ed69e887 100644 --- a/openspec/changes/fix-august-iteration-export-and-password-hints/design.md +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/design.md @@ -21,6 +21,7 @@ M1、M4~M6 原写在 `fix-august-iteration-follow-up-gaps`,改写该 Change 1. **修改密码复用 `internal/handler/validation.Message`。** 为请求字段补中文 `description`,Handler 返回“修改密码参数不合法:”加字段级消息;错误码、HTTP 状态和校验顺序不变。与项目已有中文校验提示约定一致,不在 Handler 内硬编码 validator 分支。 2. **订单“操作人”取冻结的 `operator_name`,追加在最后一列。** 不回查当前账号名称;是否输出以任务 `ResolvedHeaders` 是否含“操作人”为准。 3. **卡“套餐到期时间”按分页批量推算,追加在最后一列。** 每页调用一次 `ResolveBatch`,禁止逐卡查询;只有 `exact` 输出 `estimated_final_expires_at`,其余留空。 +3a. **卡导出 GB 列追加在“套餐到期时间”之后(M5 补充,2026-10-14 确认)。** 新任务在最后追加“使用流量(GB)”“剩余流量(GB)”,值为同行 `使用流量(MB)`、`剩余流量(MB)` ÷ 1024 保留两位小数,与设备导出 `formatMB` 口径一致;原 MB 列名与数值不变。冻结表头按末列识别三个版本:末列“剩余流量(GB)”→ 输出到期列与两列 GB;末列“套餐到期时间”→ 只输出到期列;其余 → 两者都不输出(替换现有 `iotCardHeadersIncludeFinalExpiry` 的单一判断)。 4. **设备流量数值不变,只改表头并加 GB 列。** 新任务表头改为 `ICCIDn使用的流量(MB)`、“设备合计(MB)”,在最后一列(“关联手机号”之后)加“设备合计(GB)”,值为 MB ÷ 1024 保留两位小数;历史任务按冻结表头输出旧列名与旧列数。 5. **密码规则集中在一个函数,在服务层哈希前调用。** `pkg/validator` 新增登录密码规则函数,依次判断:长度 8~12(`1001`)→ 只含 ASCII 可见字符且无空格(`1016`)→ 大写、小写、数字、特殊符号四类至少三类(`1016`)。各入口在 `bcrypt.GenerateFromPassword` 前调用;DTO 标签同步改为 `min=8,max=12` 供文档与早期拦截。不只靠 DTO 标签,因为项目存在多个独立 `validator.New()` 实例,且代理注册在领域层自行校验,集中函数能保证口径一致。 diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md b/openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md index e2eb8177..e4830ec9 100644 --- a/openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md @@ -1,6 +1,6 @@ ## Why -8-9 月迭代中的 4 个优化项原排在第 2 批,2026-10-11 确定并入第 1 批一起交付:修改密码失败只提示“参数验证失败”(M1),且各入口密码规则不一(6~20、6~50、6~64、8~32 位)、只限长度无强度要求;订单导出缺“操作人”(M4,运营清单 B-1);IoT 卡导出缺“套餐到期时间”(M5,运营清单 21);设备导出流量未标单位、无 GB 合计(M6,运营清单 22)。内容沿用原 `fix-august-iteration-follow-up-gaps` 中已核实的方案,依据为《8-9月迭代排期安排》。 +8-9 月迭代中的 4 个优化项原排在第 2 批,2026-10-11 确定并入第 1 批一起交付:修改密码失败只提示“参数验证失败”(M1),且各入口密码规则不一(6~20、6~50、6~64、8~32 位)、只限长度无强度要求;订单导出缺“操作人”(M4,运营清单 B-1);IoT 卡导出缺“套餐到期时间”(M5,运营清单 21);设备导出流量未标单位、无 GB 合计(M6,运营清单 22)。2026-10-14 确认 IoT 卡导出也补 GB 流量列,与设备导出口径一致(M5 补充)。内容沿用原 `fix-august-iteration-follow-up-gaps` 中已核实的方案,依据为《8-9月迭代排期安排》。 ## What Changes @@ -9,6 +9,7 @@ - 订单导出新任务在最后一列增加“操作人”,取订单冻结的 `operator_name`。 - IoT 卡导出新任务在最后一列增加“套餐到期时间”,复用卡列表最终到期推算,只有精确推算时输出。 - 设备导出新任务表头改为 `ICCIDn使用的流量(MB)`、“设备合计(MB)”,在最后一列新增“设备合计(GB)”(MB ÷ 1024,两位小数)。 +- IoT 卡导出新任务在“套餐到期时间”之后追加“使用流量(GB)”“剩余流量(GB)”两列(对应 MB 列 ÷ 1024,两位小数);原 MB 列不变(M5 补充,2026-10-14 确认)。 - 历史导出任务按冻结表头输出,不插入新列。 ## Capabilities diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md b/openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md index e7713cb7..77ab64d8 100644 --- a/openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md @@ -30,6 +30,26 @@ - **WHEN** 新任务导出的 IoT 卡到期推算状态为待激活、无参与套餐或数据异常 - **THEN** “套餐到期时间”列为空且该卡仍被导出 +### Requirement: IoT 卡导出提供 GB 流量列 + +系统 SHALL 在新建 IoT 卡导出任务的“套餐到期时间”之后追加“使用流量(GB)”“剩余流量(GB)”两列,作为最后两列。两列值 MUST 分别等于同行“使用流量(MB)”“剩余流量(MB)”除以 1024 并保留两位小数;原 MB 列名与数值保持不变。 + +#### Scenario: 新任务导出卡 GB 流量 + +- **WHEN** 用户新建 IoT 卡导出任务 +- **THEN** 表头最后三列依次为“套餐到期时间”“使用流量(GB)”“剩余流量(GB)” +- **AND** GB 列按同行 MB 值除以 1024 后保留两位小数 + +#### Scenario: 卡无生效套餐 + +- **WHEN** 新任务导出的 IoT 卡没有生效中的主套餐 +- **THEN** 两列 GB 输出格式化的零值且该卡仍被导出 + +#### Scenario: 重跑只含“套餐到期时间”末列的任务 + +- **WHEN** Worker 处理一个末列为“套餐到期时间”的已冻结 IoT 卡导出任务 +- **THEN** 输出包含“套餐到期时间”但不追加 GB 列,行宽与冻结表头一致 + ### Requirement: 设备导出明确流量单位并提供 GB 合计 系统 SHALL 对新建设备导出任务将每张绑定卡的当前月使用流量和设备合计明确标注为 MB,并在最后一列额外返回“设备合计(GB)”。GB 合计 MUST 等于全部当前绑定卡 MB 使用量之和除以 1024,并保留两位小数。 diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md b/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md index 8693e865..b6d7e50d 100644 --- a/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md @@ -12,6 +12,8 @@ - [x] 3.1 IoT 卡导出新任务在最后一列增加“套餐到期时间”;每个分页调用一次 `packageexpiry.Query.ResolveBatch`,只有 `exact` 输出时间,其余留空;历史任务不加列。 +- [x] 3.2 IoT 卡导出新任务在“套餐到期时间”之后追加“使用流量(GB)”“剩余流量(GB)”(同行 MB ÷ 1024,两位小数,复用 `formatMB`);冻结表头按末列识别三个版本(“剩余流量(GB)”/“套餐到期时间”/更早),历史任务行宽与冻结表头一致。 + ## 4. M6 设备导出流量单位 - [x] 4.1 设备导出新任务表头改为 `ICCIDn使用的流量(MB)`、“设备合计(MB)”,在最后一列增加“设备合计(GB)”(MB 合计 ÷ 1024,两位小数),`cardGroupCountFromHeaders` 增加对新尾部列数(7)的识别;无绑定卡设备 MB、GB 合计输出格式化零值;历史任务按冻结表头输出。 @@ -24,3 +26,4 @@ - [ ] 5.3 通知测试环境前端同步七个入口的密码提示与前端校验(8~12 位、四类至少三类)。 - [x] 5.4 测试环境为订单、IoT 卡、设备各建一个导出任务,下载核对新表头、值来源、MB/GB 换算与列数;再用变更前创建的任务验证旧表头与行宽不变;只清理本 Change 自建的任务与对象。 - [ ] 5.5 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 +- [ ] 5.6 测试环境新建 IoT 卡导出任务,核对最后三列与 GB 换算;用 3.2 部署前创建的卡导出任务重跑,确认不追加 GB 列、行宽不变。