Compare commits

...

6 Commits

Author SHA1 Message Date
root 01fa2b8309 fix quota reset reservation race
Desktop Client Build / Resolve Build Metadata (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 3m57s
Backend CI / Backend (push) Successful in 17m3s
Desktop Client Build / Build Desktop Client (push) Successful in 26m8s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 51s
2026-05-12 02:24:57 +08:00
root 1aad002a3e feat(ops): use 'reset' quota status for plan-usage reset and invalidate quota cache
When ops staff reset a tenant's current-plan usage, the existing
'refunded' terminal status conflated user-initiated refunds with
ops-driven resets, and tenants still saw the old balance in the top-nav
chip until the quota-summary cache TTL expired.

- Introduce a dedicated 'reset' status for quota_reservations, separate
  from 'refunded'. Both the initial and an incremental migration widen
  the CHECK constraint to allow it; the down migration folds 'reset'
  rows back into 'refunded' before tightening the constraint.
- resetQuotaReservations / resetAIPointReservations now write 'reset'
  without touching refunded_amount, and stop cascading into
  ai_point_usage_logs (carried no audit value for ops resets).
- AdminUserService gains an optional cache dependency and calls
  invalidateQuotaSummaryCache(tenant_id) immediately after a successful
  reset so the tenant's top-nav chip refreshes on the next request
  without waiting on TTL.
- Wire the existing appCache into AdminUserService at boot.
- Drop the now-unused ai_point_usage_logs field from the reset response
  and from the ops-web AdminUsersView type.
- Add a unit test covering the cache-key invalidation contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:55:29 +08:00
root 51d32fa96d fix(admin-web): invalidate workspace cache after every AI-points-consuming call
Top-nav quota chip (会员到期 / 生成文章 / AI 点数) only refreshes when its
query is invalidated. Several mutations and stream-based flows that debit
points or article quota on the server were not invalidating it, so users
had to refresh the page to see updated balances.

- TemplateWizardView: invalidate `['workspace']` inside `endAssistTask()`,
  covering analyze / title / outline assist tasks in one place.
- FreeCreateView: invalidate after `articlesApi.create` success.
- GenerateTaskDrawer: add `['workspace']` to the instant-generate
  `Promise.all` alongside the existing `articles` / `instantTasks` keys.
- KolPromptEditor: invalidate in the `finally` of both `runAiAssistStream`
  (generate) and `generateSelectionAiPreview` (optimize) so abort / error
  paths also refresh.
- ArticleEditorCanvas: bring in `useQueryClient` and invalidate in the
  selection-optimize stream `finally`.

Together with the existing invalidations in TemplateWizard.generate,
ImitationGenerate, KolGenerate, and article-regenerate, every quota-
debiting call site now feeds the cache refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:54:12 +08:00
root 793c310560 refactor(desktop-client): harden external-window load flow with loading page and timeout watchdog
- Introduce `navigateRemoteURL` / `showLocalPage` / `showErrorPage` helpers
  that centralize remote navigation, watchdog wiring, and error fallback,
  replacing the ad-hoc logic inside `loadWindowURLSafely`.
- Show a localized loading page (`loadingPageDataURL`) while a remote URL
  is fetching, so the window no longer sits on blank white during slow
  navigations.
- Add a 25s remote-load timeout watchdog that switches the window to the
  error page when the target never finishes loading, plus an active load
  token so stale watchdogs from earlier navigations cannot fire on the
  current attempt.
- Track `localPageKind` and `activeTargetURL` per window so blank-page
  retries, did-fail-load handlers, and ready-for-detection checks behave
  correctly when the window is currently showing a local loading/error
  page.
- Escalate the blank-page watchdog: after the retry attempt, if the page
  is still blank, surface the error page instead of looping silent
  reloads.
- Restyle the error page (drop gradients, use a neutral light/dark token
  palette, single primary retry button) and add a dedicated loading page
  template with the same look.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:31:50 +08:00
root b6cc12cefe feat(admin-web): show centered modal in Chinese when AI points are insufficient
- Add `ai_points_insufficient` / `ai_points_unavailable` Chinese mappings in
  errors.ts so the toast copy is localized.
- Add `showAiPointsInsufficientModal()` helper that renders a centered
  `Modal.confirm` with localized title/body and a "查看点数明细" action that
  routes to `/account/ai-points`. The helper is debounced so concurrent
  failed requests do not stack multiple modals.
- Trigger the modal globally from the admin-web response interceptor when
  the backend returns `ai_points_insufficient`, so every AI-generating
  endpoint surfaces the same prominent prompt without per-callsite changes.
- Drop the English `detail` for ai_points_insufficient in `formatError` so
  the secondary toast stays pure Chinese.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:31:02 +08:00
root 635782041a feat(admin-web): preserve template wizard entry source and refresh AI banner
- Carry `from=workspace` query from the workbench into the wizard so cancel
  and post-generate redirects return to the workbench instead of always
  landing on /articles/templates.
- AppShell now resolves an effective navKey from `route.query.from`, so the
  sidebar highlight and breadcrumb track the entry source while inside the
  wizard.
- Redesign the brand-info AI analyze CTA into a standalone banner with
  template-aware copy (default / product_review / research_report) and add
  the matching i18n keys.
- Drop the unused 批量生成文章 button and its styling from the templates
  page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:12:01 +08:00
33 changed files with 996 additions and 287 deletions
@@ -57,6 +57,7 @@ import {
} from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet, type EditorView } from '@milkdown/kit/prose/view'
import { callCommand, replaceAll } from '@milkdown/kit/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
// NOTE: import each common stylesheet explicitly instead of the aggregate
// `theme/common/style.css`. The aggregate transitively `@import`s `latex.css`,
@@ -121,6 +122,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const queryClient = useQueryClient()
const crepeRef = shallowRef<Crepe | null>(null)
const syncingExternalMarkdown = ref(false)
const editorRootRef = ref<HTMLElement | null>(null)
@@ -1481,6 +1483,7 @@ async function generateAiOptimizePreview(): Promise<void> {
if (aiOptimizeAbortController === controller) {
aiOptimizeAbortController = null
}
void queryClient.invalidateQueries({ queryKey: ['workspace'] })
}
}
@@ -173,6 +173,7 @@ const instantGenerateMutation = useMutation({
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['instantTasks'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
},
onError: (error) => message.error(formatError(error)),
@@ -275,6 +275,7 @@ async function runAiAssistStream(payload: KolAssistRequest) {
if (aiAssistAbortController === controller) {
aiAssistAbortController = null
}
void queryClient.invalidateQueries({ queryKey: ['workspace'] })
}
}
@@ -411,6 +412,7 @@ async function generateSelectionAiPreview() {
if (selectionAiAbortController === controller) {
selectionAiAbortController = null
}
void queryClient.invalidateQueries({ queryKey: ['workspace'] })
}
}
+13
View File
@@ -897,22 +897,35 @@ const enUS = {
},
promptVisible: 'Prompt visible',
promptSealed: 'Prompt sealed',
aiBanner: {
title: 'One-click AI analysis',
defaultDescription:
'AI will complete the brand summary, find core keywords, and extract competitor context.',
productReviewDescription:
'AI will organize the product context, extract review keywords, and suggest comparable alternatives.',
researchReportDescription:
'AI will organize the topic context, extract research keywords, and suggest reference targets.',
},
assist: {
analyzeTitle: 'AI is analyzing keywords and competitors. Please wait…',
analyzeTitleNoCompetitors: 'AI is analyzing keywords and context. Please wait…',
titleTitle: 'AI is generating titles. Please wait…',
outlineTitle: 'AI is generating the article outline. Please wait…',
analyzeStages: {
brand: 'Analyzing the brand context…',
keywords: 'Extracting keyword opportunities…',
competitors: 'Mapping competitors and websites…',
context: 'Organizing the content context…',
},
titleStages: {
context: 'Preparing the current brief…',
strategy: 'Generating title directions from keywords and competitors…',
strategyNoCompetitors: 'Generating title directions from the keywords and brief…',
finalize: 'Returning 5 title options…',
},
outlineStages: {
context: 'Preparing the title, keywords, and competitor context…',
contextNoCompetitors: 'Preparing the title, keywords, and content context…',
structure: 'Generating the article outline from the selected structure…',
finalize: 'Returning the editable outline result…',
},
@@ -858,6 +858,12 @@ const zhCN = {
},
promptVisible: "Prompt 可见",
promptSealed: "Prompt 已封装",
aiBanner: {
title: "一键智能分析",
defaultDescription: "AI 将自动补充品牌简介、发掘核心关键词并智能提取竞品信息",
productReviewDescription: "AI 将自动梳理产品背景、提炼评测关键词并生成同类替代方案参考",
researchReportDescription: "AI 将自动梳理主题背景、提炼研究关键词并生成参照对象参考",
},
assist: {
analyzeTitle: "AI 正在分析关键词和竞品,请稍后…",
analyzeTitleNoCompetitors: "AI 正在分析关键词和评测上下文,请稍后…",
+17 -6
View File
@@ -67,12 +67,23 @@ const membershipExpiryText = computed(() => {
day: '2-digit',
}).format(date)
})
const selectedKeys = computed(() => {
if (route.meta.navKey === null) {
return []
}
const navKeyOverrides: Record<string, string> = {
workspace: '/workspace',
}
return [String(route.meta.navKey ?? route.path)]
const effectiveNavKey = computed<string | null>(() => {
if (route.meta.navKey === null) {
return null
}
const from = route.query.from
if (typeof from === 'string' && navKeyOverrides[from]) {
return navKeyOverrides[from]
}
return String(route.meta.navKey ?? route.path)
})
const selectedKeys = computed(() => {
return effectiveNavKey.value ? [effectiveNavKey.value] : []
})
const pageTitle = computed(() => t(String(route.meta.titleKey ?? 'route.workspace.title')))
@@ -165,7 +176,7 @@ const navSections = computed<NavSection[]>(() => {
const headerBreadcrumbs = computed<HeaderBreadcrumb[]>(() => {
const currentTitle = pageTitle.value
const navKey = route.meta.navKey === null ? null : String(route.meta.navKey ?? route.path)
const navKey = effectiveNavKey.value
if (!navKey) {
return [{ label: currentTitle }]
+6
View File
@@ -315,6 +315,12 @@ apiClient.raw.interceptors.response.use(
markStoredMembershipBlocked(error.message)
}
if (error instanceof ApiClientError && error.message === 'ai_points_insufficient') {
void import('./errors').then(({ showAiPointsInsufficientModal }) => {
showAiPointsInsufficientModal()
})
}
return Promise.reject(error)
},
)
+36
View File
@@ -1,5 +1,8 @@
import { Modal } from 'ant-design-vue'
import { ApiClientError } from '@geo/http-client'
import { router } from '@/router'
const authSessionErrorMessages = new Set([
'not_authenticated',
'missing_bearer_token',
@@ -28,6 +31,8 @@ const errorMessageMap: Record<string, string> = {
tenant_scope_missing: '租户上下文缺失',
query_failed: '数据读取失败',
quota_insufficient: '生成额度不足',
ai_points_insufficient: 'AI 点数余额不足',
ai_points_unavailable: 'AI 点数服务暂不可用,请稍后重试',
trial_plan_expired: '免费试用已到期,请联系管理员开通',
subscription_required: '当前租户尚未开通有效套餐,请联系管理员',
subscription_inactive: '当前租户套餐已失效,请联系管理员',
@@ -89,6 +94,34 @@ const errorMessageMap: Record<string, string> = {
unknown_error: '发生未知错误',
}
export function isAiPointsInsufficient(error: unknown): boolean {
return error instanceof ApiClientError && error.message === 'ai_points_insufficient'
}
let aiPointsModalVisible = false
export function showAiPointsInsufficientModal(): void {
if (aiPointsModalVisible) {
return
}
aiPointsModalVisible = true
Modal.confirm({
title: 'AI 点数不足',
content: '当前账号的 AI 点数余额已耗尽,无法继续生成内容。请升级套餐或等待下月点数重置。',
okText: '查看点数明细',
cancelText: '我知道了',
centered: true,
okButtonProps: { type: 'primary' },
onOk: () => {
void router.push('/account/ai-points')
},
afterClose: () => {
aiPointsModalVisible = false
},
})
}
export function isHandledAuthError(error: unknown): boolean {
return (
error instanceof ApiClientError &&
@@ -104,6 +137,9 @@ export function formatError(error: unknown): string {
if (error instanceof ApiClientError) {
const message = errorMessageMap[error.message] ?? error.message.replaceAll('_', ' ')
if (isAiPointsInsufficient(error)) {
return message
}
return error.detail ? `${message}: ${error.detail}` : message
}
@@ -95,6 +95,7 @@ const deleteMutation = useMutation({
const createMutation = useMutation({
mutationFn: () => articlesApi.create({}),
onSuccess: (data) => {
void queryClient.invalidateQueries({ queryKey: ['workspace'] })
void router.push({ name: 'article-editor', params: { id: String(data.id) } })
},
onError: (error) => {
+100 -5
View File
@@ -4,6 +4,7 @@ import {
HolderOutlined,
LeftOutlined,
PlusOutlined,
RobotOutlined,
StarFilled,
} from '@ant-design/icons-vue'
import type {
@@ -254,7 +255,7 @@ const mutation = useMutation({
])
message.info(t('templates.wizard.messages.queued'))
allowWizardLeave = true
await router.replace('/articles/templates')
await router.replace(resolveExitPath())
},
onError: (error) => {
message.error(formatError(error))
@@ -408,6 +409,25 @@ const previewCardCopy = computed<TemplateCardCopy>(
const analyzeButtonLabel = computed(
() => wizardConfig.value?.basic?.analyze_button_label || t('templates.wizard.actions.analyze'),
)
const aiBannerCopy = computed(() => {
switch (templateDetail.value?.template_key) {
case 'product_review':
return {
title: t('templates.wizard.aiBanner.title'),
description: t('templates.wizard.aiBanner.productReviewDescription'),
}
case 'research_report':
return {
title: t('templates.wizard.aiBanner.title'),
description: t('templates.wizard.aiBanner.researchReportDescription'),
}
default:
return {
title: t('templates.wizard.aiBanner.title'),
description: t('templates.wizard.aiBanner.defaultDescription'),
}
}
})
const reviewAlertMessage = computed(
() => wizardConfig.value?.review?.alert_message || t('templates.wizard.hints.async'),
)
@@ -819,6 +839,7 @@ function beginAssistTask(kind: 'analyze' | 'title' | 'outline'): void {
}
function endAssistTask(): void {
void queryClient.invalidateQueries({ queryKey: ['workspace'] })
window.setTimeout(() => {
assistModalOpen.value = false
assistBusy.value = false
@@ -1216,8 +1237,12 @@ async function saveWizardDraft(): Promise<boolean> {
}
}
function resolveExitPath(): string {
return route.query.from === 'workspace' ? '/workspace' : '/articles/templates'
}
async function handleSaveDraftAndExit(): Promise<void> {
await router.replace('/articles/templates')
await router.replace(resolveExitPath())
}
function dedupeStrings(values: string[]): string[] {
@@ -2223,9 +2248,6 @@ function onStructureDragEnd(): void {
<h3>{{ brandCardCopy.title || t('templates.wizard.sections.brandInfo') }}</h3>
<p>{{ brandCardCopy.hint || t('templates.wizard.hints.brandFlow') }}</p>
</div>
<a-button type="primary" ghost :loading="assistBusy" @click="handleAnalyze">
{{ analyzeButtonLabel }}
</a-button>
</div>
<div class="field-grid">
@@ -2277,6 +2299,25 @@ function onStructureDragEnd(): void {
</div>
</div>
<div class="wizard-ai-banner">
<div class="wizard-ai-banner__info">
<RobotOutlined class="wizard-ai-banner__icon" />
<div class="wizard-ai-banner__text">
<h4>{{ aiBannerCopy.title }}</h4>
<p>{{ aiBannerCopy.description }}</p>
</div>
</div>
<a-button
type="primary"
class="wizard-ai-banner__btn"
size="large"
:loading="assistBusy"
@click="handleAnalyze"
>
{{ analyzeButtonLabel }}
</a-button>
</div>
<div class="summary-box">
<div class="summary-box__label">
{{ t('templates.wizard.sections.brandSummary') }}
@@ -2959,6 +3000,60 @@ function onStructureDragEnd(): void {
content: '*';
}
.wizard-ai-banner {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24px;
padding: 16px 20px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(22, 119, 255, 0.04), rgba(22, 119, 255, 0.08));
border: 1px solid rgba(22, 119, 255, 0.15);
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.05);
}
.wizard-ai-banner__info {
display: flex;
align-items: center;
gap: 16px;
}
.wizard-ai-banner__icon {
font-size: 28px;
background: linear-gradient(135deg, #1677ff, #36cfc9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.wizard-ai-banner__text h4 {
margin: 0 0 4px;
color: #1f2f4d;
font-size: 15px;
font-weight: 700;
}
.wizard-ai-banner__text p {
margin: 0;
color: #6b7a99;
font-size: 13px;
}
.wizard-ai-banner__btn {
border-radius: 10px;
font-weight: 600;
letter-spacing: 1px;
padding: 0 24px;
background: linear-gradient(135deg, #1677ff, #0958d9);
border: none;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.2);
transition: all 0.2s ease;
}
.wizard-ai-banner__btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.3);
}
.summary-box {
margin-top: 18px;
padding: 16px 18px;
@@ -2,7 +2,6 @@
import {
AppstoreOutlined,
ArrowRightOutlined,
BlockOutlined,
ExperimentOutlined,
FileTextOutlined,
NodeIndexOutlined,
@@ -458,10 +457,6 @@ function refreshRecords(): void {
<p>{{ t('route.templates.description') }}</p>
</div>
<div class="templates-view__header-actions">
<a-button class="batch-generate-btn">
<template #icon><BlockOutlined /></template>
{{ t('templates.actions.batchGenerate') }}
</a-button>
<a-button type="primary" class="templates-view__create-btn" @click="openTemplatePicker">
<template #icon><PlusOutlined /></template>
{{ t('templates.actions.chooseTemplate') }}
@@ -796,11 +791,6 @@ function refreshRecords(): void {
box-shadow: none;
}
.batch-generate-btn {
color: #1677ff;
border-color: #1677ff;
}
.templates-view__filters {
padding: 20px 24px;
}
+2 -1
View File
@@ -179,7 +179,7 @@ function resolveTemplateIcon(template: TemplateCard): unknown {
function openTemplate(template: TemplateCard): void {
void router.push({
path: '/articles/wizard',
query: { template_id: String(template.id) },
query: { template_id: String(template.id), from: 'workspace' },
})
}
@@ -215,6 +215,7 @@ async function openEditor(article: RecentArticle): Promise<void> {
query: {
template_id: String(detail.template_id),
article_id: String(detail.id),
from: 'workspace',
},
})
return
+407 -66
View File
@@ -5,6 +5,7 @@ import { STANDARD_USER_AGENT } from './user-agent'
const ignoredNavigationFailurePattern = /ERR_ABORTED|ERR_BLOCKED_BY_CLIENT|\((-3|-20)\) loading/i
const errorPagePrefix = 'data:text/html'
const REMOTE_PAGE_LOAD_TIMEOUT_MS = 25_000
const BLANK_PAGE_PROBE_DELAY_MS = 7_000
const BLANK_PAGE_NODE_THRESHOLD = 3
const BLANK_PAGE_TEXT_THRESHOLD = 5
@@ -12,8 +13,12 @@ const BLANK_PAGE_TEXT_THRESHOLD = 5
interface WindowNavigationState {
readyForDetection: boolean
lastMainFrameError: string | null
loadTimeoutWatchdog: NodeJS.Timeout | null
blankPageWatchdog: NodeJS.Timeout | null
blankPageRetried: boolean
localPageKind: 'loading' | 'error' | null
activeTargetURL: string | null
activeLoadToken: number
context: string
}
@@ -26,6 +31,30 @@ function clearBlankPageWatchdog(state: WindowNavigationState): void {
}
}
function clearLoadTimeoutWatchdog(state: WindowNavigationState): void {
if (state.loadTimeoutWatchdog) {
clearTimeout(state.loadTimeoutWatchdog)
state.loadTimeoutWatchdog = null
}
}
function clearNavigationWatchdogs(state: WindowNavigationState): void {
clearLoadTimeoutWatchdog(state)
clearBlankPageWatchdog(state)
}
function isLocalPageURL(url: string): boolean {
return url.startsWith(errorPagePrefix)
}
function isIgnoredNavigationFailure(errorCode: number, errorDescription = ''): boolean {
return (
errorCode === -3 ||
errorCode === -20 ||
ignoredNavigationFailurePattern.test(`${errorCode}:${errorDescription}`)
)
}
async function probePageBodyShape(
window: BrowserWindow,
): Promise<{ nodeCount: number; textLength: number; readyState: string } | null> {
@@ -78,6 +107,12 @@ function scheduleBlankPageWatchdog(window: BrowserWindow, state: WindowNavigatio
url: currentURL,
...shape,
})
void showErrorPage(
window,
state.context,
currentURL,
'页面加载完成后仍然是空白状态,可能是网络、平台风控、兼容性或远程脚本加载失败导致。',
)
return
}
@@ -86,7 +121,10 @@ function scheduleBlankPageWatchdog(window: BrowserWindow, state: WindowNavigatio
url: currentURL,
...shape,
})
window.webContents.reloadIgnoringCache()
void navigateRemoteURL(window, currentURL, state.context, {
resetBlankRetry: false,
showLoadingPage: true,
})
})
}, BLANK_PAGE_PROBE_DELAY_MS)
}
@@ -100,6 +138,126 @@ function escapeHtml(value: string): string {
.replaceAll("'", '&#39;')
}
function targetHostLabel(targetURL: string): string {
try {
return new URL(targetURL).host
} catch {
return targetURL
}
}
function loadingPageDataURL(context: string, targetURL: string): string {
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(context)} - 正在打开</title>
<style>
:root {
color-scheme: light dark;
font-family: "IBM Plex Sans", "Segoe UI Variable", "Segoe UI", sans-serif;
background: #f6f7f9;
color: #111827;
}
* {
box-sizing: border-box;
}
html {
width: 100%;
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #f6f7f9;
overflow: hidden;
}
main {
width: min(520px, 100%);
padding: 28px;
border: 1px solid #d9dee7;
border-radius: 8px;
background: #ffffff;
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
margin: 0 auto;
border-radius: 50%;
border: 3px solid #d9dee7;
border-top-color: #2563eb;
animation: spin 0.9s linear infinite;
}
h1 {
margin: 18px 0 0;
font-size: 22px;
line-height: 1.35;
letter-spacing: 0;
}
p {
margin: 10px 0 0;
color: #5b6573;
font-size: 14px;
line-height: 1.7;
}
code {
display: block;
margin-top: 16px;
padding: 10px 12px;
border-radius: 6px;
background: #f1f4f8;
color: #374151;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 13px;
word-break: break-all;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-color-scheme: dark) {
:root,
body {
background: #111827;
color: #f9fafb;
}
main {
border-color: #2f3948;
background: #182130;
}
p {
color: #b6bfcb;
}
code {
background: #111827;
color: #d1d5db;
}
.spinner {
border-color: #344154;
border-top-color: #60a5fa;
}
}
</style>
</head>
<body>
<main>
<div class="spinner" aria-hidden="true"></div>
<h1>正在打开${escapeHtml(context)}</h1>
<p>如果平台网络较慢,窗口会停留在这里;加载失败时会给出重试入口。</p>
<code>${escapeHtml(targetHostLabel(targetURL))}</code>
</main>
</body>
</html>`
return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`
}
function errorPageDataURL(context: string, targetURL: string, message: string): string {
const html = `<!doctype html>
<html lang="zh-CN">
@@ -111,6 +269,15 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
:root {
color-scheme: light dark;
font-family: "IBM Plex Sans", "Segoe UI Variable", "Segoe UI", sans-serif;
background: #f6f7f9;
color: #111827;
}
* {
box-sizing: border-box;
}
html {
width: 100%;
min-height: 100%;
}
body {
margin: 0;
@@ -118,26 +285,21 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(37, 99, 235, 0.1), transparent 24%),
linear-gradient(180deg, #eef3f8, #e4ebf3);
color: #0f172a;
background: #f6f7f9;
}
.card {
width: min(680px, 100%);
padding: 24px;
border-radius: 24px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.12);
border-radius: 8px;
border: 1px solid #d9dee7;
background: #ffffff;
}
h1, p, pre {
margin: 0;
}
h1 {
font-size: 28px;
letter-spacing: -0.04em;
font-size: 24px;
letter-spacing: 0;
}
p {
margin-top: 12px;
@@ -147,8 +309,8 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
pre {
margin-top: 16px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.05);
border-radius: 6px;
background: #f1f4f8;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
@@ -157,42 +319,70 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
margin-top: 18px;
}
button,
a {
button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 16px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
border: 1px solid #d9dee7;
background: white;
color: #0f172a;
text-decoration: none;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.72;
}
.primary {
background: linear-gradient(180deg, #44a7ff, #1b6cff);
border-color: rgba(27, 108, 255, 0.18);
background: #2563eb;
border-color: #2563eb;
color: white;
}
@media (prefers-color-scheme: dark) {
:root,
body {
background: #111827;
color: #f9fafb;
}
.card {
border-color: #2f3948;
background: #182130;
}
p {
color: #b6bfcb;
}
pre {
background: #111827;
}
button {
border-color: #344154;
background: #111827;
color: #f9fafb;
}
.primary {
border-color: #3b82f6;
background: #3b82f6;
}
}
</style>
</head>
<body>
<main class="card">
<h1>授权页加载失败</h1>
<p>${escapeHtml(context)} 在打开远程页面时出现网络或 TLS 握手异常。窗口已保留,不再直接闪退。</p>
<p>如果这是偶发网络抖动,点“重新加载”即可;如果持续失败,再看控制台里打印的 URL 和错误码定位。</p>
<p>${escapeHtml(context)} 在打开远程页面时遇到网络、TLS、平台脚本或兼容性异常。窗口已保留,可以直接重试。</p>
<p>如果持续失败,请检查代理、DNS、系统时间和平台是否触发风控。</p>
<pre>${escapeHtml(message)}
${escapeHtml(targetURL)}</pre>
<div class="actions">
<button class="primary" onclick="location.href = ${JSON.stringify(targetURL)}">重新加载</button>
<a href=${JSON.stringify(targetURL)} target="_self">打开目标地址</a>
<button class="primary" onclick="this.disabled = true; this.textContent = '正在重试...'; location.replace(${JSON.stringify(targetURL)})">重新加载</button>
</div>
</main>
</body>
@@ -201,12 +391,160 @@ ${escapeHtml(targetURL)}</pre>
return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`
}
async function showLocalPage(
window: BrowserWindow,
pageURL: string,
kind: WindowNavigationState['localPageKind'],
): Promise<void> {
if (window.isDestroyed()) {
return
}
const state = navigationStateRegistry.get(window)
if (state) {
state.localPageKind = kind
}
try {
await window.loadURL(pageURL)
} catch (error) {
if (!window.isDestroyed()) {
console.warn('[desktop-window] local page load failed', {
message: error instanceof Error ? error.message : String(error),
})
}
}
}
async function showErrorPage(
window: BrowserWindow,
context: string,
targetURL: string,
message: string,
): Promise<void> {
if (window.isDestroyed()) {
return
}
const state = navigationStateRegistry.get(window)
if (state) {
state.readyForDetection = false
state.lastMainFrameError = message
clearNavigationWatchdogs(state)
}
await showLocalPage(window, errorPageDataURL(context, targetURL, message), 'error')
}
function scheduleRemoteLoadTimeout(
window: BrowserWindow,
state: WindowNavigationState,
targetURL: string,
context: string,
loadToken: number,
): void {
clearLoadTimeoutWatchdog(state)
state.loadTimeoutWatchdog = setTimeout(() => {
if (window.isDestroyed()) {
return
}
const latestState = navigationStateRegistry.get(window)
if (!latestState || latestState.activeLoadToken !== loadToken) {
return
}
const currentURL = window.webContents.getURL()
if (isLocalPageURL(currentURL) && latestState.localPageKind === 'error') {
return
}
console.warn(`[desktop-window] ${context} remote page load timed out`, {
targetURL,
currentURL,
timeoutMs: REMOTE_PAGE_LOAD_TIMEOUT_MS,
})
void showErrorPage(
window,
context,
targetURL,
`页面加载超过 ${Math.round(REMOTE_PAGE_LOAD_TIMEOUT_MS / 1000)} 秒,可能是网络连接或平台服务暂时不可用。`,
)
}, REMOTE_PAGE_LOAD_TIMEOUT_MS)
}
async function navigateRemoteURL(
window: BrowserWindow,
targetURL: string,
context: string,
options: { resetBlankRetry: boolean; showLoadingPage: boolean },
): Promise<void> {
const state = navigationStateRegistry.get(window)
const loadToken = (state?.activeLoadToken ?? 0) + 1
if (state) {
state.activeLoadToken = loadToken
state.activeTargetURL = targetURL
state.readyForDetection = false
if (options.resetBlankRetry) {
state.blankPageRetried = false
}
clearNavigationWatchdogs(state)
}
if (options.showLoadingPage) {
await showLocalPage(window, loadingPageDataURL(context, targetURL), 'loading')
}
if (window.isDestroyed()) {
return
}
if (state && state.activeLoadToken === loadToken) {
scheduleRemoteLoadTimeout(window, state, targetURL, context, loadToken)
}
try {
await window.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT })
const latestState = navigationStateRegistry.get(window)
if (latestState?.activeLoadToken === loadToken) {
clearLoadTimeoutWatchdog(latestState)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const latestState = navigationStateRegistry.get(window)
if (latestState?.activeLoadToken === loadToken) {
clearLoadTimeoutWatchdog(latestState)
}
if (ignoredNavigationFailurePattern.test(message) || window.isDestroyed()) {
return
}
console.error(`[desktop-window] ${context} loadURL failed`, {
targetURL,
message,
})
const currentURL = window.webContents.getURL()
if (isLocalPageURL(currentURL) && latestState?.localPageKind === 'error') {
return
}
await showErrorPage(window, context, targetURL, message)
}
}
export function attachWindowDiagnostics(window: BrowserWindow, context: string): void {
navigationStateRegistry.set(window, {
readyForDetection: false,
lastMainFrameError: null,
loadTimeoutWatchdog: null,
blankPageWatchdog: null,
blankPageRetried: false,
localPageKind: null,
activeTargetURL: null,
activeLoadToken: 0,
context,
})
@@ -219,10 +557,36 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
clearBlankPageWatchdog(state)
})
window.webContents.on(
'did-start-navigation',
(_event, url, isInPlace, isMainFrame) => {
if (!isMainFrame || isInPlace || !/^https?:\/\//i.test(url)) {
return
}
const state = navigationStateRegistry.get(window)
if (!state) {
return
}
const fromLocalPage = isLocalPageURL(window.webContents.getURL())
state.readyForDetection = false
state.activeTargetURL = url
if (fromLocalPage) {
state.localPageKind = 'loading'
}
if (fromLocalPage || !state.loadTimeoutWatchdog) {
state.activeLoadToken += 1
scheduleRemoteLoadTimeout(window, state, url, context, state.activeLoadToken)
}
},
)
window.webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame || errorCode === -3) {
if (!isMainFrame || isIgnoredNavigationFailure(errorCode, errorDescription)) {
return
}
@@ -230,7 +594,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
if (state) {
state.readyForDetection = false
state.lastMainFrameError = `${errorCode}:${errorDescription}`
clearBlankPageWatchdog(state)
clearNavigationWatchdogs(state)
}
console.warn(`[desktop-window] ${context} main-frame load failed`, {
@@ -238,6 +602,9 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
errorDescription,
validatedURL,
})
const targetURL = validatedURL || state?.activeTargetURL || window.webContents.getURL()
void showErrorPage(window, context, targetURL, `${errorCode}: ${errorDescription}`)
},
)
@@ -248,7 +615,12 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
}
const currentURL = window.webContents.getURL()
state.readyForDetection = !currentURL.startsWith(errorPagePrefix)
clearLoadTimeoutWatchdog(state)
const isLocalPage = isLocalPageURL(currentURL)
state.readyForDetection = !isLocalPage
if (!isLocalPage) {
state.localPageKind = null
}
if (state.readyForDetection) {
state.lastMainFrameError = null
scheduleBlankPageWatchdog(window, state)
@@ -259,7 +631,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
const state = navigationStateRegistry.get(window)
if (state) {
state.readyForDetection = false
clearBlankPageWatchdog(state)
clearNavigationWatchdogs(state)
}
console.error(`[desktop-window] ${context} renderer gone`, details)
@@ -268,7 +640,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
window.once('closed', () => {
const state = navigationStateRegistry.get(window)
if (state) {
clearBlankPageWatchdog(state)
clearNavigationWatchdogs(state)
}
})
}
@@ -276,7 +648,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
export function isWindowReadyForDetection(window: BrowserWindow): boolean {
const state = navigationStateRegistry.get(window)
if (!state) {
return !window.webContents.getURL().startsWith(errorPagePrefix)
return !isLocalPageURL(window.webContents.getURL())
}
return state.readyForDetection
}
@@ -286,39 +658,8 @@ export async function loadWindowURLSafely(
targetURL: string,
context: string,
): Promise<void> {
const state = navigationStateRegistry.get(window)
if (state) {
state.blankPageRetried = false
clearBlankPageWatchdog(state)
}
try {
await window.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (ignoredNavigationFailurePattern.test(message) || window.isDestroyed()) {
return
}
console.error(`[desktop-window] ${context} loadURL failed`, {
targetURL,
message,
})
if (window.webContents.getURL().startsWith(errorPagePrefix)) {
return
}
try {
await window.loadURL(errorPageDataURL(context, targetURL, message))
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
console.error(`[desktop-window] ${context} error-page loadURL failed`, {
targetURL,
message: fallbackMessage,
})
}
}
await navigateRemoteURL(window, targetURL, context, {
resetBlankRetry: true,
showLoadingPage: true,
})
}
@@ -360,7 +360,6 @@ interface ResetPlanUsageResult {
article_amount: number
ai_point_reservations: number
ai_points_amount: number
ai_point_usage_logs: number
}
}
+3 -1
View File
@@ -95,7 +95,9 @@ func main() {
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard)
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).WithLoginGuard(tenantLoginGuard)
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).
WithLoginGuard(tenantLoginGuard).
WithCache(appCache)
jobSvc := app.NewJobService(jobsRepo, auditSvc, rabbitMQ)
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
+16 -3
View File
@@ -3,6 +3,7 @@ package app
import (
"context"
"errors"
"fmt"
"net/mail"
"regexp"
"strings"
@@ -15,6 +16,7 @@ import (
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
@@ -38,6 +40,7 @@ type AdminUserService struct {
users *repository.AdminUserRepository
audits *AuditService
loginGuard *sharedauth.LoginGuard
cache sharedcache.Cache
configMu sync.RWMutex
defaultPlanCode string
}
@@ -59,6 +62,11 @@ func (s *AdminUserService) WithLoginGuard(g *sharedauth.LoginGuard) *AdminUserSe
return s
}
func (s *AdminUserService) WithCache(c sharedcache.Cache) *AdminUserService {
s.cache = c
return s
}
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
if s == nil {
return
@@ -167,7 +175,6 @@ type ResetPlanUsageView struct {
ArticleAmount int64 `json:"article_amount"`
AIPointReservations int64 `json:"ai_point_reservations"`
AIPointsAmount int64 `json:"ai_points_amount"`
AIPointUsageLogs int64 `json:"ai_point_usage_logs"`
ArticleWindowStart time.Time `json:"article_window_start"`
ArticleWindowEnd time.Time `json:"article_window_end"`
AIPointsWindowStart time.Time `json:"ai_points_window_start"`
@@ -489,12 +496,12 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
ArticleAmount: reset.ArticleAmount,
AIPointReservations: reset.AIPointReservations,
AIPointsAmount: reset.AIPointsAmount,
AIPointUsageLogs: reset.AIPointUsageLogs,
ArticleWindowStart: reset.ArticleWindowStart,
ArticleWindowEnd: reset.ArticleWindowEnd,
AIPointsWindowStart: reset.AIPointsWindowStart,
AIPointsWindowEnd: reset.AIPointsWindowEnd,
}
s.invalidateQuotaSummaryCache(ctx, reset.TenantID)
if actor != nil {
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetUsage, "admin_user", id, map[string]any{
@@ -504,7 +511,6 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
"article_amount": reset.ArticleAmount,
"ai_point_reservations": reset.AIPointReservations,
"ai_points_amount": reset.AIPointsAmount,
"ai_point_usage_logs": reset.AIPointUsageLogs,
"article_window_start": reset.ArticleWindowStart,
"article_window_end": reset.ArticleWindowEnd,
"ai_points_window_start": reset.AIPointsWindowStart,
@@ -518,6 +524,13 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
}, nil
}
func (s *AdminUserService) invalidateQuotaSummaryCache(ctx context.Context, tenantID int64) {
if s == nil || s.cache == nil || tenantID <= 0 {
return
}
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID))
}
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
normalizedRole := strings.TrimSpace(role)
if !isValidTenantRole(normalizedRole) {
@@ -0,0 +1,18 @@
package app
import (
"context"
"reflect"
"testing"
)
func TestAdminUserInvalidateQuotaSummaryCache(t *testing.T) {
cache := &recordingCache{}
svc := NewAdminUserService(nil, nil, "").WithCache(cache)
svc.invalidateQuotaSummaryCache(context.Background(), 42)
if want := []string{"workspace:quota_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
t.Fatalf("deleted keys = %v, want %v", cache.deletedKeys, want)
}
}
+9 -27
View File
@@ -91,7 +91,6 @@ type ResetPlanUsageResult struct {
ArticleAmount int64
AIPointReservations int64
AIPointsAmount int64
AIPointUsageLogs int64
ArticleWindowStart time.Time
ArticleWindowEnd time.Time
AIPointsWindowStart time.Time
@@ -576,13 +575,12 @@ LIMIT 1`, userID).Scan(&tenantID); err != nil {
}
if aiEnd.After(aiStart) {
count, amount, logCount, err := resetAIPointReservations(ctx, tx, tenantID, aiStart, aiEnd)
count, amount, err := resetAIPointReservations(ctx, tx, tenantID, aiStart, aiEnd)
if err != nil {
return nil, ResetPlanUsageResult{}, err
}
result.AIPointReservations = count
result.AIPointsAmount = amount
result.AIPointUsageLogs = logCount
}
if err := tx.Commit(ctx); err != nil {
@@ -600,8 +598,7 @@ func resetQuotaReservations(ctx context.Context, tx pgx.Tx, tenantID int64, quot
row := tx.QueryRow(ctx, `
WITH reset AS (
UPDATE quota_reservations
SET status = 'refunded',
refunded_amount = reserved_amount,
SET status = 'reset',
updated_at = NOW()
WHERE tenant_id = $1
AND quota_type = $2
@@ -622,43 +619,28 @@ FROM reset`, tenantID, quotaType, startAt.UTC(), endAt.UTC())
return count, amount, nil
}
func resetAIPointReservations(ctx context.Context, tx pgx.Tx, tenantID int64, startAt, endAt time.Time) (int64, int64, int64, error) {
func resetAIPointReservations(ctx context.Context, tx pgx.Tx, tenantID int64, startAt, endAt time.Time) (int64, int64, error) {
row := tx.QueryRow(ctx, `
WITH reset AS (
UPDATE quota_reservations
SET status = 'refunded',
refunded_amount = reserved_amount,
SET status = 'reset',
updated_at = NOW()
WHERE tenant_id = $1
AND quota_type = 'ai_points'
AND status IN ('pending', 'confirmed')
AND created_at >= $2
AND created_at < $3
RETURNING id, reserved_amount
),
logs AS (
UPDATE ai_point_usage_logs l
SET status = 'refunded',
error_message = 'ops reset current plan usage',
completed_at = COALESCE(l.completed_at, NOW()),
updated_at = NOW()
FROM reset r
WHERE l.tenant_id = $1
AND l.quota_reservation_id = r.id
AND l.status IN ('pending', 'completed')
RETURNING l.id
RETURNING reserved_amount
)
SELECT (SELECT COUNT(*) FROM reset)::BIGINT,
COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT,
(SELECT COUNT(*) FROM logs)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC())
COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC())
var reservationCount int64
var amount int64
var logCount int64
if err := row.Scan(&reservationCount, &amount, &logCount); err != nil {
return 0, 0, 0, err
if err := row.Scan(&reservationCount, &amount); err != nil {
return 0, 0, err
}
return reservationCount, amount, logCount, nil
return reservationCount, amount, nil
}
func (r *AdminUserRepository) ChangeRole(ctx context.Context, userID int64, tenantRole, workspaceRole string) (*AdminUserRecord, error) {
@@ -0,0 +1,77 @@
package repository
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
testStart = time.Unix(0, 0).UTC()
testEnd = time.Unix(3600, 0).UTC()
)
type captureResetSQLTx struct {
sql string
}
func (tx *captureResetSQLTx) QueryRow(_ context.Context, sql string, _ ...any) pgx.Row {
tx.sql = sql
return failingRow{}
}
func (tx *captureResetSQLTx) Begin(context.Context) (pgx.Tx, error) { return nil, errors.New("unused") }
func (tx *captureResetSQLTx) Commit(context.Context) error { return nil }
func (tx *captureResetSQLTx) Rollback(context.Context) error { return nil }
func (tx *captureResetSQLTx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) {
return 0, errors.New("unused")
}
func (tx *captureResetSQLTx) SendBatch(context.Context, *pgx.Batch) pgx.BatchResults {
return nil
}
func (tx *captureResetSQLTx) LargeObjects() pgx.LargeObjects { return pgx.LargeObjects{} }
func (tx *captureResetSQLTx) Prepare(context.Context, string, string) (*pgconn.StatementDescription, error) {
return nil, errors.New("unused")
}
func (tx *captureResetSQLTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
return pgconn.CommandTag{}, errors.New("unused")
}
func (tx *captureResetSQLTx) Query(context.Context, string, ...any) (pgx.Rows, error) {
return nil, errors.New("unused")
}
func (tx *captureResetSQLTx) Conn() *pgx.Conn { return nil }
type failingRow struct{}
func (failingRow) Scan(...any) error { return errors.New("stop after capturing sql") }
func TestResetQuotaReservationsSQLMatchesEffectiveUsage(t *testing.T) {
tx := &captureResetSQLTx{}
_, _, _ = resetQuotaReservations(context.Background(), tx, 1, "article_generation", testStart, testEnd)
if !strings.Contains(tx.sql, "status IN ('pending', 'confirmed')") {
t.Fatalf("reset must clear every status counted as used; query:\n%s", tx.sql)
}
if strings.Contains(tx.sql, "ai_point_usage_logs") {
t.Fatalf("reset must not mutate AI point usage logs; query:\n%s", tx.sql)
}
}
func TestResetAIPointReservationsSQLDoesNotTouchUsageLogs(t *testing.T) {
tx := &captureResetSQLTx{}
_, _, _ = resetAIPointReservations(context.Background(), tx, 1, testStart, testEnd)
if !strings.Contains(tx.sql, "status IN ('pending', 'confirmed')") {
t.Fatalf("reset must clear every status counted as used; query:\n%s", tx.sql)
}
if strings.Contains(tx.sql, "ai_point_usage_logs") {
t.Fatalf("reset must not mutate AI point usage logs; query:\n%s", tx.sql)
}
}
+50 -30
View File
@@ -185,7 +185,7 @@ func CompleteAIPoints(
if err != nil || !pending {
return err
}
if err := confirmAIPointReservation(ctx, tx, tenantID, reservation.ReservationID); err != nil {
if err := confirmAIPointReservationIfPending(ctx, tx, tenantID, reservation.ReservationID); err != nil {
return err
}
var modelPtr *string
@@ -226,29 +226,31 @@ func RefundAIPoints(
return err
}
status, err := repository.NewQuotaRepository(tx).GetAIQuotaStatus(ctx, tenantID, time.Now().UTC())
refunded, err := refundAIPointReservationIfPending(ctx, tx, tenantID, reservation.ReservationID)
if err != nil {
return err
}
quotaRepo := repository.NewQuotaRepository(tx)
reason := "ai_points_refund"
referenceType := "ai_point_usage"
usageID := reservation.UsageLogID
if _, err := quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
TenantID: tenantID,
OperatorID: operatorID,
QuotaType: aiPointsQuotaType,
Delta: reservation.Points,
BalanceAfter: status.Balance + reservation.Points,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return err
}
if err := refundAIPointReservation(ctx, tx, tenantID, reservation.ReservationID); err != nil {
return err
if refunded {
status, err := repository.NewQuotaRepository(tx).GetAIQuotaStatus(ctx, tenantID, time.Now().UTC())
if err != nil {
return err
}
quotaRepo := repository.NewQuotaRepository(tx)
reason := "ai_points_refund"
referenceType := "ai_point_usage"
usageID := reservation.UsageLogID
if _, err := quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
TenantID: tenantID,
OperatorID: operatorID,
QuotaType: aiPointsQuotaType,
Delta: reservation.Points,
BalanceAfter: status.Balance + reservation.Points,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return err
}
}
if err := repository.NewAIPointUsageRepository(tx).MarkRefunded(ctx, tenantID, reservation.UsageLogID, strings.TrimSpace(errorMessage)); err != nil {
return err
@@ -375,7 +377,7 @@ func lockPendingAIPointUsage(ctx context.Context, tx pgx.Tx, tenantID, usageLogI
return status == "pending", nil
}
func confirmAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
func confirmAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'confirmed',
@@ -389,13 +391,10 @@ func confirmAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reserva
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("ai points reservation is not pending")
}
return nil
return ignoreAIPointReservationReset(ctx, tx, tag.RowsAffected() > 0, tenantID, reservationID)
}
func refundAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
func refundAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) (bool, error) {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'refunded',
@@ -406,11 +405,32 @@ func refundAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservat
AND quota_type = $3
AND status = 'pending'
`, reservationID, tenantID, aiPointsQuotaType)
if err != nil {
return false, err
}
if tag.RowsAffected() > 0 {
return true, nil
}
return false, ignoreAIPointReservationReset(ctx, tx, false, tenantID, reservationID)
}
func ignoreAIPointReservationReset(ctx context.Context, tx pgx.Tx, updated bool, tenantID, reservationID int64) error {
if updated {
return nil
}
var status string
err := tx.QueryRow(ctx, `
SELECT status
FROM quota_reservations
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
`, reservationID, tenantID, aiPointsQuotaType).Scan(&status)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("ai points reservation is not pending")
if status == "reset" {
return nil
}
return nil
return errors.New("ai points reservation is not pending")
}
@@ -512,27 +512,29 @@ func (s *ArticleImitationService) failGeneration(ctx context.Context, job articl
if job.ReservationID > 0 {
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation")
if balanceErr != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := job.TaskID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
if err := quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID); err != nil {
refunded, err := quotaRepo.RefundPendingReservation(cleanupCtx, job.ReservationID, job.TenantID)
if err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("refund generation reservation: %w", err))
} else if refunded {
if balanceErr != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := job.TaskID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
}
}
@@ -79,29 +79,34 @@ func HandleClaimedGenerationTaskFailure(
}
if task.QuotaReservationID > 0 {
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, task.TenantID, "article_generation")
if balanceErr != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else if task.OperatorID > 0 {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := task.ID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: task.TenantID,
OperatorID: task.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
var balance int
balanceErr := error(nil)
if task.OperatorID > 0 {
balance, balanceErr = quotaRepo.GetCurrentBalance(cleanupCtx, task.TenantID, "article_generation")
}
if err := quotaRepo.RefundReservation(cleanupCtx, task.QuotaReservationID, task.TenantID); err != nil {
refunded, err := quotaRepo.RefundPendingReservation(cleanupCtx, task.QuotaReservationID, task.TenantID)
if err != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("refund generation reservation: %w", err))
} else if refunded {
if balanceErr != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else if task.OperatorID > 0 {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := task.ID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: task.TenantID,
OperatorID: task.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
}
}
@@ -445,28 +445,34 @@ func HandleKolGenerationTaskFailure(
}
if task.QuotaReservationID > 0 {
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, task.TenantID, "article_generation")
if balanceErr != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else if task.OperatorID > 0 {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := task.ID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: task.TenantID,
OperatorID: task.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
var balance int
balanceErr := error(nil)
if task.OperatorID > 0 {
balance, balanceErr = quotaRepo.GetCurrentBalance(cleanupCtx, task.TenantID, "article_generation")
}
if err := quotaRepo.RefundReservation(cleanupCtx, task.QuotaReservationID, task.TenantID); err != nil {
refunded, err := quotaRepo.RefundPendingReservation(cleanupCtx, task.QuotaReservationID, task.TenantID)
if err != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("refund generation reservation: %w", err))
} else if refunded {
if balanceErr != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else if task.OperatorID > 0 {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := task.ID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: task.TenantID,
OperatorID: task.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
failureErr = errors.Join(failureErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
}
}
@@ -739,28 +739,29 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr
}
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation")
if balanceErr != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := job.TaskID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
if err := quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID); err != nil {
refunded, err := quotaRepo.RefundPendingReservation(cleanupCtx, job.ReservationID, job.TenantID)
if err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("refund generation reservation: %w", err))
} else if refunded {
if balanceErr != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := job.TaskID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
}
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
if cleanupErr != nil {
+22 -21
View File
@@ -626,28 +626,29 @@ func (s *TemplateService) failGeneration(ctx context.Context, job generationJob,
}
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation")
if balanceErr != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := job.TaskID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
if err := quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID); err != nil {
refunded, err := quotaRepo.RefundPendingReservation(cleanupCtx, job.ReservationID, job.TenantID)
if err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("refund generation reservation: %w", err))
} else if refunded {
if balanceErr != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := job.TaskID
if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.OperatorID,
QuotaType: "article_generation",
Delta: 1,
BalanceAfter: balance + 1,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &taskReferenceID,
}); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
}
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
if cleanupErr != nil {
+50 -29
View File
@@ -1417,7 +1417,7 @@ func (s *Service) completeComplianceLLMJudgePoints(ctx context.Context, job revi
if err != nil || !pending {
return err
}
if err := confirmAIPointReservation(ctx, tx, job.TenantID, reservation.ReservationID); err != nil {
if err := confirmAIPointReservationIfPending(ctx, tx, job.TenantID, reservation.ReservationID); err != nil {
return err
}
var modelPtr *string
@@ -1447,28 +1447,31 @@ func (s *Service) refundComplianceLLMJudgePoints(ctx context.Context, job review
if err != nil || !pending {
return err
}
status, err := tenantrepo.NewQuotaRepository(tx).GetAIQuotaStatus(ctx, job.TenantID, time.Now().UTC())
refunded, err := refundAIPointReservationIfPending(ctx, tx, job.TenantID, reservation.ReservationID)
if err != nil {
return err
}
quotaRepo := tenantrepo.NewQuotaRepository(tx)
reason := "ai_points_refund"
referenceType := "ai_point_usage"
usageID := reservation.UsageLogID
if _, err := quotaRepo.InsertQuotaLedger(ctx, tenantrepo.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.CheckedBy,
QuotaType: aiPointsQuotaType,
Delta: reservation.Points,
BalanceAfter: status.Balance + reservation.Points,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return err
}
if err := refundAIPointReservation(ctx, tx, job.TenantID, reservation.ReservationID); err != nil {
return err
if refunded {
status, err := tenantrepo.NewQuotaRepository(tx).GetAIQuotaStatus(ctx, job.TenantID, time.Now().UTC())
if err != nil {
return err
}
quotaRepo := tenantrepo.NewQuotaRepository(tx)
reason := "ai_points_refund"
referenceType := "ai_point_usage"
usageID := reservation.UsageLogID
if _, err := quotaRepo.InsertQuotaLedger(ctx, tenantrepo.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.CheckedBy,
QuotaType: aiPointsQuotaType,
Delta: reservation.Points,
BalanceAfter: status.Balance + reservation.Points,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return err
}
}
if err := tenantrepo.NewAIPointUsageRepository(tx).MarkRefunded(ctx, job.TenantID, reservation.UsageLogID, truncateRunes(errorString(jobErr), 2000)); err != nil {
return err
@@ -1512,7 +1515,7 @@ func lockPendingAIPointUsage(ctx context.Context, tx pgx.Tx, tenantID, usageLogI
return status == "pending", nil
}
func confirmAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
func confirmAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'confirmed',
@@ -1526,13 +1529,10 @@ func confirmAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reserva
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("ai points reservation is not pending")
}
return nil
return ignoreAIPointReservationReset(ctx, tx, tag.RowsAffected() > 0, tenantID, reservationID)
}
func refundAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
func refundAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) (bool, error) {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'refunded',
@@ -1543,13 +1543,34 @@ func refundAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservat
AND quota_type = $3
AND status = 'pending'
`, reservationID, tenantID, aiPointsQuotaType)
if err != nil {
return false, err
}
if tag.RowsAffected() > 0 {
return true, nil
}
return false, ignoreAIPointReservationReset(ctx, tx, false, tenantID, reservationID)
}
func ignoreAIPointReservationReset(ctx context.Context, tx pgx.Tx, updated bool, tenantID, reservationID int64) error {
if updated {
return nil
}
var status string
err := tx.QueryRow(ctx, `
SELECT status
FROM quota_reservations
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
`, reservationID, tenantID, aiPointsQuotaType).Scan(&status)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("ai points reservation is not pending")
if status == "reset" {
return nil
}
return nil
return errors.New("ai points reservation is not pending")
}
func (s *Service) markReviewJobDone(ctx context.Context, jobID int64) error {
@@ -13,7 +13,7 @@ import (
const confirmReservation = `-- name: ConfirmReservation :exec
UPDATE quota_reservations SET status = 'confirmed', consumed_amount = reserved_amount, updated_at = NOW()
WHERE id = $1 AND tenant_id = $2
WHERE id = $1 AND tenant_id = $2 AND status = 'pending'
`
type ConfirmReservationParams struct {
@@ -114,7 +114,7 @@ func (q *Queries) InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerPa
const refundReservation = `-- name: RefundReservation :exec
UPDATE quota_reservations SET status = 'refunded', refunded_amount = reserved_amount, updated_at = NOW()
WHERE id = $1 AND tenant_id = $2
WHERE id = $1 AND tenant_id = $2 AND status = 'pending'
`
type RefundReservationParams struct {
@@ -0,0 +1,19 @@
package generated
import (
"strings"
"testing"
)
func TestQuotaReservationTransitionsOnlyUpdatePending(t *testing.T) {
for name, query := range map[string]string{
"confirm": confirmReservation,
"refund": refundReservation,
} {
t.Run(name, func(t *testing.T) {
if !strings.Contains(query, "status = 'pending'") {
t.Fatalf("%s reservation query must only update pending reservations; query:\n%s", name, query)
}
})
}
}
@@ -23,8 +23,8 @@ WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: ConfirmReservation :exec
UPDATE quota_reservations SET status = 'confirmed', consumed_amount = reserved_amount, updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND status = 'pending';
-- name: RefundReservation :exec
UPDATE quota_reservations SET status = 'refunded', refunded_amount = reserved_amount, updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND status = 'pending';
@@ -59,6 +59,7 @@ type QuotaRepository interface {
UpdateQuotaReservationResource(ctx context.Context, reservationID, tenantID, resourceID int64) error
ConfirmReservation(ctx context.Context, reservationID, tenantID int64) error
RefundReservation(ctx context.Context, reservationID, tenantID int64) error
RefundPendingReservation(ctx context.Context, reservationID, tenantID int64) (bool, error)
}
type quotaRepository struct {
@@ -256,3 +257,19 @@ func (r *quotaRepository) RefundReservation(ctx context.Context, reservationID,
TenantID: tenantID,
})
}
func (r *quotaRepository) RefundPendingReservation(ctx context.Context, reservationID, tenantID int64) (bool, error) {
tag, err := r.db.Exec(ctx, `
UPDATE quota_reservations
SET status = 'refunded',
refunded_amount = reserved_amount,
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND status = 'pending'
`, reservationID, tenantID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
@@ -10,6 +10,8 @@ CREATE TABLE quota_reservations (
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expire_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_quota_reservation_status
CHECK (status IN ('pending', 'confirmed', 'refunded', 'reset'))
);
CREATE INDEX idx_quota_reservation_tenant_status ON quota_reservations(tenant_id, status);
@@ -0,0 +1,12 @@
UPDATE quota_reservations
SET status = 'refunded',
refunded_amount = reserved_amount,
updated_at = NOW()
WHERE status = 'reset';
ALTER TABLE quota_reservations
DROP CONSTRAINT IF EXISTS chk_quota_reservation_status;
ALTER TABLE quota_reservations
ADD CONSTRAINT chk_quota_reservation_status
CHECK (status IN ('pending', 'confirmed', 'refunded'));
@@ -0,0 +1,6 @@
ALTER TABLE quota_reservations
DROP CONSTRAINT IF EXISTS chk_quota_reservation_status;
ALTER TABLE quota_reservations
ADD CONSTRAINT chk_quota_reservation_status
CHECK (status IN ('pending', 'confirmed', 'refunded', 'reset'));