Revert payment/wechat, sora/claude-max cleanup, fork-only migrations,
and cosmetic changes that were brought in by the release sync commit.
Keep only channel-monitor related improvements:
- PublicSettingsInjectionPayload named struct with drift test
- ChannelMonitorRunner graceful shutdown in wire
- image_output_price in SupportedModelChip
- Simplified buildSelfNavItems in AppSidebar
- Gateway WARN logs for 503 branches
Service layer (payment_fulfillment_order_not_found_test.go):
- TestHandlePaymentNotification_UnknownOrder_ReturnsSentinel: in-memory
sqlite ent client, query for a non-existent out_trade_no → errors.Is
must recognise ErrOrderNotFound (handler relies on this to ack 200).
- TestHandlePaymentNotification_NonSuccessStatus_Skips: non-success
notification short-circuits before DB lookup → nil error.
- TestErrOrderNotFound_DistinctFromOtherErrors: generic errors must not
match the sentinel (prevents silently swallowing DB failures).
Handler layer (payment_webhook_handler_test.go):
- TestUnknownOrderWebhookAcksWithSuccess: locks the two ingredients the
handleNotify ack path depends on — fmt.Errorf %w wrapping preserves
errors.Is recognition, and writeSuccessResponse(stripe) returns an
empty 200 body that Stripe treats as acknowledged.
Introduce a sentinel ErrOrderNotFound in the payment service layer so the
webhook handler can distinguish "the out_trade_no does not exist in our DB"
from other fulfillment failures, and downgrade the former to a WARN log +
success response.
Background
- Providers (Stripe, Alipay, Wxpay, EasyPay, ...) retry webhooks whenever
we answer non-2xx. When a webhook endpoint is misconfigured (e.g. a
foreign environment points at us) or our orders table has been wiped,
we return 500 forever and the provider retries for days, spamming logs.
- The old code also collapsed "order not found" and "DB query failed" into
the same branch — a DB blip would be reported as "order not found" and
swallowed.
Service layer (payment_fulfillment.go)
- Add `var ErrOrderNotFound = errors.New("payment order not found")`.
- In HandlePaymentNotification, distinguish the two error paths:
* dbent.IsNotFound(err) → wrap with ErrOrderNotFound so callers can
errors.Is(...) it.
* anything else → wrap the original err with %w so it still bubbles up
as 500 and the provider retries (DB hiccup should be retried).
Handler layer (payment_webhook_handler.go)
- Before returning 500, check errors.Is(err, service.ErrOrderNotFound):
emit a WARN (with provider / outTradeNo / tradeNo for discoverability),
then call writeSuccessResponse so the provider sees its expected 2xx
body (Stripe empty body / Wxpay JSON / others "success").
- Other errors retain the existing 500 behavior.
Monitoring note: because this path now swallows unknown-order webhooks
silently from the provider's perspective, the WARN log line is the only
signal. Alert on "unknown order, acking to stop retries" if you want
visibility into misrouted webhooks or accidental data loss.
The drift test referenced service.PublicSettingsInjectionPayload, a
named type introduced by a5b05538 but dropped when we cherry-picked
that commit into feat/channel-insights (we kept the inline struct from
HEAD to avoid pulling fork-only helpers from setting_service.go). The
test therefore could not compile. The 2 new public-settings fields
(channel_monitor_enabled, available_channels_enabled) are still covered
by manual wiring in GetPublicSettingsForInjection.
Why: channels with model pricing entries but no model mapping (e.g. azcc with
3 priced claude models, no mapping) were rendering as 未配置模型 in the
'Available Channels' page. The algorithm only iterated ModelMapping and
silently dropped any platform without a mapping entry.
Changes:
- channel.go: SupportedModels now unions mapping + pricing entries.
For exact mapping src → target, pricing is looked up by target (the actually
billed name), not by src.
- channel_available.go: ListAvailable enriches each entry with nil pricing
via PricingService.GetModelPricing (global LiteLLM fallback) so the popover
always shows a price.
- channel_service.go: NewChannelService takes *PricingService as 4th param.
- channel_test.go: rewrote 4 tests that froze the old mapping-only semantics;
added pricing-only / mapping-target / target-missing coverage.
Pricing popover:
- Teleport to body + fixed-position re-measuring on hover/scroll/resize so it
escapes the card's overflow-hidden clip that was chopping off the lower
half of the panel.
- Header + border adopt the platform theme via platformBorderClass /
platformBadgeLightClass so each model card reads at a glance.
Accessible groups:
- Backend AvailableGroupRef / user DTO now expose subscription_type,
rate_multiplier and is_exclusive. User-specific rates continue to come
from /groups/rates (same pattern as the API keys page).
- Table renders groups through the shared GroupBadge, which already deepens
subscription-type colors and shows default vs custom rate as
<s>default</s> <b>custom</b>.
- Exclusive groups are labelled with a purple shield row, public groups
with a grey globe row so admins and users can tell at a glance which
groups they got via explicit grant vs. public access.
i18n keys for exclusive / public / their tooltips added to zh + en.
Root cause: GetPublicSettingsForInjection used an inline struct that silently
drifted from dto.PublicSettings and omitted channel_monitor_enabled /
available_channels_enabled. On refresh window.__APP_CONFIG__ lacked these
keys, so cachedPublicSettings.available_channels_enabled resolved to
undefined and the opt-in sidebar entry (=== true) disappeared.
Backend: extract PublicSettingsInjectionPayload as a named type with all
feature-flag fields wired, and add a reflect-based drift test in the dto
package so forgetting a future flag fails CI instead of the browser.
Frontend: introduce utils/featureFlags.ts as the single registry for
public-settings-driven toggles, with explicit opt-in / opt-out modes that
encode the pre-load fallback. AppSidebar switches to makeSidebarFlag() so
adding a new switch only touches the registry.
Switch the user-facing 'Available Channels' view from "one row per
platform" to "one channel row-group with N platform sections".
Backend: userAvailableChannel now holds Platforms []section instead
of being exploded. buildPlatformSections replaces
explodeChannelByPlatform with the same per-platform grouping logic.
Frontend: drop the DataTable wrapper for this view and write a
four-column grid table (渠道名 / 平台 / 分组 / 支持模型) where the
channel name only renders on the first platform row of each channel —
visual rowspan without hacking DataTable.
- api/channels.ts: UserChannelPlatformSection + platforms[]
- AvailableChannelsTable: rewritten as native grid (header + per-
channel section with hover row highlight)
- AvailableChannelsView: search now filters platforms sub-array;
channel-name / description hits still keep the whole channel
- i18n: add availableChannels.columns.platform (zh/en)
Backend: one source channel → N output rows, one per platform that
has user-visible groups. Each row carries a single platform, so the
frontend can color/icon an entire row without mixing sources.
- userAvailableChannel: add Platform field
- new explodeChannelByPlatform helper; drop now-redundant
collectGroupPlatforms
Frontend: use the row platform to drive theming and stop repeating
"ANTHROPIC" / "OPENAI" labels on every model chip.
- api/channels.ts: UserAvailableChannel.platform
- AvailableChannelsTable: name cell — PlatformBadge next to channel
name (replaces the two-line name/description block; description
moves to the badge's title tooltip); groups cell — each chip uses
platformBadgeLightClass + PlatformIcon; model list passes
show-platform=false + platform-hint to child chips
- SupportedModelChip: chip bg/border driven by platformBadgeClass,
leading PlatformIcon; platform-hint fallback when model.platform
missing
Remove the admin-side "Available Channels" aggregate view — admins
already see full channel configuration (groups, pricing, model
mappings) in the channel edit dialog, making a read-only admin
aggregate view redundant. The user-side "可用渠道" remains.
Backend:
- Delete handler/admin/available_channel_handler.go (+ test)
- Drop AdminHandlers.AvailableChannel field and wire injection
- Remove /admin/channels/available route
Frontend:
- Delete views/admin/AvailableChannelsView.vue
- Drop /admin/available-channels router entry
- Strip AvailableChannel types + listAvailable from api/admin/channels.ts
- 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
- service: add normalizeBillingModelSource helper, apply in Create/GetByID/Update/List/ListAvailable outputs
- handler: drop channelToResponse fallback now that service owns the default; add passthrough test
- frontend: replace ternary status/billing-source lookups with Record<Enum, ...> maps so new union members fail the build
- chip/table: drop local type aliases, reuse UserSupportedModel/UserPricingInterval directly
- tests: assert short-circuit on ListAll error, wrap-prefix preservation, and Name-based default lookup
- service: drop groupRepo nil guard (DI must inject), switch SupportedModels to SliceStable to match doc
- frontend: reuse user-side DTO types in SupportedModelChip/AvailableChannelsTable instead of duplicating shapes; narrow admin statusLabel param to ChannelStatus
- tests: replace nil-groupRepo case with ListAll/ListActive error propagation and BillingModelSource default-backfill coverage
Follow-up to the available-channels review pass. No behavior change for
end users; tightens internals based on three independent code reviews.
Backend
- service/channel.go: collapse buildPricingLookup + pricedNamesFor
into a single platformPricingIndex (byLower + originalCase + ordered
names), built once per SupportedModels call. Fixes a casing-
consistency bug where the same logical model appeared with mapping
case in the exact branch but pricing case in the wildcard branch —
pricing's original case now wins everywhere.
- service/channel.go: doc that a mapping key of just "*" expands to
every priced model on the platform (intentional "passthrough all").
- service/channel_available.go: normalize empty BillingModelSource to
channel_mapped at construction time, removing the same fallback
duplicated in the admin DTO mapper and the admin Vue template.
- handler/admin/available_channel_handler.go: unexport
availableChannelToAdminResponse (same-package usage only); mapper
is now a pure passthrough.
- handler/available_channel_handler.go: drop the middleware2 alias
(no name collision in this file).
Frontend
- utils/pricing.ts: extract formatScaled, used by SupportedModelChip
and PricingRow.
- api/admin/channels.ts: re-export BillingMode from constants/channel;
tighten Channel.status / billing_model_source to ChannelStatus /
BillingModelSource (and same for AvailableChannel).
- components/channels/AvailableChannelsTable.vue: drop dead
withDefaults wrapper (loading is required, both call sites pass it).
- views/admin/AvailableChannelsView.vue: drop the redundant
|| BILLING_MODEL_SOURCE_CHANNEL_MAPPED fallback (now applied in
service layer); remove unused import.
- i18n zh + en: delete unused tierLabel and tokenRange keys from
both availableChannels.pricing and admin.availableChannels.pricing.
Tests
- New: SupportedModels_ExactKeyUsesPricedCaseWhenAvailable locks the
pricing-case-wins rule.
- New: SupportedModels_AsteriskOnlyMappingExpandsAllPriced documents
the "*" expansion rule.
- Admin handler: existing tests adjusted to pass an explicit
BillingModelSource (default-fill is now exercised by service tests).
Add a read-only aggregate view per channel: its linked groups and a
deterministic wildcard-free supported-model list with pricing details.
Backend
- service.Channel.SupportedModels(): combine ModelMapping keys with
same-platform ModelPricing.Models; trailing "*" keys expand via
pricing prefix match; platforms without a mapping produce no
entries (intentional "no mapping = not shown" rule).
- Extract splitWildcardSuffix() shared with toModelEntry.
- Build a per-call pricing lookup map (platform+lowerName -> *pricing)
to avoid O(N*M) scans in SupportedModels.
- ChannelService.ListAvailable() aggregates channels + active groups;
filters out group IDs no longer active.
- Admin route GET /api/v1/admin/channels/available returns the full
DTO (id, status, billing_model_source, restrict_models, groups,
supported_models).
- User route GET /api/v1/channels/available applies three filters:
Status==active, visible-group intersection, and platform filter
on supported_models (prevents cross-platform leak when a channel
links to both a user-accessible group and an inaccessible one on
another platform). Response is a plain array (matches the
/groups/available sibling shape). Field whitelist omits
billing_model_source, restrict_models, ids, status, sort_order.
Frontend
- New /admin/available-channels and /available-channels views backed
by a shared AvailableChannelsTable component (admin adds status +
billing-source columns via slots).
- PricingRow extracted to its own SFC; SupportedModelChip references
shared billing-mode constants in constants/channel.ts.
- Sidebar: new entry above "渠道管理" for admin; matching entry in
user nav.
- i18n: zh + en coverage for both namespaces.
Tests
- SupportedModels: wildcard-only pricing skipped, prefix-matches-
nothing, cross-platform bleed, case-insensitive dedup, empty
platform mapping.
- ListAvailable: nil groupRepo, inactive-group-ID dropped, stable
case-insensitive name sort.
- User handler: 401 on unauthenticated, visible-group intersection,
platform filter on supported_models, JSON whitelist.
- Admin handler: full DTO including default BillingModelSource
fallback.
Refs: issue #1729
Apply flow:
- POST /admin/channel-monitor-templates/:id/apply now requires monitor_ids
(non-empty array). Service applies the template only to the selected
subset, gated by AND template_id = :id (so users can't sneak in
unrelated monitor IDs).
- New GET /admin/channel-monitor-templates/:id/monitors returns the
associated monitor briefs (id/name/provider/enabled) for the picker.
- ApplyToMonitors signature gains monitorIDs []int64; empty list returns
ErrChannelMonitorTemplateApplyEmpty.
Frontend:
- New MonitorTemplateApplyPickerDialog.vue: list of associated monitors
with checkboxes (default all checked), 全选 / 全不选 shortcuts, live
selected/total count. Submit calls apply(id, ids).
- MonitorTemplateManagerDialog replaces the old ConfirmDialog flow with
the picker; onApplied refetches the list to refresh associated counts.
i18n: applyPicker* + common.selectAll keys.
chore: bump version to 0.1.114.33
The CC 2.1.114 (sdk-cli) UA / APIKeyBetaHeader / JSON metadata.user_id
baseline (already verified working via the in-process apply on prod
template id=1) is documented in internal/pkg/claude/constants.go and
is what the seed template in the manager UI should follow.
Problem:
Upstream channels can reject monitor probes based on client fingerprint
(e.g. "only Claude Code clients allowed"). The monitor had no way to
customize the outgoing request to bypass such restrictions.
Solution:
Introduce reusable request templates that carry extra_headers plus an
optional body override; monitors reference a template and receive a
snapshot copy on apply. Template edits do NOT auto-propagate — users
must click "apply to associated monitors" to refresh snapshots, so a
bad template edit cannot instantly break all production monitors.
Data model (migration 112):
- channel_monitor_request_templates: id, name, provider, description,
extra_headers jsonb, body_override_mode ('off'|'merge'|'replace'),
body_override jsonb. Unique (provider, name).
- channel_monitors: +template_id (FK, ON DELETE SET NULL), +extra_headers,
+body_override_mode, +body_override (the three runtime snapshot fields).
Checker (channel_monitor_checker.go):
- callProvider + runCheckForModel accept a CheckOptions carrying the
snapshot fields. mergeHeaders applies user headers on top of adapter
defaults (forbidden list: Host / Content-Length / Transfer-Encoding /
Connection / Content-Encoding).
- buildRequestBody:
off -> adapter default body
merge -> shallow-merge over default; per-provider deny list
(model/messages/contents) protects the challenge contract
replace -> user body verbatim
- Replace mode skips challenge validation; instead HTTP 2xx + non-empty
extracted response text = operational, empty = failed.
- 4 new unit tests cover all three modes + replace/empty-response case.
Admin API:
- /admin/channel-monitor-templates CRUD + /:id/apply (overwrite snapshot
on all template_id=id monitors, returns affected count).
- channel_monitor request/response DTOs gain the 4 new fields.
Frontend:
- channelMonitorTemplate.ts API client.
- MonitorAdvancedRequestConfig.vue shared component for headers textarea
+ body mode radio + body JSON editor; used by both template and monitor
forms.
- MonitorTemplateManagerDialog.vue: provider tabs, list/create/edit/
delete/apply, live "associated monitors" count per row.
- MonitorFiltersBar: new 模板管理 button next to 新增监控.
- MonitorFormDialog: collapsible 高级 section with template dropdown
(filtered by form.provider, clears on provider change) + embedded
AdvancedRequestConfig. Picking a template copies its fields into the
form (snapshot semantics mirrored on the client).
- i18n zh/en entries for all new copy.
chore: bump version to 0.1.114.32
Monitor:
- callProvider now returns both textPath-extracted text and raw body;
runCheckForModel uses rawBody on non-2xx so history.message stops being
"upstream HTTP 503: " with empty body (gjson textPath produces "" for
error responses like {"error":{"message":"No available accounts..."}})
- truncateForErrorBody collapses whitespace then caps at 300 bytes
(monitorErrorBodySnippetMaxBytes); final truncateMessage still enforces
the 500-byte DB column cap
Frontend:
- MonitorFormDialog: primary_model input text color and ModelTagInput tags
now both track form.provider (via new getPlatformTextClass + existing
getPlatformTagClass with platform prop).
(cherry-picked from 1d3b0418; dropped gateway_handler logging改动,不在本 PR 范围)
Settings:
- New "功能开关" tab between 通用设置 and 安全与认证
- ChannelMonitorEnabled toggle: runner skips scheduling when false,
user-facing list returns empty
- ChannelMonitorDefaultIntervalSeconds (15-3600): pre-fills interval
when creating a new monitor; each monitor can still override
Bug fix:
- ModelTagInput now commits pending input on blur, not just Enter/Tab.
Previously clicking "save" with an un-Enter'd extra model would drop
the value (DB stored extra_models=[] even when user typed entries).
Backend:
- domain_constants: SettingKeyChannelMonitor{Enabled,DefaultIntervalSeconds}
- SettingService.GetChannelMonitorRuntime: lightweight getter used by
runner tick + user handler per-request (fail-open on DB error)
- Runner tickDueChecks: bails early when feature disabled
- ChannelMonitorUserHandler: checks feature flag before serving
- Comment on runner doc: scheduler state is implicit (every tick re-reads
ListEnabled from DB), so CRUD ops on monitors self-maintain the schedule
Bump VERSION to 0.1.114.25