diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 333c37b..ad2e8cc 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -182,6 +182,9 @@ 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.Any("/wxwork/outbox/failed/list", dashboard.ChannelAnyWxworkOutboxFailedList) + group.POST("/wxwork/outbox/retry", dashboard.ChannelPostWxworkOutboxRetry) + group.POST("/wxwork/outbox/ignore", dashboard.ChannelPostWxworkOutboxIgnore) } func registerDashboardAgentRoutes(group *gin.RouterGroup) { diff --git a/internal/handlers/dashboard/channel_handler.go b/internal/handlers/dashboard/channel_handler.go index a259bb2..bbf79ed 100644 --- a/internal/handlers/dashboard/channel_handler.go +++ b/internal/handlers/dashboard/channel_handler.go @@ -6,8 +6,10 @@ import ( "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/httpx" "agent-desk/internal/services" + "strings" "agent-desk/internal/pkg/httpx/params" @@ -63,6 +65,74 @@ func ChannelAnyWxworkKfAccounts(ctx *gin.Context) { httpx.WriteJSON(ctx, list) } +func ChannelAnyWxworkOutboxFailedList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionWxWorkOutboxView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + cnd := params.NewPagedSqlCnd(ctx, + params.QueryFilter{ParamName: "conversationId"}, + params.QueryFilter{ParamName: "messageId"}, + ).Eq("channel_type", enums.ChannelTypeWxWorkKF) + status := strings.TrimSpace(params.FormValue(ctx, "sendStatus")) + switch status { + case "": + cnd.Eq("send_status", string(enums.ChannelMessageOutboxStatusFailed)) + case "all": + cnd.In("send_status", []string{ + string(enums.ChannelMessageOutboxStatusFailed), + string(enums.ChannelMessageOutboxStatusIgnored), + }) + case string(enums.ChannelMessageOutboxStatusFailed), string(enums.ChannelMessageOutboxStatusIgnored): + cnd.Eq("send_status", status) + default: + httpx.WriteJSON(ctx, errorsx.InvalidParam("invalid sendStatus")) + return + } + list, paging := services.ChannelMessageOutboxService.FindPageByCnd(cnd.Desc("id")) + results := make([]response.ChannelMessageOutboxResponse, 0, len(list)) + for _, item := range list { + results = append(results, response.BuildChannelMessageOutboxResponse(&item)) + } + httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) +} + +func ChannelPostWxworkOutboxRetry(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionWxWorkOutboxUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.ChannelMessageOutboxActionRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ChannelMessageOutboxService.RetryWxWorkFailure(req.ID, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func ChannelPostWxworkOutboxIgnore(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionWxWorkOutboxUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.ChannelMessageOutboxActionRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ChannelMessageOutboxService.IgnoreWxWorkFailure(req.ID, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + func ChannelPostCreate(ctx *gin.Context) { operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelCreate) if err != nil { diff --git a/internal/migration/000009_sync_wxwork_outbox_permissions.go b/internal/migration/000009_sync_wxwork_outbox_permissions.go new file mode 100644 index 0000000..8eadd90 --- /dev/null +++ b/internal/migration/000009_sync_wxwork_outbox_permissions.go @@ -0,0 +1,21 @@ +package migration + +import "github.com/mlogclub/simple/sqls" + +func init() { + register(9, "sync wxwork outbox permissions", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions, err := ensurePermissions(ctx.Tx) + if err != nil { + return err + } + + roles, err := ensureRoles(ctx.Tx) + if err != nil { + return err + } + + return ensureRolePermissions(ctx.Tx, roles, permissions) + }) + }) +} diff --git a/internal/pkg/constants/auth.go b/internal/pkg/constants/auth.go index 34f9402..2dc4b8c 100644 --- a/internal/pkg/constants/auth.go +++ b/internal/pkg/constants/auth.go @@ -103,6 +103,10 @@ var ( PermissionChannelUpdate = Permission{Name: "更新接入渠道", Code: "channel.update", Type: "api", GroupName: "channel", Method: "POST", APIPath: "/api/dashboard/channel/update", SortNo: 627} PermissionChannelDelete = Permission{Name: "删除接入渠道", Code: "channel.delete", Type: "api", GroupName: "channel", Method: "POST", APIPath: "/api/dashboard/channel/delete", SortNo: 628} + // 企微 Outbox 相关权限 + PermissionWxWorkOutboxView = Permission{Name: "查看企微 Outbox", Code: "wxworkOutbox.view", Type: "api", GroupName: "wxworkOutbox", Method: "ANY", APIPath: "/api/dashboard/channel/wxwork/outbox/failed/list", SortNo: 629} + PermissionWxWorkOutboxUpdate = Permission{Name: "处置企微 Outbox", Code: "wxworkOutbox.update", Type: "api", GroupName: "wxworkOutbox", Method: "POST", APIPath: "/api/dashboard/channel/wxwork/outbox/retry", SortNo: 630} + // 客户相关权限 PermissionCustomerView = Permission{Name: "查看客户", Code: "customer.view", Type: "api", GroupName: "customer", Method: "POST", APIPath: "/api/dashboard/customer/list", SortNo: 630} PermissionCustomerCreate = Permission{Name: "创建客户", Code: "customer.create", Type: "api", GroupName: "customer", Method: "POST", APIPath: "/api/dashboard/customer/create", SortNo: 640} @@ -223,6 +227,8 @@ var Permissions = []Permission{ PermissionChannelCreate, PermissionChannelUpdate, PermissionChannelDelete, + PermissionWxWorkOutboxView, + PermissionWxWorkOutboxUpdate, PermissionCustomerView, PermissionCustomerCreate, PermissionCustomerUpdate, @@ -355,6 +361,7 @@ var builtinPermissionResourceLabels = map[string]string{ "tag": "tags", "company": "companies", "channel": "channels", + "wxworkOutbox": "WeCom outbox records", "customer": "customers", "agent": "agents", "agentTeam": "agent teams", @@ -379,6 +386,7 @@ var builtinPermissionNameOverrides = map[string]string{ "ticket.progress": "Update ticket progress", "agent.config": "Configure agent service rules", "agentTeamSchedule.batchGenerate": "Batch generate agent team schedules", + "wxworkOutbox.update": "Handle WeCom outbox records", "mcp.view": "View MCP debug information", "mcp.call": "Call MCP tools", } @@ -409,7 +417,7 @@ var RolePermissions = map[string][]Permission{ PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete, PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete, PermissionCompanyView, PermissionCompanyCreate, PermissionCompanyUpdate, PermissionCompanyDelete, - PermissionChannelView, PermissionChannelCreate, PermissionChannelUpdate, PermissionChannelDelete, + PermissionChannelView, PermissionChannelCreate, PermissionChannelUpdate, PermissionChannelDelete, PermissionWxWorkOutboxView, PermissionWxWorkOutboxUpdate, PermissionCustomerView, PermissionCustomerCreate, PermissionCustomerUpdate, PermissionCustomerDelete, PermissionAgentView, PermissionAgentCreate, PermissionAgentUpdate, PermissionAgentDelete, PermissionAgentUpdateStatus, PermissionAgentConfig, PermissionAgentTeamView, PermissionAgentTeamCreate, PermissionAgentTeamUpdate, PermissionAgentTeamDelete, diff --git a/internal/pkg/constants/auth_test.go b/internal/pkg/constants/auth_test.go index 12aeda6..d31101b 100644 --- a/internal/pkg/constants/auth_test.go +++ b/internal/pkg/constants/auth_test.go @@ -36,6 +36,7 @@ func TestBuiltinAuthSeedNamesDefaultToEnglish(t *testing.T) { "ticket.create": "Create tickets", "conversation.send": "Send conversation messages", "channel.view": "View channels", + "wxworkOutbox.view": "View WeCom outbox records", "agent.view": "View agents", } for code, want := range permissionTests { diff --git a/internal/pkg/dto/request/channel_request.go b/internal/pkg/dto/request/channel_request.go index 859e7fa..a5095cb 100644 --- a/internal/pkg/dto/request/channel_request.go +++ b/internal/pkg/dto/request/channel_request.go @@ -31,3 +31,7 @@ type DeleteChannelRequest struct { type ResetChannelUserTokenSecretRequest struct { ID int64 `json:"id"` } + +type ChannelMessageOutboxActionRequest struct { + ID int64 `json:"id"` +} diff --git a/internal/pkg/dto/response/channel_response.go b/internal/pkg/dto/response/channel_response.go index 7d47009..e1c36f9 100644 --- a/internal/pkg/dto/response/channel_response.go +++ b/internal/pkg/dto/response/channel_response.go @@ -3,6 +3,7 @@ package response import ( "agent-desk/internal/models" "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/utils" ) type ChannelResponse struct { @@ -26,6 +27,23 @@ type WxWorkKFAccountResponse struct { ManagePrivilege bool `json:"managePrivilege"` } +type ChannelMessageOutboxResponse struct { + ID int64 `json:"id"` + ChannelType string `json:"channelType"` + ConversationID int64 `json:"conversationId"` + MessageID int64 `json:"messageId"` + Payload string `json:"payload"` + SendStatus string `json:"sendStatus"` + RetryCount int `json:"retryCount"` + NextRetryAt string `json:"nextRetryAt"` + LastError string `json:"lastError"` + SentAt string `json:"sentAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CreateUserName string `json:"createUserName"` + UpdateUserName string `json:"updateUserName"` +} + func BuildChannelResponse(item *models.Channel) ChannelResponse { if item == nil { return ChannelResponse{} @@ -43,3 +61,25 @@ func BuildChannelResponse(item *models.Channel) ChannelResponse { Remark: item.Remark, } } + +func BuildChannelMessageOutboxResponse(item *models.ChannelMessageOutbox) ChannelMessageOutboxResponse { + if item == nil { + return ChannelMessageOutboxResponse{} + } + return ChannelMessageOutboxResponse{ + ID: item.ID, + ChannelType: item.ChannelType, + ConversationID: item.ConversationID, + MessageID: item.MessageID, + Payload: item.Payload, + SendStatus: item.SendStatus, + RetryCount: item.RetryCount, + NextRetryAt: utils.FormatTimePtr(item.NextRetryAt), + LastError: item.LastError, + SentAt: utils.FormatTimePtr(item.SentAt), + CreatedAt: utils.FormatTime(item.CreatedAt), + UpdatedAt: utils.FormatTime(item.UpdatedAt), + CreateUserName: item.CreateUserName, + UpdateUserName: item.UpdateUserName, + } +} diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index 76c3550..61e766f 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -14,6 +14,7 @@ const ( ChannelMessageOutboxStatusSending ChannelMessageOutboxStatus = "sending" ChannelMessageOutboxStatusSent ChannelMessageOutboxStatus = "sent" ChannelMessageOutboxStatusFailed ChannelMessageOutboxStatus = "failed" + ChannelMessageOutboxStatusIgnored ChannelMessageOutboxStatus = "ignored" ) const ( diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index bdbd643..49c7fc3 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -2,7 +2,9 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" "encoding/json" "strings" @@ -137,3 +139,46 @@ func (s *channelMessageOutboxService) ListPending(channelType string, limit int) Limit(limit) return s.Find(cnd) } + +func (s *channelMessageOutboxService) RetryWxWorkFailure(id int64, operator *dto.AuthPrincipal) error { + item := s.Get(id) + if item == nil || item.ChannelType != enums.ChannelTypeWxWorkKF { + return errorsx.InvalidParam("outbox record does not exist") + } + if item.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) && + item.SendStatus != string(enums.ChannelMessageOutboxStatusIgnored) { + return errorsx.InvalidParam("only failed or ignored outbox records can be retried") + } + now := time.Now() + columns := map[string]interface{}{ + "send_status": string(enums.ChannelMessageOutboxStatusPending), + "next_retry_at": nil, + "updated_at": now, + } + if operator != nil { + columns["update_user_id"] = operator.UserID + columns["update_user_name"] = operator.Username + } + return s.Updates(id, columns) +} + +func (s *channelMessageOutboxService) IgnoreWxWorkFailure(id int64, operator *dto.AuthPrincipal) error { + item := s.Get(id) + if item == nil || item.ChannelType != enums.ChannelTypeWxWorkKF { + return errorsx.InvalidParam("outbox record does not exist") + } + if item.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) { + return errorsx.InvalidParam("only failed outbox records can be ignored") + } + now := time.Now() + columns := map[string]interface{}{ + "send_status": string(enums.ChannelMessageOutboxStatusIgnored), + "next_retry_at": nil, + "updated_at": now, + } + if operator != nil { + columns["update_user_id"] = operator.UserID + columns["update_user_name"] = operator.Username + } + return s.Updates(id, columns) +} diff --git a/web/app/dashboard/wxwork-outbox/page.tsx b/web/app/dashboard/wxwork-outbox/page.tsx new file mode 100644 index 0000000..d777c8e --- /dev/null +++ b/web/app/dashboard/wxwork-outbox/page.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useState } from "react" +import { BanIcon, RotateCcwIcon } from "lucide-react" +import { toast } from "sonner" + +import { + DashboardListPage, + type DashboardListRenderContext, +} from "@/components/dashboard/list" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + fetchWxWorkOutboxFailures, + ignoreWxWorkOutbox, + retryWxWorkOutbox, + type ChannelMessageOutbox, +} from "@/lib/api/admin" +import { formatDateTime } from "@/lib/utils" + +const STATUS_OPTIONS = [ + { value: "failed", label: "失败" }, + { value: "ignored", label: "已忽略" }, + { value: "all", label: "全部" }, +] as const + +function statusLabel(status: string) { + if (status === "failed") return "失败" + if (status === "ignored") return "已忽略" + return status || "-" +} + +function statusVariant(status: string) { + if (status === "failed") return "destructive" as const + if (status === "ignored") return "outline" as const + return "secondary" as const +} + +function formatOptionalTime(value: string) { + return value ? formatDateTime(value) : "-" +} + +function OutboxActions({ + item, + reload, +}: { + item: ChannelMessageOutbox + reload: DashboardListRenderContext["reload"] +}) { + const [runningAction, setRunningAction] = useState<"retry" | "ignore" | null>(null) + + async function runAction(action: "retry" | "ignore") { + setRunningAction(action) + try { + if (action === "retry") { + await retryWxWorkOutbox(item.id) + toast.success("已重新加入发送队列") + } else { + await ignoreWxWorkOutbox(item.id) + toast.success("已忽略该失败记录") + } + await reload() + } catch (error) { + toast.error(error instanceof Error ? error.message : "操作失败") + } finally { + setRunningAction(null) + } + } + + return ( +
+ + {item.sendStatus === "failed" ? ( + + ) : null} +
+ ) +} + +export default function DashboardWxWorkOutboxPage() { + return ( + + filters={[ + { + name: "sendStatus", + label: "状态", + defaultValue: "failed", + type: "segment", + options: STATUS_OPTIONS, + }, + { + name: "conversationId", + label: "会话 ID", + placeholder: "会话 ID", + defaultValue: "", + valueType: "number", + className: "w-full sm:w-40", + }, + { + name: "messageId", + label: "消息 ID", + placeholder: "消息 ID", + defaultValue: "", + valueType: "number", + className: "w-full sm:w-40", + }, + ]} + fetchList={fetchWxWorkOutboxFailures} + getItemId={(item) => item.id} + columns={[ + { + key: "id", + label: "Outbox", + className: "w-28 text-xs text-muted-foreground", + render: (item) => `#${item.id}`, + }, + { + key: "message", + label: "消息", + className: "w-48", + render: (item) => ( +
+
会话 #{item.conversationId || "-"}
+
消息 #{item.messageId || "-"}
+
+ ), + }, + { + key: "status", + label: "状态", + className: "w-28", + render: (item) => ( + + {statusLabel(item.sendStatus)} + + ), + }, + { + key: "retry", + label: "重试", + className: "w-44 text-xs", + render: (item) => ( +
+
{item.retryCount} 次
+
+ 下次 {formatOptionalTime(item.nextRetryAt)} +
+
+ ), + }, + { + key: "error", + label: "失败原因", + className: "min-w-72 max-w-[32rem]", + render: (item) => + item.lastError ? ( + + {item.lastError} + + ) : ( + "-" + ), + }, + { + key: "updatedAt", + label: "更新时间", + className: "w-44 text-xs text-muted-foreground", + render: (item) => formatOptionalTime(item.updatedAt), + }, + { + key: "actions", + label: 操作, + className: "w-44", + render: (item, context) => ( + + ), + }, + ]} + labels={{ + refresh: "刷新", + query: "查询", + loading: "正在加载企业微信 outbox...", + empty: "暂无失败 outbox", + loadFailed: "加载企业微信 outbox 失败", + }} + /> + ) +} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 9d1f5a0..41188d2 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -193,6 +193,23 @@ export type WxWorkKFAccount = { managePrivilege: boolean } +export type ChannelMessageOutbox = { + id: number + channelType: string + conversationId: number + messageId: number + payload: string + sendStatus: string + retryCount: number + nextRetryAt: string + lastError: string + sentAt: string + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string +} + export type CreateAdminChannelPayload = { channelType: string aiAgentId: number @@ -907,6 +924,28 @@ export function fetchWxWorkKFAccounts() { return request("/api/dashboard/channel/wxwork/kf/accounts") } +export function fetchWxWorkOutboxFailures( + query?: Record +) { + return request>( + `/api/dashboard/channel/wxwork/outbox/failed/list${toQueryString(query)}` + ) +} + +export function retryWxWorkOutbox(id: number) { + return request("/api/dashboard/channel/wxwork/outbox/retry", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function ignoreWxWorkOutbox(id: number) { + return request("/api/dashboard/channel/wxwork/outbox/ignore", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + export function createChannel(payload: CreateAdminChannelPayload) { return request("/api/dashboard/channel/create", { method: "POST", diff --git a/web/lib/navigation-active.test.mjs b/web/lib/navigation-active.test.mjs index 342ce08..7e2656e 100644 --- a/web/lib/navigation-active.test.mjs +++ b/web/lib/navigation-active.test.mjs @@ -38,6 +38,13 @@ describe("isDashboardNavItemActive", () => { isDashboardNavItemActive("/dashboard/tickets-extra", "/dashboard/tickets"), false ) + assert.equal( + isDashboardNavItemActive( + "/dashboard/wxwork-outbox", + "/dashboard/channels" + ), + false + ) }) it("only marks the dashboard home item active on the exact dashboard path", async () => { diff --git a/web/lib/navigation.tsx b/web/lib/navigation.tsx index 4130851..a4d129c 100644 --- a/web/lib/navigation.tsx +++ b/web/lib/navigation.tsx @@ -7,6 +7,7 @@ import { GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, + MessageSquareWarningIcon, MessageSquareCodeIcon, MessageSquareMoreIcon, ShieldCheckIcon, @@ -169,6 +170,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [ icon: , requiredPermission: "channel.view", }, + { + titleKey: "nav.wxworkOutbox", + url: "/dashboard/wxwork-outbox", + icon: , + requiredPermission: "wxworkOutbox.view", + }, ], }, { diff --git a/web/lib/permission-i18n.ts b/web/lib/permission-i18n.ts index d679fc3..8e7bec4 100644 --- a/web/lib/permission-i18n.ts +++ b/web/lib/permission-i18n.ts @@ -36,6 +36,7 @@ const PERMISSION_RESOURCE_LABELS: Record = { "channel.resetUserTokenSecret": "Reset channel user token secret", "agent.config": "Configure agent service rules", "agentTeamSchedule.batchGenerate": "Batch generate agent team schedules", + "wxworkOutbox.update": "Handle WeCom outbox records", "mcp.view": "View MCP debug information", "mcp.call": "Call MCP tools", } diff --git a/web/messages/en-US.json b/web/messages/en-US.json index b1f780e..5cb4ad6 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -2341,6 +2341,7 @@ "agents": "Human Agents", "agentTeamSchedules": "Team Schedules", "channels": "Channels", + "wxworkOutbox": "WeCom Outbox", "aiCapabilities": "AI Capabilities", "knowledge": "Knowledge Base", "aiConfigs": "Model Settings", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 64aec18..1cf7a75 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -2341,6 +2341,7 @@ "agents": "人工客服", "agentTeamSchedules": "客服组排班", "channels": "接入渠道", + "wxworkOutbox": "企微 Outbox", "aiCapabilities": "AI能力", "knowledge": "知识库", "aiConfigs": "AI模型",