feat: 订单创建时快照买家手机号/昵称和套餐类型
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

新增字段:
- tb_order: buyer_phone, buyer_nickname(个人客户下单时快照)
- tb_order_item: package_type(套餐类型快照)

后台订单列表支持按 buyer_phone 精确过滤查询。

OpenSpec: order-buyer-snapshot
This commit is contained in:
2026-04-10 17:12:56 +08:00
parent 3cb16804a4
commit 5496cb58aa
20 changed files with 489 additions and 49 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-10

View File

@@ -0,0 +1,95 @@
## Context
当前 `tb_order` 仅存储 `buyer_id`(个人客户 ID 或店铺 ID售后需验证订单归属时必须跨表关联 `tb_personal_customer_phone` 查询手机号,增加了查询复杂度且在客户数据变更后可能出现不一致。
`tb_order_item` 已有 `package_name` 快照字段,但缺少 `package_type` 快照。套餐删除后 `tb_package.package_type` 不可查,导致历史订单的套餐类型信息永久丢失,影响报表统计和套餐组合校验。
相关 Store 方法已存在且可直接复用:
- `PersonalCustomerStore.GetByID(ctx, id)` → 返回 `model.PersonalCustomer`,含 `Nickname` 字段
- `PersonalCustomerPhoneStore.GetPrimaryPhone(ctx, customerID)` → 返回 `model.PersonalCustomerPhone`,含 `Phone` 字段
## Goals / Non-Goals
**Goals:**
- `tb_order` 新增 `buyer_phone``buyer_nickname` 字段,下单时快照买家信息
- `tb_order_item` 新增 `package_type` 字段,下单时快照套餐类型
- C 端下单(`client_order.Service.CreateOrder`)自动填充三个快照字段
- 后台下单(`order.Service`)在个人客户代购场景填充 `buyer_phone``buyer_nickname`;所有场景填充 `package_type`
- 后台订单列表支持按 `buyer_phone` 精确过滤
- 后台订单 DTO`OrderResponse`)新增 `buyer_phone``buyer_nickname` 字段
- 历史数据:`package_type``tb_package` 回填;`buyer_phone`/`buyer_nickname` 留空
**Non-Goals:**
- 不修改代理商订单的 `buyer_phone`/`buyer_nickname`(代理商无个人手机号概念,留空)
- 不修改 C 端订单详情 DTOC 端用户无需看到自己的手机号)
- 不实现手机号模糊搜索(仅精确匹配,避免全表扫描)
- 不同步历史订单的 `buyer_phone`/`buyer_nickname`(历史数据留空,不回填)
## Decisions
### 决策 1快照字段允许为空
`buyer_phone``buyer_nickname` 设为 `VARCHAR NOT NULL DEFAULT ''``VARCHAR NULL`
**选择**`VARCHAR NULL`,不设默认值。
**理由**:代理商订单无手机号,强制默认空字符串会导致按手机号过滤时干扰结果(空字符串会匹配到 `buyer_phone = ''` 的查询。NULL 值语义更清晰,过滤时 `buyer_phone = ?` 自动跳过 NULL 行。
### 决策 2买家信息的查询策略不阻塞下单
`GetByID``GetPrimaryPhone` 查询失败时仅记录日志,不返回错误,下单流程继续。
**理由**:买家快照是辅助信息,不影响核心业务(套餐激活、支付、佣金)。若因网络抖动或数据缺失导致查询失败而拒绝下单,损失远大于快照字段为空的代价。
### 决策 3package_type 从已有套餐对象直接读取
`buildOrderItems` 中的 `pkg` 参数已是 `*model.Package` 对象,含 `PackageType` 字段,无需额外查询。
**理由**:零额外数据库查询,直接复用已加载的套餐数据。
### 决策 4买家信息注入位置
`buildPendingOrder` 函数或其调用处注入,而非在 `buildOrderItems` 里。
**理由**`buyer_phone`/`buyer_nickname` 属于 `Order` 表字段,与 `OrderItem` 无关,职责分离清晰。
### 决策 5buyer_phone 普通索引
`buyer_phone` 建普通 B-tree 索引(允许 NULL 的列 PostgreSQL 默认跳过 NULL 值入索引,稀疏索引天然节省空间)。
**理由**:售后场景按手机号过滤是低频辅助查询,普通索引足够,无需唯一索引(同一手机号可有多个订单)。
## Risks / Trade-offs
- **[风险] 历史订单 buyer_phone/buyer_nickname 为空** → 缓解:明确文档说明历史数据为空,售后系统在展示时标注"下单时未记录"
- **[风险] package_type 回填脚本执行期间锁表** → 缓解:使用 `UPDATE ... WHERE package_type IS NULL LIMIT 500` 分批回填,或在低峰期执行
- **[权衡] C 端 Service 新增两个 Store 依赖** → 接受Store 注入是项目标准模式,结构体字段注入不影响测试性
## Migration Plan
### 部署步骤
1. 执行数据库迁移(新增字段,不修改现有列)
2. 部署新版本代码(包含 package_type 回填逻辑或手动执行 SQL
3. 验证:新订单的三个快照字段是否正确填充
4. 可选:验证 buyer_phone 过滤接口是否返回正确结果
### 历史数据回填 SQL
```sql
-- 回填 package_type仅回填能匹配到套餐的记录
UPDATE tb_order_item oi
SET package_type = p.package_type
FROM tb_package p
WHERE oi.package_id = p.id
AND oi.package_type IS NULL
AND oi.deleted_at IS NULL;
```
### 回滚策略
新增字段均为 NULL回滚代码后字段保留但不被读写无副作用。迁移本身可通过 `ALTER TABLE DROP COLUMN` 回滚(数据丢失,但新字段为快照信息,回滚成本低)。
## Open Questions
-

View File

@@ -0,0 +1,34 @@
## Why
订单创建后,售后场景无法通过手机号快速验证订单归属(需跨表关联),且 `OrderItem` 缺少套餐类型快照,套餐删除后类型信息永久丢失。将买家手机号、昵称、套餐类型在创建时快照到订单,彻底解决这两个问题。
## What Changes
- **Order 表**:新增 `buyer_phone`(下单时买家主手机号快照)、`buyer_nickname`(下单时买家昵称快照,允许为空)
- **OrderItem 表**:新增 `package_type`(套餐类型快照,`formal` / `addon`
- **数据库迁移**:新增字段;历史订单 `buyer_phone` / `buyer_nickname` 留空,`package_type` 从套餐表回填
- **下单逻辑**`client_order` Service 和 `admin_order` Service 创建订单时自动查询并填充三个快照字段
- **查询接口**:订单列表/详情 DTO 暴露新字段;后台订单列表支持按 `buyer_phone` 精确过滤
## Capabilities
### New Capabilities
无新能力,均为现有能力的字段扩充。
### Modified Capabilities
- `order-management`订单模型新增买家快照字段buyer_phone、buyer_nickname后台查询支持按手机号过滤
- `client-order-purchase`C 端下单时自动填充买家快照(手机号、昵称)
- `iot-order`OrderItem 模型新增 package_type 快照,下单时填充
## Impact
- **数据层**`tb_order`(新增 2 列)、`tb_order_item`(新增 1 列)、迁移脚本
- **代码层**
- `internal/model/order.go` — Order、OrderItem struct 新增字段
- `internal/service/client_order/service.go` — CreateOrder 填充快照
- `internal/service/admin_order/service.go`(代购场景)— 同步填充
- `internal/model/dto/order_dto.go``client_order_dto.go` — 响应 DTO 新增字段
- `internal/store/postgres/order_store.go` — List 查询支持 buyer_phone 过滤
- **无破坏性变更**:新字段均可为空,现有接口响应向下兼容(仅追加字段)

View File

@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: D1 创建套餐购买订单接口
系统 SHALL 允许个人客户发起套餐购买,创建待支付订单。下单时系统 MUST 自动查询买家手机号和昵称填入快照字段,查询失败时不阻塞下单流程。
#### Scenario: 创建套餐购买订单(正常流程)
- **WHEN** 个人客户传入合法的套餐和资产标识符
- **THEN** 系统创建待支付订单,响应包含 order_id、order_no、total_amount、payment_status
#### Scenario: 下单时买家快照被填充
- **WHEN** 个人客户成功下单,系统能查询到该客户的主手机号
- **THEN** 新创建订单的 buyer_phone 等于该客户的主手机号buyer_nickname 等于该客户的昵称
#### Scenario: 买家信息查询失败时订单仍创建成功
- **WHEN** 个人客户下单,查询手机号时数据库返回错误
- **THEN** 系统创建订单成功buyer_phone 为空,日志中记录警告信息
#### Scenario: 购买套餐时 OrderItem 含 package_type 快照
- **WHEN** 个人客户购买套餐(套餐类型为 formal 或 addon
- **THEN** 新创建的 OrderItem 中 package_type 等于该套餐的 package_type 字段值

View File

@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: 订单实体定义
系统 SHALL 定义订单(Order)实体,统一管理两种订单类型:套餐订单、号卡订单,并支持混合支付方式(钱包 + 在线支付)。
**修改说明(本次变更)**
- `Order` 新增 `buyer_phone`varchar(20)NULL下单时买家主手机号快照
- `Order` 新增 `buyer_nickname`varchar(100)NULL下单时买家昵称快照
- `OrderItem` 新增 `package_type`varchar(20)NULL套餐类型快照formal / addon
**支付规则(保持不变)**
- `wallet_payment_amount` + `online_payment_amount` = `amount`(订单总金额)
-`payment_method` 为 "wallet" 时,`wallet_payment_amount` = `amount``online_payment_amount` = 0
-`payment_method` 为 "online" 时,`online_payment_amount` = `amount``wallet_payment_amount` = 0
- 混合支付时,`payment_method` 为 "mixed",两个字段都 > 0
#### Scenario: 全额钱包支付
- **WHEN** 用户购买套餐,订单金额为 3000 分30 元),选择钱包支付,钱包余额为 10000 分
- **THEN** 系统创建订单,`amount` 为 3000`payment_method` 为 "wallet"`wallet_payment_amount` 为 3000`online_payment_amount` 为 0
#### Scenario: OrderItem 包含套餐类型快照
- **WHEN** 下单时套餐的 package_type 为 "formal"
- **THEN** 生成的 OrderItem 中 package_type 为 "formal"
#### Scenario: 套餐删除后 OrderItem 保留类型快照
- **WHEN** 套餐被软删除后查询历史订单明细
- **THEN** OrderItem 的 package_type 仍为下单时的快照值,不因套餐删除而变为空

View File

@@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: 订单买家快照字段
系统 SHALL 在订单创建时将买家手机号和昵称快照到 `tb_order` 表,字段允许为空。
- `buyer_phone`下单时买家主手机号快照varchar(20),允许 NULL代理商订单留空
- `buyer_nickname`下单时买家昵称快照varchar(100),允许 NULL
- `buyer_phone` 字段建普通索引以支持按手机号过滤
#### Scenario: 个人客户下单时快照手机号和昵称
- **WHEN** 个人客户下单,系统能查询到该客户的主手机号和昵称
- **THEN** 订单的 `buyer_phone``buyer_nickname` 被填充为查询结果
#### Scenario: 买家信息查询失败时不阻塞下单
- **WHEN** 个人客户下单,但查询主手机号或昵称时发生错误(如数据库异常)
- **THEN** 系统记录 warn 日志,订单正常创建,`buyer_phone`/`buyer_nickname` 为空
#### Scenario: 代理商订单不填充买家手机号
- **WHEN** 代理商下单buyer_type = agent
- **THEN** 订单的 `buyer_phone``buyer_nickname` 为 NULL
---
## MODIFIED Requirements
### Requirement: 查询订单列表
系统 SHALL 提供订单列表查询,支持按支付状态、订单类型、是否代购、买家手机号筛选。
#### Scenario: 个人客户查询自己的订单
- **WHEN** 个人客户查询订单列表
- **THEN** 系统只返回该客户的订单
#### Scenario: 代理查询店铺订单
- **WHEN** 代理查询订单列表
- **THEN** 系统返回该店铺及下级店铺的订单(包含代购订单和普通订单)
#### Scenario: 按代购类型筛选
- **WHEN** 指定 is_purchase_on_behalf = true 筛选
- **THEN** 系统只返回代购订单
#### Scenario: 按支付状态筛选
- **WHEN** 指定支付状态筛选
- **THEN** 系统只返回匹配状态的订单
#### Scenario: 按买家手机号精确过滤
- **WHEN** 后台请求中传入 buyer_phone 参数
- **THEN** 系统只返回 buyer_phone 精确匹配的订单
#### Scenario: 买家手机号过滤不返回空字段订单
- **WHEN** 后台按 buyer_phone 过滤,部分历史订单该字段为 NULL
- **THEN** 系统不返回 buyer_phone 为 NULL 的历史订单(精确匹配语义)

View File

@@ -0,0 +1,55 @@
## 1. 数据库迁移
- [x] 1.1 新建迁移文件ALTER TABLE tb_order 新增 buyer_phone VARCHAR(20) NULL、buyer_nickname VARCHAR(100) NULL为 buyer_phone 建普通索引
- [x] 1.2 同一迁移文件中 ALTER TABLE tb_order_item 新增 package_type VARCHAR(20) NULL
- [x] 1.3 执行迁移,运行 `migrate up` 确认迁移成功,验证三列已存在于数据库
- [x] 1.4 执行历史数据回填 SQLUPDATE tb_order_item SET package_type = p.package_type FROM tb_package p WHERE oi.package_id = p.id AND oi.package_type IS NULL
## 2. Model 层
- [x] 2.1 在 internal/model/order.go 的 Order struct 中新增 BuyerPhone、BuyerNickname 字段gorm tag 含 column/type/comment
- [x] 2.2 在 internal/model/order.go 的 OrderItem struct 中新增 PackageType 字段gorm tag 含 column/type/comment
- [x] 2.3 运行 `lsp_diagnostics` 确认 model 层无编译错误
## 3. DTO 层
- [x] 3.1 在 internal/model/dto/order_dto.go 的 OrderListRequest 中新增 BuyerPhone string 字段query tag + validate omitempty + description
- [x] 3.2 在 internal/model/dto/order_dto.go 的 OrderResponse 中新增 BuyerPhone、BuyerNickname string 字段json tag + description
- [x] 3.3 在 internal/model/dto/order_dto.go 的 OrderItemResponse 中新增 PackageType string 字段json tag + description
- [x] 3.4 运行 `lsp_diagnostics` 确认 DTO 层无编译错误
## 4. Store 层
- [x] 4.1 在 internal/store/postgres/order_store.go 的 List 方法过滤逻辑中新增 buyer_phone 精确匹配条件(仿照 identifier 过滤写法)
- [x] 4.2 运行 `lsp_diagnostics` 确认 Store 层无编译错误
## 5. C 端下单 Service 填充快照
- [x] 5.1 在 internal/service/client_order/service.go 的 Service struct 和 New() 函数中注入 personalCustomerStore *postgres.PersonalCustomerStore、personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
- [x] 5.2 在 internal/service/client_order/service.go 中新增私有方法 fetchBuyerSnapshot(ctx, customerID uint) (phone, nickname string),内部调用两个 Store任一失败时记录 warn 日志并返回空字符串
- [x] 5.3 在 buildPendingOrder 或其调用处调用 fetchBuyerSnapshot将返回值赋给 Order.BuyerPhone、Order.BuyerNickname
- [x] 5.4 在 buildOrderItems 中从 pkg.PackageType 读取值赋给 OrderItem.PackageType
- [x] 5.5 更新 internal/bootstrap/services.go或对应的初始化文件中 client_order.New() 调用,传入新增的两个 Store 参数
- [x] 5.6 运行 `lsp_diagnostics` 确认 C 端 Service 无编译错误
## 6. 后台下单 Service 填充快照
- [x] 6.1 在 internal/service/order/service.go 的 Service struct 中确认或注入 personalCustomerStore、personalCustomerPhoneStore若已有则跳过注入步骤
- [x] 6.2 在 orderBuyerType == BuyerTypePersonal 的个人客户场景分支(约 line 465 附近)调用 fetchBuyerSnapshot 逻辑,填充 Order.BuyerPhone、Order.BuyerNickname
- [x] 6.3 在 buildOrderItems 中从 pkg.PackageType 读取值赋给 OrderItem.PackageType
- [x] 6.4 运行 `lsp_diagnostics` 确认后台 Service 无编译错误
## 7. Handler / Service 响应映射
- [x] 7.1 确认后台订单列表 Handler 已将 OrderListRequest.BuyerPhone 传入 filters mapkey="buyer_phone"
- [x] 7.2 确认订单 DTO 映射函数toOrderResponse 或类似函数)已将 Order.BuyerPhone、Order.BuyerNickname 映射到 OrderResponse
- [x] 7.3 确认 OrderItem 映射函数已将 OrderItem.PackageType 映射到 OrderItemResponse.PackageType
- [x] 7.4 运行 `lsp_diagnostics` 确认 Handler 层无编译错误
## 8. 验证
- [x] 8.1 运行 `go build ./...` 确认整个项目编译通过
- [x] 8.2 使用数据库工具查询 tb_order 确认新列存在buyer_phone 索引已创建
- [x] 8.3 使用数据库工具查询 tb_order_item 确认 package_type 列存在,历史数据回填记录数符合预期
- [ ] 8.4 调用 C 端下单接口创建一个新订单,查询 tb_order 验证 buyer_phone/buyer_nickname 已填充
- [ ] 8.5 调用后台订单列表接口,传入 buyer_phone 参数,验证返回结果正确过滤