refactor(channels): normalize at cache fill and eliminate frontend as-cast
- channel.go: convert normalizeBillingModelSource into a (*Channel) method for entity cohesion - channel_service.go: normalize in populateChannelCache so every cache-backed reader (gateway, billing, future endpoints) sees the default; drop the duplicate fallback inside resolveMapping - table: tighten Row with status?: ChannelStatus / billing_model_source?: BillingModelSource, remove the [key: string]: unknown index signature - admin view: drop the `as ChannelStatus` / `as BillingModelSource` assertions and add statusStyleOf / billingSourceLabelOf helpers with runtime fallback so unseen values render as "-" instead of crashing
This commit is contained in:
@@ -111,6 +111,18 @@ func (c *Channel) IsActive() bool {
|
|||||||
return c.Status == StatusActive
|
return c.Status == StatusActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeBillingModelSource 若 BillingModelSource 为空则回填默认值 ChannelMapped。
|
||||||
|
// 作为 *Channel 的实体方法集中管理默认值,service 层只需在 Channel 进入内存
|
||||||
|
// (缓存装填、repo 读出)时调用一次,下游读路径就无需重复兜底。
|
||||||
|
func (c *Channel) normalizeBillingModelSource() {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.BillingModelSource == "" {
|
||||||
|
c.BillingModelSource = BillingModelSourceChannelMapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetModelPricing 根据模型名查找渠道定价,未找到返回 nil。
|
// GetModelPricing 根据模型名查找渠道定价,未找到返回 nil。
|
||||||
// 精确匹配,大小写不敏感。返回值拷贝,不污染缓存。
|
// 精确匹配,大小写不敏感。返回值拷贝,不污染缓存。
|
||||||
func (c *Channel) GetModelPricing(model string) *ChannelModelPricing {
|
func (c *Channel) GetModelPricing(model string) *ChannelModelPricing {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
|
|||||||
}
|
}
|
||||||
sort.SliceStable(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
|
sort.SliceStable(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
|
||||||
|
|
||||||
normalizeBillingModelSource(ch)
|
ch.normalizeBillingModelSource()
|
||||||
|
|
||||||
out = append(out, AvailableChannel{
|
out = append(out, AvailableChannel{
|
||||||
ID: ch.ID,
|
ID: ch.ID,
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ func (s *ChannelService) fetchChannelData(ctx context.Context) ([]Channel, map[i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// populateChannelCache 将渠道列表和分组平台映射填充到缓存快照中。
|
// populateChannelCache 将渠道列表和分组平台映射填充到缓存快照中。
|
||||||
|
// 装填时对每个 Channel 统一归一化 BillingModelSource,让缓存命中的所有下游
|
||||||
|
// (gateway routing / billing / 未来任何 cache-backed 读路径)都拿到已归一化的实体,
|
||||||
|
// 避免"每个出口各自记得 normalize"反模式。
|
||||||
func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *channelCache {
|
func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *channelCache {
|
||||||
cache := newEmptyChannelCache()
|
cache := newEmptyChannelCache()
|
||||||
cache.groupPlatform = groupPlatforms
|
cache.groupPlatform = groupPlatforms
|
||||||
@@ -308,6 +311,7 @@ func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *
|
|||||||
cache.loadedAt = time.Now()
|
cache.loadedAt = time.Now()
|
||||||
|
|
||||||
for i := range channels {
|
for i := range channels {
|
||||||
|
channels[i].normalizeBillingModelSource()
|
||||||
ch := &channels[i]
|
ch := &channels[i]
|
||||||
cache.byID[ch.ID] = ch
|
cache.byID[ch.ID] = ch
|
||||||
for _, gid := range ch.GroupIDs {
|
for _, gid := range ch.GroupIDs {
|
||||||
@@ -518,14 +522,13 @@ func (s *ChannelService) ResolveChannelMappingAndRestrict(ctx context.Context, g
|
|||||||
// resolveMapping 基于已查找的渠道信息解析模型映射。
|
// resolveMapping 基于已查找的渠道信息解析模型映射。
|
||||||
// antigravity 分组依次尝试所有匹配平台,确保跨平台同名映射各自独立。
|
// antigravity 分组依次尝试所有匹配平台,确保跨平台同名映射各自独立。
|
||||||
func resolveMapping(lk *channelLookup, groupID int64, model string) ChannelMappingResult {
|
func resolveMapping(lk *channelLookup, groupID int64, model string) ChannelMappingResult {
|
||||||
|
// lk.channel 来自已装填的缓存,BillingModelSource 已在 populateChannelCache 阶段归一化,
|
||||||
|
// 这里无需重复兜底。
|
||||||
result := ChannelMappingResult{
|
result := ChannelMappingResult{
|
||||||
MappedModel: model,
|
MappedModel: model,
|
||||||
ChannelID: lk.channel.ID,
|
ChannelID: lk.channel.ID,
|
||||||
BillingModelSource: lk.channel.BillingModelSource,
|
BillingModelSource: lk.channel.BillingModelSource,
|
||||||
}
|
}
|
||||||
if result.BillingModelSource == "" {
|
|
||||||
result.BillingModelSource = BillingModelSourceChannelMapped
|
|
||||||
}
|
|
||||||
|
|
||||||
modelLower := strings.ToLower(model)
|
modelLower := strings.ToLower(model)
|
||||||
if mapped := lookupMappingAcrossPlatforms(lk.cache, groupID, lk.platform, modelLower); mapped != "" {
|
if mapped := lookupMappingAcrossPlatforms(lk.cache, groupID, lk.platform, modelLower); mapped != "" {
|
||||||
@@ -686,7 +689,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
|
|||||||
ApplyPricingToAccountStats: input.ApplyPricingToAccountStats,
|
ApplyPricingToAccountStats: input.ApplyPricingToAccountStats,
|
||||||
AccountStatsPricingRules: input.AccountStatsPricingRules,
|
AccountStatsPricingRules: input.AccountStatsPricingRules,
|
||||||
}
|
}
|
||||||
normalizeBillingModelSource(channel)
|
channel.normalizeBillingModelSource()
|
||||||
|
|
||||||
if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil {
|
if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -706,7 +709,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
normalizeBillingModelSource(created)
|
created.normalizeBillingModelSource()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,18 +720,10 @@ func (s *ChannelService) GetByID(ctx context.Context, id int64) (*Channel, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
normalizeBillingModelSource(ch)
|
ch.normalizeBillingModelSource()
|
||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeBillingModelSource 若 BillingModelSource 为空则回填默认值 ChannelMapped。
|
|
||||||
// 统一在 service 层完成,避免 handler 响应层重复兜底。
|
|
||||||
func normalizeBillingModelSource(ch *Channel) {
|
|
||||||
if ch != nil && ch.BillingModelSource == "" {
|
|
||||||
ch.BillingModelSource = BillingModelSourceChannelMapped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update 更新渠道
|
// Update 更新渠道
|
||||||
func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChannelInput) (*Channel, error) {
|
func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChannelInput) (*Channel, error) {
|
||||||
channel, err := s.repo.GetByID(ctx, id)
|
channel, err := s.repo.GetByID(ctx, id)
|
||||||
@@ -762,7 +757,7 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
normalizeBillingModelSource(updated)
|
updated.normalizeBillingModelSource()
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,7 +881,7 @@ func (s *ChannelService) List(ctx context.Context, params pagination.PaginationP
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for i := range channels {
|
for i := range channels {
|
||||||
normalizeBillingModelSource(&channels[i])
|
channels[i].normalizeBillingModelSource()
|
||||||
}
|
}
|
||||||
return channels, res, nil
|
return channels, res, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import DataTable from '@/components/common/DataTable.vue'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import SupportedModelChip from './SupportedModelChip.vue'
|
import SupportedModelChip from './SupportedModelChip.vue'
|
||||||
import type { UserSupportedModel } from '@/api/channels'
|
import type { UserSupportedModel } from '@/api/channels'
|
||||||
|
import type { ChannelStatus, BillingModelSource } from '@/constants/channel'
|
||||||
|
|
||||||
interface GroupRef {
|
interface GroupRef {
|
||||||
id: number
|
id: number
|
||||||
@@ -75,7 +76,10 @@ interface Row {
|
|||||||
groups: GroupRef[]
|
groups: GroupRef[]
|
||||||
// 复用 user 侧最小 DTO;admin 侧 SupportedModel 结构上是其超集,可直接传入。
|
// 复用 user 侧最小 DTO;admin 侧 SupportedModel 结构上是其超集,可直接传入。
|
||||||
supported_models: UserSupportedModel[]
|
supported_models: UserSupportedModel[]
|
||||||
[key: string]: unknown
|
// admin 独有字段:用精确类型代替 `unknown`,让消费端无需 `as` 断言,
|
||||||
|
// 也能在后端新增 union 成员时让前端 Record 查表立刻出空而非崩溃。
|
||||||
|
status?: ChannelStatus
|
||||||
|
billing_model_source?: BillingModelSource
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Column {
|
interface Column {
|
||||||
|
|||||||
@@ -46,16 +46,16 @@
|
|||||||
|
|
||||||
<template #cell-status="{ row }">
|
<template #cell-status="{ row }">
|
||||||
<span
|
<span
|
||||||
:class="statusStyles[row.status as ChannelStatus].cls"
|
:class="statusStyleOf(row.status).cls"
|
||||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
||||||
>
|
>
|
||||||
{{ statusStyles[row.status as ChannelStatus].label }}
|
{{ statusStyleOf(row.status).label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-billing_model_source="{ row }">
|
<template #cell-billing_model_source="{ row }">
|
||||||
<span class="text-xs text-gray-700 dark:text-gray-300">
|
<span class="text-xs text-gray-700 dark:text-gray-300">
|
||||||
{{ billingSourceLabels[row.billing_model_source as BillingModelSource] }}
|
{{ billingSourceLabelOf(row.billing_model_source) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</AvailableChannelsTable>
|
</AvailableChannelsTable>
|
||||||
@@ -101,7 +101,7 @@ const columns = computed(() => [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示样式:i18n label + Tailwind class,按 ChannelStatus 完整穷举。
|
* 显示样式:i18n label + Tailwind class,按 ChannelStatus 完整穷举。
|
||||||
* 用 Record<ChannelStatus, ...> 强制未来新增状态时 TS 编译失败,避免遗漏分支。
|
* Record 键类型强制未来新增 ChannelStatus 成员时 TS 编译失败,避免遗漏分支。
|
||||||
*/
|
*/
|
||||||
const statusStyles = computed<Record<ChannelStatus, { label: string; cls: string }>>(() => ({
|
const statusStyles = computed<Record<ChannelStatus, { label: string; cls: string }>>(() => ({
|
||||||
[CHANNEL_STATUS_ACTIVE]: {
|
[CHANNEL_STATUS_ACTIVE]: {
|
||||||
@@ -124,6 +124,19 @@ const billingSourceLabels = computed<Record<BillingModelSource, string>>(() => (
|
|||||||
[BILLING_MODEL_SOURCE_CHANNEL_MAPPED]: t('admin.availableChannels.billingSource.channel_mapped')
|
[BILLING_MODEL_SOURCE_CHANNEL_MAPPED]: t('admin.availableChannels.billingSource.channel_mapped')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 运行时兜底:即便 service 层归一化漏点或后端新增未同步的 enum 值传入,
|
||||||
|
// 也不会触发 undefined.cls 崩溃;统一降级为 "-"。
|
||||||
|
const DEFAULT_STATUS_STYLE = { label: '-', cls: '' }
|
||||||
|
const DEFAULT_BILLING_LABEL = '-'
|
||||||
|
|
||||||
|
function statusStyleOf(status: ChannelStatus | undefined): { label: string; cls: string } {
|
||||||
|
return status ? statusStyles.value[status] : DEFAULT_STATUS_STYLE
|
||||||
|
}
|
||||||
|
|
||||||
|
function billingSourceLabelOf(src: BillingModelSource | undefined): string {
|
||||||
|
return src ? billingSourceLabels.value[src] : DEFAULT_BILLING_LABEL
|
||||||
|
}
|
||||||
|
|
||||||
const filteredChannels = computed(() => {
|
const filteredChannels = computed(() => {
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
if (!q) return channels.value
|
if (!q) return channels.value
|
||||||
|
|||||||
Reference in New Issue
Block a user