From 6a53619dd216903d5c79226c21df4b1c08bfd480 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 11:07:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(framework):=20=E5=9B=9E=E7=81=8C=20OpenFla?= =?UTF-8?q?re=20=E5=88=86=E5=B1=82=E3=80=81=E5=AE=89=E5=85=A8=E4=B8=8E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将平台域持久化收敛为 repository 唯一入口,model 去掉 IO。 邮件头写入前清除 CR/LF,防止 header 注入。 httppool 支持可配置 Transport;batchwriter 增加 MinBatchSize/Stats,flush 失败交回批次;任务 PermanentError 作为 SkipRetry 终态。 设置与推送页的确认改为 AlertDialog;axios 去尾斜杠并按 Gin 数组序列化查询参数。 升级共享 Go 依赖(Gin、Asynq、OTel、GORM、Redis 等)。 --- AGENTS.md | 4 + Makefile | 6 + .../admin/push/components/events-tab.tsx | 49 ++- .../admin/push/components/settings-tab.tsx | 53 ++- .../common/settings/access-token.tsx | 98 ++++- .../common/settings/security-tab.tsx | 56 ++- .../components/common/settings/templates.tsx | 51 ++- frontend/lib/services/core/api-client.ts | 25 +- frontend/messages/en.json | 26 +- frontend/messages/zh-CN.json | 26 +- frontend/next.config.ts | 2 + go.mod | 177 ++++---- go.sum | 377 +++++++++--------- internal/apps/admin/auth_source/routers.go | 16 +- internal/apps/admin/logs/routers.go | 14 +- internal/apps/admin/system_config/logics.go | 13 +- internal/apps/admin/task/routers.go | 15 +- internal/apps/admin/task/routers_test.go | 13 +- internal/apps/admin/user/logics.go | 8 +- internal/apps/oauth/handler_callback.go | 22 +- .../apps/oauth/handler_external_accounts.go | 7 +- internal/apps/oauth/middlewares.go | 17 +- internal/apps/oauth/oauth_userinfo.go | 8 +- internal/apps/risk_control/logics.go | 4 +- internal/apps/upload/storage/migration.go | 16 +- internal/apps/upload/task/cleanup.go | 3 +- internal/apps/upload/task/tasks_test.go | 3 +- internal/apps/user/logics.go | 59 ++- .../infra/persistence/batchwriter/config.go | 19 +- .../infra/persistence/batchwriter/writer.go | 63 ++- .../persistence/batchwriter/writer_test.go | 223 ++++++++++- internal/infra/task/executor.go | 33 +- internal/infra/task/executor_test.go | 33 +- internal/infra/task/permanent_error.go | 35 ++ internal/infra/task/permanent_error_test.go | 27 ++ internal/infra/task/scheduler/scheduler.go | 4 +- internal/model/auth_source.go | 204 ---------- internal/model/errs.go | 33 +- internal/model/schedule.go | 45 --- internal/model/task_execution.go | 247 +----------- internal/model/users.go | 75 ---- internal/repository/access_token.go | 69 ++++ .../repository/analytics/access_log_test.go | 7 + internal/repository/auth_source.go | 215 ++++++++++ internal/repository/auth_source_cache.go | 6 +- internal/repository/auth_source_cache_test.go | 8 +- internal/repository/errs.go | 25 ++ internal/repository/schedule.go | 53 +++ internal/repository/system_config.go | 9 +- internal/repository/task_execution.go | 304 ++++++++++++++ .../task_execution_test.go | 140 ++++--- internal/repository/user.go | 115 +++++- pkg/httppool/httppool.go | 72 +++- pkg/httppool/httppool_test.go | 63 +++ pkg/mail/mail.go | 21 +- pkg/mail/mail_test.go | 20 + pkg/push/email.go | 14 +- pkg/push/email_test.go | 26 ++ 58 files changed, 2201 insertions(+), 1175 deletions(-) create mode 100644 internal/infra/task/permanent_error.go create mode 100644 internal/infra/task/permanent_error_test.go create mode 100644 internal/repository/access_token.go create mode 100644 internal/repository/auth_source.go create mode 100644 internal/repository/errs.go create mode 100644 internal/repository/schedule.go create mode 100644 internal/repository/task_execution.go rename internal/{model => repository}/task_execution_test.go (74%) create mode 100644 pkg/push/email_test.go diff --git a/AGENTS.md b/AGENTS.md index 29580fbb..fd1060e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,9 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - 禁止在 `init()` 中注册跨模块集成(任务 Handler、推送事件、域事件监听器等),统一在 `internal/platform/bootstrap` 显式装配并在 `internal/cmd` 入口调用。 - 核心业务模块(`oauth`、`user`)禁止直接 import `push` 或 `custom_events` 触发通知,须通过 `internal/listener` 发射域事件。 - API 错误响应必须通过 `response.Abort*` 中断请求,由 `ErrorHandlerMiddleware` 统一写出 JSON 并记录 Trace;禁止在 Handler/中间件中直接 `c.JSON(status, response.Err(...))` 或 `200` 返回 `error_msg`。 +- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。 + - `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。 + - `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。 ## 技术栈与项目目录结构 @@ -112,6 +115,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - **错误日志**:底层错误在 Handler/Logic 边界用 `pkg/logger` 打印日志,禁止使用 `_ = ...` 静默吞掉关键错误。 ### 数据库操作 +- 平台域(user、auth_source、access_token、schedule、task_execution)的持久化必须走 `internal/repository`,禁止在 `internal/model` 中调用 `db.DB` / Redis。 - 管理员代码推荐使用 `db.DB(ctx)`(`internal/infra/persistence`,包名 `db`)保证 Trace 链路透传。 - 禁止在 Handler 写复杂 SQL;迁移文件位于 `internal/infra/persistence/migrator/goose/`(禁止 GORM AutoMigrate)。 - 不创建物理外键(显式建索引);Go 模型零值需与数据库默认值匹配。 diff --git a/Makefile b/Makefile index 3bcc4f05..a84e91e8 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,12 @@ build-embedded: main.go code-check: + @echo "==> Architecture guards..." + @command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; } + @if rg -n 'db\.DB\(|db\.Redis' internal/model --glob '*.go' -g '!*_test.go' ; then \ + echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \ + exit 1; \ + fi golangci-lint run cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0 diff --git a/frontend/app/(main)/admin/push/components/events-tab.tsx b/frontend/app/(main)/admin/push/components/events-tab.tsx index bbfad4d9..f2fb9e3e 100644 --- a/frontend/app/(main)/admin/push/components/events-tab.tsx +++ b/frontend/app/(main)/admin/push/components/events-tab.tsx @@ -43,6 +43,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Select, SelectContent, @@ -74,6 +84,9 @@ import { PushService } from '@/lib/services/push'; export function EventsTab() { const t = useTranslations('admin.push.events'); const queryClient = useQueryClient(); + const [deleteTarget, setDeleteTarget] = React.useState( + null, + ); // --- 获取所有自定义消息通道 --- const channelsQuery = useQuery({ @@ -149,6 +162,7 @@ export function EventsTab() { const deleteEventMutation = useMutation({ mutationFn: (id: number) => PushService.deleteEvent(id), onSuccess: () => { + setDeleteTarget(null); toast.success(t('configDeleteSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); }, @@ -455,11 +469,7 @@ export function EventsTab() { size='icon' className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10' disabled={deleteEventMutation.isPending} - onClick={() => { - if (confirm(t('deleteEventConfirm'))) { - deleteEventMutation.mutate(event.id); - } - }} + onClick={() => setDeleteTarget(event)} > @@ -919,6 +929,35 @@ export function EventsTab() { + + !open && setDeleteTarget(null)} + > + + + {t('deleteEventTitle')} + + {t('deleteEventConfirm')} + + + + + {t('cancel')} + + + deleteTarget && deleteEventMutation.mutate(deleteTarget.id) + } + > + {deleteEventMutation.isPending + ? t('deleting') + : t('confirmDelete')} + + + + ); } diff --git a/frontend/app/(main)/admin/push/components/settings-tab.tsx b/frontend/app/(main)/admin/push/components/settings-tab.tsx index a5cdd64d..1d925556 100644 --- a/frontend/app/(main)/admin/push/components/settings-tab.tsx +++ b/frontend/app/(main)/admin/push/components/settings-tab.tsx @@ -32,6 +32,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Select, SelectContent, @@ -54,6 +64,9 @@ import { PushService } from '@/lib/services/push'; export function SettingsTab() { const t = useTranslations('admin.push.settings'); const queryClient = useQueryClient(); + const [deleteTarget, setDeleteTarget] = React.useState( + null, + ); // --- 获取所有自定义消息通道 --- const channelsQuery = useQuery({ @@ -96,6 +109,7 @@ export function SettingsTab() { const deleteChannelMutation = useMutation({ mutationFn: (id: number) => PushService.deleteChannel(id), onSuccess: () => { + setDeleteTarget(null); toast.success(t('channelDeleteSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); }, @@ -419,15 +433,7 @@ export function SettingsTab() { variant='ghost' size='sm' disabled={deleteChannelMutation.isPending} - onClick={() => { - if ( - confirm( - t('deleteChannelConfirm', { name: ch.name }), - ) - ) { - deleteChannelMutation.mutate(ch.id); - } - }} + onClick={() => setDeleteTarget(ch)} className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10' > @@ -772,6 +778,35 @@ export function SettingsTab() { + + !open && setDeleteTarget(null)} + > + + + {t('deleteChannelTitle')} + + {t('deleteChannelConfirm', { name: deleteTarget?.name ?? '' })} + + + + + {t('cancel')} + + + deleteTarget && deleteChannelMutation.mutate(deleteTarget.id) + } + > + {deleteChannelMutation.isPending + ? t('deleting') + : t('confirmDelete')} + + + + ); } diff --git a/frontend/components/common/settings/access-token.tsx b/frontend/components/common/settings/access-token.tsx index f0090257..00189dbc 100644 --- a/frontend/components/common/settings/access-token.tsx +++ b/frontend/components/common/settings/access-token.tsx @@ -37,6 +37,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Breadcrumb, BreadcrumbItem, @@ -53,6 +63,8 @@ import { useLocale, useTranslations } from 'next-intl'; import { formatDateTime } from '@/i18n/format'; import type { AppLocale } from '@/i18n/config'; +type ConfirmTarget = { id: number; name: string }; + export function AccessTokenMain() { const { user } = useAuth(); const queryClient = useQueryClient(); @@ -67,6 +79,12 @@ export function AccessTokenMain() { const [copiedId, setCopiedId] = React.useState(null); const [newCreatedToken, setNewCreatedToken] = React.useState(null); + const [deleteTarget, setDeleteTarget] = React.useState( + null, + ); + const [rotateTarget, setRotateTarget] = React.useState( + null, + ); // 获取 Token 列表 const accessTokensQuery = useQuery({ @@ -98,6 +116,7 @@ export function AccessTokenMain() { const deleteTokenMutation = useMutation({ mutationFn: (id: number) => UserService.deleteAccessToken(id), onSuccess: () => { + setDeleteTarget(null); void queryClient.invalidateQueries({ queryKey: ['user', 'access-tokens'], }); @@ -112,6 +131,7 @@ export function AccessTokenMain() { const rotateTokenMutation = useMutation({ mutationFn: (id: number) => UserService.rotateAccessToken(id), onSuccess: (data) => { + setRotateTarget(null); setNewCreatedToken(data); setViewDialogOpen(true); void queryClient.invalidateQueries({ @@ -136,18 +156,6 @@ export function AccessTokenMain() { }); }; - const handleDeleteToken = (id: number, name: string) => { - if (window.confirm(ta('deleteConfirm', { name }))) { - deleteTokenMutation.mutate(id); - } - }; - - const handleRotateToken = (id: number, name: string) => { - if (window.confirm(ta('rotateConfirm', { name }))) { - rotateTokenMutation.mutate(id); - } - }; - const handleCopyText = async (text: string, id: number) => { try { await navigator.clipboard.writeText(text); @@ -297,7 +305,9 @@ export function AccessTokenMain() { variant='outline' size='sm' className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5' - onClick={() => handleRotateToken(token.id, token.name)} + onClick={() => + setRotateTarget({ id: token.id, name: token.name }) + } disabled={rotateTokenMutation.isPending} > handleDeleteToken(token.id, token.name)} + onClick={() => + setDeleteTarget({ id: token.id, name: token.name }) + } disabled={deleteTokenMutation.isPending} > @@ -482,6 +494,64 @@ export function AccessTokenMain() { + + !open && setDeleteTarget(null)} + > + + + {ta('deleteConfirmTitle')} + + {ta('deleteConfirm', { name: deleteTarget?.name ?? '' })} + + + + + {tCommon('cancel')} + + + deleteTarget && deleteTokenMutation.mutate(deleteTarget.id) + } + > + {deleteTokenMutation.isPending + ? ta('revoking') + : ta('confirmRevoke')} + + + + + + !open && setRotateTarget(null)} + > + + + {ta('rotateConfirmTitle')} + + {ta('rotateConfirm', { name: rotateTarget?.name ?? '' })} + + + + + {tCommon('cancel')} + + + rotateTarget && rotateTokenMutation.mutate(rotateTarget.id) + } + > + {rotateTokenMutation.isPending + ? ta('rotating') + : ta('confirmRotate')} + + + + ); } diff --git a/frontend/components/common/settings/security-tab.tsx b/frontend/components/common/settings/security-tab.tsx index 15fd545a..cd99a755 100644 --- a/frontend/components/common/settings/security-tab.tsx +++ b/frontend/components/common/settings/security-tab.tsx @@ -40,6 +40,16 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { AuthSourceModal } from '@/components/common/settings/auth-source-modal'; import services from '@/lib/services'; import type { AuthSource, SystemConfig } from '@/lib/services/admin'; @@ -93,8 +103,10 @@ interface SecurityTabProps { export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { const queryClient = useQueryClient(); const t = useTranslations('settings.security'); + const tCommon = useTranslations('common'); const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false); const [selectedSource, setSelectedSource] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const [capCount, setCapCount] = useState(''); const [capDifficulty, setCapDifficulty] = useState(''); @@ -233,6 +245,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { await services.adminAuthSource.deleteAuthSource(sourceId); }, onSuccess: async () => { + setDeleteTarget(null); await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] }); await queryClient.invalidateQueries({ queryKey: ['auth', 'public-sources'], @@ -490,17 +503,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { size='icon' className='size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors' disabled={deleteSourceMutation.isPending} - onClick={() => { - if ( - window.confirm( - t('deleteAuthSourceConfirm', { - name: source.display_name || source.name, - }), - ) - ) { - deleteSourceMutation.mutate(source.id); - } - }} + onClick={() => setDeleteTarget(source)} > @@ -706,6 +709,37 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { await authSourcesQuery.refetch(); }} /> + + !open && setDeleteTarget(null)} + > + + + {t('deleteAuthSourceTitle')} + + {t('deleteAuthSourceConfirm', { + name: deleteTarget?.display_name || deleteTarget?.name || '', + })} + + + + + {tCommon('cancel')} + + + deleteTarget && deleteSourceMutation.mutate(deleteTarget.id) + } + > + {deleteSourceMutation.isPending + ? t('deleting') + : t('confirmDelete')} + + + + ); } diff --git a/frontend/components/common/settings/templates.tsx b/frontend/components/common/settings/templates.tsx index 4e4a5965..7addcdd4 100644 --- a/frontend/components/common/settings/templates.tsx +++ b/frontend/components/common/settings/templates.tsx @@ -23,6 +23,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import services from '@/lib/services'; import type { Template } from '@/lib/services/admin/types'; import { toast } from 'sonner'; @@ -35,6 +45,7 @@ export function TemplatesManager() { const [selectedTemplate, setSelectedTemplate] = useState