feat(subscription): let admins set expiry, renew or replace on grant, and grant in bulk - #6326
feat(subscription): let admins set expiry, renew or replace on grant, and grant in bulk#6326YiKongk wants to merge 2 commits into
Conversation
Admin subscription grants left no trace: neither a manage log for the target user nor an operator audit entry was written, even though the grant can also upgrade the user's group. The reset handlers in the same file already do both. Both new logs store a language-neutral op descriptor (action + params) with an English fallback content, like the other audit logs, so the frontend localizes them per viewer instead of freezing one language into the database. The MaxPurchasePerUser count in CreateUserSubscriptionFromPlanTx also ran without a row lock, so two concurrent grants could both read count-1 and both insert. Locking the user row serializes them; the limit check itself is unchanged. This covers the order, balance and admin paths at once. Locking the row also rejects grants for a non-existent user up front instead of creating an orphan subscription. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (28)
🚧 Files skipped from review as they are similar to previous changes (25)
WalkthroughSubscription administration now supports create, renew, and replace modes, custom expiration times, batch assignment with per-user results, expanded audit logging, transactional user locking, and corresponding controls in both web interfaces with localized text. ChangesSubscription grant lifecycle
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Admin
participant WebDialog
participant API
participant GrantModel
participant Audit
Admin->>WebDialog: select users, plan, mode, expiration
WebDialog->>API: submit grant request
API->>GrantModel: execute grants
GrantModel-->>API: return successes and failures
API->>Audit: record grant activity
API-->>WebDialog: return result summary
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning Tools execution failed with the following error: Failed to run tools: Stream initialization permanently failed: 13 INTERNAL: Received RST_STREAM with code 2 (Internal server error) Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
model/subscription.go (1)
801-863: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winAudit log can misreport the effective grant mode for the renew-fallback case.
When
mode == SubscriptionGrantRenewbut no active subscription exists (Lines 823-828), the function silently performs a plain create instead — butAdminBindSubscriptiononly returns(string, error), with no indication of which branch actually ran. Downstream,recordSubscriptionGrantLogsincontroller/subscription.go(Lines 433-453) logsmodel.NormalizeGrantMode(opts.Mode)— i.e. the requested mode ("renew") — even though the applied behavior was a fresh create that consumed a purchase-limit slot. Given the PR explicitly calls out "audit logs, including grant modes" as a feature, this is a real accuracy gap for auditors reviewing grant history.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` around lines 801 - 863, The renew fallback in AdminBindSubscription must expose the effective grant mode so audit logging does not record renew when a fresh subscription was created. Update AdminBindSubscription and its caller recordSubscriptionGrantLogs to propagate an effective create mode for the no-active-subscription path, while preserving renew for successful renewals and existing behavior for other modes.
🧹 Nitpick comments (6)
model/subscription.go (2)
754-763: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winNormalizeGrantMode silently coerces unrecognized modes to "create".
Any non-empty, unrecognized
Modestring (e.g. a client typo like"reneww") is silently treated ascreaterather than rejected. Sincecreatemode always inserts a new row and consumes aMaxPurchasePerUserslot, this can produce an unintended extra subscription instead of surfacing a clear input error.🛡️ Proposed fix: reject unknown non-empty modes
-func NormalizeGrantMode(mode string) string { - switch strings.TrimSpace(mode) { - case SubscriptionGrantRenew: - return SubscriptionGrantRenew - case SubscriptionGrantReplace: - return SubscriptionGrantReplace - default: - return SubscriptionGrantCreate - } -} +func NormalizeGrantMode(mode string) (string, error) { + switch strings.TrimSpace(mode) { + case "", SubscriptionGrantCreate: + return SubscriptionGrantCreate, nil + case SubscriptionGrantRenew: + return SubscriptionGrantRenew, nil + case SubscriptionGrantReplace: + return SubscriptionGrantReplace, nil + default: + return "", fmt.Errorf("invalid grant mode: %s", mode) + } +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` around lines 754 - 763, Update NormalizeGrantMode to reject unknown non-empty, trimmed mode values instead of mapping them to SubscriptionGrantCreate. Preserve the existing renew and replace results, and retain create as the default only when the input is empty; propagate the invalid-mode error through the caller’s existing validation path.
515-522: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winReuse
lockedUser.Grouphere.getUserGroupByIdTx(tx, userId)reads the sameusers.groupvalue again, so this adds a redundant query while the row lock is held. UsinglockedUser.Groupremoves the extra round-trip and shortens lock time.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` around lines 515 - 522, Replace the subsequent getUserGroupByIdTx(tx, userId) lookup with lockedUser.Group in the purchase-limit flow after the lockForUpdate query. Reuse the already locked User record while preserving the existing group-dependent behavior and error handling.model/subscription_admin_grant_test.go (1)
309-338: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMissing test for batch input deduplication.
The PR explicitly calls out deduplicated batch input as a feature, but no test exercises
AdminBindSubscriptionBatchwith auserIdsslice containing repeats (e.g.[]int{101, 101, 102}) to confirm it's only granted/counted once.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription_admin_grant_test.go` around lines 309 - 338, Add coverage to TestAdminBindSubscriptionBatchReportsPerUserFailures or a focused batch test that calls AdminBindSubscriptionBatch with duplicate user IDs, such as 101, 101, and 102. Assert each unique user is processed and counted only once, including success/failure totals and user ID lists, confirming duplicate input does not create repeated grants or results.controller/subscription.go (1)
358-380: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate grant-and-respond flow across two handlers.
AdminBindSubscription(Lines 358-380) andAdminCreateUserSubscription(Lines 492-519) repeat the same validate → buildAdminGrantOptions→model.AdminBindSubscription→recordSubscriptionGrantLogs→ respond sequence, differing only in howuserId/planIdare sourced. Since there are two call sites for this exact sequence, extracting it into a shared helper aligns with the guideline to use separate functions for reusable behavior.As per coding guidelines, "Use separate functions for reusable behavior, required interface/framework callbacks, exported APIs, test fixtures, or complex business logic deserving direct tests."♻️ Proposed extraction
+func grantSubscriptionAndRespond(c *gin.Context, userId int, planId int, mode string, endTime int64) { + opts := model.AdminGrantOptions{Mode: mode, EndTime: endTime} + msg, err := model.AdminBindSubscription(userId, planId, opts) + if err != nil { + common.ApiError(c, err) + return + } + recordSubscriptionGrantLogs(c, userId, planId, opts) + if msg != "" { + common.ApiSuccess(c, gin.H{"message": msg}) + return + } + common.ApiSuccess(c, nil) +}Also applies to: 492-519
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription.go` around lines 358 - 380, Extract the shared grant-and-response sequence from AdminBindSubscription and AdminCreateUserSubscription into a helper that accepts userId, planId, and AdminGrantOptions, invokes model.AdminBindSubscription, records grant logs, and writes the appropriate success or error response. Update both handlers to retain only their distinct request validation and ID sourcing while delegating this reusable flow to the helper.Source: Coding guidelines
web/default/src/features/subscriptions/constants.ts (1)
65-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueTighten
modeparameter typing toSubscriptionGrantMode.
getGrantModeOptions,getGrantModeDescription, andgetEndTimeHinttypemodeasstring. SinceSubscriptionGrantModealready exists intypes.tsand all call sites pass aSubscriptionGrantModevalue, using the union type here would give compile-time safety against typos/invalid modes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/default/src/features/subscriptions/constants.ts` around lines 65 - 86, Update getGrantModeDescription and getEndTimeHint to type their mode parameters as SubscriptionGrantMode instead of string, importing the existing type from types.ts. Leave getGrantModeOptions unchanged because it does not accept a mode parameter.web/default/src/features/subscriptions/components/dialogs/batch-assign-subscription-dialog.tsx (1)
209-255: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCustom-expiration-time UI block is duplicated with
user-subscriptions-dialog.tsx.The DateTimePicker + preset-button block (mode select, description, expiration controls, hint) is duplicated near-verbatim between this file and
user-subscriptions-dialog.tsx. Extracting a shared<GrantExpirationFields mode setMode endTime setEndTime />component/hook would reduce duplication and, notably, would have prevented the "Plan default" bug above from existing in two places independently.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/default/src/features/subscriptions/components/dialogs/batch-assign-subscription-dialog.tsx` around lines 209 - 255, Extract the duplicated custom-expiration UI and related state/handlers from the batch assignment dialog and user subscriptions dialog into a shared GrantExpirationFields component or hook. Reuse it in both callers with mode, setMode, endTime, and setEndTime inputs, preserving the existing DateTimePicker, preset options, descriptions, and hint behavior while ensuring the Plan default fix is implemented in one shared location.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@web/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsx`:
- Around line 330-376: Update the “Plan default” button in the user
subscriptions dialog to clear endTime rather than assigning the truthy Date
returned by addTimeToDate(0, 0, 0). Preserve the existing handleCreate behavior
so a cleared endTime sends end_time as 0 and uses the plan’s default duration.
In `@web/default/src/features/usage-logs/lib/format.ts`:
- Around line 387-392: Update the subscription.admin_grant_batch template in the
audit text definitions to include the provided {{mode}} placeholder, matching
the single-grant subscription.admin_grant message while preserving the existing
plan and count details.
In `@web/default/src/i18n/locales/ja.json`:
- Line 238: Update the Japanese translation for “Admin granted you subscription
plan {{plan_title}} (ID: {{plan_id}})” to explicitly include `あなたに` after
`管理者が`, preserving the existing placeholders and remaining wording.
- Line 3346: Update the “Plan default” translation in the Japanese locale to a
natural label, using either 「プランの既定値」 or 「プランのデフォルト」 instead of 「プラン既定」.
---
Outside diff comments:
In `@model/subscription.go`:
- Around line 801-863: The renew fallback in AdminBindSubscription must expose
the effective grant mode so audit logging does not record renew when a fresh
subscription was created. Update AdminBindSubscription and its caller
recordSubscriptionGrantLogs to propagate an effective create mode for the
no-active-subscription path, while preserving renew for successful renewals and
existing behavior for other modes.
---
Nitpick comments:
In `@controller/subscription.go`:
- Around line 358-380: Extract the shared grant-and-response sequence from
AdminBindSubscription and AdminCreateUserSubscription into a helper that accepts
userId, planId, and AdminGrantOptions, invokes model.AdminBindSubscription,
records grant logs, and writes the appropriate success or error response. Update
both handlers to retain only their distinct request validation and ID sourcing
while delegating this reusable flow to the helper.
In `@model/subscription_admin_grant_test.go`:
- Around line 309-338: Add coverage to
TestAdminBindSubscriptionBatchReportsPerUserFailures or a focused batch test
that calls AdminBindSubscriptionBatch with duplicate user IDs, such as 101, 101,
and 102. Assert each unique user is processed and counted only once, including
success/failure totals and user ID lists, confirming duplicate input does not
create repeated grants or results.
In `@model/subscription.go`:
- Around line 754-763: Update NormalizeGrantMode to reject unknown non-empty,
trimmed mode values instead of mapping them to SubscriptionGrantCreate. Preserve
the existing renew and replace results, and retain create as the default only
when the input is empty; propagate the invalid-mode error through the caller’s
existing validation path.
- Around line 515-522: Replace the subsequent getUserGroupByIdTx(tx, userId)
lookup with lockedUser.Group in the purchase-limit flow after the lockForUpdate
query. Reuse the already locked User record while preserving the existing
group-dependent behavior and error handling.
In
`@web/default/src/features/subscriptions/components/dialogs/batch-assign-subscription-dialog.tsx`:
- Around line 209-255: Extract the duplicated custom-expiration UI and related
state/handlers from the batch assignment dialog and user subscriptions dialog
into a shared GrantExpirationFields component or hook. Reuse it in both callers
with mode, setMode, endTime, and setEndTime inputs, preserving the existing
DateTimePicker, preset options, descriptions, and hint behavior while ensuring
the Plan default fix is implemented in one shared location.
In `@web/default/src/features/subscriptions/constants.ts`:
- Around line 65-86: Update getGrantModeDescription and getEndTimeHint to type
their mode parameters as SubscriptionGrantMode instead of string, importing the
existing type from types.ts. Leave getGrantModeOptions unchanged because it does
not accept a mode parameter.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: afd511a8-4d2e-4ee9-9e8f-7f9e29b8078c
📒 Files selected for processing (28)
controller/audit.gocontroller/subscription.gomodel/db_time.gomodel/subscription.gomodel/subscription_admin_grant_test.gorouter/api-router.goweb/classic/src/components/table/users/modals/UserSubscriptionsModal.jsxweb/classic/src/i18n/locales/en.jsonweb/classic/src/i18n/locales/fr.jsonweb/classic/src/i18n/locales/ja.jsonweb/classic/src/i18n/locales/ru.jsonweb/classic/src/i18n/locales/vi.jsonweb/classic/src/i18n/locales/zh-CN.jsonweb/classic/src/i18n/locales/zh-TW.jsonweb/default/src/features/subscriptions/api.tsweb/default/src/features/subscriptions/components/dialogs/batch-assign-subscription-dialog.tsxweb/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsxweb/default/src/features/subscriptions/constants.tsweb/default/src/features/subscriptions/types.tsweb/default/src/features/usage-logs/lib/format.tsweb/default/src/features/users/components/data-table-bulk-actions.tsxweb/default/src/i18n/locales/en.jsonweb/default/src/i18n/locales/fr.jsonweb/default/src/i18n/locales/ja.jsonweb/default/src/i18n/locales/ru.jsonweb/default/src/i18n/locales/vi.jsonweb/default/src/i18n/locales/zh-TW.jsonweb/default/src/i18n/locales/zh.json
cbdaedb to
94ef36b
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/classic/src/i18n/locales/ja.json`:
- Line 1940: Update the Japanese subscription mode labels in the locale entries
for 「替换现有」 and 「延长现有」 to 「既存のサブスクリプションを置き換える」 and 「既存のサブスクリプションを延長する」
respectively, including the corresponding entry referenced elsewhere in the
file.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 308320bf-72c4-47fb-9455-61434e4216ba
📒 Files selected for processing (28)
controller/audit.gocontroller/subscription.gomodel/db_time.gomodel/subscription.gomodel/subscription_admin_grant_test.gorouter/api-router.goweb/classic/src/components/table/users/modals/UserSubscriptionsModal.jsxweb/classic/src/i18n/locales/en.jsonweb/classic/src/i18n/locales/fr.jsonweb/classic/src/i18n/locales/ja.jsonweb/classic/src/i18n/locales/ru.jsonweb/classic/src/i18n/locales/vi.jsonweb/classic/src/i18n/locales/zh-CN.jsonweb/classic/src/i18n/locales/zh-TW.jsonweb/default/src/features/subscriptions/api.tsweb/default/src/features/subscriptions/components/dialogs/batch-assign-subscription-dialog.tsxweb/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsxweb/default/src/features/subscriptions/constants.tsweb/default/src/features/subscriptions/types.tsweb/default/src/features/usage-logs/lib/format.tsweb/default/src/features/users/components/data-table-bulk-actions.tsxweb/default/src/i18n/locales/en.jsonweb/default/src/i18n/locales/fr.jsonweb/default/src/i18n/locales/ja.jsonweb/default/src/i18n/locales/ru.jsonweb/default/src/i18n/locales/vi.jsonweb/default/src/i18n/locales/zh-TW.jsonweb/default/src/i18n/locales/zh.json
🚧 Files skipped from review as they are similar to previous changes (26)
- router/api-router.go
- web/default/src/features/subscriptions/api.ts
- web/classic/src/i18n/locales/fr.json
- web/default/src/features/usage-logs/lib/format.ts
- web/default/src/features/subscriptions/constants.ts
- controller/audit.go
- web/default/src/features/users/components/data-table-bulk-actions.tsx
- web/classic/src/i18n/locales/zh-TW.json
- web/classic/src/i18n/locales/zh-CN.json
- web/classic/src/i18n/locales/vi.json
- model/db_time.go
- web/classic/src/components/table/users/modals/UserSubscriptionsModal.jsx
- web/classic/src/i18n/locales/en.json
- model/subscription_admin_grant_test.go
- web/classic/src/i18n/locales/ru.json
- web/default/src/features/subscriptions/components/dialogs/batch-assign-subscription-dialog.tsx
- web/default/src/features/subscriptions/types.ts
- web/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsx
- model/subscription.go
- web/default/src/i18n/locales/vi.json
- web/default/src/i18n/locales/ja.json
- web/default/src/i18n/locales/zh.json
- web/default/src/i18n/locales/zh-TW.json
- web/default/src/i18n/locales/fr.json
- web/default/src/i18n/locales/ru.json
- web/default/src/i18n/locales/en.json
94ef36b to
995a5de
Compare
… and grant in bulk Admin subscription grants could only create a new record following the plan's own duration. Three gaps this closes: - custom expiry: pass an absolute end time instead of the plan duration - renew/replace: renew extends the existing active subscription in place (an UPDATE, so it does not consume another MaxPurchasePerUser slot) and falls back to creating one when there is none; replace cancels the existing active rows first, then inserts the new one with the purchase limit skipped (the swap is net-zero on active subscriptions) and the cancelled rows' PrevUserGroup carried over, so expiry can still revert the user to their origin group. Default stays "create", so existing behaviour is unchanged. - batch: POST /subscription/admin/bind/batch grants one plan to several users, each in its own transaction, reporting per-user failures instead of rolling the whole set back. The failed list serializes as [] rather than null so the result dialog can index into it unconditionally. Renew keeps an already-scheduled future quota reset so mid-cycle renewals do not shift base-relative (custom-period) schedules; it only recomputes the schedule when it was switched off or the new end time invalidates it (calcNextResetTime zeroes the schedule when the next reset would fall past the end time). GetDBTimestamp read through the global DB handle, which checks out a second pooled connection when called inside a transaction. The subscription paths now read it through their tx, which also makes them testable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
995a5de to
fd072dc
Compare
|
@coderabbitai The remaining findings from the first review pass have been addressed in fd072dc:
Deferred to a follow-up PR to keep this one focused:
|
|
Tip For best results, initiate chat on the files or code changes.
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
Important
📝 变更描述 / Description
管理员为用户开通订阅目前仅支持「按套餐默认周期新建一条记录」,无法满足以下常见运营场景:
具体改动
授予模式(
mode):create/renew/replace,默认create;不传该参数时行为与现状完全一致。renew:定位该用户同套餐生效中的订阅,直接 UPDATE 其end_time而非新增记录。由于不新增记录,续期不占用MaxPurchasePerUser名额;无生效记录时自动回退为create。replace:先将该用户同套餐生效中的记录置为cancelled,再新建一条。两个必要的配套处理:create一致。upgrade_group,PrevUserGroup快照将取到空值;若不处理,替换产生的订阅到期后用户将永久保留升级分组(downgrade_group为空、依赖"回退到购买前分组"配置的套餐均受影响)。因此 replace 将被取消记录的PrevUserGroup带入新记录,保证到期降组链路完整。自定义到期时间(
end_time):Unix 秒;传 0 或不传时按套餐周期计算。传入正数时直接将到期时间设为该绝对时间戳(可能早于原到期时间,即缩短);传 0 时在max(现有到期时间, 当前时间)基础上叠加一个套餐周期,提前续期不损失剩余时长。UI 对两种语义均有明确提示。关于配额重置计划:
calcNextResetTime在下次重置时间晚于到期时间时返回 0(即关闭重置计划),因此 renew 延长end_time后需考虑重置计划的恢复;同时 custom 周期的重置基于基准时间,若在周期中途无条件重算,会造成已排定的重置被跳过、后一次又提前触发。最终实现为:已排定且仍然有效的重置时间保持不变,仅在其已被清零或被新的到期时间置为无效时重算。两种情形均有测试覆盖。批量授予:新增
POST /api/subscription/admin/bind/batch。每个用户使用独立事务,单个用户失败(限购已满、账号不存在等)不回滚其他用户;返回success_count/failed_count及逐用户失败原因,并对入参去重。failed字段显式初始化为空切片,全部成功时序列化为[]而非null,前端可直接遍历。日志:成功的授予按 #6325 引入的模式为每个用户写入 manage 日志(
subscription.grantedop 描述符,前端按查看者语言渲染);操作者审计新增subscription.admin_grant_batch,单用户授予的审计参数补充mode。附带修复
GetDBTimestamp()通过全局DB句柄执行查询,在DB.Transaction内调用时会额外占用一条连接池连接直至事务结束。订阅相关事务路径改为通过所在事务读取(getDBTimestampFrom(tx))。这同时解决了订阅事务路径无法编写单元测试的问题:测试使用连接数限制为 1 的内存 SQLite,原实现会在等待第二条连接时死锁。前端
web/default:单用户弹窗新增模式选择与DateTimePicker(复用兑换码抽屉的「快捷时长按钮 + 时间选择器」组合);用户表格批量操作栏新增「批量分配订阅」入口,复用现有 row selection;新增批量结果弹窗,展示成功数与失败明细。到期时间提示文案抽取为共享的getEndTimeHint,renew 模式留空时明确提示「在当前到期时间基础上顺延一个套餐周期」。web/classic:单用户弹窗新增RadioGroup模式选择与DatePicker。批量入口未实现——classic 的CardTable尚无 rowSelection 支持,实现成本高于收益。两套前端的 i18n 均已补齐全部语言。
🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。📸 运行证明 / Proof of Work
后端:
前端:
(三种模式的操作界面与批量分配结果面板截图见下方评论区补充。)
Summary by CodeRabbit
New Features
Bug Fixes
Tests