Skip to content

v0.0.20

Choose a tag to compare

@github-actions github-actions released this 13 Sep 08:58
· 1041 commits to master since this release

v0.0.20 — 管理后台 UI 全面优化 + 定价 Id 清零兜底

渠道 / 令牌管理界面对齐 web/default 老前端体验:模型可搜索多选、模型重定向、分组下拉、永不过期;模型下拉数据源从编译期硬编码 catalogue 切换到运行时可配置的 model_price 表。同时修复「新建令牌触发 Page is not found 报错」与「定价表单残留 id 写入」两条链路隐患。

Channel & token admin UIs catch up with the reference web/default experience: searchable model multi-select, model redirect, group dropdown, "never expire" toggle. The model dropdown source is switched from the compile-time hardcoded catalogue to the runtime-configurable model_price table. Also fixes the "Page is not found" ReferenceError on token create and the residual-id-leak path in pricing forms.

中文

✨ 新增功能

  • 渠道编辑弹窗:模型字段改为支持搜索的多选选择器(web/default-pro/src/views/channel/Channel.vue):
    • 把原本「逗号分隔字符串」输入框替换为 arco a-select(multiple + allow-search + allow-clear),挂载时拉取 /api/channel/models 作为候选。
    • form.models 由 string 改为 []string,提交时 join(',') 复用后端逗号存储格式;编辑时 parseModelsField 把后端返回字符串拆回数组。
    • 当前表单里已存在的模型名会自动合入候选,保留历史「野模型」可见性。
  • 渠道编辑弹窗:新增「模型重定向」输入框(Channel.vue):
    • 新增 model_mapping 字段(对应后端 channels.model_mapping),使用 a-textarea 接受 JSON 对象,提交前用 JSON.parse + typeof === 'object' 校验必须为普通对象。
    • 后端字段早已存在(model/channel.go:38),前端之前未暴露 — 现在与 web/default 老前端 EditChannel 表单对齐。
  • 渠道编辑弹窗:分组改为下拉选择器并移到名称下方(Channel.vue):
    • 把逗号分隔 a-input 改为 arco a-select(multiple + allow-search + allow-clear),候选来自 /api/group/(后端 controller/group.go)。
    • form-item 从「模型」下方移到「名称」下方,对齐 web/default 老前端顺序。
    • form.groups 改为数组,提交时 join(',') 复用后端存储格式。
  • 渠道编辑弹窗:模型下拉数据源切换到 model_price 表(Channel.vue:fetchAvailableModels):
    • 下拉候选改读 GET /api/model_price/options(admin 权限即可访问),与计费数据源对齐。
    • 运营无需改代码就能给下拉里加新模型,只要在「定价管理」里配好对应 model_name 即可。
    • 仍保留 form.models 合入候选的兜底,编辑历史渠道时已选模型不会丢。
  • 新增只读接口 GET /api/model_price/options(controller/model_price.go):
    • 仅返回 enabled=true 的 model_name 字符串切片,按名称升序;空 model_name 自动跳过避免脏数据。
    • AdminAuth 即可访问,与 RootAuth 保护的写接口隔离;旧 GET /api/model_price/ 加 // Deprecated: 注释保留给 root 查看完整字段。
  • 令牌编辑弹窗:过期时间新增「永不过期」checkbox(Token.vue):
    • 过期时间输入框右侧增加「永不过期」checkbox,勾选后 date-picker 禁用。
    • 新增 form.never_expire 字段;编辑时若现有 expired_time=0 自动勾选。
    • 提交时若勾选或时间留空,expired_time 上报 0(对齐后端「0 = 永不过期」约定);表单底部 #extra 文案随勾选状态切换。
  • 令牌编辑弹窗:额度限制右侧增加「不限制额度」checkbox(Token.vue):
    • 把原本独立成行的 checkbox 折叠到额度限制输入框内右侧,引入通用 .input-with-checkbox 横排布局供「永不过期」复用。

🐛 问题修复

  • 修复新建令牌后弹「Page is not found」报错(Token.vue:handleSubmit):
    • 原代码误写为 page.value = 0,实际变量是 activePage,触发 ReferenceError 后错误冒泡为「Page is not found」提示。
    • 改为 activePage.value = 1,新建成功后回到第一页。
  • 修复定价弹窗新建时残留 id 写入数据库(4 层防御):
    • 前端 PricingSetting.vue 新建时清空 mpForm.id / gpForm.id,避免把编辑态的旧 id 拼到 POST 请求体。
    • controller.AddModelPrice / AddGroupPrice 在 c.ShouldBindJSON 之后强制 price.Id = 0,即便前端漏改也不会用客户端 id 写入。
    • model.ModelPrice.Insert / GroupPrice.Insert 兜底清零 p.Id = 0,防御外部调用方 / 未来 caller 把编辑态旧 id 误传到 Insert() 时撞主键冲突。
    • 这条链路任何一层失守都不会污染数据,但三层全设防是因为该 bug 一旦发生就是主键冲突级别的数据事故。

🔧 重构

  • 额度限制 checkbox 移入输入框右侧(Token.vue):合并独立 <a-form-item> 到输入框内,引入通用 .input-with-checkbox flex row 样式(12px gap,checkbox 强制不换行)。
  • 模型重定向输入框与提示改为上下排列(Channel.vue):用 .form-stack 容器把 textarea 与示例提示竖向排列,新增 .form-hint-block 修饰符覆盖 .form-hint 的 margin-left、改 margin-top,其他 inline hint(key / fallback / fallback_priority)的样式不受影响。

🎨 样式优化

  • 过期时间日期选择器移除右侧日历图标(Token.vue):给 <a-date-picker> 传空 <template #suffix-icon></template> 槽覆盖 arco 默认的 IconCalendar,让 date-picker 视觉宽度与下方 <a-input-number> 对齐。
  • 模型重定向提示改用 pre-wrap + break-all(Channel.vue):JSON 示例文本较长时自动换行,不再被单行截断。

🧪 测试

  • ModelPrice / GroupPrice Insert 强制清零 Id 兜底行为(model/model_price_test.go):新增单测覆盖两条 Insert 路径在 Id != 0 时被清零的兜底。
  • AddModelPrice / AddGroupPrice HTTP 层 Id 清零端到端路径(controller/model_price_test.go):seed 一个 id=9999 的预存行,再用同样的 id=9999 提交新增,断言:旧行未被覆盖,新行拿到自增 id 而不是 9999。
  • user_test TestMain 迁移 ModelPrice / GroupPrice 表(controller/user_test.go):让 ModelPrice 与 GroupPrice 在 in-memory SQLite 里参与 AutoMigrate,供定价单测复用。
  • ListModelPriceOptions enabled 过滤与空名兜底(controller/model_price_test.go):seed 2 条 enabled + 1 条 disabled(用 raw SQL 绕开 GORM default:true;not null 对显式 false 的覆盖),断言只有 enabled 行出现在响应里;另一条 seed 空 model_name 断言响应里不出现空字符串。

⚠️ 升级注意事项

  • 零数据库迁移:本次不涉及任何表结构变更。
  • 需要重新构建前端:web/default-pro/ 的修改全部需要 npm run build 后重新 //go:embed 进二进制,升级时记得打包新前端。
  • 后端二进制必须重启:仅新增 ListModelPriceOptions handler 与对应路由,运行中的旧进程不会暴露 /api/model_price/options。
  • 行为变更:
    • 渠道编辑弹窗「模型」下拉候选从「编译期硬编码 catalogue」切换为「运行时 model_price 表中 enabled=true 的 model_name」。历史渠道如果选了 catalogue 有但 model_price 没有的「野模型」(如 gpt-4-0314、text-davinci-003 等历史快照),下拉里将看不到这些选项 — 但前端仍会把 form.models 合入候选,编辑现有渠道时已选模型仍可见,保存链路无破坏。如需把历史模型纳入下拉,请在「定价管理」里手动添加对应 model_name 行。
    • 渠道编辑弹窗「分组」字段改为下拉选择器,候选来自后端 model.GroupPrice 表(/api/group/ 接口),默认 3 个分组(default / vip / svip)。如需更多分组,请在「定价管理」里给 group_prices 表加行。
    • 令牌编辑弹窗「永不过期」与「不限制额度」checkbox 默认不勾选;编辑现有令牌时,后端值为 0 / -1 会自动反推勾选对应 checkbox(never_expire = !exp,unlimited_quota = !!record.unlimited_quota)。
  • 构建与测试:
    • go build ./... 通过。
    • go test ./controller/ ./model/ ./middleware/ 全绿(新增 4 个定价 / 模型选项相关断言)。
    • cd web/default-pro && npm run build 通过。

English

✨ New Features

  • Channel modal: searchable multi-select for the model field (web/default-pro/src/views/channel/Channel.vue):
    • Replaced the comma-separated a-input with an arco a-select (multiple + allow-search + allow-clear); the option source is /api/channel/models on mount.
    • form.models switched from string to []string; submissions join(',') to keep the backend's stored format; parseModelsField splits the backend string back into an array on edit.
    • Models already present on the form are unioned into the option set so legacy "wild" models stay visible when reopening the dialog.
  • Channel modal: new "Model Redirect" textarea (Channel.vue):
    • Adds the model_mapping field (maps to backend channels.model_mapping) backed by a-textarea accepting a JSON object; submission is gated by JSON.parse + typeof === 'object' validation.
    • The backend column has always existed (model/channel.go:38); the front-end simply never exposed it. Now aligned with the reference web/default EditChannel form.
  • Channel modal: groups converted to a multi-select dropdown and moved under the name field (Channel.vue):
    • Replaced the comma-separated a-input with arco a-select (multiple + allow-search + allow-clear) sourced from /api/group/ (controller/group.go).
    • The form-item moved from under "Models" to under "Name", matching the reference web/default order.
    • form.groups switched to an array; submissions join(',') to preserve the backend's stored format.
  • Channel modal: model dropdown source switched to the model_price table (Channel.vue:fetchAvailableModels):
    • The dropdown now reads GET /api/model_price/options (admin-accessible), aligning with the billing source of truth.
    • Operators can add a model to the dropdown without code changes — just add a row in Pricing with the desired model_name.
    • The form.models union fallback is preserved so re-opening the edit dialog on a historical channel still shows its saved selection.
  • New read-only endpoint GET /api/model_price/options (controller/model_price.go):
    • Returns only enabled=true model_name strings, sorted ascending; rows with empty model_name are silently skipped to avoid dirty data in the dropdown.
    • AdminAuth-accessible; isolated from the RootAuth-protected write endpoints. The old GET /api/model_price/ now carries a // Deprecated: comment and stays for root admins to view the full pricing payload.
  • Token modal: "Never Expire" checkbox next to the expiry picker (Token.vue):
    • Adds a "永不过期" checkbox to the right of the expiry picker; checking it disables the picker.
    • Adds form.never_expire to the form state; editing auto-checks it when the existing expired_time=0.
    • On submit, expired_time is sent as 0 when "Never Expire" is checked or the picker is empty (matching the backend's 0 = never expire convention); the helper text under the field toggles with the checked state.
  • Token modal: "Unlimited Quota" checkbox folded inline with the quota input (Token.vue):
    • The previously standalone checkbox form-item is collapsed into the right side of the remain_quota input. The shared .input-with-checkbox flex row layout is reused for the new "Never Expire" checkbox.

🐛 Bug Fixes

  • "Page is not found" ReferenceError after creating a token (Token.vue:handleSubmit):
    • The code mistakenly wrote page.value = 0 (the actual variable is activePage); the ReferenceError surfaced as a stray "Page is not found" toast.
    • Replaced with activePage.value = 1 so a successful create lands on page 1.
  • Residual-id leakage when creating pricing entries via the pricing modal (4-layer defence):
    • Frontend PricingSetting.vue clears mpForm.id / gpForm.id on a fresh "Add" click so the POST body never carries the previously-edited id.
    • controller.AddModelPrice / AddGroupPrice force price.Id = 0 after c.ShouldBindJSON, so even a buggy client cannot persist a client-supplied id.
    • model.ModelPrice.Insert / GroupPrice.Insert defensively zero p.Id = 0 as a final guard against future callers passing an edit-state id into Insert().
    • Any single layer being skipped is harmless on its own; we keep all three because a failure here is a primary-key collision level data incident.

🔧 Refactor

  • "Unlimited Quota" checkbox moved inline with the quota input (Token.vue): the standalone <a-form-item> is folded into the input row; the shared .input-with-checkbox flex row (12px gap, nowrap checkbox) is also reused by the new "Never Expire" checkbox.
  • Model Redirect input and hint stacked vertically (Channel.vue): wrapped in a .form-stack flex column; new .form-hint-block modifier overrides .form-hint's margin-left in favour of margin-top, leaving other inline hints (key / fallback / fallback_priority) untouched.

🎨 Style

  • Date-picker suffix icon removed (Token.vue): passing <template #suffix-icon></template> overrides arco's default IconCalendar, aligning the picker width with the <a-input-number> on the remain_quota row.
  • Model Redirect hint uses pre-wrap + break-all (Channel.vue): long JSON sample text wraps naturally instead of being truncated to a single line.

🧪 Tests

  • ModelPrice / GroupPrice Insert Id-zeroing fallback (model/model_price_test.go): asserts Insert() strips a non-zero Id to avoid primary-key collisions.
  • AddModelPrice / AddGroupPrice HTTP-layer Id-zeroing end-to-end (controller/model_price_test.go): seeds a pre-existing row at id=9999, then submits an "add" with id=9999; asserts the old row is intact and the new row gets an auto-incremented id rather than 9999.
  • user_test TestMain migrated to include ModelPrice / GroupPrice tables (controller/user_test.go): the in-memory SQLite now auto-migrates ModelPrice and GroupPrice so the pricing tests share the same harness.
  • ListModelPriceOptions enabled-filter + empty-name guard (controller/model_price_test.go): seeds 2 enabled + 1 disabled row (raw SQL bypasses GORM's default:true;not null coercion on explicit false); asserts only enabled rows are returned and an empty model_name never leaks into the payload.

⚠️ Upgrade Notes

  • Zero database migration: no schema changes.
  • Frontend rebuild required: all changes under web/default-pro/ require npm run build so the new assets are re-embedded into the Go binary via //go:embed.
  • Backend binary restart required: the new ListModelPriceOptions handler and its route only become available after the new binary is running.
  • Behavior changes:
    • The channel modal's "Model" dropdown source is switched from the compile-time catalogue to the runtime model_price table. Historical channels that have saved models absent from model_price (e.g. gpt-4-0314, text-davinci-003) will not see those options in the dropdown — but the form.models union fallback keeps the saved selection visible when re-opening the edit dialog, so the save path remains intact. To bring a historical model into the dropdown, add a row in Pricing with the corresponding model_name.
    • The channel modal's "Groups" field is now a dropdown sourced from /api/group/ (defaults to default / vip / svip from group_prices). Add a group_prices row to expose additional groups.
    • The token modal's "Never Expire" and "Unlimited Quota" checkboxes default to unchecked; editing an existing token auto-checks them when the backend value is 0 / -1 (never_expire = !exp, unlimited_quota = !!record.unlimited_quota).
  • Build & test:
    • go build ./... passes.
    • go test ./controller/ ./model/ ./middleware/ is green (4 new pricing / model-option assertions).
    • cd web/default-pro && npm run build passes.