Compare commits
6 Commits
f06c8dfc01
...
01fa2b8309
| Author | SHA1 | Date | |
|---|---|---|---|
| 01fa2b8309 | |||
| 1aad002a3e | |||
| 51d32fa96d | |||
| 793c310560 | |||
| b6cc12cefe | |||
| 635782041a |
@@ -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'] })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 正在分析关键词和评测上下文,请稍后…",
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("'", ''')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
Reference in New Issue
Block a user