修复上游倍率探测导致账号列表整页刷新的问题

This commit is contained in:
yardbirds0
2026-08-25 10:34:46 +08:00
parent e2d9b823f6
commit 3f1581b2d3
8 changed files with 361 additions and 39 deletions
@@ -0,0 +1,99 @@
package admin
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// upstreamBillingRatesResponse is deliberately smaller than the regular
// account-list response. The frontend uses the returned ID order to detect a
// page boundary/order change and only applies snapshots when the page is
// otherwise stable.
type upstreamBillingRatesResponse struct {
Items []service.UpstreamBillingRateSnapshotItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// GetUpstreamBillingRates returns persisted upstream billing snapshots for the
// current account-list page. It never probes an upstream and never loads
// today-stats, usage, concurrency, or credentials for the response.
//
// GET /api/v1/admin/accounts/upstream-billing-rates
func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) {
if h.adminService == nil {
response.Error(c, http.StatusServiceUnavailable, "account service unavailable")
return
}
page, pageSize := response.ParsePagination(c)
platform := c.Query("platform")
accountType := c.Query("type")
status := c.Query("status")
search := strings.TrimSpace(c.Query("search"))
if len(search) > 100 {
search = search[:100]
}
privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
sortBy := c.DefaultQuery("sort_by", "name")
sortOrder := c.DefaultQuery("sort_order", "asc")
var groupID int64
if groupQuery := c.Query("group"); groupQuery != "" {
if groupQuery == accountListGroupUngroupedQueryValue {
groupID = service.AccountListGroupUngrouped
} else {
parsed, err := strconv.ParseInt(groupQuery, 10, 64)
if err != nil || parsed < 0 {
response.BadRequest(c, "invalid group filter")
return
}
groupID = parsed
}
}
accounts, total, err := h.adminService.ListAccounts(
c.Request.Context(), page, pageSize, platform, accountType, status,
search, groupID, privacyMode, sortBy, sortOrder,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
payload := upstreamBillingRatesResponse{
Items: service.BuildUpstreamBillingRateSnapshotItems(accounts),
Total: total,
Page: page,
PageSize: pageSize,
}
c.Header("Cache-Control", "private, no-cache")
etag := buildUpstreamBillingRatesETag(payload)
if etag != "" {
c.Header("ETag", etag)
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Success(c, payload)
}
func buildUpstreamBillingRatesETag(payload upstreamBillingRatesResponse) string {
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
sum := sha256.Sum256(raw)
return "\"" + hex.EncodeToString(sum[:]) + "\""
}
+1
View File
@@ -358,6 +358,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
accounts := admin.Group("/accounts")
{
accounts.GET("", h.Admin.Account.List)
accounts.GET("/upstream-billing-rates", h.Admin.Account.GetUpstreamBillingRates)
accounts.GET("/upstream-billing-probe/settings", h.Admin.Account.GetUpstreamBillingProbeSettings)
accounts.PUT("/upstream-billing-probe/settings", h.Admin.Account.UpdateUpstreamBillingProbeSettings)
accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch)
@@ -128,6 +128,35 @@ type UpstreamBillingProbeResult struct {
Error string `json:"error,omitempty"`
}
// UpstreamBillingRateSnapshotItem is the compact representation used by the
// account table's background refresh. It intentionally excludes credentials,
// runtime counters, and usage data from the response.
type UpstreamBillingRateSnapshotItem struct {
AccountID int64 `json:"account_id"`
Snapshot *UpstreamBillingProbeSnapshot `json:"snapshot"`
}
// BuildUpstreamBillingRateSnapshotItems projects account rows into the
// read-only payload used by the rate refresh endpoint. Decode snapshots here
// so malformed or legacy extra data is handled consistently with probe logic.
func BuildUpstreamBillingRateSnapshotItems(accounts []Account) []UpstreamBillingRateSnapshotItem {
items := make([]UpstreamBillingRateSnapshotItem, 0, len(accounts))
for _, account := range accounts {
var snapshot *UpstreamBillingProbeSnapshot
// The billing endpoint is supported by every API-key platform; limiting
// this projection to OpenAI would make the background refresh erase the
// other platforms' persisted snapshots from the table.
if account.Type == AccountTypeAPIKey {
snapshot = decodeUpstreamBillingProbeSnapshot(account.Extra)
}
items = append(items, UpstreamBillingRateSnapshotItem{
AccountID: account.ID,
Snapshot: snapshot,
})
}
return items
}
type upstreamBillingProbeResponse struct {
Object string `json:"object"`
SchemaVersion int `json:"schema_version"`
@@ -29,6 +29,45 @@ func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
require.False(t, isUpstreamBillingProbeAccount(nil))
}
func TestBuildUpstreamBillingRateSnapshotItemsPreservesAllAPIKeyPlatforms(t *testing.T) {
makeAccount := func(id int64, platform, accountType string, snapshot any) Account {
return Account{
ID: id,
Platform: platform,
Type: accountType,
Extra: map[string]any{UpstreamBillingProbeExtraKey: snapshot},
}
}
accounts := []Account{
makeAccount(1, PlatformOpenAI, AccountTypeAPIKey, map[string]any{
"status": "ok",
"data": map[string]any{"effective_rate_multiplier": 0.045},
}),
makeAccount(2, PlatformAnthropic, AccountTypeAPIKey, map[string]any{
"status": "ok",
"data": map[string]any{"effective_rate_multiplier": 0.047},
}),
makeAccount(3, PlatformGemini, AccountTypeAPIKey, map[string]any{
"status": "failed",
}),
makeAccount(4, PlatformOpenAI, AccountTypeOAuth, map[string]any{
"status": "ok",
}),
}
items := BuildUpstreamBillingRateSnapshotItems(accounts)
require.Len(t, items, len(accounts))
require.Equal(t, int64(1), items[0].AccountID)
require.NotNil(t, items[0].Snapshot)
require.NotNil(t, items[1].Snapshot)
require.NotNil(t, items[2].Snapshot)
require.Equal(t, 0.045, items[0].Snapshot.Data["effective_rate_multiplier"])
require.Equal(t, 0.047, items[1].Snapshot.Data["effective_rate_multiplier"])
require.Equal(t, UpstreamBillingProbeStatusFailed, items[2].Snapshot.Status)
require.Nil(t, items[3].Snapshot)
}
func upstreamBillingProbeValidBody() io.ReadCloser {
return io.NopCloser(strings.NewReader(`{
"object":"sub2api.key_billing",
+41
View File
@@ -23,6 +23,7 @@ import type {
CheckMixedChannelResponse,
UpstreamBillingProbeResult,
UpstreamBillingProbeSettings,
UpstreamBillingRatesResponse,
OllamaCloudUsageSettings,
OllamaCloudUsageState
} from '@/types'
@@ -70,6 +71,45 @@ export interface AccountListWithEtagResult {
data: PaginatedResponse<Account> | null
}
export interface AccountUpstreamBillingRatesWithEtagResult {
notModified: boolean
etag: string | null
data: UpstreamBillingRatesResponse | null
}
export async function getUpstreamBillingRatesWithEtag(
page: number = 1,
pageSize: number = 20,
filters?: {
platform?: string
type?: string
status?: string
group?: string
search?: string
privacy_mode?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
etag?: string | null
}
): Promise<AccountUpstreamBillingRatesWithEtagResult> {
const headers: Record<string, string> = {}
if (options?.etag) headers['If-None-Match'] = options.etag
const response = await apiClient.get<UpstreamBillingRatesResponse>('/admin/accounts/upstream-billing-rates', {
params: { page, page_size: pageSize, ...filters },
headers,
signal: options?.signal,
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
})
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
if (response.status === 304) return { notModified: true, etag: etagHeader, data: null }
return { notModified: false, etag: etagHeader, data: response.data }
}
export async function listWithEtag(
page: number = 1,
pageSize: number = 20,
@@ -988,6 +1028,7 @@ export async function refreshOllamaCloudUsage(id: number): Promise<OllamaCloudUs
export const accountsAPI = {
list,
listWithEtag,
getUpstreamBillingRatesWithEtag,
getById,
create,
duplicate,
+12
View File
@@ -1060,6 +1060,18 @@ export interface UpstreamBillingProbeResult {
error?: string
}
export interface UpstreamBillingRateSnapshotItem {
account_id: number
snapshot?: UpstreamBillingProbeSnapshot | null
}
export interface UpstreamBillingRatesResponse {
items: UpstreamBillingRateSnapshotItem[]
total: number
page: number
page_size: number
}
export type OllamaCloudUsageStatus = 'ok' | 'unauthorized' | 'failed'
export interface OllamaCloudUsageWindow {
+124 -32
View File
@@ -613,7 +613,9 @@ const exportingData = ref(false)
const probingUpstreamBilling = reactive(new Set<number>())
const upstreamBillingProbeGloballyEnabled = ref<boolean | undefined>(undefined)
const upstreamBillingNow = ref(Date.now())
let lastUpstreamBillingSortRefreshMinute = -1
const upstreamBillingRateETag = ref<string | null>(null)
const upstreamBillingRateRefreshing = ref(false)
let upstreamBillingRateAbortController: AbortController | null = null
useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000)
// Account tools dropdown
@@ -1137,19 +1139,17 @@ useSwipeSelect(accountTableRef, {
const resetAutoRefreshCache = () => {
autoRefreshETag.value = null
upstreamBillingRateETag.value = null
}
const isFirstLoad = ref(true)
function markUpstreamBillingSortRefresh() {
if (sortState.sort_by === 'upstream_billing_rate') {
lastUpstreamBillingSortRefreshMinute = Math.floor(Date.now() / 60_000)
}
type AccountLoadOptions = {
refreshTodayStats?: boolean
}
const load = async () => {
const load = async (options: AccountLoadOptions = {}) => {
const requestParams = params as any
markUpstreamBillingSortRefresh()
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
@@ -1162,11 +1162,10 @@ const load = async () => {
isFirstLoad.value = false
delete requestParams.lite
}
await refreshTodayStatsBatch()
if (options.refreshTodayStats !== false) await refreshTodayStatsBatch()
}
const reload = async () => {
markUpstreamBillingSortRefresh()
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
@@ -1175,19 +1174,120 @@ const reload = async () => {
await refreshTodayStatsBatch()
}
const refreshUpstreamBillingSortedList = async (force = false) => {
if (sortState.sort_by !== 'upstream_billing_rate') return
const minute = Math.floor(upstreamBillingNow.value / 60_000)
if (!force && lastUpstreamBillingSortRefreshMinute === minute) return
lastUpstreamBillingSortRefreshMinute = minute
try {
await reload()
} catch (error) {
console.error('Failed to refresh upstream billing sort:', error)
const buildUpstreamBillingRateFilters = () => {
const rawParams = toRaw(params) as Record<string, unknown>
return {
platform: typeof rawParams.platform === 'string' ? rawParams.platform : '',
type: typeof rawParams.type === 'string' ? rawParams.type : '',
status: typeof rawParams.status === 'string' ? rawParams.status : '',
group: typeof rawParams.group === 'string' ? rawParams.group : '',
search: typeof rawParams.search === 'string' ? rawParams.search : '',
privacy_mode: typeof rawParams.privacy_mode === 'string' ? rawParams.privacy_mode : '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
}
const sameAccountIDOrder = (left: number[], right: number[]) =>
left.length === right.length && left.every((id, index) => id === right[index])
const upstreamBillingRateContextKey = () => JSON.stringify({
page: pagination.page,
pageSize: pagination.page_size,
filters: buildUpstreamBillingRateFilters()
})
const applyUpstreamBillingRateSnapshots = async (
result: NonNullable<Awaited<ReturnType<typeof adminAPI.accounts.getUpstreamBillingRatesWithEtag>>['data']>
) => {
const nextIDs = result.items.map(item => item.account_id)
const currentIDs = accounts.value.map(account => account.id)
// The compact response cannot fill a row that crossed a page boundary.
// Only that case needs the expensive, full account-list request.
if (result.total !== pagination.total || !sameAccountIDOrder(nextIDs, currentIDs)) {
try {
await load({ refreshTodayStats: false })
} catch (error) {
console.error('Failed to reconcile upstream billing sort:', error)
}
return
}
const itemsByID = new Map(result.items.map(item => [item.account_id, item]))
let changed = false
const nextAccounts = accounts.value.map(account => {
const item = itemsByID.get(account.id)
if (!item) return account
const nextSnapshot = item.snapshot ?? null
const previousSnapshot = account.extra?.upstream_billing_probe ?? null
if (JSON.stringify(previousSnapshot) === JSON.stringify(nextSnapshot)) return account
const nextExtra = { ...(account.extra ?? {}) }
if (nextSnapshot) nextExtra.upstream_billing_probe = nextSnapshot
else delete nextExtra.upstream_billing_probe
const nextAccount = {
...account,
...(typeof nextSnapshot?.synced_rate_multiplier === 'number'
? { rate_multiplier: nextSnapshot.synced_rate_multiplier }
: {}),
extra: nextExtra
}
syncAccountRefs(nextAccount)
changed = true
return nextAccount
})
if (changed) {
accounts.value = nextAccounts
upstreamBillingNow.value = Date.now()
}
}
const refreshUpstreamBillingRates = async (force = false) => {
if (upstreamBillingRateRefreshing.value || loading.value || accounts.value.length === 0) return
if (!force && (
probingUpstreamBilling.size > 0 ||
isAnyModalOpen.value ||
menu.show ||
showAccountToolsDropdown.value ||
showAutoRefreshDropdown.value ||
(typeof document !== 'undefined' && document.hidden)
)) return
const controller = new AbortController()
upstreamBillingRateAbortController = controller
upstreamBillingRateRefreshing.value = true
try {
syncAccountListDerivedParams()
const requestContextKey = upstreamBillingRateContextKey()
const result = await adminAPI.accounts.getUpstreamBillingRatesWithEtag(
pagination.page,
pagination.page_size,
buildUpstreamBillingRateFilters(),
{ etag: force ? null : upstreamBillingRateETag.value, signal: controller.signal }
)
if (loading.value || requestContextKey !== upstreamBillingRateContextKey()) return
if (result.etag) upstreamBillingRateETag.value = result.etag
if (!result.notModified && result.data) await applyUpstreamBillingRateSnapshots(result.data)
} catch (error) {
const refreshError = error as { name?: string; code?: string }
if (refreshError.name !== 'AbortError' && refreshError.name !== 'CanceledError' && refreshError.code !== 'ERR_CANCELED') {
console.error('Failed to refresh upstream billing rates:', error)
}
} finally {
if (upstreamBillingRateAbortController === controller) upstreamBillingRateAbortController = null
upstreamBillingRateRefreshing.value = false
}
}
const refreshUpstreamBillingSortedList = async (force = false) => {
if (!force && sortState.sort_by !== 'upstream_billing_rate') return
await refreshUpstreamBillingRates(force)
}
useIntervalFn(() => { void refreshUpstreamBillingRates() }, 5 * 60_000, { immediate: false })
const debouncedReload = () => {
clearSelection()
syncAccountListDerivedParams()
@@ -1255,12 +1355,6 @@ watch(accounts, (rows) => {
)
})
watch(upstreamBillingNow, () => {
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
if (typeof document !== 'undefined' && document.hidden) return
void refreshUpstreamBillingSortedList()
})
const isAnyModalOpen = computed(() => {
return (
showCreate.value ||
@@ -1373,7 +1467,6 @@ const refreshAccountsIncrementally = async () => {
pagination.pages = result.data.pages || 0
mergeAccountsIncrementally(result.data.items || [])
hasPendingListSync.value = false
markUpstreamBillingSortRefresh()
}
upstreamBillingNow.value = Date.now()
@@ -2150,19 +2243,17 @@ const patchAccountInList = (updatedAccount: Account) => {
const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBillingProbeSnapshot) => {
const account = accounts.value.find(item => item.id === accountID)
if (!account) return
markUpstreamBillingSortRefresh()
upstreamBillingNow.value = Date.now()
patchAccountInList({
...account,
...(typeof snapshot.synced_rate_multiplier === 'number'
? { rate_multiplier: snapshot.synced_rate_multiplier }
: {}),
extra: { ...account.extra, upstream_billing_probe: snapshot }
})
}
const refreshAccountsAfterUpstreamBillingProbe = async () => {
try {
await load()
} catch (error) {
console.error('Failed to refresh accounts after upstream billing probe:', error)
}
await refreshUpstreamBillingSortedList(true)
}
const handleProbeUpstreamBilling = async (account: Account) => {
if (probingUpstreamBilling.has(account.id)) return
@@ -2481,6 +2572,7 @@ onMounted(async () => {
})
onUnmounted(() => {
upstreamBillingRateAbortController?.abort()
if (usageBatchFlushTimer !== null) {
clearTimeout(usageBatchFlushTimer)
usageBatchFlushTimer = null
@@ -6,6 +6,7 @@ import AccountsView from '../AccountsView.vue'
const {
listAccounts,
listWithEtag,
getUpstreamBillingRatesWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings,
getAllProxies,
@@ -17,6 +18,7 @@ const {
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getUpstreamBillingRatesWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
getUpstreamBillingProbeSettings: vi.fn(),
getAllProxies: vi.fn(),
@@ -32,6 +34,7 @@ vi.mock('@/api/admin', () => ({
accounts: {
list: listAccounts,
listWithEtag,
getUpstreamBillingRatesWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings,
delete: vi.fn(),
@@ -127,6 +130,7 @@ describe('admin AccountsView bulk edit scope', () => {
listAccounts.mockReset()
listWithEtag.mockReset()
getUpstreamBillingRatesWithEtag.mockReset()
getBatchTodayStats.mockReset()
getUpstreamBillingProbeSettings.mockReset()
getAllProxies.mockReset()
@@ -148,6 +152,11 @@ describe('admin AccountsView bulk edit scope', () => {
etag: null,
data: null
})
getUpstreamBillingRatesWithEtag.mockResolvedValue({
notModified: true,
etag: null,
data: null
})
getBatchTodayStats.mockResolvedValue({ stats: {} })
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
getAllProxies.mockResolvedValue([])
@@ -400,7 +409,7 @@ describe('admin AccountsView bulk edit scope', () => {
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7, 11])
})
it('refreshes the current page after a batch probe and displays the synced rate', async () => {
it('updates the current page locally after a batch probe', async () => {
const account = (id: number, rateMultiplier: number) => ({
id,
name: `account-${id}`,
@@ -422,6 +431,7 @@ describe('admin AccountsView bulk edit scope', () => {
snapshot: {
status: 'ok',
data: { effective_rate_multiplier: 0.065 },
synced_rate_multiplier: 0.065,
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z'
}
@@ -471,12 +481,11 @@ describe('admin AccountsView bulk edit scope', () => {
await flushPromises()
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([11])
expect(listAccounts).toHaveBeenCalledTimes(3)
expect(listAccounts.mock.calls[2]?.[0]).toBe(2)
expect(listAccounts).toHaveBeenCalledTimes(2)
expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x')
})
it('does not report a successful batch probe as failed when the list refresh fails', async () => {
it('does not report a successful batch probe as failed when reconciliation is skipped', async () => {
const account = {
id: 7,
name: 'account-7',
@@ -490,7 +499,6 @@ describe('admin AccountsView bulk edit scope', () => {
}
listAccounts
.mockResolvedValueOnce({ items: [account], total: 1, page: 1, page_size: 20, pages: 1 })
.mockRejectedValueOnce(new Error('refresh failed'))
probeUpstreamBillingBatch.mockResolvedValue([
{
account_id: 7,
@@ -549,7 +557,7 @@ describe('admin AccountsView bulk edit scope', () => {
consoleError.mockRestore()
})
it('refreshes the account row after a successful single-account probe', async () => {
it('updates the account row after a successful single-account probe', async () => {
const account = (rateMultiplier: number) => ({
id: 7,
name: 'account-7',
@@ -570,6 +578,7 @@ describe('admin AccountsView bulk edit scope', () => {
snapshot: {
status: 'ok',
data: { effective_rate_multiplier: 0.065 },
synced_rate_multiplier: 0.065,
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z'
}
@@ -615,7 +624,7 @@ describe('admin AccountsView bulk edit scope', () => {
await flushPromises()
expect(probeUpstreamBilling).toHaveBeenCalledWith(7)
expect(listAccounts).toHaveBeenCalledTimes(2)
expect(listAccounts).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x')
})
})