# package-lifecycle 当前行为 ## Purpose 描述套餐状态流转与批量操作追踪的当前行为。 ## Requirements ### Requirement: 套餐状态流转 系统 SHALL 按当前套餐和套餐使用状态控制上架、订购、激活、失效与到期处理。主套餐到期时,系统 MUST 先确定同一载体是否存在待生效的后续主套餐:存在时,后续套餐激活与停复机重新评估 MUST 由同一条顺序流程完成;系统 MUST NOT 依据后续套餐激活前的无套餐快照发起停机。后续套餐成功生效后,系统 MUST 依据最新套餐、流量和实名事实重新判断卡网络状态,且不得遗留 `no_package` 停机。不存在后续套餐或后续套餐经业务校验不能生效时,系统 SHALL 按现有停机规则评估卡状态。后续套餐激活结果未知或任务投递失败不得被当作无后续套餐处理并据此停机,系统 SHALL 保留既有激活恢复与轮询兜底路径。 #### Scenario: 套餐状态流转 - **GIVEN** 套餐或使用记录处于允许的前置状态 - **WHEN** 执行状态操作 - **THEN** 仅发生一次允许的状态变化;不满足前置状态时返回业务错误 #### Scenario: 到期主套餐接续后续套餐 - **GIVEN** 某载体的当前主套餐到期,且存在满足激活条件的待生效后续主套餐 - **WHEN** 系统处理该主套餐到期 - **THEN** 系统先完成后续套餐激活并按最新权益事实重新评估停复机,且不得因到期前的无套餐快照对该载体发起 `no_package` 停机 #### Scenario: 到期主套餐无后续可生效套餐 - **GIVEN** 某载体的当前主套餐到期,且不存在后续主套餐或队首后续主套餐不满足激活条件 - **WHEN** 系统完成该套餐到期处理 - **THEN** 系统按当前套餐、流量和实名事实执行既有停机评估 #### Scenario: 后续套餐激活结果未知 - **GIVEN** 某载体的当前主套餐到期,存在待生效后续主套餐,但激活任务投递或执行结果暂时未知 - **WHEN** 系统处理该套餐到期 - **THEN** 系统不得将该未知结果视为不存在后续套餐而依据旧快照发起停机,并保留既有激活恢复与套餐轮询兜底 #### Scenario: 卡状态轮询发现缺失的套餐任务 - **GIVEN** 启用轮询的卡匹配套餐检查配置,且其 `polling:package` 分片队列项因异常缺失 - **WHEN** 卡状态轮询成功完成且未命中风险停机 - **THEN** 系统基于最新卡状态仅补入缺失的套餐任务,不改写已存在套餐任务的执行时间;后续套餐任务仍按既有停复机条件评估该卡 ### Requirement: 批量操作可追踪 系统 SHALL 为同步批量分配和调价直接返回处理结果;对异步批量订购返回任务标识并提供状态查询。异步批量订购完成后,系统 MUST 持久化每个输入行的成功或失败结果及与其一致的总数、成功数和失败数;部分资产因余额不足、资产校验或重复输入失败不得阻止任务进入完成终态。 #### Scenario: 批量操作可追踪 - **GIVEN** 操作者提交非空且有权处理的资源集合 - **WHEN** 创建批量操作 - **THEN** 同步操作直接返回结果;异步订购返回任务标识且可查询处理状态 #### Scenario: 批量订购部分失败后查询结果 - **GIVEN** 异步批量订购中的部分资产已成功创建订单,其他资产因钱包余额不足或输入重复失败 - **WHEN** Worker 完成全部输入行的处理 - **THEN** 任务状态为已完成,逐行结果保留成功订单与失败原因,且总数等于成功数与失败数之和 ### Requirement: 授权页面禁止重复选择套餐 系统 SHALL 使代理系列授权页面能够区分目标店铺已授权和未授权套餐;已授权套餐 MUST 以不可新增的状态返回,首次创建系列授权和既有系列新增套餐均适用。 #### Scenario: 首次创建前查询候选套餐 - **WHEN** 操作者选择目标店铺和套餐系列以创建系列授权 - **THEN** 系统返回可用于选择的候选套餐及其授权状态,前端可阻止选择已授权套餐 #### Scenario: 既有授权新增套餐前查询候选套餐 - **WHEN** 操作者为已有系列授权添加套餐 - **THEN** 系统返回同一店铺和系列的候选套餐及其授权状态,且不改变现有套餐管理提交接口的调价和删除语义 ### Requirement: 套餐使用记录价格快照 系统 SHALL 在创建套餐使用记录时分别快照套餐成本价与零售价:`paid_amount`(成本价)SHALL 取订单 `seller_cost_price`(销售成本价,即卖家店铺向平台结算的成本),`retail_amount`(零售价)SHALL 取订单 `total_amount`(零售总价)。成本价与实付金额在个人客户场景下不相等时,`paid_amount` MUST 使用成本价而非实付金额。 #### Scenario: 个人客户购买时快照成本价与零售价 - **WHEN** 个人客户为资产购买套餐,店铺成本价 10900 分,零售价 15900 分,客户实付 15900 分 - **THEN** 创建的套餐使用记录 `paid_amount = 10900`,`retail_amount = 15900` #### Scenario: 代理钱包自购时快照成本价与零售价 - **WHEN** 代理以钱包支付为自有资产购买套餐,成本价 7000 分,零售价 9900 分 - **THEN** 创建的套餐使用记录 `paid_amount = 7000`,`retail_amount = 9900` #### Scenario: 赠送套餐时快照零成本价与零售价 - **WHEN** 平台赠送套餐,零售价 9900 分,成本价 0 分 - **THEN** 创建的套餐使用记录 `paid_amount = 0`,`retail_amount = 9900` ### Requirement: 资产套餐层级投影 系统 SHALL 在后台资产详情套餐列表和 H5 资产套餐历史中,直接依据既有 `master_usage_id` 返回主套餐与关联加油包的层级,不新增关联表。普通主项 SHALL 显式返回 `master_usage_id=null`、`children` 数组和 `expand_by_default`;存在至少一个可展示关联子项时默认展开,否则不展开。子项 SHALL 保留自身使用记录 ID、原主记录 ID、状态、购买创建时间、生效时间及该入口既有历史字段。正常项 SHALL 省略关系异常字段,所有叶子 SHALL 返回 `children=[]`、`expand_by_default=false`。主套餐或加油包失效、过期、用尽、退款均 MUST NOT 拆散既有关系或改写其生命周期事实。 #### Scenario: 多个主套餐分别关联子项 - **WHEN** 同一可见资产范围内有多个主套餐,各自关联不同加油包 - **THEN** 每个加油包仅出现在其原主套餐的 `children` 中,不在顶层重复出现;有子项的主套餐默认展开 #### Scenario: 无子项的主套餐 - **WHEN** 某主套餐没有可展示关联加油包 - **THEN** 该主项返回 `master_usage_id=null`、`children=[]`、`expand_by_default=false`,不返回关系异常字段 #### Scenario: 已失效历史关系 - **WHEN** 主套餐或关联加油包已失效、过期、用尽或退款,但使用记录仍处于本入口可展示范围 - **THEN** 响应保留原主子关系及各自原状态,不因历史状态将子项拆为顶层记录 ### Requirement: 套餐历史整组筛选与分页 系统 SHALL 将一个主套餐及其全部可展示子项作为一组,每组占一个分页名额;主记录物理缺失的每条异常独立项也各占一个名额。`total` SHALL 表示筛选后、分页前的顶层项数量,子项不另计数且 MUST NOT 跨页。已有 `status` 和套餐类型筛选条件 SHALL 在同一条使用记录上联合匹配;组内任一记录满足全部条件即选中整组,返回其完整可展示父子,不因筛选拆关系。异常独立项仅以自身字段匹配。未传筛选条件时 SHALL 返回范围内全部组及异常独立项。 #### Scenario: 子项数量超过页大小 - **WHEN** 一个主套餐含三个子项,另有一个无子项主套餐,以 `page_size=1` 查询且无筛选 - **THEN** `total=2`;包含三个子项的组在同一页完整返回,子项不消耗额外分页名额,另一页只返回另一个主项 #### Scenario: 状态或类型仅命中子项 - **WHEN** 主项不满足筛选条件,但某个子项满足该请求的全部筛选条件 - **THEN** 该组被选中,返回主项及其全部可展示子项,包括未命中条件的同组记录;主项不被标记为缺失 #### Scenario: 联合条件不能分摊给不同记录 - **WHEN** 请求同时指定状态和套餐类型,组内只有主项满足状态、只有子项满足类型,且没有任何一条记录同时满足二者 - **THEN** 该组不进入结果,也不计入 `total` #### Scenario: 异常独立项计数 - **WHEN** 筛选后有一个正常关系组和两条主记录物理缺失的加油包 - **THEN** `total=3`,两条异常项各自占一个分页名额,即使它们指向同一个缺失主记录也不合并 #### Scenario: 无匹配与超出末页 - **WHEN** 没有任何顶层项匹配,或请求页码超出筛选结果末页 - **THEN** `items=[]`;无匹配时 `total=0`,超出末页时仍返回筛选后的真实顶层项总数 ### Requirement: 套餐历史稳定排序 系统 SHALL 将顶层项按自身创建时间倒序排列,同时间按 `package_usage_id` 倒序排列。每组子项 SHALL 依次分为三桶:非待生效且有生效时间的记录按生效时间正序;非待生效但生效时间为空的记录按购买创建时间正序;待生效记录按购买创建时间正序并排最后。各桶排序时间相同 SHALL 按 `package_usage_id` 正序。桶归属及排序 MUST NOT 改变记录状态;后台与 H5 对相同输入集合 SHALL 返回相同顺序。 #### Scenario: 混合生效时间与历史状态 - **WHEN** 同组包含有生效时间的已用完记录、无生效时间的已失效记录和待生效记录 - **THEN** 三者依次位于第一、第二、第三桶;已失效记录保持已失效状态,不被重新标记为待生效 #### Scenario: 同时间的确定顺序 - **WHEN** 多个顶层项创建时间相同,或同桶子项排序时间相同 - **THEN** 顶层按使用记录 ID 倒序,子项按使用记录 ID 正序;数据不变时重复请求及相邻页边界保持一致 ### Requirement: 物理缺失与不可展示关系区分 系统 SHALL 仅在 `master_usage_id` 指向的主使用记录物理不存在时返回顶层异常独立项,保留原字段及非空主记录 ID,并返回 `relationship_status=master_missing`、`relationship_status_name=关联主套餐缺失`、`children=[]`、`expand_by_default=false`。主记录未出现在筛选结果或某一页 MUST NOT 被认定为物理缺失。范围内的子项所指父记录实际存在,但处于其他资产、H5 其他世代或已软删除而不可展示时,整个查询 SHALL 返回统一读取错误,不返回部分成功列表,不越界补显、不静默丢项,也不返回物理缺失标识。商品记录缺失 MUST NOT 等同于主使用记录缺失。 #### Scenario: 主套餐物理缺失 - **WHEN** 加油包所引用的主使用记录物理不存在 - **THEN** 返回带既定 `master_missing` 标识及中文名称的异常独立项,保留原主记录 ID,不猜测替代主套餐 #### Scenario: 父子原本分处明细分页两侧 - **WHEN** 父子记录都在当前入口可展示范围,但按旧明细分页会落在不同页,或父项不满足展示筛选 - **THEN** 系统仍按真实关系返回同一个完整关系组,不将子项标记为主套餐缺失 #### Scenario: 主使用记录软删除或范围不可见 - **WHEN** 范围内某子项的主使用记录仍物理存在,但已软删除、属于其他资产,或不属于 H5 当前世代 - **THEN** 整个查询返回统一读取错误;不暴露父项内容或具体不可见原因,不静默丢项,不返回 `master_missing` #### Scenario: 商品缺失但使用关系存在 - **WHEN** 主使用记录及其子项均可展示,但关联套餐商品已软删除或物理缺失 - **THEN** 主子关系保持不变,历史名称沿用既有快照回退规则,不因商品缺失返回关系异常 ### Requirement: 套餐历史读取范围与兼容边界 系统 SHALL 保留各入口既有资产解析及授权前置校验:后台读取当前解析资产的全部世代,H5 通过当前客户资产绑定校验后仅读取资产实体当前世代;卡按卡载体、设备按设备载体读取,MUST NOT 为补齐关系而合并其他资产或扩张 H5 世代。后台 SHALL 继续只支持已有状态筛选,H5 SHALL 继续支持状态和套餐类型筛选;H5 类型匹配 SHALL 保留排除已软删除商品的既有口径,不将用于历史展示的软删除商品自动作为类型命中项。 系统 SHALL 保留后台默认页大小 50、最大 100 和 `items/total/page/page_size` 响应字段;H5 默认页大小 20、最大 100 和 `items/total/page/size` 响应字段。两端仍接收 `page/page_size`,页码小于 1 时归一为 1,页大小小于 1 时使用各自默认值,超过上限时截断。除明确变更的层级、分页和筛选语义外,系统 SHALL 保留各入口既有套餐字段和可见性:后台成本价仅平台类账号可见;H5 MUST NOT 因共享投影而新增填充原先未填的订单、退款、成本价、零售价或生效条件字段。当前生效套餐及修改套餐接口的响应 MUST NOT 被本次历史层级变更连带改变。 #### Scenario: 两端保留各自世代范围 - **WHEN** 同一资产有多个世代的独立主子组,后台有权查看该资产,H5 客户具有有效绑定 - **THEN** 后台可返回全部世代内的组,H5 只返回资产当前世代内的组,不为对齐两端数量而扩大或缩小范围 #### Scenario: 未通过既有资产授权 - **WHEN** 代理请求其店铺范围外的资产,或 H5 客户请求非其有效绑定资产 - **THEN** 沿用既有拒绝行为,不返回该资产任何主项或子项,也不因补查主记录绕过授权 #### Scenario: 字段可见性与其他接口不变 - **WHEN** 平台、代理和 H5 客户分别读取其可见资产历史,并调用原有当前套餐或修改套餐接口 - **THEN** 历史主子项均遵守各入口既有字段可见性;代理与 H5 不因层级投影获取平台成本价,其他套餐接口保持原响应结构 #### Scenario: 已软删除商品不成为类型筛选命中项 - **WHEN** H5 按套餐类型筛选,组内只有已软删除商品对应的使用记录具有该类型 - **THEN** 该记录不使整组命中;若组内另一条记录满足全部条件而选中该组,已软删除商品对应的可展示使用记录仍随组保留 #### Scenario: 商品批量读取失败的既有差异 - **WHEN** 关联商品批量读取发生数据库错误,而非单纯商品记录不存在 - **THEN** 后台沿用使用记录快照继续投影的既有行为;H5 沿用读取失败响应,不因共享关系投影统一为另一入口的行为 ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 ### 套餐管理 `GET /api/admin/packages`(套餐列表);`POST /api/admin/packages`(创建套餐);`DELETE /api/admin/packages/{id}`(删除套餐);`GET /api/admin/packages/{id}`(获取套餐详情);`PUT /api/admin/packages/{id}`(更新套餐);`PATCH /api/admin/packages/{id}/retail-price`(修改零售价(代理));`PATCH /api/admin/packages/{id}/shelf`(更新套餐上架状态);`PATCH /api/admin/packages/{id}/status`(更新套餐状态)。 ### 套餐系列管理 `GET /api/admin/package-series`(套餐系列列表);`POST /api/admin/package-series`(创建套餐系列);`DELETE /api/admin/package-series/{id}`(删除套餐系列);`GET /api/admin/package-series/{id}`(获取套餐系列详情);`PUT /api/admin/package-series/{id}`(更新套餐系列);`PATCH /api/admin/package-series/{id}/status`(更新套餐系列状态)。 ### 套餐使用记录 `GET /api/admin/package-usage/{id}/daily-records`(获取套餐流量详单)。 ### 代理系列授权 `GET /api/admin/shop-series-grants`(查询代理系列授权列表);`GET /api/admin/shop-series-grants/package-options`(查询代理系列授权套餐候选项);`POST /api/admin/shop-series-grants`(创建代理系列授权);`DELETE /api/admin/shop-series-grants/{id}`(删除代理系列授权);`GET /api/admin/shop-series-grants/{id}`(查询代理系列授权详情);`PUT /api/admin/shop-series-grants/{id}`(更新代理系列授权);`PUT /api/admin/shop-series-grants/{id}/packages`(管理授权套餐,支持新增、更新和删除)。 ### 批量套餐分配 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`(修改套餐分配生效条件覆盖);`POST /api/admin/shop-package-batch-allocations`(批量分配套餐)。 ### 批量套餐调价 `POST /api/admin/shop-package-batch-pricing`(批量调价)。 ### 批量订购套餐 `GET /api/admin/asset-package-batch-orders`(查询资产套餐批量订购任务列表);`POST /api/admin/asset-package-batch-orders`(创建资产套餐批量订购任务);`GET /api/admin/asset-package-batch-orders/{id}`(查询资产套餐批量订购任务详情)。