diff --git a/AGENTS.md b/AGENTS.md index 2c9e318c..6c436d37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,275 +1,275 @@ # AGENTS.md -This file defines the mandatory working agreement for AI agents in this repository. It is intentionally based on the current codebase rather than historical conventions. - -## 1. Scope and Priorities - -- These rules apply to the repository root and every subdirectory. -- Explicit user instructions take precedence over this file. Mention any deliberate deviation in the final summary. -- Inspect the relevant implementation before editing. Reuse current helpers, component APIs, generated-code workflows, and neighboring patterns instead of relying on memory. -- Keep changes narrowly scoped. Preserve unrelated and user-owned worktree changes, including staged changes. -- A review, investigation, or diagnosis request is read-only unless the user also asks for implementation. - -## 2. Current Architecture - -The repository contains three application areas: - -- Go server: `cmd/server` and `internal/*` -- Next.js application: `web/*` -- Embedded workflow editor: `flowgram-editor/*`, built into `web/public/flowgram-editor` - -The main stack is: - -- Go 1.26, Gin, GORM, `github.com/mlogclub/simple` -- SQLite and MySQL -- Next.js 16 App Router, React 19, TypeScript, Tailwind CSS, shadcn/Base UI -- `pnpm` for both frontend projects -- Optional LanceDB builds through CGO; Qdrant is also supported by the application - -Important entry points: - -- Server assembly and middleware: `internal/bootstrap/server.go` -- Explicit API routes: `internal/bootstrap/routes.go` -- Model registration: `internal/models/models.go` -- Schema/data migration startup: `internal/bootstrap/migration.go` -- CRUD generator: `cmd/generator/generator.go` -- Frontend enum generator: `cmd/enums/generator.go` -- Frontend API client: `web/lib/api/client.ts` -- Frontend i18n: `web/i18n/*` and `web/messages/*` -- Dashboard shared components: `web/components/dashboard/*` -- Project commands: `Taskfile.yml` - -## 3. General Change Rules - -- Read the actual type, function, or component signature before using it. -- Prefer the highest-level existing abstraction that fits the requirement. Do not duplicate query state, pagination, auth refresh, localization, or dashboard CRUD behavior. -- Do not edit generated artifacts by hand. Change their source and run the corresponding generator/build command. -- Do not add a second implementation style when the repository already has a shared path for the same concern. -- Use `log/slog` for new Go logging and structured key-value fields for relevant context. -- New Go code uses `any`, not `interface{}`. Existing generated or legacy code does not need unrelated cleanup. -- Secrets, tokens, credentials, and private customer data must never be committed or printed in logs/tests. - -## 4. Go Backend - -### 4.1 Layer Ownership - -The normal dependency and data flow is: - -`models -> repositories -> services -> handlers -> builders/response DTOs` - -- `internal/models`: entity fields, GORM mappings, associations, and schema metadata only. -- `internal/repositories`: GORM/SQL access, conditions, ordering, pagination, locks, and persistence details. -- `internal/services`: business validation, state changes, authorization-independent domain rules, aggregation, transactions, and event orchestration. -- `internal/handlers/{api,dashboard,third}`: HTTP parameter parsing, authentication/permission checks, service calls, and response writing. -- `internal/builders`: pure model/aggregate-to-response mapping. Builders must not query the database. -- `internal/pkg/dto/request` and `internal/pkg/dto/response`: external request/response contracts. - -Mandatory boundaries: - -- Handlers must not call repositories or issue GORM queries directly. -- Models and repositories must not contain HTTP, permission, or cross-resource workflow logic. -- GORM models must not be returned directly from an API. Map them to response DTOs/builders. -- A service may return models internally, but public response shape remains owned by builders/DTOs. -- Put reusable SQL in repositories. A genuinely one-off aggregate query may stay near its domain service only when extraction would make ownership less clear. - -### 4.2 Database and Transactions - -- Repository methods that participate in transactions accept `db *gorm.DB`; call them with `sqls.DB()` outside a transaction and `ctx.Tx` inside one. -- Services own transaction boundaries through `sqls.WithTransaction`. -- Use a transaction for atomic multi-write workflows and consistency-sensitive read-modify-write operations. -- Do not add a transaction around a single independent SQL write. -- Every database operation inside a transaction must use the same `ctx.Tx`; never escape to `sqls.DB()` mid-transaction. -- Use `sqls.Cnd`/`sqls.NewCnd()` and repository methods for ordinary filtering and pagination. -- Preserve SQLite and MySQL compatibility. Avoid dialect-specific SQL unless both dialects are explicitly implemented and tested. -- Use portable column types and `int64` primary/foreign identifiers. Keep time handling compatible with MySQL `parseTime=True`. - -### 4.3 Models, Generation, and Migrations - -- Register persistent models in `internal/models/models.go` so startup `AutoMigrate(models.Models...)` includes them. -- New tables, columns, indexes, and compatible constraints are normally applied by GORM `AutoMigrate` in `internal/bootstrap/migration.go`. -- Use `internal/migration/*` only for versioned, idempotent data migration/backfill/repair work. Its version must increase monotonically. -- When a model uses the standard generated repository/service surface, register it in `cmd/generator/generator.go` and run `task generator`. -- Treat generator output as mechanical infrastructure. Put business-specific methods in handwritten files and do not manually patch generated CRUD output. -- Backend/frontend shared enums are defined in `internal/pkg/enums`, annotated using the existing enum pattern, and generated with `task enums` into `web/lib/generated/enums.ts`. -- Never create a handwritten frontend duplicate of a generated backend enum. - -### 4.4 HTTP APIs - -- All routes are explicit in `internal/bootstrap/routes.go`; handler names do not create endpoints. -- Public/product APIs live under `/api/*`, authenticated management APIs under `/api/dashboard/*`, callbacks under `/api/third/*`, and WebSockets under `/api/ws/*`. -- Add a resource-specific `register...Routes` function or extend the existing one, then mount it from `addRouter` under the correct group. -- Follow the existing resource contract: detail commonly uses `GET /:id`, list uses `/list`, writes use explicit POST actions such as `/create`, `/update`, `/delete`, and domain actions retain their established snake_case path names. -- Do not introduce `/api/v1`, automatic routing assumptions, or unnecessary deeply nested resource paths. -- Handler names mirror the registered method and path, for example `XxxGetBy`, `XxxAnyList`, and `XxxPostCreate`. -- Parse JSON/form/query/path values with `internal/pkg/httpx/params` and `internal/pkg/httpx` helpers. -- Dashboard permission checks use `services.AuthService.RequirePermission` or the established permission helper before domain work. -- Write responses through `httpx.WriteJSON`; preserve the shared `JsonResult` contract. -- Paginated responses use `web.PageResult` with `data.results` and `data.page`. -- Convert not-found, validation, permission, and persistence failures into stable application errors. Never expose raw SQL errors to clients. - -### 4.5 Backend Internationalization - -- Any new or changed user-visible backend error must support every backend locale, currently `zh-CN` and `en-US`. -- Add matching keys to both `internal/pkg/i18nx/locales/zh-CN.yml` and `internal/pkg/i18nx/locales/en-US.yml` in the same change. -- Services should return localized application errors through the `errorsx.*I18n` helpers. -- Handlers that need an immediate localized response use `httpx.JsonErrorMsg(ctx, key, args...)`; other request-context translation uses `i18nx.T`. -- Do not hard-code Chinese or English error sentences in handlers/services when the text can reach a user. -- Keep format arguments equivalent across locales and cover new reusable/error-format behavior with focused tests. - -## 5. Frontend - -### 5.1 Component and Data Boundaries - -- Application routes live under `web/app`; reusable business components live under `web/components`; route-private components live in the route's `_components` directory. -- Reuse `web/components/ui/*` primitives. Do not edit these shadcn base components for a feature-specific requirement. -- Use the current Base UI/shadcn component API as implemented in the repository; do not assume APIs such as Radix `asChild` exist. -- Use `@/*` imports for code within `web`. -- Client components must declare `"use client"` when they use state, effects, browser APIs, or client-only hooks. -- Keep resource APIs in `web/lib/api/*` and route all normal requests through `web/lib/api/client.ts`. -- Pages, business components, and stores must not implement their own `JsonResult` parsing, auth header handling, token refresh, or login-expiry cleanup. -- Direct `fetch` is reserved for unsupported transports such as third-party calls, binary transfers, SSE, and WebSocket handshakes; explain the exception in code. -- Prefer `OptionCombobox` for standard dropdowns rather than adding shadcn Select-based business controls. -- Format displayed timestamps with `formatDateTime` from `web/lib/utils.ts` unless the product explicitly requires a different representation. - -### 5.2 Dashboard Pages - -Before building a dashboard page, inspect `web/components/dashboard/crud`, `web/components/dashboard/list`, and `web/components/dashboard-page.tsx`. - -Use this order of preference: - -1. `DashboardCrudPage` for standard create/read/update/delete resources. -2. `DashboardListPage` for read-only or custom-content paginated resources. -3. `useDashboardPagedList` when layout is bespoke but list query/filter/pagination lifecycle is standard. -4. `DashboardPage`, `DashboardToolbar`, `DashboardTableShell`, and related low-level primitives only for interactions that cannot fit the higher-level components. - -Rules: - -- Do not copy a resource page to recreate standard filters, query/reset/refresh actions, pagination, loading/empty states, confirmations, row actions, or dialogs. -- Configure `DashboardCrudPage` through its filters, columns, labels, service callbacks, row actions, sorting, and form/dialog extension points before adding page-local infrastructure. -- Use its schema-driven `DashboardCrudFormDialog` when supported. A genuinely custom form may live in `_components` and should normally use `react-hook-form`, `zod`, and `Field`. -- Configure `DashboardListPage` with columns or `renderContent`, and use `renderToolbarActions` for resource-specific actions. -- Business API knowledge stays in the page/service module; generic dashboard components must not import a resource-specific API. -- A change to `web/components/dashboard/*` must be generic, backward-compatible, and useful beyond one page. Otherwise keep it local to the feature. - -### 5.3 Frontend Internationalization - -- Every frontend feature and modification must work in all `SUPPORTED_LOCALES`, currently `zh-CN` and `en-US`. -- Add every new key to both `web/messages/zh-CN.json` and `web/messages/en-US.json` in the same change, preserving matching structure. -- React pages/components use `useI18n()`; locale-aware formatting/mapping may also use `useAppLocale()`. -- Non-React code uses `translateCurrentMessage()` or `translateMessage()` from `web/i18n/messages.ts`. -- Do not hard-code user-visible copy in JSX/TSX, toast messages, dialogs, confirmations, placeholders, validation, empty/loading/error states, tooltips, accessibility labels, or client-side fallback errors. -- Product names, protocol literals, user content, and raw business data do not need translation unless the UI already provides a display-name mapping. -- Dashboard labels supplied to shared CRUD/list components must come from translation keys. -- Centralize localized display names for backend identifiers/enums in a reusable `web/lib/*-i18n.ts` helper instead of duplicating locale switches across pages. -- Preserve the same placeholders in every locale and interpolate with `t(key, values)`; do not assemble sentences by concatenating translated fragments. -- Locale configuration belongs to `AppI18nProvider` and `web/i18n/config.ts`; features must not introduce separate locale detection or state. - -### 5.4 Commercial-Grade UI and Interaction - -This is a commercial product, not a prototype or component demo. UI work is complete only when it is visually coherent, interaction-complete, responsive, localized, and credible with real production data. - -#### Visual Quality - -- Follow the existing design language, spacing scale, typography, radius, color tokens, and component variants. A new feature must look native to the product rather than like a pasted template. -- In support platform UI, if rounded corners are needed, use `rounded-md` consistently. -- Support platform components should not add `shadow`. -- Establish a clear hierarchy: page title/primary action, filters or context, main content, and secondary information. Do not make every region a card or every action visually prominent. -- Prefer restrained, purposeful styling. Avoid decorative gradients, oversized hero text, excessive shadows, glass effects, emoji icons, random accent colors, and ornamental copy unless the product context explicitly calls for them. -- Use Lucide icons consistently. Choose icons by meaning, keep icon size and stroke weight aligned with neighboring controls, and never use icons as decoration without communicative value. -- Keep spacing and alignment deliberate at every breakpoint. Labels, inputs, table columns, action groups, dialog footers, and empty states must align cleanly without ad hoc offsets. -- Design for realistic content, not ideal sample text. Verify long names, multiline content, large counts, missing optional fields, and mixed Chinese/English values. Use wrapping, truncation, tooltips, or scroll containers intentionally. -- Preserve information density appropriate to an operations dashboard. Do not waste large areas on decoration, but do not compress controls until scanning and clicking become difficult. - -#### Interaction Completeness - -- Every asynchronous action must have an immediate and unambiguous state: pending/loading, success, failure, and retry or recovery when appropriate. -- Prevent duplicate submissions. Disable or lock the initiating control while a mutation is pending and show action-specific progress text or a spinner without causing layout shift. -- Keep feedback close to the action. Use inline validation for field problems, contextual error states for failed content, and toast notifications for completed background or page-level actions. -- Never silently discard user input. Warn before closing, navigating away, resetting, or switching context when there are meaningful unsaved changes. -- Destructive, irreversible, security-sensitive, or broad-impact operations require confirmation that clearly names the object and consequence. Do not use a generic “Are you sure?” message. -- Confirmation is not a substitute for good defaults: routine reversible actions should remain efficient and should not be interrupted by unnecessary modal prompts. -- After create/update/delete operations, keep list state coherent: refresh affected data, preserve useful filters/page position when possible, close dialogs only on success, and prevent stale selections or detail panels. -- Buttons and menu items must use precise verbs describing the result. Avoid vague labels such as “OK”, “Submit”, or “Process” when a specific action name is available. -- Preserve keyboard behavior and focus flow: Enter submits only where expected, Escape closes dismissible overlays, focus moves into dialogs and returns to the trigger, and destructive actions are not the accidental default. -- Interactive rows, icons, badges, and text links must look interactive only when they are interactive. Do not rely on hover-only discoverability for essential actions. - -#### Forms and Dialogs - -- Use the smallest suitable interaction container: inline editing for simple local changes, a dialog for focused tasks, and a full page/workbench for complex or multi-step workflows. -- Dialogs need a clear title, concise context when necessary, stable body layout, and a consistent footer with secondary action before primary action. Long content must scroll inside the dialog without pushing actions off-screen. -- Forms must have visible labels, appropriate controls, useful defaults, required/optional semantics, and examples or help text only where they reduce ambiguity. -- Validate at the right time: do not show errors before the user has interacted, clear stale errors after correction, and map backend validation failures to the relevant field when possible. -- Preserve entered values after failed submission. Do not reset or close a form until the server confirms success. -- Dependent fields must clearly reflect disabled/loading/empty states. When one field invalidates another, update it predictably and explain the dependency when it is not obvious. -- Configuration and high-impact forms should not expose a permanently editable surface when a deliberate edit mode improves safety. Saving sensitive configuration requires explicit user intent and appropriate confirmation. - -#### Lists, Tables, and Operational Screens - -- Use the shared Dashboard CRUD/list system and maintain consistent toolbar, filter, pagination, loading, empty, and action placement across resources. -- Filters must distinguish draft values from applied query state. Query, reset, refresh, pagination, and URL/state behavior should be predictable and must not unexpectedly erase one another. -- Tables must remain scannable: align comparable values, keep action columns stable, use badges sparingly for status, avoid dense multiline cells when a detail view is more appropriate, and provide horizontal overflow on narrow screens. -- Empty states must distinguish “no data exists” from “no results match the current filters” and offer the most relevant next action when the user can resolve the state. -- Loading states should preserve layout. Prefer skeletons for content whose shape is known and compact spinners for localized actions; avoid replacing an entire stable page with a centered spinner. -- Error states must explain what failed in user terms and provide retry when retry is meaningful. Never leave a blank table or empty panel after a request failure. -- Bulk actions must show selection count, affected scope, eligibility, and partial-failure results. Clear selection when it is no longer valid. - -#### Responsive and Accessibility Requirements - -- Every changed screen must work at desktop and narrow/mobile widths. Do not treat horizontal clipping, overlapping controls, wrapped action chaos, or off-screen dialog buttons as acceptable. -- Responsive behavior must preserve task priority: primary actions remain reachable, secondary actions may move into menus, filters may stack or collapse, and tables may scroll without hiding row identity/actions. -- Use semantic controls and accessible names. Icon-only buttons require localized accessible labels and tooltips where the meaning is not universally obvious. -- Maintain visible focus states, logical tab order, sufficient target sizes, and adequate text/background contrast. Do not encode status or errors using color alone. -- Respect reduced-motion preferences. Animations should explain state or continuity, remain subtle, and never delay work. - -#### Product Copy and Data Credibility - -- Copy must sound like a finished product: concise, specific, consistent, and action-oriented. Do not expose implementation jargon, placeholder prose, “TODO”, mock labels, debug wording, or developer instructions to users. -- Do not ship fake statistics, sample records, disabled-looking placeholder buttons, decorative charts without meaning, or interactions that only log to the console. -- Distinguish unavailable features from empty data. If a capability is not implemented, do not render a control that pretends it is functional. -- User-visible names, statuses, permissions, dates, and errors must use the established formatting and i18n mappings rather than raw backend identifiers. - -#### UI Verification - -- For meaningful UI changes, inspect the finished screen in a real browser at representative desktop and narrow widths. Source review and typecheck alone are not sufficient visual validation. -- Exercise the complete interaction, including initial load, populated state, empty/filtered state, validation failure, server failure when practical, pending/disabled behavior, success, cancel/close, and refresh. -- Check both `zh-CN` and `en-US`; verify that translated copy does not overflow, truncate critical meaning, or break control alignment. -- Before handoff, remove temporary data, debug UI, console output, test-only shortcuts, and visual artifacts introduced during verification. -- When browser verification cannot be performed, state that limitation explicitly; do not describe the UI as visually verified. - -### 5.5 Generated and Embedded Frontend Assets - -- `web/lib/generated/enums.ts` is generated by `task enums`; do not edit it manually. -- `web/public/sdk/agent-desk-sdk.min.js` is generated from the SDK source. When SDK source changes, run `cd web && pnpm build:sdk` and its focused SDK tests. -- `web/public/flowgram-editor` is produced from `flowgram-editor`; edit the source project, not the generated public output. -- When changing `flowgram-editor`, use its own `pnpm` scripts and ensure the embedding build still succeeds. -- The Next.js application is statically exported/embedded into the Go binary. Avoid runtime-only Next.js features that conflict with the current export and embedding model. - -## 6. Testing and Validation - -Validation must match the changed surface. Do not claim checks that were not run. - -- Go formatting: run `gofmt` on every changed `.go` file. -- Go behavior: add focused tests for changed business rules, transactions, security boundaries, parsing, and reusable helpers; run the narrow package tests first, then `go test ./...` when practical. -- Frontend TypeScript: run `cd web && pnpm typecheck` for frontend changes. -- Frontend lint: run `cd web && pnpm lint` for broader component/page changes or when lint-sensitive code changed. -- Frontend logic: run relevant `node --test ...` files when changing utilities, i18n mappings, generated SDK behavior, or other modules with focused tests. -- Workflow editor: run the relevant `flowgram-editor` lint/build commands for changes in that project. -- Generation: after model CRUD or shared enum changes, run the corresponding `task generator` or `task enums` and review generated diffs. -- Build: use `task build` when changes affect frontend embedding, build configuration, generated public assets, or release assembly. -- Browser verification is expected for meaningful visual or interaction changes when a runnable environment is available; state explicitly when it was not performed. -- Documentation-only changes require at least `git diff --check` and verification that every referenced path/command exists. - -## 7. Completion Checklist - -Before handing off a change, confirm the applicable items: - -- The implementation follows current layer/component ownership and does not create reverse dependencies. -- Transactions cover exactly the operations that must be atomic, and all transactional DB calls use the same `ctx.Tx`. -- API routes are explicitly registered and responses preserve `JsonResult`/`PageResult` contracts. -- Models, migrations, queries, and tests remain compatible with SQLite and MySQL. -- Backend and frontend user-visible text is complete in both Chinese and English. -- Dashboard pages reuse the highest-level suitable component under `web/components/dashboard/*`. -- UI changes meet the commercial-grade standard: complete states, precise feedback, safe mutations, responsive layout, accessible controls, credible copy/data, and no demo-only behavior. -- Generated files were regenerated from their source and were not manually edited. -- Relevant tests/typechecks/lint/build/browser checks were run, and any validation limitation is reported. -- `git diff --check` passes and unrelated worktree changes remain untouched. +本文件定义了本仓库中 AI 智能体必须遵守的工作约定。它刻意以当前代码库的实际情况为准,而非历史惯例。 + +## 1. 适用范围与优先级 + +- 本规则适用于仓库根目录及所有子目录。 +- 用户的明确指令优先于本文件。如有刻意偏离,须在最终总结中说明。 +- 修改前先查看相关实现。复用现有的辅助函数、组件 API、代码生成工作流和邻近代码的模式,不要依赖记忆行事。 +- 保持改动范围聚焦。保留与本次任务无关的、用户自有的工作区改动,包括已暂存的改动。 +- 审查、调研或诊断类请求默认为只读,除非用户同时要求实现。 + +## 2. 当前架构 + +仓库包含三个应用区域: + +- Go 服务端:`cmd/server` 和 `internal/*` +- Next.js 应用:`web/*` +- 内嵌的工作流编辑器:`flowgram-editor/*`,构建产物位于 `web/public/flowgram-editor` + +主要技术栈: + +- Go 1.26、Gin、GORM、`github.com/mlogclub/simple` +- SQLite 和 MySQL +- Next.js 16 App Router、React 19、TypeScript、Tailwind CSS、shadcn/Base UI +- 两个前端项目均使用 `pnpm` +- 可选通过 CGO 构建 LanceDB 支持;应用同时支持 Qdrant + +重要入口: + +- 服务装配与中间件:`internal/bootstrap/server.go` +- 显式 API 路由:`internal/bootstrap/routes.go` +- 模型注册:`internal/models/models.go` +- 启动时结构/数据迁移:`internal/bootstrap/migration.go` +- CRUD 生成器:`cmd/generator/generator.go` +- 前端枚举生成器:`cmd/enums/generator.go` +- 前端 API 客户端:`web/lib/api/client.ts` +- 前端国际化:`web/i18n/*` 和 `web/messages/*` +- 控制台共享组件:`web/components/dashboard/*` +- 项目命令:`Taskfile.yml` + +## 3. 通用改动规则 + +- 使用某个类型、函数或组件前,先阅读其真实签名。 +- 优先使用满足需求的最高层级现有抽象。不要重复实现查询状态、分页、鉴权刷新、本地化或控制台 CRUD 行为。 +- 不要手工编辑生成产物。应修改其源头并运行对应的生成器/构建命令。 +- 当仓库对同一关注点已有共享实现路径时,不要引入第二种实现风格。 +- 新的 Go 日志使用 `log/slog`,并以结构化键值字段记录相关上下文。 +- 新 Go 代码使用 `any`,不使用 `interface{}`。已有的生成代码或遗留代码无需做无关清理。 +- 密钥、令牌、凭据及客户隐私数据绝不能提交到仓库,也不能打印到日志/测试中。 + +## 4. Go 后端 + +### 4.1 分层职责 + +正常的依赖与数据流方向为: + +`models -> repositories -> services -> handlers -> builders/响应 DTO` + +- `internal/models`:仅包含实体字段、GORM 映射、关联关系和 schema 元数据。 +- `internal/repositories`:GORM/SQL 访问、条件、排序、分页、锁及持久化细节。 +- `internal/services`:业务校验、状态变更、与授权无关的领域规则、聚合、事务及事件编排。 +- `internal/handlers/{api,dashboard,third}`:HTTP 参数解析、认证/权限检查、调用服务、写入响应。 +- `internal/builders`:纯粹的模型/聚合到响应的映射。Builder 不得查询数据库。 +- `internal/pkg/dto/request` 和 `internal/pkg/dto/response`:对外请求/响应契约。 + +强制边界: + +- Handler 不得直接调用 repository 或发起 GORM 查询。 +- Model 和 repository 不得包含 HTTP、权限或跨资源工作流逻辑。 +- GORM model 不得直接从 API 返回。必须映射为响应 DTO/builder。 +- Service 内部可以返回 model,但对外响应结构仍由 builder/DTO 负责。 +- 可复用的 SQL 放在 repository 中。只有在抽取出来反而会使职责归属更不清晰时,真正一次性的聚合查询才可以留在其领域 service 附近。 + +### 4.2 数据库与事务 + +- 参与事务的 repository 方法接收 `db *gorm.DB`;事务外用 `sqls.DB()` 调用,事务内用 `ctx.Tx` 调用。 +- Service 通过 `sqls.WithTransaction` 拥有事务边界。 +- 原子性的多次写入工作流和对一致性敏感的读-改-写操作必须使用事务。 +- 不要为单个独立的 SQL 写操作添加事务。 +- 事务内的每个数据库操作都必须使用同一个 `ctx.Tx`;绝不能在事务中途逃逸到 `sqls.DB()`。 +- 普通的过滤和分页使用 `sqls.Cnd`/`sqls.NewCnd()` 及 repository 方法。 +- 保持 SQLite 和 MySQL 兼容。避免方言专属 SQL,除非两种方言都已显式实现并测试。 +- 使用可移植的列类型和 `int64` 主键/外键标识。时间处理需兼容 MySQL 的 `parseTime=True`。 + +### 4.3 模型、代码生成与迁移 + +- 持久化模型须注册到 `internal/models/models.go`,以便启动时的 `AutoMigrate(models.Models...)` 包含它们。 +- 新表、新列、索引及兼容性约束通常由 `internal/bootstrap/migration.go` 中的 GORM `AutoMigrate` 应用。 +- `internal/migration/*` 仅用于有版本的、幂等的数据迁移/回填/修复工作。其版本号必须单调递增。 +- 当模型使用标准的生成 repository/service 接口时,将其注册到 `cmd/generator/generator.go` 并运行 `task generator`。 +- 把生成器输出视为机械基础设施。业务专属方法放在手写文件中,不要手工修补生成的 CRUD 代码。 +- 后端/前端共享枚举定义在 `internal/pkg/enums`,按现有枚举模式添加注解,通过 `task enums` 生成到 `web/lib/generated/enums.ts`。 +- 绝不要在前端手写一份与已生成后端枚举重复的枚举。 + +### 4.4 HTTP API + +- 所有路由都在 `internal/bootstrap/routes.go` 中显式声明;handler 名称不会自动创建端点。 +- 公开/产品 API 位于 `/api/*` 下,需要认证的管理 API 位于 `/api/dashboard/*` 下,回调位于 `/api/third/*` 下,WebSocket 位于 `/api/ws/*` 下。 +- 新增资源专属的 `register...Routes` 函数或扩展现有的函数,然后在 `addRouter` 中挂载到正确的分组。 +- 遵循现有资源约定:详情通常用 `GET /:id`,列表用 `/list`,写操作使用显式 POST 动作如 `/create`、`/update`、`/delete`,领域动作保留其既定的 snake_case 路径名。 +- 不要引入 `/api/v1`、自动路由假设或不必要的深层嵌套资源路径。 +- Handler 名称与注册的方法和路径对应,例如 `XxxGetBy`、`XxxAnyList`、`XxxPostCreate`。 +- 使用 `internal/pkg/httpx/params` 和 `internal/pkg/httpx` 辅助函数解析 JSON/表单/query/path 参数。 +- 控制台权限检查在领域工作之前使用 `services.AuthService.RequirePermission` 或既定的权限辅助函数。 +- 通过 `httpx.WriteJSON` 写入响应;保持共享的 `JsonResult` 契约。 +- 分页响应使用 `web.PageResult`,包含 `data.results` 和 `data.page`。 +- 将未找到、校验失败、权限不足和持久化失败转换为稳定的应用错误。绝不要把原始 SQL 错误暴露给客户端。 + +### 4.5 后端国际化 + +- 任何新增或变更的、用户可见的后端错误都必须支持全部后端语言,目前为 `zh-CN` 和 `en-US`。 +- 在同一次改动中,向 `internal/pkg/i18nx/locales/zh-CN.yml` 和 `internal/pkg/i18nx/locales/en-US.yml` 添加对应的键。 +- Service 应通过 `errorsx.*I18n` 辅助函数返回本地化的应用错误。 +- 需要立即返回本地化响应的 handler 使用 `httpx.JsonErrorMsg(ctx, key, args...)`;其他请求上下文内的翻译使用 `i18nx.T`。 +- 当中英文案可能触达用户时,不要在 handler/service 中硬编码中英文错误语句。 +- 各语言之间保持格式化参数一致,并为新的可复用/错误格式化行为补充聚焦测试。 + +## 5. 前端 + +### 5.1 组件与数据边界 + +- 应用路由位于 `web/app`;可复用业务组件位于 `web/components`;路由私有组件位于该路由的 `_components` 目录。 +- 复用 `web/components/ui/*` 基础组件。不要为了某个功能的特定需求修改这些 shadcn 基础组件。 +- 使用仓库中实际实现的 Base UI/shadcn 组件 API;不要假设存在 Radix 的 `asChild` 等 API。 +- `web` 内的代码使用 `@/*` 导入。 +- 客户端组件在使用状态、effect、浏览器 API 或仅客户端可用的 hook 时,必须声明 `"use client"`。 +- 资源 API 放在 `web/lib/api/*`,所有常规请求都通过 `web/lib/api/client.ts` 发起。 +- 页面、业务组件和 store 不得自行实现 `JsonResult` 解析、鉴权头处理、令牌刷新或登录过期清理。 +- 直接使用 `fetch` 仅限于不支持的传输场景,例如第三方调用、二进制传输、SSE 和 WebSocket 握手;须在代码中说明例外原因。 +- 标准下拉选择优先使用 `OptionCombobox`,而不是新增基于 shadcn Select 的业务控件。 +- 展示的时间戳使用 `web/lib/utils.ts` 中的 `formatDateTime` 格式化,除非产品明确要求其他表现形式。 + +### 5.2 控制台页面 + +构建控制台页面之前,先查看 `web/components/dashboard/crud`、`web/components/dashboard/list` 和 `web/components/dashboard-page.tsx`。 + +按以下优先级顺序选择: + +1. 标准增删改查资源使用 `DashboardCrudPage`。 +2. 只读或自定义内容的分页资源使用 `DashboardListPage`。 +3. 布局是定制的、但列表查询/筛选/分页生命周期是标准的,使用 `useDashboardPagedList`。 +4. 只有当高层级组件无法满足交互需求时,才使用 `DashboardPage`、`DashboardToolbar`、`DashboardTableShell` 及相关底层原语。 + +规则: + +- 不要复制某个资源页面来重建标准筛选、查询/重置/刷新动作、分页、加载/空状态、确认框、行操作或对话框。 +- 在添加页面本地基础设施之前,先通过 `DashboardCrudPage` 的筛选器、列、标签、服务回调、行操作、排序以及表单/对话框扩展点进行配置。 +- 在支持的情况下使用其 schema 驱动的 `DashboardCrudFormDialog`。真正自定义的表单可以放在 `_components` 中,通常应使用 `react-hook-form`、`zod` 和 `Field`。 +- 通过列或 `renderContent` 配置 `DashboardListPage`,资源专属动作使用 `renderToolbarActions`。 +- 业务 API 知识留在页面/服务模块中;通用控制台组件不得导入资源专属 API。 +- 对 `web/components/dashboard/*` 的改动必须是通用的、向后兼容的、且对不止一个页面有用。否则应将其局限在功能模块内。 + +### 5.3 前端国际化 + +- 每一个前端功能和改动都必须在所有 `SUPPORTED_LOCALES`(目前为 `zh-CN` 和 `en-US`)下正常工作。 +- 每个新键都要在同一次改动中同时添加到 `web/messages/zh-CN.json` 和 `web/messages/en-US.json`,保持结构一致。 +- React 页面/组件使用 `useI18n()`;语言相关的格式化/映射也可以使用 `useAppLocale()`。 +- 非 React 代码使用 `web/i18n/messages.ts` 中的 `translateCurrentMessage()` 或 `translateMessage()`。 +- 不要在 JSX/TSX、toast 消息、对话框、确认框、占位符、校验提示、空/加载/错误状态、工具提示、无障碍标签或客户端兜底错误中硬编码用户可见文案。 +- 产品名称、协议字面量、用户内容和原始业务数据无需翻译,除非 UI 已提供显示名映射。 +- 提供给共享 CRUD/list 组件的控制台标签必须来自翻译键。 +- 将后端标识/枚举的本地化显示名集中到可复用的 `web/lib/*-i18n.ts` 辅助文件中,而不是在各页面重复编写语言分支。 +- 每种语言保留相同的占位符并用 `t(key, values)` 插值;不要通过拼接翻译片段来组装句子。 +- 语言配置归属于 `AppI18nProvider` 和 `web/i18n/config.ts`;各功能不得引入独立的语言检测或状态。 + +### 5.4 商业级 UI 与交互 + +这是一个商业产品,不是原型或组件演示。UI 工作只有在视觉统一、交互完整、响应式、已本地化、并能以真实生产数据可信呈现时才算完成。 + +#### 视觉质量 + +- 遵循现有设计语言、间距尺度、字体排版、圆角、色彩令牌和组件变体。新功能必须看起来像产品原生的一部分,而不是粘贴进来的模板。 +- 在控制台(support platform)UI 中,如需圆角,统一使用 `rounded-md`。 +- 控制台组件不应添加 `shadow`。 +- 建立清晰的层次:页面标题/主要操作、筛选或上下文、主要内容、次要信息。不要把每个区域都做成卡片,也不要让每个操作都在视觉上突出。 +- 优先采用克制、有目的性的样式。避免装饰性渐变、超大标题文字、过度阴影、玻璃拟态、emoji 图标、随意的强调色和装饰性文案,除非产品场景明确需要。 +- 一致地使用 Lucide 图标。按含义选择图标,保持图标尺寸和描边粗细与邻近控件一致,绝不使用没有传达意义的纯装饰性图标。 +- 在每个断点下保持间距和对齐的严谨。标签、输入框、表格列、操作组、对话框页脚和空状态必须整齐对齐,不得有临时拼凑的偏移。 +- 为真实内容而非理想示例文本而设计。验证长名称、多行内容、大数量、缺失可选字段以及中英文混排的情况。有意识地使用换行、截断、工具提示或滚动容器。 +- 保持适合运营控制台的信息密度。不要把大面积空间浪费在装饰上,但也不要把控件压缩到难以浏览和点击。 + +#### 交互完整性 + +- 每个异步操作都必须有即时且明确的状态:进行中/加载中、成功、失败,以及在适当时提供重试或恢复。 +- 防止重复提交。变更进行中时禁用或锁定触发控件,并显示针对该操作的进度文案或加载图标,且不引起布局跳动。 +- 反馈要贴近操作本身。字段问题使用内联校验,内容加载失败使用情境化错误状态,已完成的后台或页面级操作使用 toast 通知。 +- 绝不静默丢弃用户输入。当存在有意义的未保存改动时,在关闭、离开页面、重置或切换上下文之前发出警告。 +- 破坏性、不可逆、安全敏感或影响广泛的操作需要确认,确认文案须明确指出操作对象和后果。不要使用泛化的“你确定吗?”消息。 +- 确认不能替代良好的默认值:常规可逆操作应保持高效,不应被不必要的弹窗打断。 +- 创建/更新/删除操作后,保持列表状态一致:刷新受影响的数据,尽可能保留有用的筛选条件/页码位置,仅在成功时关闭对话框,防止出现过期的选中项或详情面板。 +- 按钮和菜单项必须使用准确描述结果的动词。当有具体的操作名可用时,避免“确定”“提交”“处理”这类模糊标签。 +- 保持键盘行为和焦点流转:Enter 仅在预期场景提交,Escape 关闭可 dismiss 的浮层,焦点进入对话框并在关闭后返回触发元素,破坏性操作不能是意外的默认项。 +- 可交互的行、图标、徽标和文字链接必须仅在确实可交互时才呈现可交互的外观。不要依赖仅悬停可见来发现关键操作。 + +#### 表单与对话框 + +- 使用最小且合适的交互容器:简单的局部改动用内联编辑,聚焦的任务用对话框,复杂或多步骤工作流用整页/工作台。 +- 对话框需要清晰的标题、必要时简洁的上下文说明、稳定的正文布局,以及一致的页脚(次要操作在前、主要操作在后)。长内容必须在对话框内部滚动,而不能把按钮挤出屏幕。 +- 表单必须有可见标签、合适的控件、有用的默认值、必填/选填语义,示例或帮助文本仅在能减少歧义时提供。 +- 在正确的时机校验:不要在用户交互前显示错误,修正后清除过期错误,并尽可能把后端校验失败映射到对应字段。 +- 提交失败后保留已输入的值。在服务器确认成功之前不要重置或关闭表单。 +- 依赖字段必须清晰反映禁用/加载/空状态。当一个字段使另一个字段失效时,应以可预测的方式更新后者,并在依赖关系不明显时加以说明。 +- 配置类和高影响表单不应在刻意的编辑模式能提升安全性时,仍暴露永久可编辑的界面。保存敏感配置需要明确的用户意图和恰当的确认。 + +#### 列表、表格与运营界面 + +- 使用共享的控制台 CRUD/list 体系,在各资源之间保持一致的工具栏、筛选、分页、加载、空状态和操作位置。 +- 筛选必须区分草稿值与已应用的查询状态。查询、重置、刷新、分页和 URL/状态行为应当可预测,不得意外地相互清除。 +- 表格必须保持可扫描性:对齐可比较的值,保持操作列稳定,状态徽标少用,当详情视图更合适时避免拥挤的多行单元格,并在窄屏上提供横向滚动。 +- 空状态必须区分“数据不存在”与“当前筛选无匹配结果”,并在用户可以自行解决时提供最相关的下一步操作。 +- 加载状态应保持布局。形状已知的内容优先使用骨架屏,局部操作使用紧凑的加载图标;避免用居中的加载图标替换整个稳定页面。 +- 错误状态必须用用户能理解的措辞说明失败原因,并在重试有意义时提供重试。请求失败后绝不要留下空白表格或空面板。 +- 批量操作必须显示选中数量、影响范围、适用条件及部分失败的结果。当选中项不再有效时清除选择。 + +#### 响应式与无障碍要求 + +- 每个改动的界面都必须在桌面宽度和窄屏/移动宽度下可用。不得把横向裁切、控件重叠、操作换行混乱或对话框按钮超出屏幕视为可接受。 +- 响应式行为必须保持任务优先级:主要操作始终可触达,次要操作可以收进菜单,筛选项可以堆叠或折叠,表格可以滚动但不得隐藏行标识/操作。 +- 使用语义化控件和无障碍名称。仅图标的按钮在含义并非普遍显而易见时,需要本地化的无障碍标签和工具提示。 +- 保持可见的焦点状态、合理的 Tab 顺序、足够的点击目标尺寸和充分的文字/背景对比度。不要仅用颜色表达状态或错误。 +- 尊重“减少动态效果”偏好。动画应有助于表达状态或连续性,保持克制,且绝不耽误操作。 + +#### 产品文案与数据可信度 + +- 文案必须像成品:简洁、具体、一致、以行动为导向。不要向用户暴露实现术语、占位符式文字、“TODO”、模拟标签、调试措辞或开发者说明。 +- 不要交付虚假统计、示例记录、看似禁用的占位按钮、无意义的装饰图表,或只会向控制台打印日志的交互。 +- 区分功能不可用与数据为空。如果某项能力尚未实现,不要渲染一个假装可用的控件。 +- 用户可见的名称、状态、权限、日期和错误必须使用既定的格式化和 i18n 映射,而不是原始的后端标识。 + +#### UI 验证 + +- 对于有意义的 UI 改动,在真实浏览器中以有代表性的桌面宽度和窄屏宽度检查完成后的界面。仅靠源码审查和类型检查不足以完成视觉验证。 +- 演练完整交互,包括初始加载、有数据状态、空/筛选状态、校验失败、在可行时模拟服务器失败、进行中/禁用行为、成功、取消/关闭以及刷新。 +- 同时检查 `zh-CN` 和 `en-US`;验证翻译文案不会溢出、截断关键含义或破坏控件对齐。 +- 交付前移除验证期间引入的临时数据、调试 UI、控制台输出、仅用于测试的快捷方式和视觉残留。 +- 当无法进行浏览器验证时,须明确说明这一限制;不要将 UI 描述为已经过视觉验证。 + +### 5.5 生成的与内嵌的前端资产 + +- `web/lib/generated/enums.ts` 由 `task enums` 生成;不要手工编辑。 +- `web/public/sdk/agent-desk-sdk.min.js` 由 SDK 源码生成。SDK 源码变更时,运行 `cd web && pnpm build:sdk` 及其聚焦的 SDK 测试。 +- `web/public/flowgram-editor` 由 `flowgram-editor` 产出;编辑源项目,而不是生成的 public 产物。 +- 修改 `flowgram-editor` 时,使用它自己的 `pnpm` 脚本,并确保内嵌构建仍然成功。 +- Next.js 应用以静态导出方式内嵌进 Go 二进制文件。避免与当前导出和内嵌模式冲突的、仅运行时可用的 Next.js 特性。 + +## 6. 测试与验证 + +验证必须与改动的范围相匹配。不要声称运行过并未执行的检查。 + +- Go 格式化:对每个改动的 `.go` 文件运行 `gofmt`。 +- Go 行为:为改动的业务规则、事务、安全边界、参数解析和可复用辅助函数添加聚焦测试;先运行小范围的包测试,条件允许时再运行 `go test ./...`。 +- 前端 TypeScript:前端改动运行 `cd web && pnpm typecheck`。 +- 前端 lint:较广泛的组件/页面改动或改动了 lint 敏感代码时,运行 `cd web && pnpm lint`。 +- 前端逻辑:改动工具函数、i18n 映射、生成的 SDK 行为或其他有聚焦测试的模块时,运行相关的 `node --test ...` 文件。 +- 工作流编辑器:改动该项目时运行 `flowgram-editor` 相关的 lint/build 命令。 +- 代码生成:模型 CRUD 或共享枚举变更后,运行对应的 `task generator` 或 `task enums` 并审查生成的差异。 +- 构建:当改动影响前端内嵌、构建配置、生成的公共资产或发布装配时,使用 `task build`。 +- 当有可运行环境时,有意义的视觉或交互改动应进行浏览器验证;未执行时须明确说明。 +- 仅文档类改动至少需要 `git diff --check`,并验证每个引用的路径/命令确实存在。 + +## 7. 完成检查清单 + +交付改动之前,确认适用的各项: + +- 实现遵循当前的分层/组件职责划分,没有产生反向依赖。 +- 事务恰好覆盖必须原子的操作,且所有事务内数据库调用使用同一个 `ctx.Tx`。 +- API 路由已显式注册,响应保持 `JsonResult`/`PageResult` 契约。 +- 模型、迁移、查询和测试保持 SQLite 和 MySQL 兼容。 +- 后端和前端用户可见文本均中英文完整。 +- 控制台页面复用了 `web/components/dashboard/*` 下最高层级的合适组件。 +- UI 改动达到商业级标准:状态完整、反馈精准、变更安全、响应式布局、控件无障碍、文案/数据可信,无仅用于演示的行为。 +- 生成文件由源头重新生成,未被手工编辑。 +- 已运行相关的测试/类型检查/lint/构建/浏览器检查,并报告了任何验证限制。 +- `git diff --check` 通过,且无关的工作区改动保持原样未动。 diff --git a/Dockerfile b/Dockerfile index 77572bcd..8d159647 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ RUN pnpm build:sdk && pnpm build FROM golang:1.26-alpine AS server-builder WORKDIR /src +ENV GOPROXY=https://goproxy.cn,direct RUN apk add --no-cache git COPY go.mod go.sum ./ @@ -43,6 +44,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ FROM golang:1.26-trixie AS server-builder-lancedb WORKDIR /src +ENV GOPROXY=https://goproxy.cn,direct ARG TARGETOS=linux ARG TARGETARCH diff --git a/README_ZH.md b/README_ZH.md index 6a59a116..2ad527f7 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -257,15 +257,21 @@ flowchart LR 如果只需要构建应用镜像,可以自行准备 MySQL 和 Qdrant,并挂载配置文件: ```bash -docker build -t mlogclub/agent-desk . +docker build -t yekay/agent-desk:1.6.3.3 . docker run --rm -p 8083:8083 \ -v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \ -v agent-desk-data:/app/data \ - mlogclub/agent-desk + yekay/agent-desk:1.6.3.3 ``` Compose 使用 [docker/agent-desk.yaml](docker/agent-desk.yaml) 作为容器内配置,应用会通过 Docker 内部服务名访问 `mysql` 和 `qdrant`。 +导出镜像: + +```bash +docker save -o agent-desk.tar yekay/agent-desk:1.6.3.3 +``` + ## 开源定位 `AgentDesk` 适合作为以下方向的开源基础项目: diff --git a/cmd/import_jsh_knowledge/main.go b/cmd/import_jsh_knowledge/main.go new file mode 100644 index 00000000..ac90f820 --- /dev/null +++ b/cmd/import_jsh_knowledge/main.go @@ -0,0 +1,314 @@ +// Package main 是一次性数据导入工具:将本地 jsh-knowledge 目录按层级 +// 导入 t_knowledge_directory / t_knowledge_document。 +// +// 用法: +// +// go run ./cmd/import_jsh_knowledge -kb 3 -root ./jsh-knowledge +// +// 工具可重复执行(幂等):已存在的同名目录会复用,同目录下同名文档会跳过。 +package main + +import ( + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "log" + "log/slog" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +// frontmatterRe 匹配文件开头的 YAML frontmatter(---\n ... \n---\n)。 +var frontmatterRe = regexp.MustCompile(`(?s)\A---\r?\n.*?\r?\n---\r?\n?`) + +// leadingNumberRe 提取目录名开头的数字前缀(如 "02-合作平台" -> 2)。 +var leadingNumberRe = regexp.MustCompile(`^\d+`) + +type options struct { + dsn string + root string + kbID int64 + stripFrontmatter bool + dryRun bool +} + +func main() { + opt := options{} + flag.StringVar(&opt.dsn, "dsn", "root:123456abcde@tcp(127.0.0.1:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&loc=Local", "MySQL DSN") + flag.StringVar(&opt.root, "root", "jsh-knowledge", "待导入的知识目录根路径") + flag.Int64Var(&opt.kbID, "kb", 3, "目标知识库 ID(t_knowledge_base.id)") + flag.BoolVar(&opt.stripFrontmatter, "strip-frontmatter", true, "是否剥离 Markdown 开头的 YAML frontmatter") + flag.BoolVar(&opt.dryRun, "dry-run", false, "只演练不写库") + flag.Parse() + + if err := run(opt); err != nil { + slog.Error("import failed", "error", err) + os.Exit(1) + } +} + +func run(opt options) error { + entries, err := scanRoot(opt.root) + if err != nil { + return err + } + if len(entries) == 0 { + return fmt.Errorf("目录 %s 下没有可导入的子目录", opt.root) + } + + db, err := gorm.Open(mysql.Open(opt.dsn), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + Logger: logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Warn, + IgnoreRecordNotFoundError: true, + }, + ), + }) + if err != nil { + return fmt.Errorf("连接数据库失败: %w", err) + } + + // 校验目标知识库存在。 + var kb models.KnowledgeBase + if err := db.Select("id", "name", "knowledge_type", "status"). + First(&kb, opt.kbID).Error; err != nil { + return fmt.Errorf("知识库 id=%d 不存在或不可读: %w", opt.kbID, err) + } + slog.Info("目标知识库", "id", kb.ID, "name", kb.Name, "type", kb.KnowledgeType, "status", kb.Status) + + var dirCreated, dirReused, docCreated, docSkipped int + now := time.Now() + + for idx, entry := range entries { + sortNo := parseLeadingNumber(entry.name) + if sortNo == 0 { + sortNo = idx + 1 + } + + // 每个分类目录一个事务:目录行与其下文档要么全部落库要么整体回滚。 + var dirID int64 + var dirNew bool + var n importCounters + err := db.Transaction(func(tx *gorm.DB) error { + var terr error + dirID, dirNew, terr = findOrCreateDirectory(tx, opt, entry.name, sortNo, now) + if terr != nil { + return terr + } + n, terr = importDocuments(tx, opt, dirID, entry.files, now) + return terr + }) + if err != nil { + return fmt.Errorf("目录 %s 处理失败: %w", entry.name, err) + } + if dirNew { + dirCreated++ + } else { + dirReused++ + } + docCreated += n.created + docSkipped += n.skipped + slog.Info("目录完成", + "name", entry.name, "dir_id", dirID, "new_dir", dirNew, + "new_docs", n.created, "skipped_docs", n.skipped) + } + + slog.Info("导入完成", + "dry_run", opt.dryRun, + "directories_created", dirCreated, + "directories_reused", dirReused, + "documents_created", docCreated, + "documents_skipped", docSkipped) + return nil +} + +type dirEntry struct { + name string + files []string +} + +func scanRoot(root string) ([]dirEntry, error) { + top, err := os.ReadDir(root) + if err != nil { + return nil, fmt.Errorf("读取根目录失败: %w", err) + } + var out []dirEntry + for _, d := range top { + if !d.IsDir() { + continue + } + full := filepath.Join(root, d.Name()) + files, err := collectMarkdownFiles(full) + if err != nil { + return nil, err + } + if len(files) == 0 { + slog.Warn("目录下没有 md 文件,已跳过", "dir", full) + continue + } + out = append(out, dirEntry{name: d.Name(), files: files}) + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out, nil +} + +// collectMarkdownFiles 递归收集 .md 文件(当前数据只有一层,递归以兼容未来嵌套)。 +func collectMarkdownFiles(dir string) ([]string, error) { + var files []string + + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != dir { + // 当前导入器只支持一层分类目录;出现嵌套时显式告警, + // 避免文件被静默平铺到错误的目录下。 + slog.Warn("发现嵌套子目录,其内文件将被平铺到一级目录(如需多层请扩展导入器)", "subdir", path) + } + return nil + } + if strings.EqualFold(filepath.Ext(d.Name()), ".md") { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(files) + return files, nil +} + +func parseLeadingNumber(name string) int { + m := leadingNumberRe.FindString(name) + if m == "" { + return 0 + } + n, err := strconv.Atoi(m) + if err != nil { + return 0 + } + return n +} + +// findOrCreateDirectory 在同一知识库/同一父级(0)下按名称查找目录,没有则创建。 +func findOrCreateDirectory(db *gorm.DB, opt options, name string, sortNo int, now time.Time) (int64, bool, error) { + var existing models.KnowledgeDirectory + err := db.Where("knowledge_base_id = ? AND parent_id = 0 AND name = ?", opt.kbID, name). + First(&existing).Error + if err == nil { + return existing.ID, false, nil + } + if err != gorm.ErrRecordNotFound { + return 0, false, err + } + + dir := &models.KnowledgeDirectory{ + KnowledgeBaseID: opt.kbID, + ParentID: 0, + Name: name, + SortNo: sortNo, + Status: enums.StatusOk, + AuditFields: buildAuditFields(now), + } + if opt.dryRun { + slog.Info("[dry-run] 将创建目录", "name", name, "sort_no", sortNo) + return int64(sortNo), true, nil + } + if err := db.Create(dir).Error; err != nil { + return 0, false, err + } + return dir.ID, true, nil +} + +type importCounters struct { + created int + skipped int +} + +func importDocuments(db *gorm.DB, opt options, dirID int64, files []string, now time.Time) (importCounters, error) { + var c importCounters + for _, path := range files { + title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + + var existing models.KnowledgeDocument + err := db.Where("knowledge_base_id = ? AND directory_id = ? AND title = ?", opt.kbID, dirID, title). + First(&existing).Error + if err == nil { + c.skipped++ + continue + } + if err != gorm.ErrRecordNotFound { + return c, err + } + + raw, err := os.ReadFile(path) + if err != nil { + return c, fmt.Errorf("读取文件 %s 失败: %w", path, err) + } + content := string(raw) + if opt.stripFrontmatter { + content = frontmatterRe.ReplaceAllString(content, "") + } + content = strings.TrimSpace(content) + if content == "" { + slog.Warn("文档内容为空,仍将导入", "file", path) + } + + doc := &models.KnowledgeDocument{ + KnowledgeBaseID: opt.kbID, + DirectoryID: dirID, + Title: title, + ContentType: enums.KnowledgeDocumentContentTypeMarkdown, + Content: content, + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + AuditFields: buildAuditFields(now), + } + // 与正常建文档流程保持一致:hash 取正文纯文本的 SHA-256。 + if plain := rag.ExtractPlainText(content, enums.KnowledgeDocumentContentTypeMarkdown); plain != "" { + sum := sha256.Sum256([]byte(plain)) + doc.ContentHash = hex.EncodeToString(sum[:]) + } + + if opt.dryRun { + c.created++ + continue + } + if err := db.Create(doc).Error; err != nil { + return c, fmt.Errorf("写入文档 %s 失败: %w", path, err) + } + c.created++ + } + return c, nil +} + +func buildAuditFields(now time.Time) models.AuditFields { + return models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "system", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "system", + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 1e478faf..500abcf3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,9 +3,13 @@ package main import ( "flag" "log/slog" + "os" + "os/signal" + "syscall" "agent-desk/internal/bootstrap" "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/logx" ) func main() { @@ -25,6 +29,15 @@ func main() { return } + // 监听退出信号,确保 db sink 中缓冲的日志在进程退出前 flush 到数据库。 + go func() { + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + _ = logx.Close() + os.Exit(0) + }() + if err := app.Run(cfg.Server.Address()); err != nil { slog.Error("start server failed", "error", err) return diff --git a/docker-compose.qdrant.yml b/docker-compose.qdrant.yml new file mode 100644 index 00000000..84e192d2 --- /dev/null +++ b/docker-compose.qdrant.yml @@ -0,0 +1,30 @@ +services: + qdrant: + image: qdrant/qdrant:latest + restart: unless-stopped + volumes: + - ./data/qdrant-data:/qdrant/storage + ports: + - "6333:6333" + - "6334:6334" + + agent-desk: + image: yekay/agent-desk:1.6.3 + restart: unless-stopped + depends_on: + qdrant: + condition: service_started + ports: + - "8083:8083" + volumes: + - ./data/agent-desk-data:/app/data + - ./docker/agent-desk.yaml:/app/config/config.yaml:ro + environment: + TZ: Asia/Shanghai + # 兼容 Linux 宿主机访问;Docker Desktop (Windows/Mac) 默认已提供 host.docker.internal + extra_hosts: + - "host.docker.internal:host-gateway" + +volumes: + qdrant-data: + agent-desk-data: diff --git a/internal/ai/runtime/reply_commit_service.go b/internal/ai/runtime/reply_commit_service.go index 56b7110b..d0067389 100644 --- a/internal/ai/runtime/reply_commit_service.go +++ b/internal/ai/runtime/reply_commit_service.go @@ -37,24 +37,37 @@ func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Messag if err != nil { return nil, err } - replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( - input.Conversation.ID, - input.AIAgent.ID, - fmt.Sprintf("%s_%d", strings.TrimSpace(input.ClientPrefix), input.Message.ID), - enums.IMMessageTypeText, + // 优先原地替换 web 渠道的占位消息;不支持替换时回退为新消息 + replyMessage := svc.MessageService.TryReplaceAIReplyPlaceholder( + &input.Conversation, + input.Message.ID, replyText, - "", - s.buildAIPrincipal(input.AIAgent), - input.Message.RequestID, input.WorkflowRunID, + input.AIAgent, ) - if err != nil || !input.IncrementRound { - return replyMessage, err + if replyMessage == nil { + replyMessage, err = svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( + input.Conversation.ID, + input.AIAgent.ID, + fmt.Sprintf("%s_%d", strings.TrimSpace(input.ClientPrefix), input.Message.ID), + enums.IMMessageTypeText, + replyText, + "", + s.buildAIPrincipal(input.AIAgent), + input.Message.RequestID, + input.WorkflowRunID, + ) + if err != nil { + return nil, err + } + } + if !input.IncrementRound { + return replyMessage, nil } if err := s.IncrementAIReplyRounds(input.Conversation.ID, input.Conversation.AIReplyRounds+1, input.AIAgent.Name); err != nil { return nil, err } - return replyMessage, err + return replyMessage, nil } func (s *replyCommitService) CommitAIReply(input replyCommitInput) (*models.Message, error) { diff --git a/internal/ai/runtime/reply_service_test.go b/internal/ai/runtime/reply_service_test.go index 9f44e9d9..6ac41055 100644 --- a/internal/ai/runtime/reply_service_test.go +++ b/internal/ai/runtime/reply_service_test.go @@ -70,19 +70,29 @@ func TestResolveReplyTimeout(t *testing.T) { service := newAIReplyService() aiAgent := newAIAgentFixture() - if got := service.resolveReplyTimeout(aiAgent); got != 180*time.Second { + if got := service.resolveReplyTimeout(nil, aiAgent); got != 120*time.Second { t.Fatalf("expected default timeout, got %v", got) } aiAgent.ReplyTimeoutSeconds = 30 - if got := service.resolveReplyTimeout(aiAgent); got != 30*time.Second { - t.Fatalf("expected exact timeout, got %v", got) + if got := service.resolveReplyTimeout(nil, aiAgent); got != 30*time.Second { + t.Fatalf("expected agent timeout, got %v", got) } aiAgent.ReplyTimeoutSeconds = 999 - if got := service.resolveReplyTimeout(aiAgent); got != 600*time.Second { + if got := service.resolveReplyTimeout(nil, aiAgent); got != 600*time.Second { t.Fatalf("expected clamped timeout, got %v", got) } + + // 渠道配置优先于智能体配置 + channel := &models.Channel{AIReplyTimeoutSeconds: 45} + if got := service.resolveReplyTimeout(channel, aiAgent); got != 45*time.Second { + t.Fatalf("expected channel timeout to take precedence, got %v", got) + } + channel.AIReplyTimeoutSeconds = 0 + if got := service.resolveReplyTimeout(channel, models.AIAgent{}); got != 120*time.Second { + t.Fatalf("expected default timeout when channel and agent unset, got %v", got) + } } func TestResolveInterruptPrompt(t *testing.T) { diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index f3a18e03..b399c8c0 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -13,14 +13,22 @@ import ( svc "agent-desk/internal/services" ) -func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration { - if aiAgent.ReplyTimeoutSeconds <= 0 { - return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second +// resolveReplyTimeout 解析 AI 回复超时时长:接入渠道配置优先,其次智能体配置,最后使用系统默认。 +func (s *aiReplyService) resolveReplyTimeout(channel *models.Channel, aiAgent models.AIAgent) time.Duration { + seconds := 0 + if channel != nil && channel.AIReplyTimeoutSeconds > 0 { + seconds = channel.AIReplyTimeoutSeconds } - if aiAgent.ReplyTimeoutSeconds > maxAIReplyAsyncTimeoutSeconds { - return time.Duration(maxAIReplyAsyncTimeoutSeconds) * time.Second + if seconds <= 0 && aiAgent.ReplyTimeoutSeconds > 0 { + seconds = aiAgent.ReplyTimeoutSeconds } - return time.Duration(aiAgent.ReplyTimeoutSeconds) * time.Second + if seconds <= 0 { + seconds = models.DefaultAIReplyTimeoutSeconds + } + if seconds > maxAIReplyAsyncTimeoutSeconds { + seconds = maxAIReplyAsyncTimeoutSeconds + } + return time.Duration(seconds) * time.Second } func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, message models.Message) { @@ -29,8 +37,9 @@ func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, mes if aiAgent == nil || aiAgent.Status != enums.StatusOk { return } + channel := svc.ChannelService.Get(conversation.ChannelID) startedAt := time.Now() - timeout := s.resolveReplyTimeout(*aiAgent) + timeout := s.resolveReplyTimeout(channel, *aiAgent) ctx, cancel := context.WithTimeout(tracex.ContextWithRequestID(context.Background(), message.RequestID), timeout) defer cancel() if err := s.TriggerReply(ctx, conversation, message, *aiAgent); err != nil { @@ -40,6 +49,7 @@ func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, mes "timeout_ms", timeout.Milliseconds(), "elapsed_ms", time.Since(startedAt).Milliseconds(), "error", err) + svc.MessageService.CompleteAIReplyPlaceholderAsFailed(&conversation, aiAgent, &message, message.RequestID) } }() } @@ -58,9 +68,12 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) { return nil } - if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) { + channel := svc.ChannelService.Get(conversation.ChannelID) + if !IsAIAgentRolloutEligible(conversation, aiAgent, channel) { return nil } + // 正式回复生成前先发送占位提示,生成后由回复提交环节按渠道能力原地替换或追加新消息 + svc.MessageService.SendAIReplyPlaceholder(&conversation, &aiAgent, &message, channel, message.RequestID) if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil { replyCtx.PendingInterrupt = pendingInterrupt return s.resumePendingInterrupt(ctx, replyCtx) @@ -108,6 +121,9 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte if err != nil { return err } + return nil } + // 工作流执行完成但未产出回复文本,补发失败提示避免占位消息悬挂 + svc.MessageService.CompleteAIReplyPlaceholderAsFailed(&replyCtx.Conversation, &replyCtx.AIAgent, &replyCtx.Message, replyCtx.Message.RequestID) return nil } diff --git a/internal/ai/runtime/reply_types.go b/internal/ai/runtime/reply_types.go index d16afb21..730edbd8 100644 --- a/internal/ai/runtime/reply_types.go +++ b/internal/ai/runtime/reply_types.go @@ -1,6 +1,6 @@ package runtime -const ( - defaultAIReplyAsyncTimeoutSeconds = 180 - maxAIReplyAsyncTimeoutSeconds = 600 -) +import "agent-desk/internal/models" + +// maxAIReplyAsyncTimeoutSeconds 为 AI 回复超时的上限;默认时长由 models.DefaultAIReplyTimeoutSeconds 提供。 +const maxAIReplyAsyncTimeoutSeconds = models.MaxAIReplyTimeoutSeconds diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index b3648e12..882efc3e 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -11,6 +11,8 @@ import ( "context" "log/slog" + "github.com/mlogclub/simple/sqls" + _ "agent-desk/internal/services/event_handlers" ) @@ -24,9 +26,10 @@ func Init(configPath string) error { i18nx.SetDefaultLocale(cfg.LanguageOrDefault()) logx.Init(logx.Config{ - Level: cfg.Logger.Level, - Format: cfg.Logger.Format, - AddSource: cfg.Logger.AddSource, + Level: cfg.Logger.Level, + Format: cfg.Logger.Format, + AddSource: cfg.Logger.AddSource, + EnableDBSink: true, }) if _, err := InitDB(cfg.DB); err != nil { @@ -37,6 +40,7 @@ func Init(configPath string) error { slog.Error("init migrations failed", "error", err) return err } + logx.AttachDB(sqls.DB()) if err := vectordb.Init(&cfg.VectorDB); err != nil { slog.Error("init vector db failed", "error", err) return err diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index e35dff8c..d5961c10 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -53,6 +53,7 @@ func registerApiMessageRoutes(group *gin.RouterGroup) { func registerApiSupportRoutes(group *gin.RouterGroup) { group.GET("/config", api.SupportConfigGetConfig) + group.GET("/ai-customer-service/user-token", api.SupportConfigGetAICustomerServiceUserToken) group.POST("/auth/register", api.SupportAuthPostRegister) group.GET("/me", api.SupportGetMe) group.Any("/doc-page/list", api.DocPageAnyList) @@ -223,6 +224,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.POST("/update", dashboard.ChannelPostUpdate) group.POST("/update_status", dashboard.ChannelPostUpdate_status) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) + group.POST("/wxwork/kf/test_read_messages", dashboard.ChannelPostWxworkKfTest_read_messages) group.Any("/wxwork/outbox/failed/list", dashboard.ChannelAnyWxworkOutboxFailedList) group.POST("/wxwork/outbox/retry", dashboard.ChannelPostWxworkOutboxRetry) group.POST("/wxwork/outbox/ignore", dashboard.ChannelPostWxworkOutboxIgnore) @@ -339,6 +341,7 @@ func registerDashboardKnowledgeDirectoryRoutes(group *gin.RouterGroup) { func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.KnowledgeDocumentGetBy) group.POST("/batch_delete", dashboard.KnowledgeDocumentPostBatch_delete) + group.POST("/batch_build", dashboard.KnowledgeDocumentPostBatch_build) group.POST("/batch_move", dashboard.KnowledgeDocumentPostBatch_move) group.POST("/create", dashboard.KnowledgeDocumentPostCreate) group.POST("/delete", dashboard.KnowledgeDocumentPostDelete) @@ -421,6 +424,11 @@ func registerDashboardMCPRoutes(group *gin.RouterGroup) { group.POST("/test_connection", dashboard.MCPPostTest_connection) } +func registerDashboardSystemLogRoutes(group *gin.RouterGroup) { + group.GET("/:id", dashboard.SystemLogGetBy) + group.Any("/list", dashboard.SystemLogAnyList) +} + func registerThirdWechatRoutes(group *gin.RouterGroup) { group.GET("/callback", third.WechatGetCallback) group.POST("/callback", third.WechatPostCallback) diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index d5fc669a..4ad021b4 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -62,11 +62,35 @@ func corsMiddleware() gin.HandlerFunc { } allowedOriginSet[origin] = struct{}{} } + // publicAnyOriginPaths 是可被任意来源跨域访问的公开只读接口。 + // 这些接口不携带鉴权、不返回敏感信息,且供嵌入式 SDK 挂件在任意宿主站点 + // 预拉取展示配置(之后聊天 iframe 与后端同源,不再触发跨域)。 + publicAnyOriginPaths := map[string]struct{}{ + "/api/config": {}, + "/api/channel/config": {}, + } + applyPublicCORS := func(ctx *gin.Context) { + ctx.Header("Access-Control-Allow-Origin", "*") + ctx.Header("Access-Control-Allow-Methods", allowMethods) + ctx.Header("Access-Control-Allow-Headers", allowHeaders) + ctx.Header("Access-Control-Expose-Headers", exposeHeaders) + ctx.Header("Access-Control-Max-Age", "600") + if ctx.Request.Method == http.MethodOptions { + ctx.AbortWithStatus(http.StatusNoContent) + return + } + ctx.Next() + } return func(ctx *gin.Context) { if isWebsocketUpgrade(ctx) { ctx.Next() return } + // 公开只读接口:放行任意来源,无需白名单。 + if _, isPublic := publicAnyOriginPaths[ctx.Request.URL.Path]; isPublic { + applyPublicCORS(ctx) + return + } origin := strings.TrimRight(strings.TrimSpace(ctx.GetHeader("Origin")), "/") if origin != "" { ctx.Header("Vary", "Origin") @@ -143,6 +167,8 @@ func addRouter(app *gin.Engine) { apiGroup := app.Group("/api") apiGroup.GET("/health", api.Health) apiGroup.GET("/config", api.PublicConfig) + apiGroup.GET("/avatar/user/:userId", api.AvatarUserGet) + apiGroup.GET("/avatar/agent/:agentProfileId", api.AvatarAgentGet) registerApiAuthRoutes(apiGroup.Group("/auth")) registerApiChannelRoutes(apiGroup.Group("/channel")) registerApiCustomerRoutes(apiGroup.Group("/customer")) @@ -192,6 +218,7 @@ func addRouter(app *gin.Engine) { registerDashboardCommunityPostRoutes(dashboardGroup.Group("/support-community/posts")) registerDashboardSkillDefinitionRoutes(dashboardGroup.Group("/skill-definition")) registerDashboardMCPRoutes(dashboardGroup.Group("/mcp")) + registerDashboardSystemLogRoutes(dashboardGroup.Group("/system-log")) thirdGroup := app.Group("/api/third") registerThirdWechatRoutes(thirdGroup.Group("/wechat")) diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 328b293f..29801528 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -35,6 +35,8 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { expected := []string{ http.MethodPost + " /api/auth/login", http.MethodGet + " /api/config", + http.MethodGet + " /api/avatar/user/:userId", + http.MethodGet + " /api/avatar/agent/:agentProfileId", http.MethodGet + " /api/health", http.MethodGet + " /api/auth/oidc_login", http.MethodGet + " /api/auth/oidc_callback", @@ -370,6 +372,114 @@ func TestNewServerRejectsUnconfiguredCORSOrigin(t *testing.T) { } } +// TestNewServerPublicConfigEndpointsAllowAnyOrigin 验证嵌入式 SDK 挂件预拉取的 +// 两个公开只读接口对任意来源放行(含带 X-Channel-Id 头触发的预检请求), +// 同时非公开接口仍受白名单约束。 +func TestNewServerPublicConfigEndpointsAllowAnyOrigin(t *testing.T) { + config.SetCurrent(&config.Config{ + Language: "zh-CN", + Server: config.ServerConfig{ + CORS: config.CORSConfig{ + // 白名单为空:公开接口仍应放行,非公开接口应被拒。 + AllowedOrigins: nil, + }, + }, + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Root: "storage", + BaseURL: "/storage", + }, + }, + }) + + app, err := NewServer() + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + cases := []struct { + name string + method string + path string + origin string + extraHdrs map[string]string + wantStatus int + wantACAO string + }{ + { + name: "GET /api/config with arbitrary origin", + method: http.MethodGet, + path: "/api/config", + origin: "http://localhost:5201", + wantStatus: http.StatusOK, + wantACAO: "*", + }, + { + name: "OPTIONS /api/config preflight passes for arbitrary origin", + method: http.MethodOptions, + path: "/api/config", + origin: "http://localhost:5201", + extraHdrs: map[string]string{"Access-Control-Request-Method": http.MethodGet}, + wantStatus: http.StatusNoContent, + wantACAO: "*", + }, + { + name: "OPTIONS /api/channel/config preflight passes with X-Channel-Id request header", + method: http.MethodOptions, + path: "/api/channel/config", + origin: "http://localhost:5201", + extraHdrs: map[string]string{"Access-Control-Request-Method": http.MethodGet, "Access-Control-Request-Headers": "X-Channel-Id"}, + wantStatus: http.StatusNoContent, + wantACAO: "*", + }, + { + name: "GET /api/config without Origin header still works (same-origin)", + method: http.MethodGet, + path: "/api/config", + origin: "", + wantStatus: http.StatusOK, + wantACAO: "*", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, nil) + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + for k, v := range tt.extraHdrs { + req.Header.Set(k, v) + } + app.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Fatalf("status=%d want %d, body=%s", rec.Code, tt.wantStatus, rec.Body.String()) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tt.wantACAO { + t.Fatalf("Access-Control-Allow-Origin=%q want %q", got, tt.wantACAO) + } + if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Channel-Id") { + t.Fatalf("Access-Control-Allow-Headers=%q should contain X-Channel-Id", got) + } + }) + } + + // 回归:非公开接口在白名单为空时,预检应被拒。 + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil) + req.Header.Set("Origin", "http://localhost:5201") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + app.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("non-public preflight status=%d want %d", rec.Code, http.StatusForbidden) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("non-public Access-Control-Allow-Origin=%q want empty", got) + } +} + func TestNewServerEchoesRequestID(t *testing.T) { config.SetCurrent(&config.Config{ Storage: config.StorageConfig{ diff --git a/internal/builders/agent_profile_builder.go b/internal/builders/agent_profile_builder.go index 8179de81..fa9eb472 100644 --- a/internal/builders/agent_profile_builder.go +++ b/internal/builders/agent_profile_builder.go @@ -61,7 +61,8 @@ func doBuildAgentProfileResponse(item *models.AgentProfile, user *models.User, t TeamID: item.TeamID, AgentCode: item.AgentCode, DisplayName: item.DisplayName, - Avatar: item.Avatar, + Avatar: item.AgentAvatar(), + AvatarAssetID: item.AgentAvatarAssetID(), ServiceStatus: item.ServiceStatus, MaxConcurrentCount: item.MaxConcurrentCount, PriorityLevel: item.PriorityLevel, diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index 0846aecc..0491d473 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -47,6 +47,10 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil { ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID) } + if channel := services.ChannelService.Get(item.ChannelID); channel != nil { + ret.ChannelType = channel.ChannelType + ret.ChannelName = channel.Name + } if item.CurrentAssigneeID > 0 { if user := services.UserService.Get(item.CurrentAssigneeID); user != nil { ret.CurrentAssigneeName = user.Nickname @@ -179,7 +183,7 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c if dn := strings.TrimSpace(profile.DisplayName); dn != "" { ret.SenderName = dn } - if av := strings.TrimSpace(profile.Avatar); av != "" { + if av := profile.AgentAvatar(); av != "" { ret.SenderAvatar = av } } diff --git a/internal/builders/support_builder.go b/internal/builders/support_builder.go index 9920de44..dabc5b98 100644 --- a/internal/builders/support_builder.go +++ b/internal/builders/support_builder.go @@ -4,6 +4,7 @@ import ( "agent-desk/internal/models" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/utils" "encoding/json" "time" ) @@ -88,26 +89,35 @@ func BuildPostCategories(list []models.Category) []response.CategoryResponse { return ret } +const communityPostSummaryLength = 128 + +func BuildSimpleUserInfo(item *models.User) response.SimpleUserInfo { + if item == nil { + return response.SimpleUserInfo{} + } + displayName := item.Nickname + if displayName == "" { + displayName = item.Username + } + return response.SimpleUserInfo{ + ID: item.ID, + Username: item.Username, + Nickname: item.Nickname, + DisplayName: displayName, + Avatar: item.UserAvatarURL(), + UserType: item.UserType, + } +} + func BuildPost(item *models.Post, categoryName string, user *models.User) *response.PostResponse { if item == nil { return nil } - userName := "" - userType := enums.UserTypeUser - if user != nil { - userName = user.Nickname - if userName == "" { - userName = user.Username - } - userType = user.UserType - } return &response.PostResponse{ ID: item.ID, CategoryID: item.CategoryID, CategoryName: categoryName, - UserID: item.UserID, - UserName: userName, - UserType: userType, + User: BuildSimpleUserInfo(user), Title: item.Title, ContentType: item.ContentType, Content: item.Content, @@ -125,7 +135,38 @@ func BuildPost(item *models.Post, categoryName string, user *models.User) *respo } } -func BuildComment(item *models.Comment, authorName string) *response.CommentResponse { +func BuildPostList(items []models.Post, categories map[int64]*models.Category, users map[int64]*models.User) []response.PostListItemResponse { + results := make([]response.PostListItemResponse, 0, len(items)) + for i := range items { + item := &items[i] + categoryName := "" + if category := categories[item.CategoryID]; category != nil { + categoryName = category.Name + } + results = append(results, response.PostListItemResponse{ + ID: item.ID, + CategoryID: item.CategoryID, + CategoryName: categoryName, + User: BuildSimpleUserInfo(users[item.UserID]), + Title: item.Title, + Summary: utils.BuildContentSummary(item.ContentType, item.Content, communityPostSummaryLength), + Tags: parseSupportTags(item.TagsJSON), + Status: item.Status, + AcceptedCommentID: item.AcceptedCommentID, + CommentCount: item.CommentCount, + ReactionCount: item.ReactionCount, + ViewCount: item.ViewCount, + LastCommentedAt: formatSupportTime(item.LastCommentedAt), + LastCommentUserType: item.LastCommentUserType, + LastCommentUserID: item.LastCommentUserID, + CreatedAt: formatSupportTime(&item.CreatedAt), + UpdatedAt: formatSupportTime(&item.UpdatedAt), + }) + } + return results +} + +func BuildComment(item *models.Comment, user *models.User) *response.CommentResponse { if item == nil { return nil } @@ -140,8 +181,7 @@ func BuildComment(item *models.Comment, authorName string) *response.CommentResp PostID: item.PostID, ParentID: item.ParentID, AuthorType: item.AuthorType, - AuthorID: item.AuthorID, - AuthorName: authorName, + User: BuildSimpleUserInfo(user), ContentType: contentType, Content: content, Status: item.Status, diff --git a/internal/builders/support_builder_test.go b/internal/builders/support_builder_test.go index 95a01cda..118d7c10 100644 --- a/internal/builders/support_builder_test.go +++ b/internal/builders/support_builder_test.go @@ -2,16 +2,57 @@ package builders import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "encoding/json" + "strings" "testing" ) -func TestBuildDocPageNavigationTree(t *testing.T) { - menu := BuildDocPageNavigationTree([]models.DocPage{ - {ID: 1, Title: "Install", Slug: "install"}, - {ID: 2, Title: "Overview", Slug: "overview"}, - {ID: 3, ParentID: 2, Title: "Change log", Slug: "changelog"}, +func TestBuildPostListUsesSimpleUserInfoAndSummary(t *testing.T) { + posts := []models.Post{{ + ID: 1, + CategoryID: 2, + UserID: 3, + Title: "标题", + ContentType: "markdown", + Content: "# 正文\n\n这是帖子内容", + Status: enums.PostStatusNormal, + }} + items := BuildPostList(posts, + map[int64]*models.Category{2: {ID: 2, Name: "产品使用"}}, + map[int64]*models.User{3: {ID: 3, Username: "alice", Nickname: "Alice", Avatar: "https://example.com/a.png", UserType: enums.UserTypeUser}}, + ) + if len(items) != 1 { + t.Fatalf("items length = %d, want 1", len(items)) + } + item := items[0] + if item.Summary != "正文 这是帖子内容" { + t.Fatalf("summary = %q", item.Summary) + } + if item.User.ID != 3 || item.User.DisplayName != "Alice" || item.User.Avatar == "" { + t.Fatalf("unexpected simple user info: %#v", item.User) + } + encoded, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal post list item: %v", err) + } + if strings.Contains(string(encoded), "content") { + t.Fatalf("post list item must not include full content: %s", encoded) + } +} + +func TestBuildCommentUsesSimpleUserInfo(t *testing.T) { + comment := BuildComment(&models.Comment{ID: 1, AuthorID: 2, ContentType: "markdown", Content: "评论", Status: enums.CommentStatusNormal}, &models.User{ + ID: 2, Username: "bob", Nickname: "Bob", UserType: enums.UserTypeUser, }) - if len(menu) != 2 || menu[0].Slug != "install" || menu[1].Slug != "overview" || len(menu[1].Children) != 1 || menu[1].Children[0].Slug != "changelog" { - t.Fatalf("unexpected navigation tree: %#v", menu) + if comment.User.ID != 2 || comment.User.DisplayName != "Bob" { + t.Fatalf("unexpected simple user info: %#v", comment.User) + } + encoded, err := json.Marshal(comment) + if err != nil { + t.Fatalf("marshal comment: %v", err) + } + if strings.Contains(string(encoded), "authorId") || strings.Contains(string(encoded), "authorName") { + t.Fatalf("comment must use user instead of flat author fields: %s", encoded) } } diff --git a/internal/builders/system_log_builder.go b/internal/builders/system_log_builder.go new file mode 100644 index 00000000..655169db --- /dev/null +++ b/internal/builders/system_log_builder.go @@ -0,0 +1,33 @@ +package builders + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" +) + +// BuildSystemLog 将 SystemLog 模型映射为响应 DTO。 +func BuildSystemLog(item *models.SystemLog) response.SystemLogResponse { + return response.SystemLogResponse{ + ID: item.ID, + Level: item.Level, + LevelName: systemLogLevelName(item.Level), + Message: item.Message, + Source: item.Source, + LoggerName: item.LoggerName, + Attrs: item.Attrs, + CreatedAt: item.CreatedAt, + } +} + +func systemLogLevelName(level string) string { + switch level { + case "INFO": + return "Info" + case "WARN": + return "Warn" + case "ERROR": + return "Error" + default: + return level + } +} diff --git a/internal/builders/user_builder.go b/internal/builders/user_builder.go index 9ef02607..66ef297a 100644 --- a/internal/builders/user_builder.go +++ b/internal/builders/user_builder.go @@ -25,14 +25,15 @@ func BuildUserResponse(item *models.User, options UserBuildOptions) *response.Us return nil } ret := &response.UserResponse{ - ID: item.ID, - Username: item.Username, - Nickname: item.Nickname, - Avatar: item.Avatar, - UserType: item.UserType, - Status: item.Status, - LastLoginAt: utils.FormatTimePtr(item.LastLoginAt), - LastLoginIP: item.LastLoginIP, + ID: item.ID, + Username: item.Username, + Nickname: item.Nickname, + Avatar: item.UserAvatarURL(), + AvatarAssetID: item.UserAvatarAssetID(), + UserType: item.UserType, + Status: item.Status, + LastLoginAt: utils.FormatTimePtr(item.LastLoginAt), + LastLoginIP: item.LastLoginIP, } if item.Mobile != nil { diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index 83ffa970..98590927 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -211,6 +211,42 @@ func UploadProfileAvatar(ctx *gin.Context) { httpx.WriteJSON(ctx, builders.BuildAsset(item)) } +func AvatarUserGet(ctx *gin.Context) { + userID, ok := httpx.GetPathInt64(ctx, "userId") + if !ok { + return + } + user := services.UserService.Get(userID) + if user == nil { + ctx.AbortWithStatus(http.StatusNotFound) + return + } + redirectAvatarAsset(ctx, user.UserAvatarAssetID()) +} + +func AvatarAgentGet(ctx *gin.Context) { + profileID, ok := httpx.GetPathInt64(ctx, "agentProfileId") + if !ok { + return + } + profile := services.AgentProfileService.Get(profileID) + if profile == nil { + ctx.AbortWithStatus(http.StatusNotFound) + return + } + redirectAvatarAsset(ctx, profile.AgentAvatarAssetID()) +} + +func redirectAvatarAsset(ctx *gin.Context, assetID string) { + accessURL, err := services.AssetService.GetSignedURLByAssetID(assetID) + if err != nil || accessURL == "" { + ctx.AbortWithStatus(http.StatusNotFound) + return + } + ctx.Header("Cache-Control", "private, no-store") + ctx.Redirect(http.StatusFound, accessURL) +} + func wxWorkErrorMessage(message string) string { return loginErrorMessage(message) } diff --git a/internal/handlers/api/support_config_handler.go b/internal/handlers/api/support_config_handler.go index 7d3538da..7b5ac45e 100644 --- a/internal/handlers/api/support_config_handler.go +++ b/internal/handlers/api/support_config_handler.go @@ -10,3 +10,27 @@ import ( func SupportConfigGetConfig(ctx *gin.Context) { httpx.WriteJSON(ctx, services.SystemConfigService.GetPublicSupportConfig()) } + +func SupportConfigGetAICustomerServiceUserToken(ctx *gin.Context) { + principal, err := services.AuthService.Authenticate(ctx) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + channel := services.SystemConfigService.GetPublicSupportAICustomerServiceChannel() + if channel == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209")) + return + } + user := services.UserService.Get(principal.UserID) + if user == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0256")) + return + } + token, err := services.CustomerSessionService.SignSupportUserToken(channel, user) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, token) +} diff --git a/internal/handlers/api/support_handler.go b/internal/handlers/api/support_handler.go index 16c86fa1..8c3ee882 100644 --- a/internal/handlers/api/support_handler.go +++ b/internal/handlers/api/support_handler.go @@ -126,7 +126,8 @@ func PostAnyList(ctx *gin.Context) { if hasMore && len(list) > 0 { nextCursor = cast.ToString(list[len(list)-1].ID) } - httpx.WriteJSON(ctx, httpx.CursorData(buildPostList(list), nextCursor, hasMore)) + data := services.SupportService.LoadCommunityResponseData(list, nil, nil) + httpx.WriteJSON(ctx, httpx.CursorData(builders.BuildPostList(list, data.Categories, data.Users), nextCursor, hasMore)) } func PostGetBy(ctx *gin.Context) { @@ -134,6 +135,11 @@ func PostGetBy(ctx *gin.Context) { if !ok { return } + principal, err := services.SupportService.OptionalSupportUser(ctx) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } post := repositories.PostRepository.Get(sqls.DB(), id) if post == nil || post.Status == enums.PostStatusHidden || post.Status == enums.PostStatusDeleted { httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.notFound")) @@ -145,7 +151,14 @@ func PostGetBy(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - httpx.WriteJSON(ctx, response.PostDetailResponse{Post: *builders.BuildPost(post, supportCategoryName(post.CategoryID), supportUser(post.UserID)), Comments: buildCommentListWithReplies(comments.Comments, comments.Replies)}) + data := services.SupportService.LoadCommunityResponseData([]models.Post{*post}, comments.Comments, comments.Replies) + categoryName := "" + if category := data.Categories[post.CategoryID]; category != nil { + categoryName = category.Name + } + postResponse := builders.BuildPost(post, categoryName, data.Users[post.UserID]) + postResponse.IsLiked = services.SupportService.HasReaction(enums.ReactionTargetPost, post.ID, enums.ReactionTypeLike, principal) + httpx.WriteJSON(ctx, response.PostDetailResponse{Post: *postResponse, Comments: buildCommentListWithReplies(comments.Comments, comments.Replies, data.Users)}) } func PostPostCreate(ctx *gin.Context) { @@ -164,7 +177,12 @@ func PostPostCreate(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - httpx.WriteJSON(ctx, builders.BuildPost(item, supportCategoryName(item.CategoryID), supportUser(principal.UserID))) + data := services.SupportService.LoadCommunityResponseData([]models.Post{*item}, nil, nil) + categoryName := "" + if category := data.Categories[item.CategoryID]; category != nil { + categoryName = category.Name + } + httpx.WriteJSON(ctx, builders.BuildPost(item, categoryName, data.Users[item.UserID])) } func PostPostUpdate(ctx *gin.Context) { @@ -211,7 +229,8 @@ func CommentPostCreate(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - httpx.WriteJSON(ctx, builders.BuildComment(item, supportPrincipalDisplayName(principal.UserID))) + data := services.SupportService.LoadCommunityResponseData(nil, []models.Comment{*item}, nil) + httpx.WriteJSON(ctx, builders.BuildComment(item, data.Users[item.AuthorID])) } func CommentAnyList(ctx *gin.Context) { @@ -225,7 +244,8 @@ func CommentAnyList(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - httpx.WriteJSON(ctx, &web.PageResult{Results: buildCommentListWithReplies(result.Comments, result.Replies), Page: result.Paging}) + data := services.SupportService.LoadCommunityResponseData(nil, result.Comments, result.Replies) + httpx.WriteJSON(ctx, &web.PageResult{Results: buildCommentListWithReplies(result.Comments, result.Replies, data.Users), Page: result.Paging}) } func CommentPostUpdate(ctx *gin.Context) { @@ -294,26 +314,12 @@ func buildDocPageList(list []models.DocPage, includeContent bool) []response.Doc return results } -func buildPostList(list []models.Post) []response.PostResponse { - results := make([]response.PostResponse, 0, len(list)) - for _, item := range list { - if resp := builders.BuildPost(&item, supportCategoryName(item.CategoryID), supportUser(item.UserID)); resp != nil { - results = append(results, *resp) - } - } - return results -} - -func buildCommentList(list []models.Comment) []response.CommentResponse { - return buildCommentListWithReplies(list, nil) -} - -func buildCommentListWithReplies(list []models.Comment, replies map[int64][]models.Comment) []response.CommentResponse { +func buildCommentListWithReplies(list []models.Comment, replies map[int64][]models.Comment, users map[int64]*models.User) []response.CommentResponse { results := make([]response.CommentResponse, 0, len(list)) for _, item := range list { - if resp := builders.BuildComment(&item, supportCommentAuthorName(item)); resp != nil { + if resp := builders.BuildComment(&item, users[item.AuthorID]); resp != nil { if len(replies[item.ID]) > 0 { - resp.Replies = buildCommentListWithReplies(replies[item.ID], nil) + resp.Replies = buildCommentListWithReplies(replies[item.ID], nil, users) } results = append(results, *resp) } @@ -321,14 +327,6 @@ func buildCommentListWithReplies(list []models.Comment, replies map[int64][]mode return results } -func supportCategoryName(id int64) string { - item := repositories.CategoryRepository.Get(sqls.DB(), id) - if item == nil { - return "" - } - return item.Name -} - func supportUser(id int64) *models.User { return repositories.UserRepository.Get(sqls.DB(), id) } @@ -343,14 +341,3 @@ func supportPrincipalDisplayName(id int64) string { } return user.Username } - -func supportCommentAuthorName(item models.Comment) string { - user := repositories.UserRepository.Get(sqls.DB(), item.AuthorID) - if user == nil { - return "" - } - if user.Nickname != "" { - return user.Nickname - } - return user.Username -} diff --git a/internal/handlers/dashboard/channel_handler.go b/internal/handlers/dashboard/channel_handler.go index bbf79ed3..2c5e4014 100644 --- a/internal/handlers/dashboard/channel_handler.go +++ b/internal/handlers/dashboard/channel_handler.go @@ -65,6 +65,26 @@ func ChannelAnyWxworkKfAccounts(ctx *gin.Context) { httpx.WriteJSON(ctx, list) } +// ChannelPostWxworkKfTest_read_messages 测试企业微信客服渠道能否读取近 3 天的消息与事件。 +// 只读操作,使用 channel.view 权限;失败原因以结构化结果返回,不抛 JsonResult 错误。 +func ChannelPostWxworkKfTest_read_messages(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.TestWxWorkKFReadMessagesRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + result, err := services.ChannelService.TestWxWorkKFReadMessages(req.ID) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} + func ChannelAnyWxworkOutboxFailedList(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionWxWorkOutboxView); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/handlers/dashboard/knowledge_document_handler.go b/internal/handlers/dashboard/knowledge_document_handler.go index cc36a536..9740907e 100644 --- a/internal/handlers/dashboard/knowledge_document_handler.go +++ b/internal/handlers/dashboard/knowledge_document_handler.go @@ -163,6 +163,24 @@ func KnowledgeDocumentPostBatch_move(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func KnowledgeDocumentPostBatch_build(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeDocumentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.BatchBuildKnowledgeDocumentRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeDocumentService.BatchBuildKnowledgeDocuments(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + func KnowledgeDocumentPostBatch_delete(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeDocumentDelete); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/handlers/dashboard/support_handler.go b/internal/handlers/dashboard/support_handler.go index d5a8b192..e87807fe 100644 --- a/internal/handlers/dashboard/support_handler.go +++ b/internal/handlers/dashboard/support_handler.go @@ -241,7 +241,8 @@ func PostAnyList(ctx *gin.Context) { params.QueryFilter{ParamName: "status"}, params.QueryFilter{ParamName: "title", Op: params.Like}, ).Desc("id")) - httpx.WriteJSON(ctx, &web.PageResult{Results: buildDashboardPosts(list), Page: paging}) + data := services.SupportService.LoadCommunityResponseData(list, nil, nil) + httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildPostList(list, data.Categories, data.Users), Page: paging}) } func PostGetBy(ctx *gin.Context) { @@ -259,7 +260,12 @@ func PostGetBy(ctx *gin.Context) { return } comments := repositories.CommentRepository.Find(sqls.DB(), sqls.NewCnd().Eq("post_id", id).Desc("is_accepted").Asc("id")) - httpx.WriteJSON(ctx, response.PostDetailResponse{Post: *builders.BuildPost(post, dashboardCategoryName(post.CategoryID), dashboardSupportUser(post.UserID)), Comments: buildDashboardComments(comments)}) + data := services.SupportService.LoadCommunityResponseData([]models.Post{*post}, comments, nil) + categoryName := "" + if category := data.Categories[post.CategoryID]; category != nil { + categoryName = category.Name + } + httpx.WriteJSON(ctx, response.PostDetailResponse{Post: *builders.BuildPost(post, categoryName, data.Users[post.UserID]), Comments: buildDashboardComments(comments, data.Users)}) } func PostPostModerate(ctx *gin.Context) { @@ -305,7 +311,8 @@ func CommentPostCreate(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - httpx.WriteJSON(ctx, builders.BuildComment(item, operator.Nickname)) + data := services.SupportService.LoadCommunityResponseData(nil, []models.Comment{*item}, nil) + httpx.WriteJSON(ctx, builders.BuildComment(item, data.Users[item.AuthorID])) } func CommentPostModerate(ctx *gin.Context) { @@ -331,45 +338,12 @@ func buildDashboardDocPages(list []models.DocPage, includeContent bool) []respon return results } -func buildDashboardPosts(list []models.Post) []response.PostResponse { - results := make([]response.PostResponse, 0, len(list)) - for _, item := range list { - if resp := builders.BuildPost(&item, dashboardCategoryName(item.CategoryID), dashboardSupportUser(item.UserID)); resp != nil { - results = append(results, *resp) - } - } - return results -} - -func buildDashboardComments(list []models.Comment) []response.CommentResponse { +func buildDashboardComments(list []models.Comment, users map[int64]*models.User) []response.CommentResponse { results := make([]response.CommentResponse, 0, len(list)) for _, item := range list { - if resp := builders.BuildComment(&item, dashboardCommentAuthorName(item)); resp != nil { + if resp := builders.BuildComment(&item, users[item.AuthorID]); resp != nil { results = append(results, *resp) } } return results } - -func dashboardCategoryName(id int64) string { - item := repositories.CategoryRepository.Get(sqls.DB(), id) - if item == nil { - return "" - } - return item.Name -} - -func dashboardSupportUser(id int64) *models.User { - return repositories.UserRepository.Get(sqls.DB(), id) -} - -func dashboardCommentAuthorName(item models.Comment) string { - user := repositories.UserRepository.Get(sqls.DB(), item.AuthorID) - if user == nil { - return "" - } - if user.Nickname != "" { - return user.Nickname - } - return user.Username -} diff --git a/internal/handlers/dashboard/system_log_handler.go b/internal/handlers/dashboard/system_log_handler.go new file mode 100644 index 00000000..d9b71175 --- /dev/null +++ b/internal/handlers/dashboard/system_log_handler.go @@ -0,0 +1,56 @@ +package dashboard + +import ( + "agent-desk/internal/builders" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" +) + +// SystemLogAnyList 系统日志分页列表,支持按级别、关键字、时间范围筛选。 +func SystemLogAnyList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSystemLogView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + cnd := params.NewPagedSqlCnd(ctx, + params.QueryFilter{ParamName: "level", Op: params.Eq}, + params.QueryFilter{ParamName: "message", Op: params.Like}, + params.QueryFilter{ParamName: "source", Op: params.Like}, + params.QueryFilter{ParamName: "startTime", Op: params.Gte, ColumnName: "created_at"}, + params.QueryFilter{ParamName: "endTime", Op: params.Lte, ColumnName: "created_at"}, + ).Desc("id") + + list, paging := services.SystemLogService.FindPageByCnd(cnd) + results := make([]response.SystemLogResponse, 0, len(list)) + for i := range list { + results = append(results, builders.BuildSystemLog(&list[i])) + } + httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) +} + +// SystemLogGetBy 系统日志详情。 +func SystemLogGetBy(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSystemLogView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + + item := services.SystemLogService.Get(id) + if item == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.notFound")) + return + } + httpx.WriteJSON(ctx, builders.BuildSystemLog(item)) +} diff --git a/internal/migration/000002_init_auth_data.go b/internal/migration/000002_init_auth_data.go index 177b5379..b459cd3f 100644 --- a/internal/migration/000002_init_auth_data.go +++ b/internal/migration/000002_init_auth_data.go @@ -205,6 +205,7 @@ func ensureBootstrapAdmin(tx *gorm.DB, superAdminRole *models.Role) error { Username: username, Nickname: nickname, Password: string(hashedPassword), + UserType: enums.UserTypeEmployee, Status: enums.StatusOk, Remark: "bootstrap super admin", AuditFields: models.AuditFields{ @@ -223,6 +224,7 @@ func ensureBootstrapAdmin(tx *gorm.DB, superAdminRole *models.Role) error { } else { if err := repositories.UserRepository.Updates(tx, user.ID, map[string]any{ "nickname": nickname, + "user_type": enums.UserTypeEmployee, "status": enums.StatusOk, "update_user_id": constants.SystemAuditUserID, "update_user_name": constants.SystemAuditUserName, diff --git a/internal/migration/000010_backfill_employee_user_type.go b/internal/migration/000010_backfill_employee_user_type.go new file mode 100644 index 00000000..3c3781ba --- /dev/null +++ b/internal/migration/000010_backfill_employee_user_type.go @@ -0,0 +1,32 @@ +package migration + +import ( + "agent-desk/internal/pkg/enums" + "log/slog" + + "github.com/mlogclub/simple/sqls" +) + +func init() { + register(10, "backfill employee user_type for staff users", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + // t_user stores both staff accounts (user_type=employee, allowed on /api/dashboard) + // and support-portal customers (user_type=user). Existing staff accounts created + // before the user_type column was introduced kept the column default "user" and + // were rejected by the dashboard auth middleware. Staff accounts are identified + // by having at least one role binding in t_user_role. + res := ctx.Tx.Exec( + "UPDATE t_user SET user_type = ? WHERE user_type = ? "+ + "AND EXISTS (SELECT 1 FROM t_user_role ur WHERE ur.user_id = t_user.id)", + enums.UserTypeEmployee, enums.UserTypeUser, + ) + if res.Error != nil { + return res.Error + } + if res.RowsAffected > 0 { + slog.Info("backfilled employee user_type for staff users", "count", res.RowsAffected) + } + return nil + }) + }) +} diff --git a/internal/models/channel.go b/internal/models/channel.go new file mode 100644 index 00000000..343a332d --- /dev/null +++ b/internal/models/channel.go @@ -0,0 +1,31 @@ +package models + +import "strings" + +// AI 回复占位与超时的系统默认文案与上限;接入渠道可通过对应字段按渠道覆盖。 +const ( + DefaultAIReplyPlaceholder = "正在查看,请稍候……" + DefaultAIReplyTimeoutSeconds = 120 + MaxAIReplyTimeoutSeconds = 600 + DefaultAIReplyTimeoutNotice = "抱歉,AI 客服暂时未能处理您的问题,请重新发送问题或转人工客服。" +) + +// EffectiveAIReplyPlaceholder 返回渠道生效的 AI 接待占位提示语。 +func (c *Channel) EffectiveAIReplyPlaceholder() string { + if c != nil { + if value := strings.TrimSpace(c.AIReplyPlaceholder); value != "" { + return value + } + } + return DefaultAIReplyPlaceholder +} + +// EffectiveAIReplyTimeoutNotice 返回渠道生效的 AI 回复超时/失败提示语。 +func (c *Channel) EffectiveAIReplyTimeoutNotice() string { + if c != nil { + if value := strings.TrimSpace(c.AIReplyTimeoutNotice); value != "" { + return value + } + } + return DefaultAIReplyTimeoutNotice +} diff --git a/internal/models/models.go b/internal/models/models.go index 01b3e70d..b1ae9f97 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -77,6 +77,7 @@ var Models = []any{ &AIWorkflowNodeRun{}, &ConversationInterrupt{}, &SystemConfig{}, + &SystemLog{}, } // AgentToolInvocation persists the idempotency boundary for a business tool. @@ -753,6 +754,9 @@ type Channel struct { AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。 AIAgentRolloutPercent int `gorm:"type:int;not null;default:100"` // AIAgentRolloutPercent 为该渠道对 AI 自动回复施加的灰度百分比,100 表示不额外限制。 PreviousAIAgentRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousAIAgentRolloutPercent 保存渠道上一次生效的 Agent 灰度比例,0 表示尚无可回滚值。 + AIReplyPlaceholder string `gorm:"type:varchar(255);not null;default:''"` // AIReplyPlaceholder 为 AI 接待占位提示语,AI 收到客户消息后先回复该内容,正式回复生成后原地替换;空值使用系统默认文案。 + AIReplyTimeoutSeconds int `gorm:"type:int;not null;default:0"` // AIReplyTimeoutSeconds 为 AI 回复超时秒数,超时后向客户提示处理失败;0 表示使用系统默认(120 秒)。 + AIReplyTimeoutNotice string `gorm:"type:varchar(500);not null;default:''"` // AIReplyTimeoutNotice 为 AI 回复超时或处理失败时向客户提示的文案;空值使用系统默认文案。 // ConfigJSON 为渠道专属扩展配置,使用 JSON 存储。 // 例如: // 1. web 渠道可记录允许域名、品牌配置等; diff --git a/internal/models/models_method.go b/internal/models/models_method.go new file mode 100644 index 00000000..f92c029c --- /dev/null +++ b/internal/models/models_method.go @@ -0,0 +1,45 @@ +package models + +import ( + "strconv" + "strings" +) + +func (u User) UserAvatarURL() string { + if isExternalAvatarURL(u.Avatar) { + return strings.TrimSpace(u.Avatar) + } + if u.UserAvatarAssetID() == "" { + return "" + } + return "/api/avatar/user/" + strconv.FormatInt(u.ID, 10) +} + +func (u User) UserAvatarAssetID() string { + if isExternalAvatarURL(u.Avatar) { + return "" + } + return strings.TrimSpace(u.Avatar) +} + +func (u AgentProfile) AgentAvatar() string { + if isExternalAvatarURL(u.Avatar) { + return strings.TrimSpace(u.Avatar) + } + if u.AgentAvatarAssetID() == "" { + return "" + } + return "/api/avatar/agent/" + strconv.FormatInt(u.ID, 10) +} + +func (u AgentProfile) AgentAvatarAssetID() string { + if isExternalAvatarURL(u.Avatar) { + return "" + } + return strings.TrimSpace(u.Avatar) +} + +func isExternalAvatarURL(value string) bool { + value = strings.TrimSpace(value) + return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") +} diff --git a/internal/models/models_method_test.go b/internal/models/models_method_test.go new file mode 100644 index 00000000..a9e7af96 --- /dev/null +++ b/internal/models/models_method_test.go @@ -0,0 +1,31 @@ +package models + +import "testing" + +func TestUserAvatarMethods(t *testing.T) { + user := User{ID: 3, Avatar: "asset_1"} + if got := user.UserAvatarAssetID(); got != "asset_1" { + t.Fatalf("UserAvatarAssetID() = %q", got) + } + if got := user.UserAvatarURL(); got != "/api/avatar/user/3" { + t.Fatalf("UserAvatarURL() = %q", got) + } + + user.Avatar = "https://example.com/avatar.png" + if got := user.UserAvatarAssetID(); got != "" { + t.Fatalf("UserAvatarAssetID() = %q, want empty", got) + } + if got := user.UserAvatarURL(); got != "https://example.com/avatar.png" { + t.Fatalf("UserAvatarURL() = %q", got) + } +} + +func TestAgentAvatarMethods(t *testing.T) { + profile := AgentProfile{ID: 5, Avatar: "asset_2"} + if got := profile.AgentAvatarAssetID(); got != "asset_2" { + t.Fatalf("AgentAvatarAssetID() = %q", got) + } + if got := profile.AgentAvatar(); got != "/api/avatar/agent/5" { + t.Fatalf("AgentAvatar() = %q", got) + } +} diff --git a/internal/models/system_log.go b/internal/models/system_log.go new file mode 100644 index 00000000..2b39f33b --- /dev/null +++ b/internal/models/system_log.go @@ -0,0 +1,18 @@ +package models + +import "time" + +// SystemLog 系统日志记录,持久化 INFO/WARN/ERROR 级别的运行日志。 +type SystemLog struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + Level string `gorm:"type:varchar(20);not null;default:'';index"` // INFO / WARN / ERROR + Message string `gorm:"type:text"` // 日志消息 + Source string `gorm:"type:varchar(255);not null;default:'';index"` // file:line + LoggerName string `gorm:"type:varchar(128);not null;default:'';index"` // logger 名称 + Attrs string `gorm:"type:text"` // JSON 序列化的 attrs + CreatedAt time.Time `gorm:"not null;index"` // 日志发生时间 +} + +func (SystemLog) TableName() string { + return "t_system_log" +} diff --git a/internal/pkg/constants/auth.go b/internal/pkg/constants/auth.go index eae23100..0e095657 100644 --- a/internal/pkg/constants/auth.go +++ b/internal/pkg/constants/auth.go @@ -186,6 +186,9 @@ var ( // MCP 调试相关权限 PermissionMCPView = Permission{Name: "查看MCP调试信息", Code: "mcp.view", Type: "api", GroupName: "mcp", Method: "POST", APIPath: "/api/dashboard/mcp/list_tools", SortNo: 1710} PermissionMCPCall = Permission{Name: "调用MCP工具", Code: "mcp.call", Type: "api", GroupName: "mcp", Method: "POST", APIPath: "/api/dashboard/mcp/call_tool", SortNo: 1720} + + // 系统日志相关权限 + PermissionSystemLogView = Permission{Name: "查看系统日志", Code: "systemLog.view", Type: "api", GroupName: "systemLog", Method: "ANY", APIPath: "/api/dashboard/system-log/list", SortNo: 1810} ) // Permissions 内置权限列表 @@ -295,6 +298,7 @@ var Permissions = []Permission{ PermissionSkillDefinitionDelete, PermissionMCPView, PermissionMCPCall, + PermissionSystemLogView, } // PermissionMap 权限映射,用于通过 Code 查找 Permission @@ -392,6 +396,7 @@ var builtinPermissionResourceLabels = map[string]string{ "knowledgeFAQ": "knowledge FAQs", "skillDefinition": "Skill definitions", "mcp": "MCP tools", + "systemLog": "system logs", } var builtinPermissionNameOverrides = map[string]string{ @@ -444,6 +449,7 @@ var RolePermissions = map[string][]Permission{ PermissionAIAgentView, PermissionAIAgentCreate, PermissionAIAgentUpdate, PermissionAIAgentDelete, PermissionAIConfigView, PermissionAIConfigCreate, PermissionAIConfigUpdate, PermissionAIConfigDelete, PermissionSkillDefinitionView, PermissionSkillDefinitionCreate, PermissionSkillDefinitionUpdate, PermissionSkillDefinitionDelete, + PermissionSystemLogView, }, RoleCodeCsTeamLeader: { PermissionUserView, diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 276d03a1..85b1f002 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -33,6 +33,21 @@ type WechatMPChannelConfig struct { UserTokenSecret string `json:"userTokenSecret,omitempty"` } +// WechatMiniProgramChannelConfig 微信小程序客服消息渠道配置。 +// +// 注意与 WechatMPChannelConfig(微信公众号)区分。 +type WechatMiniProgramChannelConfig struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ThemeColor string `json:"themeColor"` + UserTokenSecret string `json:"userTokenSecret,omitempty"` + AppID string `json:"appId"` + Token string `json:"token"` + EncodingAESKey string `json:"encodingAESKey"` + TokenServiceURL string `json:"tokenServiceUrl"` + TokenServiceSecret string `json:"tokenServiceSecret,omitempty"` +} + type TelegramChannelConfig struct { BotToken string `json:"botToken"` BotUsername string `json:"botUsername,omitempty"` diff --git a/internal/pkg/dto/request/channel_request.go b/internal/pkg/dto/request/channel_request.go index a5095cb3..0853a98a 100644 --- a/internal/pkg/dto/request/channel_request.go +++ b/internal/pkg/dto/request/channel_request.go @@ -4,6 +4,9 @@ type CreateChannelRequest struct { ChannelType string `json:"channelType"` AIAgentID int64 `json:"aiAgentId"` AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"` + AIReplyPlaceholder string `json:"aiReplyPlaceholder"` + AIReplyTimeoutSeconds int `json:"aiReplyTimeoutSeconds"` + AIReplyTimeoutNotice string `json:"aiReplyTimeoutNotice"` Name string `json:"name"` ConfigJSON string `json:"configJson"` Status int `json:"status"` @@ -32,6 +35,10 @@ type ResetChannelUserTokenSecretRequest struct { ID int64 `json:"id"` } +type TestWxWorkKFReadMessagesRequest struct { + ID int64 `json:"id"` +} + type ChannelMessageOutboxActionRequest struct { ID int64 `json:"id"` } diff --git a/internal/pkg/dto/request/knowledge_request.go b/internal/pkg/dto/request/knowledge_request.go index f0d20b02..a9e5305b 100644 --- a/internal/pkg/dto/request/knowledge_request.go +++ b/internal/pkg/dto/request/knowledge_request.go @@ -65,6 +65,10 @@ type BatchDeleteKnowledgeDocumentRequest struct { IDs []int64 `json:"ids"` } +type BatchBuildKnowledgeDocumentRequest struct { + IDs []int64 `json:"ids"` +} + type CreateKnowledgeFAQRequest struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` DirectoryID int64 `json:"directoryId"` diff --git a/internal/pkg/dto/request/support_request.go b/internal/pkg/dto/request/support_request.go index 7343fa7f..f50903ac 100644 --- a/internal/pkg/dto/request/support_request.go +++ b/internal/pkg/dto/request/support_request.go @@ -126,3 +126,8 @@ type SupportNavigationMenuItemRequest struct { Visible *bool `json:"visible"` Children []SupportNavigationMenuItemRequest `json:"children"` } + +type SupportAICustomerServiceConfigRequest struct { + Enabled bool `json:"enabled"` + ChannelID string `json:"channelId"` +} diff --git a/internal/pkg/dto/response/admin_response.go b/internal/pkg/dto/response/admin_response.go index c94d07ec..97d25cdc 100644 --- a/internal/pkg/dto/response/admin_response.go +++ b/internal/pkg/dto/response/admin_response.go @@ -31,18 +31,19 @@ type RoleResponse struct { } type UserResponse struct { - ID int64 `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - Mobile string `json:"mobile,omitempty"` - Email string `json:"email,omitempty"` - UserType enums.UserType `json:"userType"` - Status enums.Status `json:"status"` - LastLoginAt string `json:"lastLoginAt,omitempty"` - LastLoginIP string `json:"lastLoginIp,omitempty"` - Roles []RoleResponse `json:"roles,omitempty"` - Permissions []string `json:"permissions,omitempty"` + ID int64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + AvatarAssetID string `json:"avatarAssetId,omitempty"` + Mobile string `json:"mobile,omitempty"` + Email string `json:"email,omitempty"` + UserType enums.UserType `json:"userType"` + Status enums.Status `json:"status"` + LastLoginAt string `json:"lastLoginAt,omitempty"` + LastLoginIP string `json:"lastLoginIp,omitempty"` + Roles []RoleResponse `json:"roles,omitempty"` + Permissions []string `json:"permissions,omitempty"` } // CreateUserResultResponse 创建用户成功响应;password 仅在本次响应中返回一次。 diff --git a/internal/pkg/dto/response/agent_response.go b/internal/pkg/dto/response/agent_response.go index b48f85b3..fd1ae947 100644 --- a/internal/pkg/dto/response/agent_response.go +++ b/internal/pkg/dto/response/agent_response.go @@ -12,6 +12,7 @@ type AgentProfileResponse struct { AgentCode string `json:"agentCode"` DisplayName string `json:"displayName"` Avatar string `json:"avatar"` + AvatarAssetID string `json:"avatarAssetId,omitempty"` ServiceStatus enums.ServiceStatus `json:"serviceStatus"` MaxConcurrentCount int `json:"maxConcurrentCount"` PriorityLevel int `json:"priorityLevel"` diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go index 50449137..44a4e261 100644 --- a/internal/pkg/dto/response/auth_response.go +++ b/internal/pkg/dto/response/auth_response.go @@ -3,14 +3,15 @@ package response import "agent-desk/internal/pkg/enums" type AuthUserResponse struct { - ID int64 `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - Email string `json:"email,omitempty"` - UserType enums.UserType `json:"userType"` - Status enums.Status `json:"status"` - Roles []string `json:"roles"` + ID int64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + AvatarAssetID string `json:"avatarAssetId,omitempty"` + Email string `json:"email,omitempty"` + UserType enums.UserType `json:"userType"` + Status enums.Status `json:"status"` + Roles []string `json:"roles"` } type LoginResponse struct { diff --git a/internal/pkg/dto/response/channel_response.go b/internal/pkg/dto/response/channel_response.go index e1c36f99..e2a25a3d 100644 --- a/internal/pkg/dto/response/channel_response.go +++ b/internal/pkg/dto/response/channel_response.go @@ -13,6 +13,9 @@ type ChannelResponse struct { AIAgentID int64 `json:"aiAgentId"` AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"` PreviousAIAgentRolloutPercent int `json:"previousAiAgentRolloutPercent"` + AIReplyPlaceholder string `json:"aiReplyPlaceholder"` + AIReplyTimeoutSeconds int `json:"aiReplyTimeoutSeconds"` + AIReplyTimeoutNotice string `json:"aiReplyTimeoutNotice"` AIAgentName string `json:"aiAgentName,omitempty"` Name string `json:"name"` ConfigJSON string `json:"configJson"` @@ -55,6 +58,9 @@ func BuildChannelResponse(item *models.Channel) ChannelResponse { AIAgentID: item.AIAgentID, AIAgentRolloutPercent: item.AIAgentRolloutPercent, PreviousAIAgentRolloutPercent: item.PreviousAIAgentRolloutPercent, + AIReplyPlaceholder: item.AIReplyPlaceholder, + AIReplyTimeoutSeconds: item.AIReplyTimeoutSeconds, + AIReplyTimeoutNotice: item.AIReplyTimeoutNotice, Name: item.Name, ConfigJSON: item.ConfigJSON, Status: item.Status, diff --git a/internal/pkg/dto/response/conversation_response.go b/internal/pkg/dto/response/conversation_response.go index 697dbfdf..f357b5ae 100644 --- a/internal/pkg/dto/response/conversation_response.go +++ b/internal/pkg/dto/response/conversation_response.go @@ -21,6 +21,8 @@ type ConversationResponse struct { ID int64 `json:"id"` AIAgentID int64 `json:"aiAgentId"` ChannelID int64 `json:"channelId"` + ChannelType string `json:"channelType,omitempty"` + ChannelName string `json:"channelName,omitempty"` CustomerID int64 `json:"customerId"` CustomerName string `json:"customerName"` Status enums.IMConversationStatus `json:"status"` diff --git a/internal/pkg/dto/response/support_response.go b/internal/pkg/dto/response/support_response.go index cabc4f60..144a0fac 100644 --- a/internal/pkg/dto/response/support_response.go +++ b/internal/pkg/dto/response/support_response.go @@ -27,11 +27,23 @@ type SupportNavigationMenuItemResponse struct { } type PublicSupportConfigResponse struct { - NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + AICustomerService SupportAICustomerServiceConfigResponse `json:"aiCustomerService"` } type DashboardSupportConfigResponse struct { - NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + AICustomerService SupportAICustomerServiceConfigResponse `json:"aiCustomerService"` +} + +type SupportAICustomerServiceConfigResponse struct { + Enabled bool `json:"enabled"` + ChannelID string `json:"channelId"` +} + +type SupportAICustomerServiceUserTokenResponse struct { + UserToken string `json:"userToken"` + ExpiresAt string `json:"expiresAt"` } type DocPageResponse struct { @@ -79,13 +91,22 @@ type CategoryResponse struct { UpdatedAt string `json:"updatedAt"` } +// SimpleUserInfo is the public, reusable user shape embedded in community posts +// and comments. It deliberately excludes account contact and authentication data. +type SimpleUserInfo struct { + ID int64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + UserType enums.UserType `json:"userType"` +} + type PostResponse struct { ID int64 `json:"id"` CategoryID int64 `json:"categoryId"` CategoryName string `json:"categoryName"` - UserID int64 `json:"userId"` - UserName string `json:"userName"` - UserType enums.UserType `json:"userType"` + User SimpleUserInfo `json:"user"` Title string `json:"title"` ContentType string `json:"contentType"` Content string `json:"content"` @@ -94,6 +115,29 @@ type PostResponse struct { AcceptedCommentID int64 `json:"acceptedCommentId"` CommentCount int64 `json:"commentCount"` ReactionCount int64 `json:"reactionCount"` + IsLiked bool `json:"isLiked"` + ViewCount int64 `json:"viewCount"` + LastCommentedAt string `json:"lastCommentedAt"` + LastCommentUserType enums.CommentAuthorType `json:"lastCommentUserType"` + LastCommentUserID int64 `json:"lastCommentUserId"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// PostListItemResponse keeps community list payloads lightweight. Full content +// remains available exclusively from the post detail endpoint. +type PostListItemResponse struct { + ID int64 `json:"id"` + CategoryID int64 `json:"categoryId"` + CategoryName string `json:"categoryName"` + User SimpleUserInfo `json:"user"` + Title string `json:"title"` + Summary string `json:"summary"` + Tags []string `json:"tags"` + Status enums.PostStatus `json:"status"` + AcceptedCommentID int64 `json:"acceptedCommentId"` + CommentCount int64 `json:"commentCount"` + ReactionCount int64 `json:"reactionCount"` ViewCount int64 `json:"viewCount"` LastCommentedAt string `json:"lastCommentedAt"` LastCommentUserType enums.CommentAuthorType `json:"lastCommentUserType"` @@ -107,8 +151,7 @@ type CommentResponse struct { PostID int64 `json:"postId"` ParentID int64 `json:"parentId"` AuthorType enums.CommentAuthorType `json:"authorType"` - AuthorID int64 `json:"authorId"` - AuthorName string `json:"authorName"` + User SimpleUserInfo `json:"user"` ContentType string `json:"contentType"` Content string `json:"content"` Status enums.CommentStatus `json:"status"` diff --git a/internal/pkg/dto/response/system_log_response.go b/internal/pkg/dto/response/system_log_response.go new file mode 100644 index 00000000..562e23ec --- /dev/null +++ b/internal/pkg/dto/response/system_log_response.go @@ -0,0 +1,15 @@ +package response + +import "time" + +// SystemLogResponse 系统日志列表/详情响应。 +type SystemLogResponse struct { + ID int64 `json:"id"` + Level string `json:"level"` + LevelName string `json:"levelName"` + Message string `json:"message"` + Source string `json:"source"` + LoggerName string `json:"loggerName"` + Attrs string `json:"attrs"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/internal/pkg/dto/response/wxwork_kf_message_read_test_response.go b/internal/pkg/dto/response/wxwork_kf_message_read_test_response.go new file mode 100644 index 00000000..e98f0b18 --- /dev/null +++ b/internal/pkg/dto/response/wxwork_kf_message_read_test_response.go @@ -0,0 +1,32 @@ +package response + +// WxWorkKFMessageReadTestResult 为“测试读取企业微信客服消息”的结果。 +// 该接口是只读连通性测试:不消费消息、不创建会话、不更新同步游标; +// 调用失败也以 HTTP 200 返回结构化结果,由前端按错误码给出排查建议。 +type WxWorkKFMessageReadTestResult struct { + Success bool `json:"success"` // 是否成功读取 + Stage string `json:"stage,omitempty"` // 失败环节:gettoken / syncmsg + ErrorCode string `json:"errorCode,omitempty"` // 企业微信原始 errcode,或本地错误码 + ErrorMessage string `json:"errorMessage,omitempty"` // 企业微信原始 errmsg 或网络错误原文 + OpenKfID string `json:"openKfId,omitempty"` // 测试使用的客服账号 ID + + TotalScanned int `json:"totalScanned"` // 本次扫描到的消息/事件总条数(受分页上限约束) + MessageCount int `json:"messageCount"` // 普通消息条数 + EventCount int `json:"eventCount"` // 事件条数 + Truncated bool `json:"truncated"` // 是否因达到分页扫描上限而提前停止 + EarliestTime string `json:"earliestTime,omitempty"` + LatestTime string `json:"latestTime,omitempty"` + Samples []WxWorkKFMessageReadSample `json:"samples"` // 最近若干条样例(新消息在前) +} + +// WxWorkKFMessageReadSample 为测试结果中的单条消息/事件样例,仅携带展示所需的原始字段,本地化在前端完成。 +type WxWorkKFMessageReadSample struct { + MsgID string `json:"msgId,omitempty"` + SendTime string `json:"sendTime,omitempty"` + Origin int `json:"origin"` // 3-客户发送 4-系统事件 5-接待人员发送 + MsgType string `json:"msgType,omitempty"` // text/image/file/voice/video/event 等 + EventType string `json:"eventType,omitempty"` // msgtype=event 时的事件类型 + TextContent string `json:"textContent,omitempty"` // 文本消息原文(已截断) + ExternalUserID string `json:"externalUserId,omitempty"` // 客户 external_userid + ServicerUserID string `json:"servicerUserId,omitempty"` // 接待人员 userid +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 8135fd56..15f24d91 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -6,19 +6,23 @@ package enums type ExternalSource string const ( - ExternalSourceGuest ExternalSource = "guest" // 访客 - ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服 - ExternalSourceUser ExternalSource = "user" // 用户信息 - ExternalSourceTelegram ExternalSource = "telegram" // Telegram - ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo OA + ExternalSourceGuest ExternalSource = "guest" // 访客 + ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服 + ExternalSourceUser ExternalSource = "user" // 站内用户 + ExternalSourceExternal ExternalSource = "external" // 外部接入方用户 + ExternalSourceTelegram ExternalSource = "telegram" // Telegram + ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo OA + ExternalSourceWechatMiniProgram ExternalSource = "wechat_miniprogram" // 微信小程序 ) var externalSourceLabelMap = map[ExternalSource]string{ - ExternalSourceGuest: "访客", - ExternalSourceWxWorkKF: "企业微信客服", - ExternalSourceUser: "用户", - ExternalSourceTelegram: "Telegram", - ExternalSourceZaloOA: "Zalo OA", + ExternalSourceGuest: "访客", + ExternalSourceWxWorkKF: "企业微信客服", + ExternalSourceUser: "站内用户", + ExternalSourceExternal: "外部用户", + ExternalSourceTelegram: "Telegram", + ExternalSourceZaloOA: "Zalo OA", + ExternalSourceWechatMiniProgram: "微信小程序", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go index da23f4c3..0743b7fe 100644 --- a/internal/pkg/enums/im.go +++ b/internal/pkg/enums/im.go @@ -246,6 +246,7 @@ const ( IMRealtimeEventUnsubscribed = "unsubscribed" IMRealtimeEventResyncRequired = "resyncRequired" IMRealtimeEventMessageCreated = "message.created" + IMRealtimeEventMessageUpdated = "message.updated" IMRealtimeEventMessageRecalled = "message.recalled" IMRealtimeEventConversationCreated = "conversation.created" IMRealtimeEventConversationUpdated = "conversation.updated" diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 9fbec600..0e4469d2 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -345,6 +345,7 @@ error.e0344: "Invalid attachment message payload format." error.e0345: "Attachment message is missing assetId." error.e0346: "Attachment message is missing payload." error.e0347: "Default team queue mode requires at least one agent team." +error.e0348: "Invalid AI reply timeout." error.profile.nicknameRequired: "Enter a nickname." error.profile.nicknameTooLong: "Nickname cannot exceed 100 characters." error.profile.avatarTooLong: "Avatar link cannot exceed 255 characters." @@ -503,12 +504,21 @@ error.supportConfig.navigationTitleTooLong: "Navigation titles cannot exceed 64 error.supportConfig.navigationURLRequired: "Enter a navigation URL" error.supportConfig.navigationURLInvalid: "Navigation URLs must be internal paths or http/https URLs" error.supportConfig.navigationVisibleRequired: "Show at least one navigation item" +error.supportConfig.aiCustomerServiceInvalidJSON: "AI support config must be a valid JSON object" +error.supportConfig.aiCustomerServiceChannelRequired: "Select an AI support channel" +error.supportConfig.aiCustomerServiceChannelNotFound: "AI support channel not found" +error.supportConfig.aiCustomerServiceChannelTypeInvalid: "AI support can only use a web channel" +error.supportConfig.aiCustomerServiceChannelDisabled: "AI support channel is not enabled" +error.supportConfig.aiCustomerServiceAgentDisabled: "The AI Agent linked to the AI support channel does not exist or is not enabled" +error.supportConfig.aiCustomerServiceAgentUnpublished: "The AI Agent linked to the AI support channel has not been published" error.supportConfig.validationFailed: "Config validation failed" error.supportConfig.groupUnsupported: "Unsupported config group" error.supportConfig.keyUnsupported: "Unsupported config key: %v" error.supportConfig.emptyPayload: "Submit at least one config item" systemConfig.support.navigationMenu.title: "Support center navigation menu" systemConfig.support.navigationMenu.description: "Navigation menu for the support center public header and mobile views" +systemConfig.support.aiCustomerService.title: "Support center AI support" +systemConfig.support.aiCustomerService.description: "AI support channel used by the public support center" systemConfig.support.navigationMenu.default.home: "Home" systemConfig.support.navigationMenu.default.docs: "Docs" systemConfig.support.navigationMenu.default.community: "Community" diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 6b3aa8eb..1b1cc564 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -345,6 +345,7 @@ error.e0344: "附件消息 payload 格式错误" error.e0345: "附件消息缺少 assetId" error.e0346: "附件消息缺少 payload" error.e0347: "默认客服组待接入池模式必须至少选择一个客服组" +error.e0348: "AI 回复超时时长不合法" error.profile.nicknameRequired: "请输入昵称" error.profile.nicknameTooLong: "昵称不能超过 100 个字符" error.profile.avatarTooLong: "头像链接不能超过 255 个字符" @@ -503,12 +504,21 @@ error.supportConfig.navigationTitleTooLong: "导航标题不能超过 64 个字 error.supportConfig.navigationURLRequired: "请填写导航链接" error.supportConfig.navigationURLInvalid: "导航链接必须是站内路径或 http/https 地址" error.supportConfig.navigationVisibleRequired: "请至少显示一个导航菜单" +error.supportConfig.aiCustomerServiceInvalidJSON: "AI 客服配置必须是合法的 JSON 对象" +error.supportConfig.aiCustomerServiceChannelRequired: "请选择 AI 客服接入渠道" +error.supportConfig.aiCustomerServiceChannelNotFound: "AI 客服接入渠道不存在" +error.supportConfig.aiCustomerServiceChannelTypeInvalid: "AI 客服只能使用 Web 渠道" +error.supportConfig.aiCustomerServiceChannelDisabled: "AI 客服接入渠道未启用" +error.supportConfig.aiCustomerServiceAgentDisabled: "AI 客服渠道绑定的 AI Agent 不存在或未启用" +error.supportConfig.aiCustomerServiceAgentUnpublished: "AI 客服渠道绑定的 AI Agent 尚未发布" error.supportConfig.validationFailed: "配置校验失败" error.supportConfig.groupUnsupported: "不支持该配置分组" error.supportConfig.keyUnsupported: "不支持的配置项:%v" error.supportConfig.emptyPayload: "请至少提交一个配置项" systemConfig.support.navigationMenu.title: "支持中心导航菜单" systemConfig.support.navigationMenu.description: "支持中心公开页面顶部和移动端导航菜单" +systemConfig.support.aiCustomerService.title: "支持中心 AI 客服" +systemConfig.support.aiCustomerService.description: "支持中心公开页面使用的 AI 客服接入渠道" systemConfig.support.navigationMenu.default.home: "首页" systemConfig.support.navigationMenu.default.docs: "文档" systemConfig.support.navigationMenu.default.community: "社区" diff --git a/internal/pkg/logx/db_sink.go b/internal/pkg/logx/db_sink.go new file mode 100644 index 00000000..1f34d3ee --- /dev/null +++ b/internal/pkg/logx/db_sink.go @@ -0,0 +1,225 @@ +package logx + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "runtime" + "sync" + "sync/atomic" + "time" + + "agent-desk/internal/models" + + "gorm.io/gorm" +) + +const ( + dbSinkChannelCapacity = 4096 + dbSinkBatchSize = 200 + dbSinkFlushInterval = 1 * time.Second +) + +// dbSink 包装底层 stdout handler,并将 INFO/WARN/ERROR 级别日志异步写入数据库。 +type dbSink struct { + inner slog.Handler + + // DB 引用,通过 atomic 实现 lazy 注入。nil 时跳过 DB 写入。 + db atomic.Pointer[gorm.DB] + + // 日志 channel,worker goroutine 消费。 + ch chan models.SystemLog + + // 控制 worker 生命周期。 + stopOnce sync.Once + stop chan struct{} + done chan struct{} +} + +// dbSinkDropped 记录因 channel 满而丢弃的日志条数(进程级累计)。 +var dbSinkDropped atomic.Uint64 + +// dbSinkLastDropReportSec 记录上次输出丢弃告警的秒级时间戳,用于限频,避免 INFO 高频时刷屏 stderr。 +var dbSinkLastDropReportSec atomic.Int64 + +var sink *dbSink + +// AttachDB 注入 DB 并启动 worker goroutine。调用幂等。 +func AttachDB(db *gorm.DB) { + if sink == nil { + return + } + if sink.db.Load() != nil { + return + } + sink.db.Store(db) + go sink.worker() +} + +// Close 触发 worker 最终 flush 并退出。在 server shutdown 时调用。 +func Close() error { + if sink == nil { + return nil + } + sink.stopOnce.Do(func() { + close(sink.stop) + }) + select { + case <-sink.done: + case <-time.After(3 * time.Second): + } + return nil +} + +func (h *dbSink) Handle(ctx context.Context, r slog.Record) error { + // 先走 stdout + if err := h.inner.Handle(ctx, r.Clone()); err != nil { + return err + } + + // 只持久化 INFO 及以上级别(DEBUG 不写库) + if r.Level < slog.LevelInfo { + return nil + } + + // DB 未注入时跳过 + db := h.db.Load() + if db == nil { + return nil + } + + // 构造 SystemLog + logEntry := buildSystemLog(r) + + // 非阻塞写入 channel,满时丢弃(每秒最多输出一次 stderr 告警,避免高频日志刷屏) + select { + case h.ch <- logEntry: + default: + dropped := dbSinkDropped.Add(1) + nowSec := time.Now().Unix() + if dbSinkLastDropReportSec.Swap(nowSec) != nowSec { + fmt.Fprintf(os.Stderr, "logx: db sink channel full, dropped %d log entries\n", dropped) + } + } + + return nil +} + +func (h *dbSink) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +func (h *dbSink) WithAttrs(attrs []slog.Attr) slog.Handler { + return &dbSink{ + inner: h.inner.WithAttrs(attrs), + db: h.db, + ch: h.ch, + stop: h.stop, + done: h.done, + } +} + +func (h *dbSink) WithGroup(name string) slog.Handler { + return &dbSink{ + inner: h.inner.WithGroup(name), + db: h.db, + ch: h.ch, + stop: h.stop, + done: h.done, + } +} + +func (h *dbSink) worker() { + defer close(h.done) + + batch := make([]models.SystemLog, 0, dbSinkBatchSize) + ticker := time.NewTicker(dbSinkFlushInterval) + defer ticker.Stop() + + flush := func() { + if len(batch) == 0 { + return + } + db := h.db.Load() + if db == nil { + batch = batch[:0] + return + } + if err := db.CreateInBatches(batch, dbSinkBatchSize).Error; err != nil { + // 写入失败不影响主流程,输出到 stderr + fmt.Fprintf(os.Stderr, "logx: batch insert system logs failed: %v\n", err) + } + batch = batch[:0] + } + + for { + select { + case <-h.stop: + // drain 剩余日志 + for { + select { + case entry := <-h.ch: + batch = append(batch, entry) + if len(batch) >= dbSinkBatchSize { + flush() + } + default: + flush() + return + } + } + case entry := <-h.ch: + batch = append(batch, entry) + if len(batch) >= dbSinkBatchSize { + flush() + } + case <-ticker.C: + flush() + } + } +} + +func buildSystemLog(r slog.Record) models.SystemLog { + // 提取 source(file:line) + source := "" + if r.PC != 0 { + frames := runtimeCallerFrames(r.PC) + if frames != nil { + source = fmt.Sprintf("%s:%d", frames.File, frames.Line) + } + } + + // 提取 attrs + attrs := map[string]any{} + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.Any() + return true + }) + + attrsJSON := "" + if len(attrs) > 0 { + if b, err := json.Marshal(attrs); err == nil { + attrsJSON = string(b) + } + } + + return models.SystemLog{ + Level: r.Level.String(), + Message: r.Message, + Source: source, + LoggerName: "default", + Attrs: attrsJSON, + CreatedAt: r.Time, + } +} + +func runtimeCallerFrames(pc uintptr) *runtime.Frame { + frames := runtime.CallersFrames([]uintptr{pc}) + frame, _ := frames.Next() + if frame.File == "" { + return nil + } + return &frame +} diff --git a/internal/pkg/logx/logger.go b/internal/pkg/logx/logger.go index da116583..de2b66af 100644 --- a/internal/pkg/logx/logger.go +++ b/internal/pkg/logx/logger.go @@ -4,28 +4,45 @@ import ( "log/slog" "os" "strings" + + "agent-desk/internal/models" ) // Config 定义日志初始化参数。 type Config struct { - Level string `yaml:"level"` - Format string `yaml:"format"` - AddSource bool `yaml:"addSource"` + Level string `yaml:"level"` + Format string `yaml:"format"` + AddSource bool `yaml:"addSource"` + EnableDBSink bool `yaml:"enableDBSink"` } // Init 初始化全局 slog logger,并设置为默认 logger。 +// 当 EnableDBSink 为 true 时,创建复合 handler(stdout + dbSink), +// dbSink 的 DB 引用在 AttachDB 调用前为 nil,不会写库。 func Init(cfg Config) *slog.Logger { level := parseLevel(cfg.Level) opts := &slog.HandlerOptions{ Level: level, - AddSource: cfg.AddSource, + AddSource: true, // 强制开启,以便 DB sink 记录 source } - var handler slog.Handler + var stdoutHandler slog.Handler if strings.EqualFold(cfg.Format, "json") { - handler = slog.NewJSONHandler(os.Stdout, opts) + stdoutHandler = slog.NewJSONHandler(os.Stdout, opts) } else { - handler = slog.NewTextHandler(os.Stdout, opts) + stdoutHandler = slog.NewTextHandler(os.Stdout, opts) + } + + var handler slog.Handler = stdoutHandler + + if cfg.EnableDBSink { + sink = &dbSink{ + inner: stdoutHandler, + ch: make(chan models.SystemLog, dbSinkChannelCapacity), + stop: make(chan struct{}), + done: make(chan struct{}), + } + handler = sink } logger := slog.New(handler) diff --git a/internal/pkg/openidentity/openidentity.go b/internal/pkg/openidentity/openidentity.go index cb6b3653..3444af46 100644 --- a/internal/pkg/openidentity/openidentity.go +++ b/internal/pkg/openidentity/openidentity.go @@ -1,6 +1,7 @@ package openidentity import ( + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "errors" @@ -22,19 +23,32 @@ type ExternalUser struct { } type UserTokenClaims struct { - UserID string `json:"userId"` - Name string `json:"name"` + TokenType string `json:"typ,omitempty"` + UserID string `json:"userId"` + Name string `json:"name"` jwt.RegisteredClaims } -func GetExternalUser(ctx *gin.Context, secret string) (*ExternalUser, error) { +const SupportUserTokenType = "support_user" + +func GetExternalUser(ctx *gin.Context, externalUserSecret string) (*ExternalUser, error) { if userToken := getUserToken(ctx); strs.IsNotBlank(userToken) { - claims, err := verifyUserToken(userToken, secret) + supportUserSecret := config.Current().CustomerSession.Secret + if strs.IsNotBlank(supportUserSecret) { + if claims, err := verifySupportUserToken(userToken, supportUserSecret); err == nil { + return &ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: claims.UserID, + ExternalName: claims.Name, + }, nil + } + } + claims, err := verifyUserToken(userToken, externalUserSecret) if err != nil { return nil, err } return &ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: claims.UserID, ExternalName: claims.Name, }, nil @@ -84,6 +98,17 @@ func verifyUserToken(userToken, secret string) (*UserTokenClaims, error) { return claims, nil } +func verifySupportUserToken(userToken, secret string) (*UserTokenClaims, error) { + claims, err := verifyUserToken(userToken, secret) + if err != nil { + return nil, err + } + if claims.TokenType != SupportUserTokenType { + return nil, errorsx.UnauthorizedI18n("error.e0265") + } + return claims, nil +} + func getUserToken(ctx *gin.Context) string { auth := strings.TrimSpace(ctx.GetHeader("Authorization")) if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") { diff --git a/internal/pkg/openidentity/openidentity_test.go b/internal/pkg/openidentity/openidentity_test.go deleted file mode 100644 index d6e74172..00000000 --- a/internal/pkg/openidentity/openidentity_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package openidentity - -import ( - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -func TestVerifyUserTokenOK(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(time.Hour).Unix(), - }, "secret") - - claims, err := verifyUserToken(token, "secret") - if err != nil { - t.Fatalf("expected token to verify: %v", err) - } - if claims.UserID != "u_10001" || claims.Name != "张三" { - t.Fatalf("unexpected claims: %#v", claims) - } -} - -func TestVerifyUserTokenUsesJWTHeaderAlgorithm(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS384, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(time.Hour).Unix(), - }, "secret") - - claims, err := verifyUserToken(token, "secret") - if err != nil { - t.Fatalf("expected HS384 token to verify from JWT header: %v", err) - } - if claims.UserID != "u_10001" || claims.Name != "张三" { - t.Fatalf("unexpected claims: %#v", claims) - } -} - -func TestVerifyUserTokenRejectsInvalidSignature(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(time.Hour).Unix(), - }, "secret") - - if _, err := verifyUserToken(token, "other-secret"); err == nil { - t.Fatalf("expected invalid signature to fail") - } -} - -func TestVerifyUserTokenRejectsExpiredToken(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(-time.Minute).Unix(), - }, "secret") - - if _, err := verifyUserToken(token, "secret"); err == nil { - t.Fatalf("expected expired token to fail") - } -} - -func TestVerifyUserTokenRequiresUserIDAndName(t *testing.T) { - tests := []map[string]any{ - {"name": "张三", "exp": time.Now().Add(time.Hour).Unix()}, - {"userId": "u_10001", "exp": time.Now().Add(time.Hour).Unix()}, - } - for _, payload := range tests { - token := signTestUserToken(t, jwt.SigningMethodHS256, payload, "secret") - if _, err := verifyUserToken(token, "secret"); err == nil { - t.Fatalf("expected payload %#v to fail", payload) - } - } -} - -func signTestUserToken(t *testing.T, method jwt.SigningMethod, payload map[string]any, secret string) string { - t.Helper() - token, err := jwt.NewWithClaims(method, jwt.MapClaims(payload)).SignedString([]byte(secret)) - if err != nil { - t.Fatal(err) - } - return token -} diff --git a/internal/pkg/utils/content_summary.go b/internal/pkg/utils/content_summary.go new file mode 100644 index 00000000..42fff062 --- /dev/null +++ b/internal/pkg/utils/content_summary.go @@ -0,0 +1,76 @@ +package utils + +import ( + "strings" + + "github.com/gomarkdown/markdown" + "golang.org/x/net/html" +) + +// BuildContentSummary converts Markdown or HTML to plain text before truncating +// it by runes. This follows the bbs-go summary flow while keeping the helper +// independent from any feature-specific package. +func BuildContentSummary(contentType, content string, maxRunes int) string { + content = strings.TrimSpace(content) + if content == "" || maxRunes <= 0 { + return "" + } + if strings.EqualFold(contentType, "markdown") { + content = string(markdown.ToHTML([]byte(content), nil, nil)) + } + if strings.EqualFold(contentType, "markdown") || strings.EqualFold(contentType, "html") { + content = ExtractHTMLPlainText(content) + } else { + content = normalizeSummaryWhitespace(content) + } + + runes := []rune(content) + if len(runes) <= maxRunes { + return content + } + return string(runes[:maxRunes]) + "..." +} + +func ExtractHTMLPlainText(content string) string { + doc, err := html.Parse(strings.NewReader("
" + content + "
")) + if err != nil { + return normalizeSummaryWhitespace(content) + } + var builder strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node == nil { + return + } + if node.Type == html.ElementNode && (node.Data == "script" || node.Data == "style") { + return + } + if node.Type == html.ElementNode && isSummaryBlockElement(node.Data) { + builder.WriteByte(' ') + } + if node.Type == html.TextNode { + builder.WriteString(node.Data) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + if node.Type == html.ElementNode && isSummaryBlockElement(node.Data) { + builder.WriteByte(' ') + } + } + walk(doc) + return normalizeSummaryWhitespace(builder.String()) +} + +func isSummaryBlockElement(tag string) bool { + switch tag { + case "p", "div", "br", "li", "ul", "ol", "blockquote", "pre", "table", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6": + return true + default: + return false + } +} + +func normalizeSummaryWhitespace(content string) string { + return strings.Join(strings.Fields(strings.TrimSpace(content)), " ") +} diff --git a/internal/pkg/utils/content_summary_test.go b/internal/pkg/utils/content_summary_test.go new file mode 100644 index 00000000..11ba1721 --- /dev/null +++ b/internal/pkg/utils/content_summary_test.go @@ -0,0 +1,26 @@ +package utils + +import "testing" + +func TestBuildContentSummary(t *testing.T) { + tests := []struct { + name string + contentType string + content string + maxRunes int + want string + }{ + {name: "markdown", contentType: "markdown", content: "# 标题\n\n**你好**,世界", maxRunes: 128, want: "标题 你好,世界"}, + {name: "html", contentType: "html", content: "

Hello world

", maxRunes: 128, want: "Hello world"}, + {name: "rune truncation", contentType: "markdown", content: "你好世界", maxRunes: 3, want: "你好世..."}, + {name: "plain text", contentType: "text", content: " first\n second ", maxRunes: 128, want: "first second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := BuildContentSummary(tt.contentType, tt.content, tt.maxRunes); got != tt.want { + t.Fatalf("BuildContentSummary() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/repositories/support_repository.go b/internal/repositories/support_repository.go index f717d5e9..482567cf 100644 --- a/internal/repositories/support_repository.go +++ b/internal/repositories/support_repository.go @@ -87,6 +87,15 @@ func (r *supportCategoryRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []mod return } +func (r *supportCategoryRepository) FindByIDs(db *gorm.DB, ids []int64) []models.Category { + if len(ids) == 0 { + return []models.Category{} + } + var list []models.Category + db.Where("id IN ?", ids).Find(&list) + return list +} + func (r *supportCategoryRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.Category, paging *sqls.Paging) { cnd.Find(db, &list) paging = &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: cnd.Count(db, &models.Category{})} diff --git a/internal/repositories/system_log_repository.go b/internal/repositories/system_log_repository.go new file mode 100644 index 00000000..a42a0854 --- /dev/null +++ b/internal/repositories/system_log_repository.go @@ -0,0 +1,47 @@ +package repositories + +import ( + "time" + + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var SystemLogRepository = newSystemLogRepository() + +func newSystemLogRepository() *systemLogRepository { + return &systemLogRepository{} +} + +type systemLogRepository struct { +} + +func (r *systemLogRepository) Get(db *gorm.DB, id int64) *models.SystemLog { + ret := &models.SystemLog{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *systemLogRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.SystemLog, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.SystemLog{}) + + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *systemLogRepository) DeleteOlderThan(db *gorm.DB, before time.Time) (count int64) { + res := db.Where("created_at < ?", before).Delete(&models.SystemLog{}) + if res.Error == nil { + count = res.RowsAffected + } + return +} diff --git a/internal/repositories/user_repository.go b/internal/repositories/user_repository.go index ebbcb3c1..521d7ffe 100644 --- a/internal/repositories/user_repository.go +++ b/internal/repositories/user_repository.go @@ -111,6 +111,17 @@ func (r *userRepository) FindByIds(db *gorm.DB, ids []int64) []models.User { return list } +// FindSimpleInfoByIDs selects exactly the fields required by public community +// user cards, instead of loading account contact or authentication fields. +func (r *userRepository) FindSimpleInfoByIDs(db *gorm.DB, ids []int64) []models.User { + if len(ids) == 0 { + return []models.User{} + } + var list []models.User + db.Select("id", "username", "nickname", "avatar", "user_type").Where("id IN ?", ids).Find(&list) + return list +} + func (r *userRepository) GetByUsername(db *gorm.DB, username string) *models.User { username = strings.TrimSpace(username) if username == "" { diff --git a/internal/services/ai_reply_placeholder_service.go b/internal/services/ai_reply_placeholder_service.go new file mode 100644 index 00000000..c4e8d613 --- /dev/null +++ b/internal/services/ai_reply_placeholder_service.go @@ -0,0 +1,205 @@ +package services + +import ( + "log/slog" + "strconv" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +// AI 回复占位消息使用确定性 clientMsgID 前缀,保证同一触发消息的占位与超时提示幂等; +// aiReplyPendingPayload 为占位消息的 payload 标记,原地替换前用于守卫,避免覆盖已生成的正式回复。 +const ( + aiReplyPlaceholderClientMsgIDPrefix = "ai_placeholder_" + aiReplyTimeoutNoticeClientMsgIDPrefix = "ai_timeout_" + aiReplyPendingPayload = `{"aiReplyPending":true}` +) + +// aiReplyPrincipal 构建 AI 消息的操作者信息。 +func aiReplyPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal { + username := "AI" + if strings.TrimSpace(aiAgent.Name) != "" { + username = strings.TrimSpace(aiAgent.Name) + } + return &dto.AuthPrincipal{ + UserID: 0, + Username: username, + Nickname: username, + } +} + +// aiReplyPlaceholderClientMsgID 返回触发消息对应的占位消息 clientMsgID。 +func aiReplyPlaceholderClientMsgID(triggerMessageID int64) string { + return aiReplyPlaceholderClientMsgIDPrefix + strconv.FormatInt(triggerMessageID, 10) +} + +// SendAIReplyPlaceholder 在正式 AI 回复生成前,向客户发送一条占位提示消息。 +// 通过确定性 clientMsgID 保证幂等;发送失败仅记录日志,不阻断 AI 回复流程。 +func (s *messageService) SendAIReplyPlaceholder(conversation *models.Conversation, aiAgent *models.AIAgent, triggerMessage *models.Message, channel *models.Channel, requestID string) { + if conversation == nil || aiAgent == nil || triggerMessage == nil { + return + } + content := models.DefaultAIReplyPlaceholder + if channel != nil { + content = channel.EffectiveAIReplyPlaceholder() + } + if _, err := s.SendAIMessageWithRequestIDAndWorkflowRunID( + conversation.ID, + aiAgent.ID, + aiReplyPlaceholderClientMsgID(triggerMessage.ID), + enums.IMMessageTypeText, + content, + aiReplyPendingPayload, + aiReplyPrincipal(*aiAgent), + requestID, + 0, + ); err != nil { + slog.Error("send ai reply placeholder failed", + "conversation_id", conversation.ID, + "message_id", triggerMessage.ID, + "error", err, + ) + } +} + +// TryReplaceAIReplyPlaceholder 尝试将 web 渠道仍处于待回复状态的占位消息原地替换为正式回复。 +// 仅 web 渠道支持原地替换;占位消息不存在或已被替换时返回 nil,由调用方回退为新消息。 +func (s *messageService) TryReplaceAIReplyPlaceholder(conversation *models.Conversation, triggerMessageID int64, replyText string, workflowRunID int64, aiAgent models.AIAgent) *models.Message { + if conversation == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeWeb { + return nil + } + placeholder := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, aiReplyPlaceholderClientMsgID(triggerMessageID)) + if placeholder == nil || strings.TrimSpace(placeholder.Payload) != aiReplyPendingPayload { + return nil + } + content, _, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, replyText, "") + if err != nil { + slog.Error("normalize ai reply content failed", + "conversation_id", conversation.ID, + "message_id", placeholder.ID, + "error", err, + ) + return nil + } + return s.replaceAIReplyPlaceholder(conversation, placeholder, content, summary, workflowRunID, aiAgent) +} + +// CompleteAIReplyPlaceholderAsFailed 在 AI 处理超时或失败时向客户发送失败提示: +// web 渠道且占位消息仍待回复时原地替换为提示语;否则以新消息补发(确定性 clientMsgID 保证幂等)。 +// 已转人工或已关闭的会话不再补发提示。 +func (s *messageService) CompleteAIReplyPlaceholderAsFailed(conversation *models.Conversation, aiAgent *models.AIAgent, triggerMessage *models.Message, requestID string) { + if conversation == nil || aiAgent == nil || triggerMessage == nil { + return + } + if conversation.Status == enums.IMConversationStatusClosed { + return + } + if conversation.CurrentAssigneeID > 0 || conversation.HandoffAt != nil { + return + } + notice := models.DefaultAIReplyTimeoutNotice + if channel := ChannelService.Get(conversation.ChannelID); channel != nil { + notice = channel.EffectiveAIReplyTimeoutNotice() + } + content, _, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, notice, "") + if err != nil { + slog.Error("normalize ai timeout notice failed", + "conversation_id", conversation.ID, + "message_id", triggerMessage.ID, + "error", err, + ) + return + } + + placeholder := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, aiReplyPlaceholderClientMsgID(triggerMessage.ID)) + if placeholder != nil { + if strings.TrimSpace(placeholder.Payload) != aiReplyPendingPayload { + // 正式回复已生成并替换占位消息,无需再提示失败 + return + } + if channel := ChannelService.Get(conversation.ChannelID); channel != nil && channel.ChannelType == enums.ChannelTypeWeb { + s.replaceAIReplyPlaceholder(conversation, placeholder, content, summary, 0, *aiAgent) + return + } + } + + // 外部渠道无法编辑已发送消息,或占位消息缺失时,以新消息补发失败提示 + if _, err := s.SendAIMessageWithRequestIDAndWorkflowRunID( + conversation.ID, + aiAgent.ID, + aiReplyTimeoutNoticeClientMsgIDPrefix+strconv.FormatInt(triggerMessage.ID, 10), + enums.IMMessageTypeText, + content, + "", + aiReplyPrincipal(*aiAgent), + requestID, + 0, + ); err != nil { + slog.Error("send ai timeout notice failed", + "conversation_id", conversation.ID, + "message_id", triggerMessage.ID, + "error", err, + ) + } +} + +// replaceAIReplyPlaceholder 将占位消息原地更新为指定内容,并同步会话摘要与实时事件。 +func (s *messageService) replaceAIReplyPlaceholder(conversation *models.Conversation, placeholder *models.Message, content string, summary string, workflowRunID int64, aiAgent models.AIAgent) *models.Message { + now := time.Now() + agentName := strings.TrimSpace(aiAgent.Name) + // 占位消息内容与会话摘要的更新保持原子性 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.MessageRepository.Updates(ctx.Tx, placeholder.ID, map[string]any{ + "content": content, + "payload": "", + "workflow_run_id": workflowRunID, + "update_user_id": 0, + "update_user_name": agentName, + "updated_at": now, + }); err != nil { + return err + } + + // 占位消息仍是会话最后一条消息时,同步列表摘要 + if conversation.LastMessageID == placeholder.ID { + summaryText := limitText(summary, 255) + conversation.LastMessageSummary = summaryText + conversation.UpdatedAt = now + return repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{ + "last_message_summary": summaryText, + "update_user_id": 0, + "update_user_name": agentName, + "updated_at": now, + }) + } + return nil + }); err != nil { + slog.Error("replace ai reply placeholder failed", + "conversation_id", conversation.ID, + "message_id", placeholder.ID, + "error", err, + ) + return nil + } + placeholder.Content = content + placeholder.Payload = "" + placeholder.WorkflowRunID = workflowRunID + placeholder.UpdatedAt = now + placeholder.UpdateUserID = 0 + placeholder.UpdateUserName = agentName + + WsService.PublishMessageUpdated(conversation, placeholder) + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated) + return placeholder +} diff --git a/internal/services/asset_service.go b/internal/services/asset_service.go index b7596beb..8746994a 100644 --- a/internal/services/asset_service.go +++ b/internal/services/asset_service.go @@ -136,6 +136,15 @@ func (s *assetService) Upload(reader io.Reader, info storage.UploadInfo) (*model func (s *assetService) GetSignedURL(id int64) (string, error) { item := s.Get(id) + return s.getSignedURL(item) +} + +func (s *assetService) GetSignedURLByAssetID(assetID string) (string, error) { + item := s.GetByAssetID(assetID) + return s.getSignedURL(item) +} + +func (s *assetService) getSignedURL(item *models.Asset) (string, error) { if item == nil { return "", errorsx.InvalidParamI18n("error.e0214") } diff --git a/internal/services/asset_service_test.go b/internal/services/asset_service_test.go new file mode 100644 index 00000000..43df5486 --- /dev/null +++ b/internal/services/asset_service_test.go @@ -0,0 +1,48 @@ +package services + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "testing" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestGetSignedURLByAssetID(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:asset_service_test?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.Asset{}); err != nil { + t.Fatalf("migrate asset: %v", err) + } + sqls.SetDB(db) + config.SetCurrent(&config.Config{Storage: config.StorageConfig{ + Default: enums.AssetProviderLocal, + Local: config.LocalStorageConfig{BaseURL: "/storage"}, + }}) + asset := &models.Asset{ + AssetID: "avatar_asset_1", + Provider: enums.AssetProviderLocal, + StorageKey: "avatars/avatar.png", + MimeType: "image/png", + Status: enums.AssetStatusSuccess, + } + if err := db.Create(asset).Error; err != nil { + t.Fatalf("create asset: %v", err) + } + + got, err := AssetService.GetSignedURLByAssetID(asset.AssetID) + if err != nil { + t.Fatalf("GetSignedURLByAssetID() error = %v", err) + } + if got != "/storage/avatars/avatar.png" { + t.Fatalf("GetSignedURLByAssetID() = %q", got) + } +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index 5765768c..645d4e0d 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -205,14 +205,15 @@ func (s *authService) CurrentProfile(ctx *gin.Context) (*response.LoginResponse, return &response.LoginResponse{ User: &response.AuthUserResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Avatar: user.Avatar, - Email: derefString(user.Email), - UserType: user.UserType, - Status: user.Status, - Roles: principal.Roles, + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.UserAvatarURL(), + AvatarAssetID: user.UserAvatarAssetID(), + Email: derefString(user.Email), + UserType: user.UserType, + Status: user.Status, + Roles: principal.Roles, }, Permissions: principal.Permissions, Roles: principal.Roles, @@ -308,14 +309,15 @@ func (s *authService) issueTokens(ctx *sqls.TxContext, user *models.User, client AccessToken: accessToken, ExpiresAt: now.Add(tokenTTL).Format(time.DateTime), User: &response.AuthUserResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Avatar: user.Avatar, - Email: derefString(user.Email), - UserType: user.UserType, - Status: user.Status, - Roles: roles, + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.UserAvatarURL(), + AvatarAssetID: user.UserAvatarAssetID(), + Email: derefString(user.Email), + UserType: user.UserType, + Status: user.Status, + Roles: roles, }, Permissions: permissions, Roles: roles, diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 2660a8bc..8a088503 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -254,12 +254,19 @@ func (s *channelMessageOutboxService) ListPending(channelType string, limit int) if limit <= 0 { limit = 20 } + // 只取"现在就可以尝试发送"的记录,保证每批取出的记录都会被真正尝试、投递循环必然收敛: + // pending:无重试计划限制(新入队或人工重试后 next_retry_at 为空); + // failed:必须存在重试计划且已到期。达到最大重试次数(next_retry_at 为空)的记录 + // 停止自动重试,等待管理端人工处置;退避窗口内的记录不占用批次。 + now := time.Now() cnd := sqls.NewCnd(). Eq("channel_type", strings.TrimSpace(channelType)). - In("send_status", []string{ - string(enums.ChannelMessageOutboxStatusPending), - string(enums.ChannelMessageOutboxStatusFailed), - }). + Where( + "((send_status = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)) "+ + "OR (send_status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?))", + string(enums.ChannelMessageOutboxStatusPending), now, + string(enums.ChannelMessageOutboxStatusFailed), now, + ). Asc("id"). Limit(limit) return s.Find(cnd) diff --git a/internal/services/channel_message_outbox_service_test.go b/internal/services/channel_message_outbox_service_test.go new file mode 100644 index 00000000..914f583d --- /dev/null +++ b/internal/services/channel_message_outbox_service_test.go @@ -0,0 +1,201 @@ +package services + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/wxwork" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupChannelMessageOutboxTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.ChannelMessageOutbox{}, &models.Message{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func createOutboxRecordForTest(t *testing.T, db *gorm.DB, item *models.ChannelMessageOutbox) { + t.Helper() + if err := repositories.ChannelMessageOutboxRepository.Create(db, item); err != nil { + t.Fatalf("create outbox error = %v", err) + } +} + +// TestChannelMessageOutboxListPendingEligibility 校验 ListPending 只取"现在可以尝试发送"的记录: +// failed 达到最大重试次数(next_retry_at 为空)后必须停止自动重试,退避窗口内的记录不能占用批次。 +func TestChannelMessageOutboxListPendingEligibility(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + + now := time.Now() + past := now.Add(-time.Minute) + future := now.Add(time.Hour) + + cases := []struct { + name string + channel string + status enums.ChannelMessageOutboxStatus + retryCount int + nextAt *time.Time + expected bool + }{ + {"pending 无重试时间", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusPending, 0, nil, true}, + {"failed 已到期", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusFailed, 1, &past, true}, + {"failed 退避窗口内", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusFailed, 1, &future, false}, + {"failed 达上限无重试时间", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusFailed, 6, nil, false}, + {"pending 未来重试时间", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusPending, 0, &future, false}, + {"sending 状态", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusSending, 0, nil, false}, + {"sent 状态", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusSent, 0, nil, false}, + {"其他渠道", enums.ChannelTypeTelegram, enums.ChannelMessageOutboxStatusPending, 0, nil, false}, + } + for i, tc := range cases { + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: tc.channel, + MessageID: int64(i + 1), + Payload: "{}", + SendStatus: string(tc.status), + RetryCount: tc.retryCount, + NextRetryAt: tc.nextAt, + }) + } + + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeWxWorkKF, 20) + included := make(map[int64]bool, len(items)) + for _, item := range items { + included[item.MessageID] = true + } + var mismatches []string + for i, tc := range cases { + messageID := int64(i + 1) + if included[messageID] != tc.expected { + mismatches = append(mismatches, + "case "+tc.name+": 期望 included="+boolText(tc.expected)+", 实际 included="+boolText(included[messageID])) + } + } + if len(mismatches) > 0 { + t.Fatalf("ListPending 资格判定不符合预期:\n%s", strings.Join(mismatches, "\n")) + } +} + +func boolText(v bool) string { + if v { + return "true" + } + return "false" +} + +// TestTelegramDispatchSkipsBackoffBatch 回归守卫:整批记录都处于退避窗口时投递必须零进度终止, +// 而不是把 skip 当作成功导致外层 drain 循环空转。 +func TestTelegramDispatchSkipsBackoffBatch(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + future := time.Now().Add(time.Hour) + for i := 1; i <= 3; i++ { + nextAt := future + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeTelegram, + MessageID: int64(i), + Payload: "{}", + SendStatus: string(enums.ChannelMessageOutboxStatusFailed), + RetryCount: 1, + NextRetryAt: &nextAt, + }) + } + + if got := TelegramOutboundService.doDispatchPendingOutbox(20); got != 0 { + t.Fatalf("全退避批次应零进度终止, got %d", got) + } +} + +// TestWxWorkExhaustedOutboxNotAutoRetried 回归守卫:达到最大重试次数的记录不参与自动投递, +// 状态与重试次数保持不变,等待管理端人工处置。 +func TestWxWorkExhaustedOutboxNotAutoRetried(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + enableWxWorkForTest(t) + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeWxWorkKF, + MessageID: 1, + Payload: "{}", + SendStatus: string(enums.ChannelMessageOutboxStatusFailed), + RetryCount: wxWorkKFOutboxMaxRetry, + }) + + if got := WxWorkKFOutboundService.doDispatchPendingOutbox(20); got != 0 { + t.Fatalf("达上限记录不应被自动重试, got %d", got) + } + item := repositories.ChannelMessageOutboxRepository.Get(db, 1) + if item == nil { + t.Fatal("outbox record not found") + } + if item.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) || item.RetryCount != wxWorkKFOutboxMaxRetry { + t.Fatalf("达上限记录应保持 failed 且重试次数不变, got status=%s retryCount=%d", item.SendStatus, item.RetryCount) + } +} + +// enableWxWorkForTest 在测试期间启用 wxwork(doDispatchPendingOutbox 有 Enabled 门),结束后复位。 +func enableWxWorkForTest(t *testing.T) { + t.Helper() + config.SetCurrent(&config.Config{ + WxWork: config.WxWorkConfig{ + Enabled: true, + CorpID: "corp-test", + CorpSecret: "secret-test", + }, + }) + wxwork.Init() + t.Cleanup(func() { + config.SetCurrent(&config.Config{}) + wxwork.Init() + }) +} + +// TestRetryWxWorkFailureRevivesExhaustedRecord 校验人工重试可以把达上限记录复活为 pending。 +func TestRetryWxWorkFailureRevivesExhaustedRecord(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeWxWorkKF, + MessageID: 1, + Payload: "{}", + SendStatus: string(enums.ChannelMessageOutboxStatusFailed), + RetryCount: wxWorkKFOutboxMaxRetry, + }) + + if err := ChannelMessageOutboxService.RetryWxWorkFailure(1, &dto.AuthPrincipal{}); err != nil { + t.Fatalf("retry error = %v", err) + } + + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeWxWorkKF, 20) + if len(items) != 1 { + t.Fatalf("人工重试后应重新可投递, got %d", len(items)) + } + if items[0].SendStatus != string(enums.ChannelMessageOutboxStatusPending) { + t.Fatalf("人工重试后状态应为 pending, got %s", items[0].SendStatus) + } +} diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 6a5399fc..452e9c53 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -465,6 +465,9 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if req.AIAgentRolloutPercent < 1 || req.AIAgentRolloutPercent > 100 { return nil, errorsx.InvalidParam("channel ai agent rollout percent must be between 1 and 100") } + if req.AIReplyTimeoutSeconds < 0 || req.AIReplyTimeoutSeconds > models.MaxAIReplyTimeoutSeconds { + return nil, errorsx.InvalidParamI18n("error.e0348") + } aiAgent := AIAgentService.Get(req.AIAgentID) if aiAgent == nil || aiAgent.Status != enums.StatusOk { return nil, errorsx.InvalidParamI18n("error.e0004") @@ -603,6 +606,9 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe ChannelID: channelID, AIAgentID: req.AIAgentID, AIAgentRolloutPercent: req.AIAgentRolloutPercent, + AIReplyPlaceholder: strings.TrimSpace(req.AIReplyPlaceholder), + AIReplyTimeoutSeconds: req.AIReplyTimeoutSeconds, + AIReplyTimeoutNotice: strings.TrimSpace(req.AIReplyTimeoutNotice), Name: name, ConfigJSON: configJSON, Status: status, diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go index 52ab00e2..d47d6cf3 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -4,12 +4,18 @@ import ( "agent-desk/internal/services" "fmt" "log/slog" + "time" "github.com/robfig/cron/v3" ) func Init() { - c := cron.New() + // Recover:任务 panic 不拖垮整个 cron; + // SkipIfStillRunning:上一轮未结束前跳过本轮触发,避免任务慢于间隔时 goroutine 无限堆积。 + c := cron.New(cron.WithChain( + cron.Recover(cron.DefaultLogger), + cron.SkipIfStillRunning(cron.DefaultLogger), + )) addFunc(c, "0 4 ? * *", func() { fmt.Println("cron test") @@ -36,6 +42,15 @@ func Init() { } }) + // 每天凌晨 3 点清理一个月以前的系统日志。 + addFunc(c, "0 3 * * *", func() { + before := time.Now().AddDate(0, -1, 0) + deleted := services.SystemLogService.DeleteOlderThan(before) + if deleted > 0 { + slog.Info("system log cleanup completed", "deleted", deleted, "before", before.Format(time.DateTime)) + } + }) + c.Start() } diff --git a/internal/services/customer_service.go b/internal/services/customer_service.go index 14d8fa4a..e4b838c5 100644 --- a/internal/services/customer_service.go +++ b/internal/services/customer_service.go @@ -4,6 +4,9 @@ import ( "crypto/md5" "encoding/hex" "log/slog" + "strconv" + "strings" + "time" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" @@ -13,8 +16,6 @@ import ( "agent-desk/internal/pkg/openidentity" "agent-desk/internal/pkg/utils" "agent-desk/internal/repositories" - "strings" - "time" "agent-desk/internal/pkg/httpx/params" @@ -124,11 +125,18 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs return 0, errorsx.UnauthorizedI18n("error.e0149") } now := time.Now() + localUserID, localUserEmail := supportUserProfileFromExternalUser(externalUser) if identity := repositories.CustomerIdentityRepository.GetBy(ctx.Tx, externalSource, externalID); identity != nil { updates := map[string]any{ "last_active_at": now, "updated_at": now, } + if localUserID > 0 { + updates["user_id"] = localUserID + } + if localUserEmail != "" { + updates["primary_email"] = localUserEmail + } if strs.IsNotBlank(externalUser.ExternalName) { updates["name"] = externalUser.ExternalName } @@ -151,8 +159,10 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs } customer := &models.Customer{ + UserID: localUserID, Name: buildExternalCustomerName(externalUser), LastActiveAt: &now, + PrimaryEmail: localUserEmail, Status: enums.StatusOk, AuditFields: utils.BuildAuditFields(nil), } @@ -171,6 +181,25 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs return customer.ID, nil } +func supportUserProfileFromExternalUser(externalUser openidentity.ExternalUser) (int64, string) { + if externalUser.ExternalSource != enums.ExternalSourceUser { + return 0, "" + } + userID, err := strconv.ParseInt(strings.TrimSpace(externalUser.ExternalID), 10, 64) + if err != nil || userID <= 0 { + return 0, "" + } + user := UserService.Get(userID) + if user == nil || user.Status != enums.StatusOk { + return 0, "" + } + email := "" + if user.Email != nil { + email = strings.TrimSpace(*user.Email) + } + return user.ID, email +} + func buildExternalCustomerName(externalUser openidentity.ExternalUser) string { if strs.IsNotBlank(externalUser.ExternalName) { return externalUser.ExternalName diff --git a/internal/services/customer_service_test.go b/internal/services/customer_service_test.go index b23349cb..6cf80b8e 100644 --- a/internal/services/customer_service_test.go +++ b/internal/services/customer_service_test.go @@ -1,6 +1,7 @@ package services_test import ( + "strconv" "testing" "time" @@ -21,7 +22,7 @@ func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) { var firstID int64 if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: "user-1", ExternalName: "张三", }) @@ -44,7 +45,7 @@ func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) { var secondID int64 if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: "user-1", ExternalName: "李四", }) @@ -74,6 +75,47 @@ func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) { } } +func TestEnsureExternalCustomerLinksSupportUserProfile(t *testing.T) { + db := setupCustomerServiceTestDB(t) + email := "support-user@example.com" + user := models.User{ + Username: "support-user", + Nickname: "支持中心用户", + Email: &email, + Status: enums.StatusOk, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + + var customerID int64 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: strconv.FormatInt(user.ID, 10), + ExternalName: "支持中心用户", + }) + customerID = id + return err + }); err != nil { + t.Fatalf("EnsureExternalCustomer() error = %v", err) + } + + customer := services.CustomerService.Get(customerID) + if customer == nil { + t.Fatal("customer not found") + } + if customer.UserID != user.ID { + t.Fatalf("customer.UserID = %d, want %d", customer.UserID, user.ID) + } + if customer.Name != "支持中心用户" { + t.Fatalf("customer.Name = %q", customer.Name) + } + if customer.PrimaryEmail != email { + t.Fatalf("customer.PrimaryEmail = %q, want %q", customer.PrimaryEmail, email) + } +} + func setupCustomerServiceTestDB(t *testing.T) *gorm.DB { t.Helper() @@ -92,7 +134,7 @@ func setupCustomerServiceTestDB(t *testing.T) *gorm.DB { _ = sqlDB.Close() } }) - if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Conversation{}); err != nil { + if err := db.AutoMigrate(&models.User{}, &models.Customer{}, &models.CustomerIdentity{}, &models.Conversation{}); err != nil { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) diff --git a/internal/services/customer_session_service.go b/internal/services/customer_session_service.go index 1bd4dc9a..9ed6657e 100644 --- a/internal/services/customer_session_service.go +++ b/internal/services/customer_session_service.go @@ -2,6 +2,7 @@ package services import ( "errors" + "strconv" "strings" "time" @@ -24,6 +25,7 @@ const ( customerSessionTokenType = "customer_session" customerSessionHeader = "X-Customer-Session-Token" customerSessionExpHeader = "X-Customer-Session-Expires-At" + supportUserTokenTTL = 10 * time.Minute ) var CustomerSessionService = newCustomerSessionService() @@ -86,6 +88,39 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser }, nil } +func (s *customerSessionService) SignSupportUserToken(channel *models.Channel, user *models.User) (*response.SupportAICustomerServiceUserTokenResponse, error) { + if channel == nil || channel.Status != enums.StatusOk || channel.ChannelType != enums.ChannelTypeWeb { + return nil, errorsx.InvalidParamI18n("error.e0209") + } + if user == nil || user.Status != enums.StatusOk { + return nil, errorsx.UnauthorizedI18n("error.e0256") + } + secret := strings.TrimSpace(config.Current().CustomerSession.Secret) + if strings.TrimSpace(secret) == "" { + return nil, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") + } + now := time.Now() + expiresAt := now.Add(supportUserTokenTTL) + name := strings.TrimSpace(user.Nickname) + if name == "" { + name = strings.TrimSpace(user.Username) + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "typ": openidentity.SupportUserTokenType, + "userId": strconv.FormatInt(user.ID, 10), + "name": name, + "iat": now.Unix(), + "exp": expiresAt.Unix(), + }).SignedString([]byte(secret)) + if err != nil { + return nil, err + } + return &response.SupportAICustomerServiceUserTokenResponse{ + UserToken: token, + ExpiresAt: expiresAt.Format(time.DateTime), + }, nil +} + func (s *customerSessionService) Sign(channel *models.Channel, customer *models.Customer, externalUser openidentity.ExternalUser) (string, time.Time, error) { cfg := config.Current().CustomerSession secret := strings.TrimSpace(cfg.Secret) @@ -206,6 +241,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC switch parts[0] { case "user": source = enums.ExternalSourceUser + case "external": + source = enums.ExternalSourceExternal case "guest": source = enums.ExternalSourceGuest default: @@ -235,6 +272,8 @@ func (s *customerSessionService) identityKey(externalUser openidentity.ExternalU switch externalUser.ExternalSource { case enums.ExternalSourceUser: return "user:" + strings.TrimSpace(externalUser.ExternalID) + case enums.ExternalSourceExternal: + return "external:" + strings.TrimSpace(externalUser.ExternalID) default: return "guest:" + strings.TrimSpace(externalUser.ExternalID) } diff --git a/internal/services/knowledge_document_service.go b/internal/services/knowledge_document_service.go index 0e700473..1db89870 100644 --- a/internal/services/knowledge_document_service.go +++ b/internal/services/knowledge_document_service.go @@ -237,6 +237,31 @@ func (s *knowledgeDocumentService) BatchMoveKnowledgeDocuments(req request.Batch return nil } +// BatchBuildKnowledgeDocuments 对所选文档逐个重建索引。 +// 单篇失败不中断整体(失败状态会由 IndexDocumentByID 落库为 failed), +// 与 BatchMoveKnowledgeDocuments 移动后重建索引的处理保持一致。 +func (s *knowledgeDocumentService) BatchBuildKnowledgeDocuments(req request.BatchBuildKnowledgeDocumentRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParamI18n("error.e0331") + } + for _, id := range ids { + current := s.Get(id) + if current == nil || current.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0218") + } + } + for _, id := range ids { + if err := rag.Index.IndexDocumentByID(context.Background(), id); err != nil { + slog.Error("failed to batch rebuild knowledge document index", "document_id", id, "error", err) + } + } + return nil +} + func (s *knowledgeDocumentService) BatchDeleteKnowledgeDocuments(req request.BatchDeleteKnowledgeDocumentRequest) error { ids := uniquePositiveIDs(req.IDs) if len(ids) == 0 { diff --git a/internal/services/message_service_test.go b/internal/services/message_service_test.go index 222e636e..897a5e42 100644 --- a/internal/services/message_service_test.go +++ b/internal/services/message_service_test.go @@ -100,7 +100,7 @@ func createWelcomeTestAIAgent(t *testing.T, db *gorm.DB, welcomeMessage string) func welcomeTestExternalUser(id string) openidentity.ExternalUser { return openidentity.ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: id, ExternalName: "访客" + id, } diff --git a/internal/services/support_service.go b/internal/services/support_service.go index 170495d9..5f691609 100644 --- a/internal/services/support_service.go +++ b/internal/services/support_service.go @@ -36,6 +36,60 @@ type CommentListResult struct { Paging *sqls.Paging } +// CommunityResponseData contains the shared, batch-loaded relations needed by +// community response builders. It keeps handlers and builders free of per-row +// repository lookups. +type CommunityResponseData struct { + Users map[int64]*models.User + Categories map[int64]*models.Category +} + +func mapKeys(values map[int64]struct{}) []int64 { + keys := make([]int64, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +func (s *supportService) LoadCommunityResponseData(posts []models.Post, comments []models.Comment, replies map[int64][]models.Comment) CommunityResponseData { + userIDs := make(map[int64]struct{}) + categoryIDs := make(map[int64]struct{}) + for _, post := range posts { + if post.UserID > 0 { + userIDs[post.UserID] = struct{}{} + } + if post.CategoryID > 0 { + categoryIDs[post.CategoryID] = struct{}{} + } + } + collectCommentUserIDs := func(items []models.Comment) { + for _, item := range items { + if item.AuthorID > 0 { + userIDs[item.AuthorID] = struct{}{} + } + } + } + collectCommentUserIDs(comments) + for _, items := range replies { + collectCommentUserIDs(items) + } + + users := repositories.UserRepository.FindSimpleInfoByIDs(sqls.DB(), mapKeys(userIDs)) + categories := repositories.CategoryRepository.FindByIDs(sqls.DB(), mapKeys(categoryIDs)) + data := CommunityResponseData{ + Users: make(map[int64]*models.User, len(users)), + Categories: make(map[int64]*models.Category, len(categories)), + } + for i := range users { + data.Users[users[i].ID] = &users[i] + } + for i := range categories { + data.Categories[categories[i].ID] = &categories[i] + } + return data +} + func (s *supportService) RegisterUser(req request.SupportCustomerRegisterRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) { name := strings.TrimSpace(req.Name) email := normalizeSupportEmail(req.Email) @@ -90,6 +144,16 @@ func (s *supportService) RequireSupportUser(ctx *gin.Context) (*dto.AuthPrincipa return principal, nil } +func (s *supportService) OptionalSupportUser(ctx *gin.Context) (*dto.AuthPrincipal, error) { + if principal := s.GetSupportUser(ctx); principal != nil { + return principal, nil + } + if ctx == nil || (strings.TrimSpace(ctx.GetHeader("Authorization")) == "" && strings.TrimSpace(ctx.Query("accessToken")) == "") { + return nil, nil + } + return s.RequireSupportUser(ctx) +} + func (s *supportService) GetSupportUser(ctx *gin.Context) *dto.AuthPrincipal { if ctx == nil { return nil @@ -642,6 +706,13 @@ func (s *supportService) ToggleReaction(targetType enums.ReactionTarget, targetI }) } +func (s *supportService) HasReaction(targetType enums.ReactionTarget, targetID int64, reactionType enums.ReactionType, principal *dto.AuthPrincipal) bool { + if principal == nil || principal.UserID <= 0 { + return false + } + return repositories.ReactionRepository.Get(sqls.DB(), string(targetType), targetID, principal.UserID, string(reactionType)) != nil +} + func (s *supportService) ModeratePost(req request.ModeratePostRequest) error { if repositories.PostRepository.Get(sqls.DB(), req.ID) == nil { return errorsx.InvalidParam("post not found") diff --git a/internal/services/support_service_test.go b/internal/services/support_service_test.go index 9b777baa..cc916d7f 100644 --- a/internal/services/support_service_test.go +++ b/internal/services/support_service_test.go @@ -274,6 +274,21 @@ func TestCommentDiscussionWorkflow(t *testing.T) { if err != nil { t.Fatalf("create post: %v", err) } + if SupportService.HasReaction(enums.ReactionTargetPost, post.ID, enums.ReactionTypeLike, commenter) { + t.Fatal("new post should not be liked by the commenter") + } + if err := SupportService.ToggleReaction(enums.ReactionTargetPost, post.ID, enums.ReactionTypeLike, commenter); err != nil { + t.Fatalf("like post: %v", err) + } + if !SupportService.HasReaction(enums.ReactionTargetPost, post.ID, enums.ReactionTypeLike, commenter) { + t.Fatal("post should be liked by the commenter") + } + if err := SupportService.ToggleReaction(enums.ReactionTargetPost, post.ID, enums.ReactionTypeLike, commenter); err != nil { + t.Fatalf("unlike post: %v", err) + } + if SupportService.HasReaction(enums.ReactionTargetPost, post.ID, enums.ReactionTypeLike, commenter) { + t.Fatal("post should not be liked after toggling again") + } comment, err := SupportService.CreateCustomerComment(request.CreateCommentRequest{ PostID: post.ID, Content: "top level comment", diff --git a/internal/services/system_config_service.go b/internal/services/system_config_service.go index 57f24050..47a0bfcd 100644 --- a/internal/services/system_config_service.go +++ b/internal/services/system_config_service.go @@ -29,8 +29,9 @@ type systemConfigService struct { } const ( - systemConfigGroupSupportCenter = "support" - systemConfigKeySupportNavMenu = "navigationMenu" + systemConfigGroupSupportCenter = "support" + systemConfigKeySupportNavMenu = "navigationMenu" + systemConfigKeySupportAICustomerService = "aiCustomerService" ) type configValidator interface { @@ -89,6 +90,14 @@ var systemConfigDefinitions = map[string]map[string]systemConfigDefinition{ DefaultValue: defaultSupportNavigationMenu(), Validator: supportNavigationMenuValidator{}, }, + systemConfigKeySupportAICustomerService: { + GroupCode: systemConfigGroupSupportCenter, + Key: systemConfigKeySupportAICustomerService, + TitleKey: "systemConfig.support.aiCustomerService.title", + DescriptionKey: "systemConfig.support.aiCustomerService.description", + DefaultValue: defaultSupportAICustomerServiceConfig(), + Validator: supportAICustomerServiceConfigValidator{}, + }, }, } @@ -118,14 +127,24 @@ func (s *systemConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.System func (s *systemConfigService) GetPublicSupportConfig() response.PublicSupportConfigResponse { return response.PublicSupportConfigResponse{ - NavigationMenu: s.enabledSupportNavigationMenu(), + NavigationMenu: s.enabledSupportNavigationMenu(), + AICustomerService: s.publicSupportAICustomerServiceConfig(), } } func (s *systemConfigService) GetDashboardSupportConfig() response.DashboardSupportConfigResponse { return response.DashboardSupportConfigResponse{ - NavigationMenu: s.supportNavigationMenu(), + NavigationMenu: s.supportNavigationMenu(), + AICustomerService: s.supportAICustomerServiceConfig(), + } +} + +func (s *systemConfigService) GetPublicSupportAICustomerServiceChannel() *models.Channel { + cfg := s.publicSupportAICustomerServiceConfig() + if !cfg.Enabled || strings.TrimSpace(cfg.ChannelID) == "" { + return nil } + return repositories.ChannelRepository.GetByChannelID(sqls.DB(), cfg.ChannelID) } func (s *systemConfigService) SaveSupportConfig(payload map[string]json.RawMessage, operator *dto.AuthPrincipal) (response.DashboardSupportConfigResponse, error) { @@ -247,6 +266,35 @@ func (s *systemConfigService) supportNavigationMenu() []response.SupportNavigati return sortSupportNavigationMenu(list) } +func (s *systemConfigService) publicSupportAICustomerServiceConfig() response.SupportAICustomerServiceConfigResponse { + cfg := s.supportAICustomerServiceConfig() + if !cfg.Enabled { + return response.SupportAICustomerServiceConfigResponse{} + } + channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), cfg.ChannelID) + if channel == nil || channel.Status != enums.StatusOk || channel.ChannelType != enums.ChannelTypeWeb { + return response.SupportAICustomerServiceConfigResponse{} + } + aiAgent := repositories.AIAgentRepository.Get(sqls.DB(), channel.AIAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk || aiAgent.PublishedRevisionID <= 0 { + return response.SupportAICustomerServiceConfigResponse{} + } + return cfg +} + +func (s *systemConfigService) supportAICustomerServiceConfig() response.SupportAICustomerServiceConfigResponse { + item := repositories.SystemConfigRepository.FindByGroupAndKey(sqls.DB(), systemConfigGroupSupportCenter, systemConfigKeySupportAICustomerService) + if item == nil || strings.TrimSpace(item.ConfigValue) == "" { + return defaultSupportAICustomerServiceConfig() + } + var cfg response.SupportAICustomerServiceConfigResponse + if err := json.Unmarshal([]byte(item.ConfigValue), &cfg); err != nil { + return defaultSupportAICustomerServiceConfig() + } + cfg.ChannelID = strings.TrimSpace(cfg.ChannelID) + return cfg +} + func sortSupportNavigationMenu(items []response.SupportNavigationMenuItemResponse) []response.SupportNavigationMenuItemResponse { ret := append([]response.SupportNavigationMenuItemResponse(nil), items...) for i := 0; i < len(ret)-1; i++ { diff --git a/internal/services/system_config_support_validator.go b/internal/services/system_config_support_validator.go index 79ebc65c..6c99ae0c 100644 --- a/internal/services/system_config_support_validator.go +++ b/internal/services/system_config_support_validator.go @@ -8,13 +8,18 @@ import ( "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/repositories" "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" ) type supportNavigationMenuValidator struct{} +type supportAICustomerServiceConfigValidator struct{} + func (supportNavigationMenuValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { var input []request.SupportNavigationMenuItemRequest if err := json.Unmarshal(raw, &input); err != nil { @@ -31,6 +36,53 @@ func (supportNavigationMenuValidator) Validate(raw json.RawMessage) (json.RawMes return normalized, nil, nil } +func (supportAICustomerServiceConfigValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { + var input request.SupportAICustomerServiceConfigRequest + if err := json.Unmarshal(raw, &input); err != nil { + return nil, []response.ConfigFieldError{configFieldError("aiCustomerService", "invalid_json", "error.supportConfig.aiCustomerServiceInvalidJSON")}, nil + } + cfg, fieldErrors := normalizeSupportAICustomerServiceConfig(input) + if len(fieldErrors) > 0 { + return nil, fieldErrors, nil + } + normalized, err := json.Marshal(cfg) + if err != nil { + return nil, nil, err + } + return normalized, nil, nil +} + +func normalizeSupportAICustomerServiceConfig(input request.SupportAICustomerServiceConfigRequest) (response.SupportAICustomerServiceConfigResponse, []response.ConfigFieldError) { + cfg := response.SupportAICustomerServiceConfigResponse{ + Enabled: input.Enabled, + ChannelID: strings.TrimSpace(input.ChannelID), + } + if !cfg.Enabled { + return cfg, nil + } + if cfg.ChannelID == "" { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "required", "error.supportConfig.aiCustomerServiceChannelRequired")} + } + channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), cfg.ChannelID) + if channel == nil || channel.Status == enums.StatusDeleted { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "not_found", "error.supportConfig.aiCustomerServiceChannelNotFound")} + } + if channel.ChannelType != enums.ChannelTypeWeb { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "type_invalid", "error.supportConfig.aiCustomerServiceChannelTypeInvalid")} + } + if channel.Status != enums.StatusOk { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "disabled", "error.supportConfig.aiCustomerServiceChannelDisabled")} + } + aiAgent := repositories.AIAgentRepository.Get(sqls.DB(), channel.AIAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "agent_disabled", "error.supportConfig.aiCustomerServiceAgentDisabled")} + } + if aiAgent.PublishedRevisionID <= 0 { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "agent_unpublished", "error.supportConfig.aiCustomerServiceAgentUnpublished")} + } + return cfg, nil +} + func normalizeSupportNavigationMenu(input []request.SupportNavigationMenuItemRequest) ([]response.SupportNavigationMenuItemResponse, []response.ConfigFieldError) { if len(input) == 0 { return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "required", "error.supportConfig.navigationRequired")} @@ -168,3 +220,7 @@ func defaultSupportNavigationMenu() []response.SupportNavigationMenuItemResponse {ID: "community", Title: i18nx.Get("systemConfig.support.navigationMenu.default.community"), URL: "/support/community/posts", SortNo: 30, Visible: true}, } } + +func defaultSupportAICustomerServiceConfig() response.SupportAICustomerServiceConfigResponse { + return response.SupportAICustomerServiceConfigResponse{} +} diff --git a/internal/services/system_config_support_validator_test.go b/internal/services/system_config_support_validator_test.go index b56df300..eb076d87 100644 --- a/internal/services/system_config_support_validator_test.go +++ b/internal/services/system_config_support_validator_test.go @@ -3,8 +3,16 @@ package services import ( "encoding/json" "testing" + "time" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/openidentity" + + "github.com/golang-jwt/jwt/v5" ) func TestSystemConfigValidationErrorLocalizesFieldErrors(t *testing.T) { @@ -28,3 +36,116 @@ func TestSystemConfigValidationErrorLocalizesFieldErrors(t *testing.T) { t.Fatalf("message key = %q", localized[0].MessageKey) } } + +func TestSupportAICustomerServiceConfigValidatesWebChannel(t *testing.T) { + db := setupChannelServiceTestDB(t) + if err := db.AutoMigrate(&models.SystemConfig{}); err != nil { + t.Fatalf("migrate system config: %v", err) + } + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "支持中心 AI 客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + + config, err := SystemConfigService.SaveSupportConfig(map[string]json.RawMessage{ + systemConfigKeySupportAICustomerService: json.RawMessage(`{"enabled":true,"channelId":"` + channel.ChannelID + `"}`), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("SaveSupportConfig() error = %v", err) + } + if !config.AICustomerService.Enabled || config.AICustomerService.ChannelID != channel.ChannelID { + t.Fatalf("unexpected dashboard config: %#v", config.AICustomerService) + } + publicConfig := SystemConfigService.GetPublicSupportConfig() + if !publicConfig.AICustomerService.Enabled || publicConfig.AICustomerService.ChannelID != channel.ChannelID { + t.Fatalf("unexpected public config: %#v", publicConfig.AICustomerService) + } +} + +func TestPublicSupportAICustomerServiceHidesDisabledChannel(t *testing.T) { + db := setupChannelServiceTestDB(t) + if err := db.AutoMigrate(&models.SystemConfig{}); err != nil { + t.Fatalf("migrate system config: %v", err) + } + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "支持中心 AI 客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := SystemConfigService.SaveSupportConfig(map[string]json.RawMessage{ + systemConfigKeySupportAICustomerService: json.RawMessage(`{"enabled":true,"channelId":"` + channel.ChannelID + `"}`), + }, channelServiceTestOperator()); err != nil { + t.Fatalf("SaveSupportConfig() error = %v", err) + } + if err := ChannelService.UpdateStatus(channel.ID, int(enums.StatusDisabled), channelServiceTestOperator()); err != nil { + t.Fatalf("disable channel: %v", err) + } + + publicConfig := SystemConfigService.GetPublicSupportConfig() + if publicConfig.AICustomerService.Enabled || publicConfig.AICustomerService.ChannelID != "" { + t.Fatalf("disabled channel should be hidden from public config: %#v", publicConfig.AICustomerService) + } +} + +func TestSupportAICustomerServiceConfigAllowsDisabledConfigWithStaleChannel(t *testing.T) { + _, fieldErrors, err := supportAICustomerServiceConfigValidator{}.Validate(json.RawMessage(`{"enabled":false,"channelId":"stale"}`)) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + if len(fieldErrors) != 0 { + t.Fatalf("disabled config should not validate stale channel: %#v", fieldErrors) + } +} + +func TestSignSupportUserTokenUsesInternalUserSource(t *testing.T) { + db := setupChannelServiceTestDB(t) + config.SetCurrent(&config.Config{ + CustomerSession: config.CustomerSessionConfig{Secret: "customer-session-secret"}, + }) + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "支持中心 AI 客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + user := &models.User{ID: 88, Username: "support-user", Nickname: "支持中心用户", Status: enums.StatusOk} + + result, err := CustomerSessionService.SignSupportUserToken(channel, user) + if err != nil { + t.Fatalf("SignSupportUserToken() error = %v", err) + } + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(result.UserToken, claims, func(token *jwt.Token) (any, error) { + return []byte(config.Current().CustomerSession.Secret), nil + }, jwt.WithExpirationRequired()) + if err != nil || token == nil || !token.Valid { + t.Fatalf("parse signed token: token=%#v err=%v", token, err) + } + if claims["typ"] != openidentity.SupportUserTokenType { + t.Fatalf("typ claim = %#v", claims["typ"]) + } + if claims["userId"] != "88" { + t.Fatalf("userId claim = %#v", claims["userId"]) + } + if claims["name"] != "支持中心用户" { + t.Fatalf("name claim = %#v", claims["name"]) + } + if expiresAt, err := time.Parse(time.DateTime, result.ExpiresAt); err != nil || time.Until(expiresAt) <= 0 { + t.Fatalf("invalid expiresAt %q: %v", result.ExpiresAt, err) + } +} diff --git a/internal/services/system_log_service.go b/internal/services/system_log_service.go new file mode 100644 index 00000000..aec8c0a6 --- /dev/null +++ b/internal/services/system_log_service.go @@ -0,0 +1,32 @@ +package services + +import ( + "time" + + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var SystemLogService = newSystemLogService() + +func newSystemLogService() *systemLogService { + return &systemLogService{} +} + +type systemLogService struct { +} + +func (s *systemLogService) Get(id int64) *models.SystemLog { + return repositories.SystemLogRepository.Get(sqls.DB(), id) +} + +func (s *systemLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SystemLog, paging *sqls.Paging) { + return repositories.SystemLogRepository.FindPageByCnd(sqls.DB(), cnd) +} + +// DeleteOlderThan 清理指定时间之前的日志,返回受影响行数。用于 cron 定时清理。 +func (s *systemLogService) DeleteOlderThan(before time.Time) int64 { + return repositories.SystemLogRepository.DeleteOlderThan(sqls.DB(), before) +} diff --git a/internal/services/ws_realtime_types.go b/internal/services/ws_realtime_types.go index 14813840..7d2cd371 100644 --- a/internal/services/ws_realtime_types.go +++ b/internal/services/ws_realtime_types.go @@ -164,6 +164,19 @@ func (e RealtimeMessageCreatedEvent) EventPayload() RealtimeEventPayload { return e.Payload } +// RealtimeMessageUpdatedEvent 通知客户端某条消息内容已被更新(如 AI 占位回复被正式回复替换)。 +type RealtimeMessageUpdatedEvent struct { + Payload RealtimeMessageCreatedPayload +} + +func (e RealtimeMessageUpdatedEvent) EventType() string { + return enums.IMRealtimeEventMessageUpdated +} + +func (e RealtimeMessageUpdatedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + type RealtimeMessageRecalledPayload struct { ConversationID int64 `json:"conversationId,omitempty"` MessageID int64 `json:"messageId,omitempty"` diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index 2f37acc2..7067b294 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -318,6 +318,32 @@ func (s *wsService) PublishMessageCreated(conversation *models.Conversation, mes s.PublishToTopics(s.routeConversationTopics(conversation), event) } +func (s *wsService) PublishMessageUpdated(conversation *models.Conversation, message *models.Message) { + if conversation == nil || message == nil { + return + } + content, payload := utils.BuildRenderableMessage(message) + + event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageUpdatedEvent{ + Payload: RealtimeMessageCreatedPayload{ + ConversationID: conversation.ID, + MessageID: message.ID, + RequestID: message.RequestID, + Message: s.buildRealtimeMessage(message), + Status: conversation.Status, + CurrentAssigneeID: conversation.CurrentAssigneeID, + SenderType: message.SenderType, + SenderID: message.SenderID, + MessageType: message.MessageType, + Content: content, + Payload: payload, + SendStatus: message.SendStatus, + SentAt: formatWsTime(message.SentAt), + }, + }) + s.PublishToTopics(s.routeConversationTopics(conversation), event) +} + func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageResponse { if item == nil { return response.MessageResponse{} @@ -364,7 +390,7 @@ func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, ite if displayName := strings.TrimSpace(profile.DisplayName); displayName != "" { ret.SenderName = displayName } - if avatar := strings.TrimSpace(profile.Avatar); avatar != "" { + if avatar := profile.AgentAvatar(); avatar != "" { ret.SenderAvatar = avatar } } diff --git a/internal/services/wxwork_kf_message_read_test_service.go b/internal/services/wxwork_kf_message_read_test_service.go new file mode 100644 index 00000000..993b63fa --- /dev/null +++ b/internal/services/wxwork_kf_message_read_test_service.go @@ -0,0 +1,332 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/utils" +) + +// 企业微信客服“测试读取消息”相关常量。 +// 说明:silenceper/wechat SDK 的 SyncMsg 在 errcode!=0 时只返回 errmsg 文本、丢失 errcode, +// 无法按错误码给出排查建议,因此该测试直接调用官方 API 并自行解析 errcode。 +const ( + wxWorkGetTokenURLTemplate = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s" + wxWorkSyncMsgURLTemplate = "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token=%s" + + wxWorkReadTestStageGetToken = "gettoken" + wxWorkReadTestStageSyncMsg = "syncmsg" + + // 本地错误码(非企业微信返回),前端按这些码做本地化排查建议。 + wxWorkReadTestErrInvalidChannel = "INVALID_CHANNEL" + wxWorkReadTestErrOpenKFMissing = "OPENKFID_MISSING" + wxWorkReadTestErrConfigJSON = "INVALID_CONFIG_JSON" + wxWorkReadTestErrDisabled = "WXWORK_DISABLED" + wxWorkReadTestErrNetwork = "NETWORK_ERROR" + wxWorkReadTestErrBadResponse = "BAD_RESPONSE" + + wxWorkReadTestSyncLimit uint64 = 1000 // 单次 sync_msg 拉取条数(官方最大值) + wxWorkReadTestMaxPages = 5 // 最多翻页次数,限制测试请求耗时 + wxWorkReadTestSampleSize = 20 // 返回最近样例条数 + wxWorkReadTestMaxPreviewRunes = 500 // 文本样例最大字符数 + wxWorkReadTestHTTPTimeout = 10 * time.Second +) + +var wxWorkReadTestHTTPClient = &http.Client{Timeout: wxWorkReadTestHTTPTimeout} + +type wxWorkReadTestTokenResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +type wxWorkReadTestSyncRequest struct { + Cursor string `json:"cursor"` + Limit uint64 `json:"limit"` + OpenKfID string `json:"open_kfid"` +} + +type wxWorkReadTestSyncResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + NextCursor string `json:"next_cursor"` + HasMore uint32 `json:"has_more"` + MsgList []map[string]any `json:"msg_list"` +} + +// TestWxWorkKFReadMessages 对指定企微客服渠道执行只读消息拉取测试: +// 使用空游标从企业微信拉取该客服账号最近保留期(3 天)内的消息与事件, +// 不消费消息、不创建会话、不更新同步游标。返回结构化结果供前端展示与错误码排查。 +func (s *channelService) TestWxWorkKFReadMessages(channelID int64) (*response.WxWorkKFMessageReadTestResult, error) { + channel := s.Get(channelID) + if channel == nil || channel.Status == enums.StatusDeleted || channel.ChannelType != enums.ChannelTypeWxWorkKF { + return s.failReadTestResult("", wxWorkReadTestErrInvalidChannel, ""), nil + } + + openKfID := "" + if cfg, err := s.ParseWxWorkKFChannelConfig(channel.ConfigJSON); err != nil { + return s.failReadTestResult("", wxWorkReadTestErrConfigJSON, err.Error()), nil + } else if cfg != nil { + openKfID = cfg.OpenKfID + } + if openKfID == "" { + return s.failReadTestResult("", wxWorkReadTestErrOpenKFMissing, ""), nil + } + + wxConfig := config.Current().WxWork + corpID := strings.TrimSpace(wxConfig.CorpID) + corpSecret := strings.TrimSpace(wxConfig.CorpSecret) + if !wxConfig.Enabled || corpID == "" || corpSecret == "" { + return s.failReadTestResult("", wxWorkReadTestErrDisabled, ""), nil + } + + // 1. 获取 access_token(独立调用,不接触 SDK 令牌缓存;人工测试频率低,可接受) + accessToken, result := s.fetchReadTestAccessToken(corpID, corpSecret) + if result != nil { + return result, nil + } + + // 2. 从空游标开始分页拉取(空游标返回保留期内最早的消息,按时间升序) + scanned := make([]map[string]any, 0) + cursor := "" + truncated := false + for page := 0; page < wxWorkReadTestMaxPages; page++ { + resp, err := s.callReadTestSyncMsg(accessToken, openKfID, cursor) + if err != nil { + slog.Warn("wxwork kf read test sync_msg failed", + "channel_id", channelID, + "open_kfid", openKfID, + "error", err, + ) + return s.failReadTestResult(wxWorkReadTestStageSyncMsg, wxWorkReadTestErrNetwork, err.Error()), nil + } + if resp.ErrCode != 0 { + slog.Warn("wxwork kf read test sync_msg rejected", + "channel_id", channelID, + "open_kfid", openKfID, + "errcode", resp.ErrCode, + "errmsg", resp.ErrMsg, + ) + return s.failReadTestResult(wxWorkReadTestStageSyncMsg, strconv.FormatInt(resp.ErrCode, 10), resp.ErrMsg), nil + } + + scanned = append(scanned, resp.MsgList...) + if resp.HasMore != 1 || strings.TrimSpace(resp.NextCursor) == "" { + truncated = false + break + } + cursor = resp.NextCursor + if page == wxWorkReadTestMaxPages-1 { + truncated = true + } + } + + result2 := s.buildReadTestResult(openKfID, scanned, truncated) + slog.Info("wxwork kf read test succeeded", + "channel_id", channelID, + "open_kfid", openKfID, + "total_scanned", result2.TotalScanned, + "message_count", result2.MessageCount, + "event_count", result2.EventCount, + "truncated", result2.Truncated, + ) + return result2, nil +} + +func (s *channelService) fetchReadTestAccessToken(corpID, corpSecret string) (string, *response.WxWorkKFMessageReadTestResult) { + requestURL := fmt.Sprintf(wxWorkGetTokenURLTemplate, url.QueryEscape(corpID), url.QueryEscape(corpSecret)) + ctx, cancel := context.WithTimeout(context.Background(), wxWorkReadTestHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrNetwork, err.Error()) + } + resp, err := wxWorkReadTestHTTPClient.Do(req) + if err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrNetwork, err.Error()) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrNetwork, err.Error()) + } + tokenResp := wxWorkReadTestTokenResponse{} + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrBadResponse, truncateReadTestText(string(body), 300)) + } + if tokenResp.ErrCode != 0 || strings.TrimSpace(tokenResp.AccessToken) == "" { + code := strconv.FormatInt(tokenResp.ErrCode, 10) + if tokenResp.ErrCode == 0 { + code = wxWorkReadTestErrBadResponse + } + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, code, tokenResp.ErrMsg) + } + return tokenResp.AccessToken, nil +} + +func (s *channelService) callReadTestSyncMsg(accessToken, openKfID, cursor string) (*wxWorkReadTestSyncResponse, error) { + payload, err := json.Marshal(wxWorkReadTestSyncRequest{ + Cursor: cursor, + Limit: wxWorkReadTestSyncLimit, + OpenKfID: openKfID, + }) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), wxWorkReadTestHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf(wxWorkSyncMsgURLTemplate, url.QueryEscape(accessToken)), + bytes.NewReader(payload), + ) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := wxWorkReadTestHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, err + } + syncResp := wxWorkReadTestSyncResponse{} + if err := json.Unmarshal(body, &syncResp); err != nil { + return nil, fmt.Errorf("invalid response: %s", truncateReadTestText(string(body), 300)) + } + return &syncResp, nil +} + +// buildReadTestResult 汇总扫描结果并抽取最近若干条样例(新消息在前)。 +func (s *channelService) buildReadTestResult(openKfID string, scanned []map[string]any, truncated bool) *response.WxWorkKFMessageReadTestResult { + result := &response.WxWorkKFMessageReadTestResult{ + Success: true, + OpenKfID: openKfID, + TotalScanned: len(scanned), + Samples: make([]response.WxWorkKFMessageReadSample, 0), + Truncated: truncated, + } + + var earliestUnix, latestUnix int64 + for _, raw := range scanned { + msgType := readTestAsString(raw["msgtype"]) + if msgType == "event" { + result.EventCount++ + } else { + result.MessageCount++ + } + sendTime := readTestAsInt64(raw["send_time"]) + if sendTime > 0 { + if earliestUnix == 0 || sendTime < earliestUnix { + earliestUnix = sendTime + } + if sendTime > latestUnix { + latestUnix = sendTime + } + } + } + if earliestUnix > 0 { + result.EarliestTime = utils.FormatTime(time.Unix(earliestUnix, 0)) + } + if latestUnix > 0 { + result.LatestTime = utils.FormatTime(time.Unix(latestUnix, 0)) + } + + // 接口按时间升序返回,取最后 N 条后反转为新消息在前 + start := len(scanned) - wxWorkReadTestSampleSize + if start < 0 { + start = 0 + } + for i := len(scanned) - 1; i >= start; i-- { + result.Samples = append(result.Samples, buildWxWorkReadSample(scanned[i])) + } + return result +} + +func buildWxWorkReadSample(raw map[string]any) response.WxWorkKFMessageReadSample { + sample := response.WxWorkKFMessageReadSample{ + MsgID: readTestAsString(raw["msgid"]), + Origin: int(readTestAsInt64(raw["origin"])), + MsgType: readTestAsString(raw["msgtype"]), + ExternalUserID: readTestAsString(raw["external_userid"]), + ServicerUserID: readTestAsString(raw["servicer_userid"]), + } + if sendTime := readTestAsInt64(raw["send_time"]); sendTime > 0 { + sample.SendTime = utils.FormatTime(time.Unix(sendTime, 0)) + } + if sample.MsgType == "text" { + if textMap, ok := raw["text"].(map[string]any); ok { + sample.TextContent = truncateReadTestText(strings.TrimSpace(readTestAsString(textMap["content"])), wxWorkReadTestMaxPreviewRunes) + } + } + if sample.MsgType == "event" { + if eventMap, ok := raw["event"].(map[string]any); ok { + sample.EventType = readTestAsString(eventMap["event_type"]) + // 事件消息的客户/客服账号在 event 对象内 + if sample.ExternalUserID == "" { + sample.ExternalUserID = readTestAsString(eventMap["external_userid"]) + } + if sample.ServicerUserID == "" { + sample.ServicerUserID = readTestAsString(eventMap["new_servicer_userid"]) + } + } + } + return sample +} + +func (s *channelService) failReadTestResult(stage, code, message string) *response.WxWorkKFMessageReadTestResult { + return &response.WxWorkKFMessageReadTestResult{ + Success: false, + Stage: stage, + ErrorCode: code, + ErrorMessage: truncateReadTestText(strings.TrimSpace(message), 1000), + Samples: make([]response.WxWorkKFMessageReadSample, 0), + } +} + +func readTestAsString(value any) string { + if value == nil { + return "" + } + if s, ok := value.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func readTestAsInt64(value any) int64 { + switch v := value.(type) { + case float64: + return int64(v) + case int64: + return v + case json.Number: + n, _ := v.Int64() + return n + default: + return 0 + } +} + +func truncateReadTestText(value string, maxRunes int) string { + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + return string(runes[:maxRunes]) + "…" +} diff --git a/web/app/(dashboard)/dashboard/agents/_components/edit.tsx b/web/app/(dashboard)/dashboard/agents/_components/edit.tsx index 7b64494b..3ef5868b 100644 --- a/web/app/(dashboard)/dashboard/agents/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/agents/_components/edit.tsx @@ -122,7 +122,7 @@ function buildForm(item: AdminAgentProfile | null): EditForm { teamId: String(item.teamId), agentCode: item.agentCode, displayName: item.displayName, - avatar: item.avatar || "", + avatar: item.avatarAssetId || item.avatar || "", serviceStatus: String(item.serviceStatus) as EditForm["serviceStatus"], maxConcurrentCount: String(item.maxConcurrentCount), priorityLevel: String(item.priorityLevel), @@ -208,6 +208,7 @@ function AgentEditDialogBody({ const [users, setUsers] = useState([]); const [userSelectOpen, setUserSelectOpen] = useState(false); const [loading, setLoading] = useState(false); + const [avatarPreview, setAvatarPreview] = useState(""); const userOptions = users.map((user) => ({ value: String(user.id), label: `${user.nickname || user.username} (${user.username})`, @@ -243,12 +244,14 @@ function AgentEditDialogBody({ useEffect(() => { async function loadDetail() { if (!itemId) { + setAvatarPreview(""); reset(buildFormWithDefaultTeam(null, defaultTeamId)); return; } setLoading(true); try { const data = await fetchAgentProfile(itemId); + setAvatarPreview(data.avatar || ""); reset(buildForm(data)); } catch (error) { toast.error(error instanceof Error ? error.message : t("agentProfile.loadDetailFailed")); @@ -405,6 +408,7 @@ function AgentEditDialogBody({ render={({ field }) => ( 0 ? String(item.aiAgentId) : "", aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100, + aiReplyPlaceholder: item.aiReplyPlaceholder || "", + aiReplyTimeoutSeconds: item.aiReplyTimeoutSeconds || 0, + aiReplyTimeoutNotice: item.aiReplyTimeoutNotice || "", name: item.name, openKfId: parseOpenKfId(item.configJson), botToken: telegramConfig?.botToken ?? "", @@ -359,6 +376,9 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin channelType, aiAgentId: Number(form.aiAgentId), aiAgentRolloutPercent: form.aiAgentRolloutPercent, + aiReplyPlaceholder: form.aiReplyPlaceholder.trim(), + aiReplyTimeoutSeconds: Number.isFinite(form.aiReplyTimeoutSeconds) ? form.aiReplyTimeoutSeconds : 0, + aiReplyTimeoutNotice: form.aiReplyTimeoutNotice.trim(), name: form.name.trim(), configJson, status, @@ -837,6 +857,7 @@ function ChannelFormBody({ )} /> + ) : null} @@ -967,6 +988,56 @@ function ChannelFormBody({ ) : null} +
+
+
{t("channel.aiReplySectionTitle")}
+
{t("channel.aiReplySectionDescription")}
+
+ +
+ + {t("channel.aiReplyPlaceholder")} + + + + + + + + {t("channel.aiReplyTimeoutSeconds")} + + +
{t("channel.aiReplyTimeoutSecondsHint")}
+ +
+
+
+ + + {t("channel.aiReplyTimeoutNotice")} + +