Skip to content

fix(billing): keep deleted groups out of subscription plan coverage - #1990

Merged
smartcmd merged 5 commits into
TokenFlux:mainfrom
SHLE1:fix/plan-group-references
Oct 24, 2026
Merged

smartcmd merged 5 commits into
TokenFlux:mainfrom
SHLE1:fix/plan-group-references

Conversation

@SHLE1

@SHLE1 SHLE1 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

问题

套餐记录的分组可以在分组被删除之后继续保留,订阅页于是把它显示成 #15 这样的裸编号,套餐里也一直占着一个永远匹配不到的分组位。实际触发场景是一个 Max 套餐,group_ids 里仍然列着已经删除的分组 15,同一份列表里的其他八个分组都正常显示名称。

原因在四个环节:

  • 分组删除是软删除(mixins.SoftDeleteMixin),行还留在表里,subscription_plan_groups.group_id 上的 ON DELETE CASCADE 不会触发。
  • GroupStore.DeleteCascade 清理分组授权和提供商关联,套餐侧没有对应处理。
  • NormalizePlanGroupIDs 只过滤非正数和重复值,套餐写入接受任何正整数。
  • 管理端编辑页保留套餐原有的分组选择,只为现存分组渲染复选框,这个编号在每次保存时被原样提交,界面上也没有能取消它的地方。EnrichSubscriptionPlanGroups 对无法利用的查询结果保留只有 ID 的条目,SubscriptionsView.vue 把它渲染成 #{id}。

这个编号从未带来权限,额度判定读的是同一份列表,没有分组与之匹配。它占着位置,还让订阅者看到一个没有信息量的标签。

修改

  • 管理端编辑页(PlanEditDialog.vue):分组列表加载成功后,不在列表中的编号连同倍率覆盖一起从草稿移除,并提示移除了几个。停用的分组仍可勾选;加载失败时草稿保持不变;请求代次守卫让迟到的旧响应无法用过期列表裁剪新草稿。
  • 订阅展示(EnrichSubscriptionPlanGroups):只在存储明确报告分组不存在时移除条目。返回空行、读取器缺失或读取失败都保留只有 ID 的条目,因为这些结果说明存在性仍未确定。
  • 套餐写入(Plans.CreatePlan、Plans.UpdatePlan):提交的分组选择在落库前核对现存分组,指向已删除或不存在的分组返回 GROUP_NOT_FOUND(404)。billing 定义 PlanGroupValidator,由 app 适配到 routing.ValidateGroupIDs;billing 不能直接 import routing,否则会经 provider 形成 import cycle。
  • 文档:docs/domains/payments_and_entitlements.md 记录规则与展示行为,docs/interfaces/http_api.md 补充两个套餐接口的 GROUP_NOT_FOUND 响应。

取舍

  • 更新不带 group_ids 时保留原有分组选择,也不做分组查询,已记录旧编号的套餐仍能改价格和额度,编辑页下一次保存时顺带修复。
  • 不写数据迁移:把悬空编号清成空数组会让受限套餐变成不限制分组,扩大现有订阅者的可用范围,这个决定留给运维人员。
  • 删除分组时仍然不反写套餐,校验放在写入引用的位置,避免 routing 反向依赖套餐存储。

验证

  • make test-go:后端全量测试,含 tests/integration
  • pnpm run test:run:400 个文件、3101 个测试通过
  • make lint-go:架构规则、文件布局检查、golangci-lint(0 issues)
  • make fmt-check、前端 typecheck、注释与 UI token 检查通过

新增覆盖:删除的分组连同倍率覆盖被移除并提示;停用分组及其覆盖保留;加载失败保留原有选择;迟到的旧响应不裁剪新草稿;展示层移除确认不存在的分组、保留不可读或空行的分组;创建和更新拒绝指向已删除分组的列表(含 legacy group_id 合并),省略分组时不做查询。

SHLE1 added 4 commits October 24, 2026 12:12
The plan editor loaded the stored group selection and submitted it
unchanged, while only rendering checkboxes for groups that still exist.
A group deleted after the plan was saved therefore stayed in the request
without any control that could remove it, so the plan kept naming a group
that no longer resolves.

Loading the group choices now prunes selected identifiers missing from
the loaded list together with their rate overrides, and reports how many
were dropped. A disabled group remains selectable, and a failed load
leaves the draft untouched so a temporary error cannot erase the
selection.
A plan keeps the group identifiers recorded when it was saved, so a group
deleted afterwards still appeared in a subscriber's plan card as a bare
identifier with no name. The lookup result was discarded, leaving an
identifier-only entry for every unresolved group.

Enrichment now removes an entry when storage reports that the group does
not exist, and keeps the identifier-only entry for a nil row or another
read failure, where the group's existence is undetermined.
Group deletion removes a group's permissions and provider memberships,
but plans keep the identifiers they recorded. A later plan save then
resubmitted a selection pointing at a group that no longer resolves, and
nothing rejected it because plan writes only deduplicate and drop
nonpositive identifiers.

Plan writes now confirm a declared group selection against stored groups
before persistence. CreatePlan checks the selection it normalizes, and
UpdatePlan checks the selection it is about to store, including a legacy
leading identifier. An update that leaves group_ids out keeps the
recorded selection and runs no lookup, so an unrelated edit still works
on a plan that already names a deleted group.
The plan and subscription documents described what a nonempty group
selection means without saying what happens when a selected group is
deleted. Add the write-time check, the display behaviour for a confirmed
missing group, and the editor repair, and list the plan endpoints with
their GROUP_NOT_FOUND response.
Call PlanStore directly and require a foreign-key error before checking that the earlier plan insert was rolled back.
@smartcmd
smartcmd merged commit d15137e into TokenFlux:main Oct 24, 2026
9 checks passed
@SHLE1
SHLE1 deleted the fix/plan-group-references branch October 25, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants