From af53c48f7f717f95d77294dedc976dd58ff74156 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 30 Jun 2026 09:43:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=B0=8F=E7=BA=A2?= =?UTF-8?q?=E4=B9=A6=E6=A8=A1=E5=9D=97=E5=90=8E=E7=AB=AF=EF=BC=88Agent/?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1/=E5=8F=91=E5=B8=83/=E5=88=86=E6=9E=90/?= =?UTF-8?q?=E6=8A=80=E8=83=BD/AI=EF=BC=89=E5=8F=8A=20SQL=20=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E4=B8=8E=20RBAC=20GUID=20=E4=BF=AE=E5=A4=8D=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 181 ++++++ sql/diagnose_rbac_bad_guids.sql | 45 ++ sql/fix_rbac_bad_guids.sql | 86 +++ sql/fix_xhs_menu_bad_guids.sql | 56 ++ sql/install_xhs_module.sql | 219 +++++++ sql/migrate_t_xhs.sql | 181 ++++++ sql/seed_xhs_ai_providers.sql | 36 ++ sql/seed_xhs_rbac_menu.sql | 90 +++ sql/seed_xhs_role_permissions.sql | 49 ++ sql/seed_xhs_skills.sql | 47 ++ src/Oms.Application/Dtos/Xhs/XhsDtos.cs | 533 ++++++++++++++++++ src/Oms.Application/OmsApplicationModule.cs | 4 + .../Services/Xhs/XhsAgentService.cs | 207 +++++++ .../Services/Xhs/XhsAiService.cs | 137 +++++ .../Services/Xhs/XhsAnalysisService.cs | 276 +++++++++ .../Services/Xhs/XhsCreationService.cs | 130 +++++ .../Services/Xhs/XhsHumanConfigService.cs | 105 ++++ .../Services/Xhs/XhsMediaService.cs | 71 +++ .../Services/Xhs/XhsPublishService.cs | 163 ++++++ .../Services/Xhs/XhsSkillService.cs | 200 +++++++ .../Services/Xhs/XhsTaskService.cs | 274 +++++++++ src/Oms.Application/Shared/XhsHelper.cs | 82 +++ .../Shared/XhsHumanDefaults.cs | 47 ++ src/Oms.Application/Shared/XhsLlmClient.cs | 118 ++++ src/Oms.Application/Shared/XhsTaskFactory.cs | 36 ++ .../Workers/XhsScheduledPublishWorker.cs | 85 +++ .../XhsAggregate/Consts/XhsCodes.cs | 65 +++ .../XhsAggregate/Entities/XhsAgent.cs | 32 ++ .../XhsAggregate/Entities/XhsAiProvider.cs | 28 + .../Entities/XhsAnalysisSession.cs | 33 ++ .../XhsAggregate/Entities/XhsHumanConfig.cs | 18 + .../XhsAggregate/Entities/XhsMedia.cs | 22 + .../XhsAggregate/Entities/XhsPublishRecord.cs | 49 ++ .../XhsAggregate/Entities/XhsSkill.cs | 24 + .../XhsAggregate/Entities/XhsTask.cs | 51 ++ .../Repositories/IXhsRepositories.cs | 42 ++ src/Oms.SqlSugarCore/Xhs/XhsRepositories.cs | 96 ++++ .../Auth/XhsRolePermissionSeedContributor.cs | 63 +++ 38 files changed, 3981 insertions(+) create mode 100644 sql/diagnose_rbac_bad_guids.sql create mode 100644 sql/fix_rbac_bad_guids.sql create mode 100644 sql/fix_xhs_menu_bad_guids.sql create mode 100644 sql/install_xhs_module.sql create mode 100644 sql/migrate_t_xhs.sql create mode 100644 sql/seed_xhs_ai_providers.sql create mode 100644 sql/seed_xhs_rbac_menu.sql create mode 100644 sql/seed_xhs_role_permissions.sql create mode 100644 sql/seed_xhs_skills.sql create mode 100644 src/Oms.Application/Dtos/Xhs/XhsDtos.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsAgentService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsAiService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsAnalysisService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsCreationService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsHumanConfigService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsMediaService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsPublishService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsSkillService.cs create mode 100644 src/Oms.Application/Services/Xhs/XhsTaskService.cs create mode 100644 src/Oms.Application/Shared/XhsHelper.cs create mode 100644 src/Oms.Application/Shared/XhsHumanDefaults.cs create mode 100644 src/Oms.Application/Shared/XhsLlmClient.cs create mode 100644 src/Oms.Application/Shared/XhsTaskFactory.cs create mode 100644 src/Oms.Application/Workers/XhsScheduledPublishWorker.cs create mode 100644 src/Oms.Domain/XhsAggregate/Consts/XhsCodes.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsAgent.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsAiProvider.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsAnalysisSession.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsHumanConfig.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsMedia.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsPublishRecord.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsSkill.cs create mode 100644 src/Oms.Domain/XhsAggregate/Entities/XhsTask.cs create mode 100644 src/Oms.Domain/XhsAggregate/Repositories/IXhsRepositories.cs create mode 100644 src/Oms.SqlSugarCore/Xhs/XhsRepositories.cs create mode 100644 src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs diff --git a/db.sql b/db.sql index 6da1c1f..9f81c85 100644 --- a/db.sql +++ b/db.sql @@ -1028,4 +1028,185 @@ CREATE TABLE `t_warehouse_address` ( KEY `idx_wa_type` (`WarehouseType`) ) COMMENT='仓库地址表'; +-- 小红书(LIKE AI → OMS)模块,详见 sql/migrate_t_xhs.sql 与设计文档 docs/小红书模块设计-063001.md + +CREATE TABLE IF NOT EXISTS `t_xhs_agent` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID → t_customer.Id', + `DeviceId` VARCHAR(64) NOT NULL COMMENT 'Agent 本机设备标识', + `DeviceName` VARCHAR(128) NULL COMMENT '用户自定义名称', + `AgentVersion` VARCHAR(32) NULL COMMENT 'Agent 版本号', + `BridgePort` INT NOT NULL DEFAULT 9333 COMMENT 'Bridge WebSocket 端口', + `Status` VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT 'online/offline', + `EngineRunning` TINYINT NOT NULL DEFAULT 0 COMMENT 'Bridge 引擎是否运行', + `ExtensionConnected` TINYINT NOT NULL DEFAULT 0 COMMENT 'Chrome 扩展是否连上 Bridge', + `XhsLoggedIn` TINYINT NOT NULL DEFAULT 0 COMMENT '小红书是否已登录', + `LastHeartbeatTime` DATETIME NULL COMMENT '最后心跳时间', + `LastError` VARCHAR(512) NULL COMMENT '最近一次 Agent 上报错误摘要', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_agent_device` (`DeviceId`), + KEY `idx_xhs_agent_customer` (`CustomerId`, `Status`) +) COMMENT='小红书本地 Agent 设备表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_task` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `AgentId` INT NULL COMMENT '指定执行的 Agent;NULL 表示任意在线 Agent', + `TaskType` VARCHAR(32) NOT NULL COMMENT '任务类型', + `ToolName` VARCHAR(64) NULL COMMENT 'LIKE AI 工具名', + `Priority` INT NOT NULL DEFAULT 0 COMMENT '越大越优先', + `Status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/claimed/running/succeeded/failed/cancelled', + `ArgumentsJson` JSON NOT NULL COMMENT '工具参数 JSON', + `ProgressText` VARCHAR(512) NULL COMMENT '执行过程摘要', + `ResultJson` JSON NULL COMMENT '成功时结构化结果', + `ErrorMessage` VARCHAR(1024) NULL COMMENT '失败原因', + `Source` VARCHAR(32) NOT NULL DEFAULT 'web' COMMENT 'web/schedule/skill', + `SourceRefId` VARCHAR(64) NULL COMMENT '来源关联 ID', + `ScheduledTime` DATETIME NULL COMMENT '计划执行时间', + `ClaimedAt` DATETIME NULL COMMENT 'Agent 认领时间', + `StartedAt` DATETIME NULL COMMENT '开始执行', + `FinishedAt` DATETIME NULL COMMENT '结束时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_task_poll` (`CustomerId`, `Status`, `ScheduledTime`, `Priority`), + KEY `idx_xhs_task_agent` (`AgentId`, `Status`), + KEY `idx_xhs_task_source` (`Source`, `SourceRefId`) +) COMMENT='小红书 Agent 任务队列表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_publish_record` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `TaskId` INT NULL COMMENT '关联 t_xhs_task.Id', + `ToolName` VARCHAR(64) NOT NULL COMMENT 'publish_content / publish_with_video', + `Title` VARCHAR(256) NOT NULL COMMENT '笔记标题', + `ContentPreview` VARCHAR(512) NULL COMMENT '正文摘要', + `ContentFull` TEXT NULL COMMENT '完整正文', + `MediaJson` JSON NULL COMMENT '图片/视频 URL 列表', + `TagsJson` JSON NULL COMMENT '标签数组', + `Visibility` VARCHAR(32) NULL COMMENT '可见范围', + `Status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/scheduled/publishing/succeeded/failed/cancelled', + `Success` TINYINT NULL COMMENT '最终是否成功', + `ResultSummary` VARCHAR(1024) NULL COMMENT 'Agent 回传结果摘要', + `ScheduledTime` DATETIME NULL COMMENT '定时发布时间', + `PublishedTime` DATETIME NULL COMMENT '实际发布完成时间', + `SkillId` INT NULL COMMENT '若来自技能卡片', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_publish_customer_time` (`CustomerId`, `CreateTime`), + KEY `idx_xhs_publish_status_sched` (`CustomerId`, `Status`, `ScheduledTime`), + KEY `idx_xhs_publish_task` (`TaskId`) +) COMMENT='小红书发布记录表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_analysis_session` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `SessionName` VARCHAR(128) NULL COMMENT '用户备注名', + `AnalysisType` VARCHAR(32) NOT NULL COMMENT 'home_feed/search/my_feeds/feed_detail', + `Keyword` VARCHAR(128) NULL COMMENT '搜索关键词', + `TaskId` INT NULL COMMENT '采集任务 ID', + `FeedsJson` JSON NULL COMMENT '采集笔记列表', + `SelectedFeedId` VARCHAR(64) NULL COMMENT '当前选中笔记 ID', + `AiAnalysisMarkdown` MEDIUMTEXT NULL COMMENT 'AI 分析结果', + `SkillId` INT NULL COMMENT '技能配方 ID', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_analysis_customer` (`CustomerId`, `CreateTime`) +) COMMENT='小红书内容分析会话表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_skill` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示系统预置', + `ExternalId` VARCHAR(32) NOT NULL COMMENT '对外稳定 ID', + `Title` VARCHAR(256) NOT NULL COMMENT '技能名称', + `SkillType` VARCHAR(32) NOT NULL DEFAULT 'ai_create' COMMENT '技能类型', + `PresetId` VARCHAR(64) NULL COMMENT '内置预设标识', + `RecipeJson` JSON NOT NULL COMMENT '配方 JSON', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_skill_external` (`CustomerId`, `ExternalId`), + KEY `idx_xhs_skill_customer` (`CustomerId`, `SortOrder`) +) COMMENT='小红书技能卡片表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_human_config` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `BehaviorJson` JSON NOT NULL COMMENT 'human_behavior 配置', + `GuardJson` JSON NOT NULL COMMENT 'human_guard 配置', + `Version` INT NOT NULL DEFAULT 1 COMMENT '配置版本', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_human_customer` (`CustomerId`) +) COMMENT='小红书拟人行为与防封配置表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_ai_provider` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示全局', + `ProviderKey` VARCHAR(32) NOT NULL COMMENT 'ai_chatgpt / ai_zhipu 等', + `ProviderType` VARCHAR(32) NOT NULL COMMENT 'chatgpt/doubao/deepseek/zhipu', + `DisplayName` VARCHAR(64) NOT NULL COMMENT '展示名', + `BaseUrl` VARCHAR(512) NOT NULL COMMENT 'API Base URL', + `ApiKeyEnc` VARCHAR(512) NULL COMMENT 'API Key(暂明文,后续可加密)', + `ApiSecretEnc` VARCHAR(512) NULL COMMENT 'Secret/模型名', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认', + `Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_ai_provider` (`CustomerId`, `ProviderKey`), + KEY `idx_xhs_ai_customer` (`CustomerId`, `Enabled`) +) COMMENT='小红书 AI 提供商配置表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_media` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `FileName` VARCHAR(256) NOT NULL COMMENT '原始文件名', + `StoragePath` VARCHAR(512) NOT NULL COMMENT '相对路径', + `PublicUrl` VARCHAR(512) NOT NULL COMMENT '对外 HTTPS URL', + `MimeType` VARCHAR(64) NULL COMMENT 'MIME', + `FileSizeBytes` BIGINT NULL COMMENT '字节数', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_media_customer` (`CustomerId`, `CreateTime`) +) COMMENT='小红书创作上传媒体表'; + SET FOREIGN_KEY_CHECKS = 1; diff --git a/sql/diagnose_rbac_bad_guids.sql b/sql/diagnose_rbac_bad_guids.sql new file mode 100644 index 0000000..e4f4c54 --- /dev/null +++ b/sql/diagnose_rbac_bad_guids.sql @@ -0,0 +1,45 @@ +-- RBAC 库:诊断所有 CHAR(36) / GUID 字段中的非法值(如 "0") +-- 症状:登录 / get-menu-tree → Could not parse CHAR(36) value as Guid: 0 +-- +-- ⚠️ 必须在 API 实际连接的 RBAC 库执行 +-- 本地前端 dev(localhost:5173)默认代理到 http://api.nanafa.com/admin → 修本机库无效 + +SET @GUID_RE = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; + +SELECT 'menu bad rows' AS section, m.`Id`, m.`Name`, m.`Route`, m.`PId`, m.`CreateUserId`, m.`UpdateUserId`, m.`ConcurrencyStamp` +FROM `menu` m +WHERE (m.`PId` IS NOT NULL AND m.`PId` NOT REGEXP @GUID_RE) + OR (m.`CreateUserId` IS NOT NULL AND m.`CreateUserId` NOT REGEXP @GUID_RE) + OR (m.`UpdateUserId` IS NOT NULL AND m.`UpdateUserId` NOT REGEXP @GUID_RE) + OR (m.`ConcurrencyStamp` IS NOT NULL AND m.`ConcurrencyStamp` NOT REGEXP @GUID_RE); + +SELECT 'role bad rows' AS section, r.`Id`, r.`Code`, r.`Name`, r.`CreateUserId`, r.`UpdateUserId`, r.`ConcurrencyStamp` +FROM `role` r +WHERE (r.`CreateUserId` IS NOT NULL AND CAST(r.`CreateUserId` AS CHAR) NOT REGEXP @GUID_RE) + OR (r.`UpdateUserId` IS NOT NULL AND CAST(r.`UpdateUserId` AS CHAR) NOT REGEXP @GUID_RE) + OR (r.`ConcurrencyStamp` IS NOT NULL AND r.`ConcurrencyStamp` NOT REGEXP @GUID_RE); + +SELECT 'user bad rows' AS section, u.`Id`, u.`Account`, u.`OrganizationId`, u.`ConcurrencyStamp` +FROM `user` u +WHERE (u.`OrganizationId` IS NOT NULL AND u.`OrganizationId` <> '' AND u.`OrganizationId` NOT REGEXP @GUID_RE) + OR (u.`ConcurrencyStamp` IS NOT NULL AND u.`ConcurrencyStamp` NOT REGEXP @GUID_RE); + +SELECT 'user_role bad rows' AS section, ur.`Id`, ur.`UserId`, ur.`RoleId` +FROM `user_role` ur +WHERE (ur.`UserId` IS NOT NULL AND ur.`UserId` NOT REGEXP @GUID_RE) + OR (ur.`RoleId` IS NOT NULL AND ur.`RoleId` NOT REGEXP @GUID_RE); + +SELECT 'role_permission bad RoleId' AS section, rp.`Id`, rp.`RoleId`, rp.`PermissionCode` +FROM `role_permission` rp +WHERE rp.`RoleId` IS NOT NULL AND rp.`RoleId` NOT REGEXP @GUID_RE; + +SELECT 'organization bad rows' AS section, o.`Id`, o.`Name`, o.`ParentId`, o.`LeaderId` +FROM `organization` o +WHERE (o.`ParentId` IS NOT NULL AND o.`ParentId` <> '' AND o.`ParentId` NOT REGEXP @GUID_RE) + OR (o.`LeaderId` IS NOT NULL AND o.`LeaderId` <> '' AND o.`LeaderId` NOT REGEXP @GUID_RE) + OR (o.`ConcurrencyStamp` IS NOT NULL AND o.`ConcurrencyStamp` NOT REGEXP @GUID_RE); + +-- 快速查 menu 里最常见的脏值(勿只查 Id 列) +SELECT 'menu CreateUserId=0' AS hint, COUNT(*) AS cnt FROM `menu` WHERE CAST(`CreateUserId` AS CHAR) = '0'; +SELECT 'menu UpdateUserId=0' AS hint, COUNT(*) AS cnt FROM `menu` WHERE CAST(`UpdateUserId` AS CHAR) = '0'; +SELECT 'menu PId=0' AS hint, COUNT(*) AS cnt FROM `menu` WHERE CAST(`PId` AS CHAR) = '0'; diff --git a/sql/fix_rbac_bad_guids.sql b/sql/fix_rbac_bad_guids.sql new file mode 100644 index 0000000..bc42348 --- /dev/null +++ b/sql/fix_rbac_bad_guids.sql @@ -0,0 +1,86 @@ +-- RBAC 库:修复 GUID 脏数据(menu / role / user / organization 等) +-- 与 diagnose_rbac_bad_guids.sql 配套;修复后重新跑诊断应无结果 +-- +-- ⚠️ 在 API 实际连接的 RBAC 库执行(api.nanafa.com 对应库,不是本机 oms 除非 API 连本机) + +SET @GUID_RE = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; +SET @ROOT_PID = '00000000-0000-0000-0000-000000000000'; + +SET @ADMIN_USER_ID = ( + SELECT `Id` FROM `user` + WHERE `Account` = 'admin' AND (`IsDeleted` = 0 OR `IsDeleted` IS NULL) + LIMIT 1 +); +SET @ADMIN_USER_ID = IFNULL( + @ADMIN_USER_ID, + (SELECT `Id` FROM `user` WHERE `IsDeleted` = 0 OR `IsDeleted` IS NULL ORDER BY `CreateTime` LIMIT 1) +); + +SELECT @ADMIN_USER_ID AS admin_user_id_used; + +-- ========== menu ========== +UPDATE `menu` +SET `CreateUserId` = @ADMIN_USER_ID +WHERE `CreateUserId` IS NOT NULL AND `CreateUserId` NOT REGEXP @GUID_RE; + +UPDATE `menu` +SET `UpdateUserId` = @ADMIN_USER_ID +WHERE `UpdateUserId` IS NOT NULL AND `UpdateUserId` NOT REGEXP @GUID_RE; + +UPDATE `menu` +SET `PId` = @ROOT_PID +WHERE `PId` IS NOT NULL AND `PId` NOT REGEXP @GUID_RE; + +UPDATE `menu` +SET `ConcurrencyStamp` = UUID() +WHERE `ConcurrencyStamp` IS NOT NULL AND `ConcurrencyStamp` NOT REGEXP @GUID_RE; + +-- 小红书种子菜单(精确 Id) +UPDATE `menu` +SET `CreateUserId` = @ADMIN_USER_ID, + `UpdateUserId` = @ADMIN_USER_ID +WHERE `Id` IN ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567802', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567803', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567804', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567805', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567806', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567807', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567808' +); + +-- ========== role(若列为 CHAR(36) GUID)========== +UPDATE `role` +SET `CreateUserId` = @ADMIN_USER_ID +WHERE `CreateUserId` IS NOT NULL AND CAST(`CreateUserId` AS CHAR) NOT REGEXP @GUID_RE; + +UPDATE `role` +SET `UpdateUserId` = @ADMIN_USER_ID +WHERE `UpdateUserId` IS NOT NULL AND CAST(`UpdateUserId` AS CHAR) NOT REGEXP @GUID_RE; + +UPDATE `role` +SET `ConcurrencyStamp` = UUID() +WHERE `ConcurrencyStamp` IS NOT NULL AND `ConcurrencyStamp` NOT REGEXP @GUID_RE; + +-- ========== user ========== +UPDATE `user` +SET `OrganizationId` = NULL +WHERE `OrganizationId` IS NOT NULL + AND `OrganizationId` <> '' + AND `OrganizationId` NOT REGEXP @GUID_RE; + +UPDATE `user` +SET `ConcurrencyStamp` = UUID() +WHERE `ConcurrencyStamp` IS NOT NULL AND `ConcurrencyStamp` NOT REGEXP @GUID_RE; + +-- ========== organization ========== +UPDATE `organization` +SET `ParentId` = NULL +WHERE `ParentId` IS NOT NULL AND `ParentId` <> '' AND `ParentId` NOT REGEXP @GUID_RE; + +UPDATE `organization` +SET `LeaderId` = NULL +WHERE `LeaderId` IS NOT NULL AND `LeaderId` <> '' AND `LeaderId` NOT REGEXP @GUID_RE; + +-- 修复后请再执行 diagnose_rbac_bad_guids.sql 确认无脏行 diff --git a/sql/fix_xhs_menu_bad_guids.sql b/sql/fix_xhs_menu_bad_guids.sql new file mode 100644 index 0000000..8c26fdd --- /dev/null +++ b/sql/fix_xhs_menu_bad_guids.sql @@ -0,0 +1,56 @@ +-- 修复 menu 表 CreateUserId / UpdateUserId 非法 GUID(如 "0") +-- 症状:登录或 GET /rbac/menu/get-menu-tree → Could not parse CHAR(36) value as Guid: 0 +-- +-- ⚠️ 在 RBAC 库执行(不是 OMS 业务库 fake_simple_admin) +-- ⚠️ WHERE 中禁止写 CreateUserId = 0(整数),会触发 1292 GUID 转数字错误 + +-- 1) 管理员 GUID:优先 admin 账号,否则取第一个用户 +SET @ADMIN_USER_ID = ( + SELECT `Id` FROM `user` + WHERE `Account` = 'admin' AND (`IsDeleted` = 0 OR `IsDeleted` IS NULL) + LIMIT 1 +); +SET @ADMIN_USER_ID = IFNULL( + @ADMIN_USER_ID, + (SELECT `Id` FROM `user` WHERE `IsDeleted` = 0 OR `IsDeleted` IS NULL ORDER BY `CreateTime` LIMIT 1) +); + +SELECT @ADMIN_USER_ID AS admin_user_id_used; + +-- 2) 诊断:修复前查看脏数据 +SELECT `Id`, `Name`, `Route`, `CreateUserId`, `UpdateUserId` +FROM `menu` +WHERE (`CreateUserId` IS NOT NULL AND `CreateUserId` NOT REGEXP '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + OR (`UpdateUserId` IS NOT NULL AND `UpdateUserId` NOT REGEXP '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'); + +-- 3) 小红书种子菜单(按 Id 精确修复) +UPDATE `menu` +SET `CreateUserId` = @ADMIN_USER_ID, + `UpdateUserId` = @ADMIN_USER_ID +WHERE `Id` IN ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567802', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567803', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567804', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567805', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567806', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567807', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567808' +); + +-- 4) 兜底:全表非法 GUID(字符串比较,勿用 = 0) +UPDATE `menu` +SET `CreateUserId` = @ADMIN_USER_ID +WHERE `CreateUserId` IS NOT NULL + AND `CreateUserId` NOT REGEXP '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; + +UPDATE `menu` +SET `UpdateUserId` = @ADMIN_USER_ID +WHERE `UpdateUserId` IS NOT NULL + AND `UpdateUserId` NOT REGEXP '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; + +-- 5) 修复后应无结果 +SELECT `Id`, `Name`, `CreateUserId`, `UpdateUserId` +FROM `menu` +WHERE (`CreateUserId` IS NOT NULL AND `CreateUserId` NOT REGEXP '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + OR (`UpdateUserId` IS NOT NULL AND `UpdateUserId` NOT REGEXP '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'); diff --git a/sql/install_xhs_module.sql b/sql/install_xhs_module.sql new file mode 100644 index 0000000..b652b76 --- /dev/null +++ b/sql/install_xhs_module.sql @@ -0,0 +1,219 @@ +-- 小红书模块:建表 + 业务种子数据(在 OMS 库 fake_simple_admin / oms 执行) +-- 执行顺序:本文件 → seed_xhs_rbac_menu.sql(RBAC 库 menu 表)→ seed_xhs_role_permissions.sql +-- +-- Navicat:选中 OMS 业务库后运行本文件 + +-- ========== 1. 建表 migrate_t_xhs.sql ========== +-- (内容与 migrate_t_xhs.sql 相同,便于一键安装) + +CREATE TABLE IF NOT EXISTS `t_xhs_agent` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID → t_customer.Id', + `DeviceId` VARCHAR(64) NOT NULL COMMENT 'Agent 本机设备标识', + `DeviceName` VARCHAR(128) NULL COMMENT '用户自定义名称', + `AgentVersion` VARCHAR(32) NULL COMMENT 'Agent 版本号', + `BridgePort` INT NOT NULL DEFAULT 9333 COMMENT 'Bridge WebSocket 端口', + `Status` VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT 'online/offline', + `EngineRunning` TINYINT NOT NULL DEFAULT 0 COMMENT 'Bridge 引擎是否运行', + `ExtensionConnected` TINYINT NOT NULL DEFAULT 0 COMMENT 'Chrome 扩展是否连上 Bridge', + `XhsLoggedIn` TINYINT NOT NULL DEFAULT 0 COMMENT '小红书是否已登录', + `LastHeartbeatTime` DATETIME NULL COMMENT '最后心跳时间', + `LastError` VARCHAR(512) NULL COMMENT '最近一次 Agent 上报错误摘要', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_agent_device` (`DeviceId`), + KEY `idx_xhs_agent_customer` (`CustomerId`, `Status`) +) COMMENT='小红书本地 Agent 设备表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_task` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `AgentId` INT NULL COMMENT '指定执行的 Agent;NULL 表示任意在线 Agent', + `TaskType` VARCHAR(32) NOT NULL COMMENT '任务类型', + `ToolName` VARCHAR(64) NULL COMMENT 'LIKE AI 工具名', + `Priority` INT NOT NULL DEFAULT 0 COMMENT '越大越优先', + `Status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/claimed/running/succeeded/failed/cancelled', + `ArgumentsJson` JSON NOT NULL COMMENT '工具参数 JSON', + `ProgressText` VARCHAR(512) NULL COMMENT '执行过程摘要', + `ResultJson` JSON NULL COMMENT '成功时结构化结果', + `ErrorMessage` VARCHAR(1024) NULL COMMENT '失败原因', + `Source` VARCHAR(32) NOT NULL DEFAULT 'web' COMMENT 'web/schedule/skill', + `SourceRefId` VARCHAR(64) NULL COMMENT '来源关联 ID', + `ScheduledTime` DATETIME NULL COMMENT '计划执行时间', + `ClaimedAt` DATETIME NULL COMMENT 'Agent 认领时间', + `StartedAt` DATETIME NULL COMMENT '开始执行', + `FinishedAt` DATETIME NULL COMMENT '结束时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_task_poll` (`CustomerId`, `Status`, `ScheduledTime`, `Priority`), + KEY `idx_xhs_task_agent` (`AgentId`, `Status`), + KEY `idx_xhs_task_source` (`Source`, `SourceRefId`) +) COMMENT='小红书 Agent 任务队列表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_publish_record` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `TaskId` INT NULL COMMENT '关联 t_xhs_task.Id', + `ToolName` VARCHAR(64) NOT NULL COMMENT 'publish_content / publish_with_video', + `Title` VARCHAR(256) NOT NULL COMMENT '笔记标题', + `ContentPreview` VARCHAR(512) NULL COMMENT '正文摘要', + `ContentFull` TEXT NULL COMMENT '完整正文', + `MediaJson` JSON NULL COMMENT '图片/视频 URL 列表', + `TagsJson` JSON NULL COMMENT '标签数组', + `Visibility` VARCHAR(32) NULL COMMENT '可见范围', + `Status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/scheduled/publishing/succeeded/failed/cancelled', + `Success` TINYINT NULL COMMENT '最终是否成功', + `ResultSummary` VARCHAR(1024) NULL COMMENT 'Agent 回传结果摘要', + `ScheduledTime` DATETIME NULL COMMENT '定时发布时间', + `PublishedTime` DATETIME NULL COMMENT '实际发布完成时间', + `SkillId` INT NULL COMMENT '若来自技能卡片', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_publish_customer_time` (`CustomerId`, `CreateTime`), + KEY `idx_xhs_publish_status_sched` (`CustomerId`, `Status`, `ScheduledTime`), + KEY `idx_xhs_publish_task` (`TaskId`) +) COMMENT='小红书发布记录表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_analysis_session` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `SessionName` VARCHAR(128) NULL COMMENT '用户备注名', + `AnalysisType` VARCHAR(32) NOT NULL COMMENT 'home_feed/search/my_feeds/feed_detail', + `Keyword` VARCHAR(128) NULL COMMENT '搜索关键词', + `TaskId` INT NULL COMMENT '采集任务 ID', + `FeedsJson` JSON NULL COMMENT '采集笔记列表', + `SelectedFeedId` VARCHAR(64) NULL COMMENT '当前选中笔记 ID', + `AiAnalysisMarkdown` MEDIUMTEXT NULL COMMENT 'AI 分析结果', + `SkillId` INT NULL COMMENT '技能配方 ID', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_analysis_customer` (`CustomerId`, `CreateTime`) +) COMMENT='小红书内容分析会话表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_skill` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示系统预置', + `ExternalId` VARCHAR(32) NOT NULL COMMENT '对外稳定 ID', + `Title` VARCHAR(256) NOT NULL COMMENT '技能名称', + `SkillType` VARCHAR(32) NOT NULL DEFAULT 'ai_create' COMMENT '技能类型', + `PresetId` VARCHAR(64) NULL COMMENT '内置预设标识', + `RecipeJson` JSON NOT NULL COMMENT '配方 JSON', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_skill_external` (`CustomerId`, `ExternalId`), + KEY `idx_xhs_skill_customer` (`CustomerId`, `SortOrder`) +) COMMENT='小红书技能卡片表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_human_config` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `BehaviorJson` JSON NOT NULL COMMENT 'human_behavior 配置', + `GuardJson` JSON NOT NULL COMMENT 'human_guard 配置', + `Version` INT NOT NULL DEFAULT 1 COMMENT '配置版本', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_human_customer` (`CustomerId`) +) COMMENT='小红书拟人行为与防封配置表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_ai_provider` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示全局', + `ProviderKey` VARCHAR(32) NOT NULL COMMENT 'ai_chatgpt / ai_zhipu 等', + `ProviderType` VARCHAR(32) NOT NULL COMMENT 'chatgpt/doubao/deepseek/zhipu', + `DisplayName` VARCHAR(64) NOT NULL COMMENT '展示名', + `BaseUrl` VARCHAR(512) NOT NULL COMMENT 'API Base URL', + `ApiKeyEnc` VARCHAR(512) NULL COMMENT 'API Key(暂明文,后续可加密)', + `ApiSecretEnc` VARCHAR(512) NULL COMMENT 'Secret/模型名', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认', + `Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_ai_provider` (`CustomerId`, `ProviderKey`), + KEY `idx_xhs_ai_customer` (`CustomerId`, `Enabled`) +) COMMENT='小红书 AI 提供商配置表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_media` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `FileName` VARCHAR(256) NOT NULL COMMENT '原始文件名', + `StoragePath` VARCHAR(512) NOT NULL COMMENT '相对路径', + `PublicUrl` VARCHAR(512) NOT NULL COMMENT '对外 HTTPS URL', + `MimeType` VARCHAR(64) NULL COMMENT 'MIME', + `FileSizeBytes` BIGINT NULL COMMENT '字节数', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_media_customer` (`CustomerId`, `CreateTime`) +) COMMENT='小红书创作上传媒体表'; + +-- ========== 2. 种子 seed_xhs_skills + seed_xhs_ai_providers ========== + +INSERT INTO `t_xhs_skill` ( + `CustomerId`, `ExternalId`, `Title`, `SkillType`, `PresetId`, `RecipeJson`, `SortOrder`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES +(0, '5a9d3c012d50', '数字型/疑问型 · 物流知识 · 图文', 'ai_create', NULL, + '{"formula":["emotion","utility","visual","social"],"title_types":["number","question"],"body":{"domain":"物流知识","content_type":"干货教程","emotion":"生活场景","social":"知识炫耀"},"image_modes":["cover_impact"],"video_modes":[],"notes":"讲解9610的简易申报和汇总申报,退税,以及申报要求"}', + 1, 0, 0, NOW()), +(0, '7dd58bfe4770', '效率工具 · 干货教程(新号)', 'ai_create', 'efficiency_tutorial', + '{"formula":["emotion","utility","visual","social"],"title_types":["number","question"],"body":{"domain":"效率工具","content_type":"干货教程","emotion":"焦虑共鸣","social":"价值传递"},"image_modes":["cover_impact","step_cards"],"video_modes":[],"notes":"话题示例:笔记工具提升效率"}', + 2, 0, 0, NOW()), +(0, '3f6ed9f62afd', '职场发展 · 经验分享', 'ai_create', 'career_experience', + '{"formula":["emotion","utility","social"],"title_types":["story","compare"],"body":{"domain":"职场发展","content_type":"经验分享","emotion":"理想生活","social":"身份标签"},"image_modes":["cover_impact","lifestyle"],"video_modes":[],"notes":"话题示例:职场人际关系"}', + 3, 0, 0, NOW()), +(0, '61a4d4febada', '情感共鸣 · 真实场景', 'ai_create', 'emotional_relatable', + '{"formula":["emotion","social"],"title_types":["story","question"],"body":{"domain":"情感共鸣","content_type":"经验分享","emotion":"生活场景","social":"情感支持"},"image_modes":["lifestyle"],"video_modes":["vlog_clip"],"notes":"让读者觉得这也太真实了"}', + 4, 0, 0, NOW()), +(0, 'ec62cc03eea9', '避坑指南 · 观点表达', 'ai_create', 'pitfall_opinion', + '{"formula":["emotion","utility","visual","social"],"title_types":["number"],"body":{"domain":"职场发展","content_type":"避坑指南","emotion":"吐槽宣泄","social":"观点表达"},"image_modes":["info_chart"],"video_modes":["talking_head"],"notes":"话题示例:避坑指南"}', + 5, 0, 0, NOW()) +ON DUPLICATE KEY UPDATE `Title` = VALUES(`Title`), `RecipeJson` = VALUES(`RecipeJson`); + +INSERT INTO `t_xhs_ai_provider` ( + `CustomerId`, `ProviderKey`, `ProviderType`, `DisplayName`, `BaseUrl`, + `ApiKeyEnc`, `ApiSecretEnc`, `IsDefault`, `Enabled`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES +(0, 'ai_zhipu', 'zhipu', '智谱 AI', 'https://open.bigmodel.cn/api/paas/v4', NULL, 'glm-4-flash', 1, 0, 0, 0, NOW()), +(0, 'ai_deepseek', 'deepseek', 'DeepSeek', 'https://api.deepseek.com/v1', NULL, 'deepseek-chat', 0, 0, 0, 0, NOW()), +(0, 'ai_chatgpt', 'chatgpt', 'OpenAI', 'https://api.openai.com/v1', NULL, 'gpt-4o-mini', 0, 0, 0, 0, NOW()) +ON DUPLICATE KEY UPDATE `DisplayName` = VALUES(`DisplayName`), `BaseUrl` = VALUES(`BaseUrl`); diff --git a/sql/migrate_t_xhs.sql b/sql/migrate_t_xhs.sql new file mode 100644 index 0000000..875d619 --- /dev/null +++ b/sql/migrate_t_xhs.sql @@ -0,0 +1,181 @@ +-- 小红书(LIKE AI → OMS)模块表结构 +-- 设计文档:E:\oms0414\docs\小红书模块设计-063001.md + +CREATE TABLE IF NOT EXISTS `t_xhs_agent` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID → t_customer.Id', + `DeviceId` VARCHAR(64) NOT NULL COMMENT 'Agent 本机设备标识', + `DeviceName` VARCHAR(128) NULL COMMENT '用户自定义名称', + `AgentVersion` VARCHAR(32) NULL COMMENT 'Agent 版本号', + `BridgePort` INT NOT NULL DEFAULT 9333 COMMENT 'Bridge WebSocket 端口', + `Status` VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT 'online/offline', + `EngineRunning` TINYINT NOT NULL DEFAULT 0 COMMENT 'Bridge 引擎是否运行', + `ExtensionConnected` TINYINT NOT NULL DEFAULT 0 COMMENT 'Chrome 扩展是否连上 Bridge', + `XhsLoggedIn` TINYINT NOT NULL DEFAULT 0 COMMENT '小红书是否已登录', + `LastHeartbeatTime` DATETIME NULL COMMENT '最后心跳时间', + `LastError` VARCHAR(512) NULL COMMENT '最近一次 Agent 上报错误摘要', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_agent_device` (`DeviceId`), + KEY `idx_xhs_agent_customer` (`CustomerId`, `Status`) +) COMMENT='小红书本地 Agent 设备表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_task` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `AgentId` INT NULL COMMENT '指定执行的 Agent;NULL 表示任意在线 Agent', + `TaskType` VARCHAR(32) NOT NULL COMMENT '任务类型', + `ToolName` VARCHAR(64) NULL COMMENT 'LIKE AI 工具名', + `Priority` INT NOT NULL DEFAULT 0 COMMENT '越大越优先', + `Status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/claimed/running/succeeded/failed/cancelled', + `ArgumentsJson` JSON NOT NULL COMMENT '工具参数 JSON', + `ProgressText` VARCHAR(512) NULL COMMENT '执行过程摘要', + `ResultJson` JSON NULL COMMENT '成功时结构化结果', + `ErrorMessage` VARCHAR(1024) NULL COMMENT '失败原因', + `Source` VARCHAR(32) NOT NULL DEFAULT 'web' COMMENT 'web/schedule/skill', + `SourceRefId` VARCHAR(64) NULL COMMENT '来源关联 ID', + `ScheduledTime` DATETIME NULL COMMENT '计划执行时间', + `ClaimedAt` DATETIME NULL COMMENT 'Agent 认领时间', + `StartedAt` DATETIME NULL COMMENT '开始执行', + `FinishedAt` DATETIME NULL COMMENT '结束时间', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_task_poll` (`CustomerId`, `Status`, `ScheduledTime`, `Priority`), + KEY `idx_xhs_task_agent` (`AgentId`, `Status`), + KEY `idx_xhs_task_source` (`Source`, `SourceRefId`) +) COMMENT='小红书 Agent 任务队列表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_publish_record` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `TaskId` INT NULL COMMENT '关联 t_xhs_task.Id', + `ToolName` VARCHAR(64) NOT NULL COMMENT 'publish_content / publish_with_video', + `Title` VARCHAR(256) NOT NULL COMMENT '笔记标题', + `ContentPreview` VARCHAR(512) NULL COMMENT '正文摘要', + `ContentFull` TEXT NULL COMMENT '完整正文', + `MediaJson` JSON NULL COMMENT '图片/视频 URL 列表', + `TagsJson` JSON NULL COMMENT '标签数组', + `Visibility` VARCHAR(32) NULL COMMENT '可见范围', + `Status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/scheduled/publishing/succeeded/failed/cancelled', + `Success` TINYINT NULL COMMENT '最终是否成功', + `ResultSummary` VARCHAR(1024) NULL COMMENT 'Agent 回传结果摘要', + `ScheduledTime` DATETIME NULL COMMENT '定时发布时间', + `PublishedTime` DATETIME NULL COMMENT '实际发布完成时间', + `SkillId` INT NULL COMMENT '若来自技能卡片', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_publish_customer_time` (`CustomerId`, `CreateTime`), + KEY `idx_xhs_publish_status_sched` (`CustomerId`, `Status`, `ScheduledTime`), + KEY `idx_xhs_publish_task` (`TaskId`) +) COMMENT='小红书发布记录表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_analysis_session` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `SessionName` VARCHAR(128) NULL COMMENT '用户备注名', + `AnalysisType` VARCHAR(32) NOT NULL COMMENT 'home_feed/search/my_feeds/feed_detail', + `Keyword` VARCHAR(128) NULL COMMENT '搜索关键词', + `TaskId` INT NULL COMMENT '采集任务 ID', + `FeedsJson` JSON NULL COMMENT '采集笔记列表', + `SelectedFeedId` VARCHAR(64) NULL COMMENT '当前选中笔记 ID', + `AiAnalysisMarkdown` MEDIUMTEXT NULL COMMENT 'AI 分析结果', + `SkillId` INT NULL COMMENT '技能配方 ID', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_analysis_customer` (`CustomerId`, `CreateTime`) +) COMMENT='小红书内容分析会话表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_skill` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示系统预置', + `ExternalId` VARCHAR(32) NOT NULL COMMENT '对外稳定 ID', + `Title` VARCHAR(256) NOT NULL COMMENT '技能名称', + `SkillType` VARCHAR(32) NOT NULL DEFAULT 'ai_create' COMMENT '技能类型', + `PresetId` VARCHAR(64) NULL COMMENT '内置预设标识', + `RecipeJson` JSON NOT NULL COMMENT '配方 JSON', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_skill_external` (`CustomerId`, `ExternalId`), + KEY `idx_xhs_skill_customer` (`CustomerId`, `SortOrder`) +) COMMENT='小红书技能卡片表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_human_config` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `BehaviorJson` JSON NOT NULL COMMENT 'human_behavior 配置', + `GuardJson` JSON NOT NULL COMMENT 'human_guard 配置', + `Version` INT NOT NULL DEFAULT 1 COMMENT '配置版本', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_human_customer` (`CustomerId`) +) COMMENT='小红书拟人行为与防封配置表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_ai_provider` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示全局', + `ProviderKey` VARCHAR(32) NOT NULL COMMENT 'ai_chatgpt / ai_zhipu 等', + `ProviderType` VARCHAR(32) NOT NULL COMMENT 'chatgpt/doubao/deepseek/zhipu', + `DisplayName` VARCHAR(64) NOT NULL COMMENT '展示名', + `BaseUrl` VARCHAR(512) NOT NULL COMMENT 'API Base URL', + `ApiKeyEnc` VARCHAR(512) NULL COMMENT 'API Key(暂明文,后续可加密)', + `ApiSecretEnc` VARCHAR(512) NULL COMMENT 'Secret/模型名', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认', + `Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_ai_provider` (`CustomerId`, `ProviderKey`), + KEY `idx_xhs_ai_customer` (`CustomerId`, `Enabled`) +) COMMENT='小红书 AI 提供商配置表'; + +CREATE TABLE IF NOT EXISTS `t_xhs_media` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `FileName` VARCHAR(256) NOT NULL COMMENT '原始文件名', + `StoragePath` VARCHAR(512) NOT NULL COMMENT '相对路径', + `PublicUrl` VARCHAR(512) NOT NULL COMMENT '对外 HTTPS URL', + `MimeType` VARCHAR(64) NULL COMMENT 'MIME', + `FileSizeBytes` BIGINT NULL COMMENT '字节数', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + KEY `idx_xhs_media_customer` (`CustomerId`, `CreateTime`) +) COMMENT='小红书创作上传媒体表'; diff --git a/sql/seed_xhs_ai_providers.sql b/sql/seed_xhs_ai_providers.sql new file mode 100644 index 0000000..92b4536 --- /dev/null +++ b/sql/seed_xhs_ai_providers.sql @@ -0,0 +1,36 @@ +-- 小红书 AI 提供商模板(CustomerId=0 全局,需填入 ApiKeyEnc / ApiSecretEnc 后启用) +-- ApiSecretEnc 字段存模型名,如 glm-4-flash / deepseek-chat / gpt-4o-mini +-- +-- 若报 1146 Table doesn't exist:请先执行 migrate_t_xhs.sql + +CREATE TABLE IF NOT EXISTS `t_xhs_ai_provider` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示全局', + `ProviderKey` VARCHAR(32) NOT NULL COMMENT 'ai_chatgpt / ai_zhipu 等', + `ProviderType` VARCHAR(32) NOT NULL COMMENT 'chatgpt/doubao/deepseek/zhipu', + `DisplayName` VARCHAR(64) NOT NULL COMMENT '展示名', + `BaseUrl` VARCHAR(512) NOT NULL COMMENT 'API Base URL', + `ApiKeyEnc` VARCHAR(512) NULL COMMENT 'API Key(暂明文,后续可加密)', + `ApiSecretEnc` VARCHAR(512) NULL COMMENT 'Secret/模型名', + `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认', + `Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_ai_provider` (`CustomerId`, `ProviderKey`), + KEY `idx_xhs_ai_customer` (`CustomerId`, `Enabled`) +) COMMENT='小红书 AI 提供商配置表'; + +INSERT INTO `t_xhs_ai_provider` ( + `CustomerId`, `ProviderKey`, `ProviderType`, `DisplayName`, `BaseUrl`, + `ApiKeyEnc`, `ApiSecretEnc`, `IsDefault`, `Enabled`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES +(0, 'ai_zhipu', 'zhipu', '智谱 AI', 'https://open.bigmodel.cn/api/paas/v4', NULL, 'glm-4-flash', 1, 0, 0, 0, NOW()), +(0, 'ai_deepseek', 'deepseek', 'DeepSeek', 'https://api.deepseek.com/v1', NULL, 'deepseek-chat', 0, 0, 0, 0, NOW()), +(0, 'ai_chatgpt', 'chatgpt', 'OpenAI', 'https://api.openai.com/v1', NULL, 'gpt-4o-mini', 0, 0, 0, 0, NOW()) +ON DUPLICATE KEY UPDATE `DisplayName` = VALUES(`DisplayName`), `BaseUrl` = VALUES(`BaseUrl`); diff --git a/sql/seed_xhs_rbac_menu.sql b/sql/seed_xhs_rbac_menu.sql new file mode 100644 index 0000000..755135f --- /dev/null +++ b/sql/seed_xhs_rbac_menu.sql @@ -0,0 +1,90 @@ +-- 小红书模块 RBAC 菜单种子(Fake.Rbac menu 表) +-- 设计文档:E:\oms0414\docs\小红书模块设计-063001.md +-- +-- 注意:本环境 menu.CreateUserId / UpdateUserId 为 CHAR(36) GUID,不可写整数 0 +-- 若已误插入导致 get-menu-tree 报错,先执行 fix_xhs_menu_bad_guids.sql + +SET @ADMIN_USER_ID = ( + SELECT `Id` FROM `user` + WHERE `Account` = 'admin' AND (`IsDeleted` = 0 OR `IsDeleted` IS NULL) + LIMIT 1 +); +SET @ADMIN_USER_ID = IFNULL( + @ADMIN_USER_ID, + '08de4226-3fd4-4a5c-83e5-70e164b15f2f' +); +SET @ROOT_PID = '00000000-0000-0000-0000-000000000000'; + +-- 一级:小红书 +INSERT INTO `menu` ( + `Id`, `PId`, `Name`, `PermissionCode`, `Type`, `Icon`, `Route`, `Component`, `Order`, + `IsHidden`, `IsCached`, `Description`, `IsDeleted`, + `ConcurrencyStamp`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) VALUES ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', + @ROOT_PID, + '小红书', + 'Xhs.Agent.Query', + 1, + 'icon-fire', + '/xhs', + NULL, + 4, + 0, 0, 'LIKE AI 小红书自动化', 0, + UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW() +) ON DUPLICATE KEY UPDATE + `Name` = VALUES(`Name`), + `CreateUserId` = VALUES(`CreateUserId`), + `UpdateUserId` = VALUES(`UpdateUserId`); + +INSERT INTO `menu` ( + `Id`, `PId`, `Name`, `PermissionCode`, `Type`, `Icon`, `Route`, `Component`, `Order`, + `IsHidden`, `IsCached`, `Description`, `IsDeleted`, + `ConcurrencyStamp`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) VALUES +('a1b2c3d4-e5f6-7890-abcd-ef1234567802', 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', '启动服务', 'Xhs.Agent.Query', 1, NULL, '/xhs/startService', 'views/xhs/startService/index', 1, 0, 1, NULL, 0, UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW()), +('a1b2c3d4-e5f6-7890-abcd-ef1234567803', 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', '内容分析', 'Xhs.Analysis.Use', 1, NULL, '/xhs/analysis', 'views/xhs/analysis/index', 2, 0, 1, NULL, 0, UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW()), +('a1b2c3d4-e5f6-7890-abcd-ef1234567804', 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', '爆款创作', 'Xhs.Creation.Use', 1, NULL, '/xhs/creation', 'views/xhs/creation/index', 3, 0, 1, NULL, 0, UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW()), +('a1b2c3d4-e5f6-7890-abcd-ef1234567805', 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', '发布记录', 'Xhs.Publish.Query', 1, NULL, '/xhs/publishHistory', 'views/xhs/publishHistory/index', 4, 0, 1, NULL, 0, UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW()), +('a1b2c3d4-e5f6-7890-abcd-ef1234567806', 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', '技能卡片', 'Xhs.Skills.Query', 1, NULL, '/xhs/skills', 'views/xhs/skills/index', 5, 0, 1, NULL, 0, UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW()), +('a1b2c3d4-e5f6-7890-abcd-ef1234567807', 'a1b2c3d4-e5f6-7890-abcd-ef1234567801', '拟人设置', 'Xhs.HumanSettings.Save', 1, NULL, '/xhs/humanSettings', 'views/xhs/humanSettings/index', 6, 0, 1, NULL, 0, UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW()) +ON DUPLICATE KEY UPDATE + `Name` = VALUES(`Name`), + `Route` = VALUES(`Route`), + `CreateUserId` = VALUES(`CreateUserId`), + `UpdateUserId` = VALUES(`UpdateUserId`); + +-- AI 助手:挂在「系统管理」下(Route=/system);若不存在则挂根节点 +SET @SYSTEM_PID = ( + SELECT `Id` FROM `menu` + WHERE `Route` = '/system' AND `IsDeleted` = 0 + ORDER BY `Order` + LIMIT 1 +); +SET @AI_ASSISTANT_PID = IFNULL(@SYSTEM_PID, @ROOT_PID); + +INSERT INTO `menu` ( + `Id`, `PId`, `Name`, `PermissionCode`, `Type`, `Icon`, `Route`, `Component`, `Order`, + `IsHidden`, `IsCached`, `Description`, `IsDeleted`, + `ConcurrencyStamp`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime` +) VALUES ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567808', + @AI_ASSISTANT_PID, + 'AI 助手', + 'Xhs.Ai.Use', + 1, + NULL, + '/system/xhsAiAssistant', + 'views/system/xhsAiAssistant/index', + 99, + 0, 1, '小红书 AI 助手(系统菜单)', 0, + UUID(), @ADMIN_USER_ID, NOW(), @ADMIN_USER_ID, NOW() +) ON DUPLICATE KEY UPDATE + `Name` = VALUES(`Name`), + `PId` = VALUES(`PId`), + `Route` = VALUES(`Route`), + `Component` = VALUES(`Component`), + `CreateUserId` = VALUES(`CreateUserId`), + `UpdateUserId` = VALUES(`UpdateUserId`); + +-- 权限分配见 sql/seed_xhs_role_permissions.sql(或重启 API 由 XhsRolePermissionSeedContributor 自动合并) diff --git a/sql/seed_xhs_role_permissions.sql b/sql/seed_xhs_role_permissions.sql new file mode 100644 index 0000000..ae3a923 --- /dev/null +++ b/sql/seed_xhs_role_permissions.sql @@ -0,0 +1,49 @@ +-- 为客户角色(Code=Customer)批量追加小红书权限(幂等,可重复执行) +-- RBAC 库执行;role_permission.CreateUserId 为 INT,可写 0 + +INSERT INTO `role_permission` (`Id`, `RoleId`, `PermissionCode`, `CreateUserId`, `CreateTime`) +SELECT UUID(), r.`Id`, p.`Code`, 0, NOW() +FROM `role` r +CROSS JOIN ( + SELECT 'Xhs.Agent.Query' AS `Code` UNION ALL + SELECT 'Xhs.Agent.Control' UNION ALL + SELECT 'Xhs.Analysis.Use' UNION ALL + SELECT 'Xhs.Creation.Use' UNION ALL + SELECT 'Xhs.Publish.Query' UNION ALL + SELECT 'Xhs.Publish.Cancel' UNION ALL + SELECT 'Xhs.Skills.Query' UNION ALL + SELECT 'Xhs.Skills.Save' UNION ALL + SELECT 'Xhs.Skills.Run' UNION ALL + SELECT 'Xhs.HumanSettings.Save' UNION ALL + SELECT 'Xhs.Ai.Use' UNION ALL + SELECT 'Xhs.Ai.Save' +) p +WHERE r.`Code` = 'Customer' + AND NOT EXISTS ( + SELECT 1 FROM `role_permission` rp + WHERE rp.`RoleId` = r.`Id` AND rp.`PermissionCode` = p.`Code` + ); + +-- 超级管理员(可选,便于内测) +INSERT INTO `role_permission` (`Id`, `RoleId`, `PermissionCode`, `CreateUserId`, `CreateTime`) +SELECT UUID(), r.`Id`, p.`Code`, 0, NOW() +FROM `role` r +CROSS JOIN ( + SELECT 'Xhs.Agent.Query' AS `Code` UNION ALL + SELECT 'Xhs.Agent.Control' UNION ALL + SELECT 'Xhs.Analysis.Use' UNION ALL + SELECT 'Xhs.Creation.Use' UNION ALL + SELECT 'Xhs.Publish.Query' UNION ALL + SELECT 'Xhs.Publish.Cancel' UNION ALL + SELECT 'Xhs.Skills.Query' UNION ALL + SELECT 'Xhs.Skills.Save' UNION ALL + SELECT 'Xhs.Skills.Run' UNION ALL + SELECT 'Xhs.HumanSettings.Save' UNION ALL + SELECT 'Xhs.Ai.Use' UNION ALL + SELECT 'Xhs.Ai.Save' +) p +WHERE r.`Code` = 'Admin' + AND NOT EXISTS ( + SELECT 1 FROM `role_permission` rp + WHERE rp.`RoleId` = r.`Id` AND rp.`PermissionCode` = p.`Code` + ); diff --git a/sql/seed_xhs_skills.sql b/sql/seed_xhs_skills.sql new file mode 100644 index 0000000..0d29d57 --- /dev/null +++ b/sql/seed_xhs_skills.sql @@ -0,0 +1,47 @@ +-- 小红书系统预置技能卡片(CustomerId=0) +-- 来源:LIKE AI .xhs_bridge/skills.json +-- +-- 若报 1146 Table doesn't exist:请先执行 migrate_t_xhs.sql(建全部 t_xhs_* 表) +-- 或仅缺本表时,下方 CREATE TABLE 会自动创建 t_xhs_skill + +CREATE TABLE IF NOT EXISTS `t_xhs_skill` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID;0 表示系统预置', + `ExternalId` VARCHAR(32) NOT NULL COMMENT '对外稳定 ID', + `Title` VARCHAR(256) NOT NULL COMMENT '技能名称', + `SkillType` VARCHAR(32) NOT NULL DEFAULT 'ai_create' COMMENT '技能类型', + `PresetId` VARCHAR(64) NULL COMMENT '内置预设标识', + `RecipeJson` JSON NOT NULL COMMENT '配方 JSON', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `IsDeleted` TINYINT NOT NULL DEFAULT 0, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()), + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NULL, + `UpdateTime` DATETIME NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_xhs_skill_external` (`CustomerId`, `ExternalId`), + KEY `idx_xhs_skill_customer` (`CustomerId`, `SortOrder`) +) COMMENT='小红书技能卡片表'; + +INSERT INTO `t_xhs_skill` ( + `CustomerId`, `ExternalId`, `Title`, `SkillType`, `PresetId`, `RecipeJson`, `SortOrder`, + `IsDeleted`, `CreateUserId`, `CreateTime` +) VALUES +(0, '5a9d3c012d50', '数字型/疑问型 · 物流知识 · 图文', 'ai_create', NULL, + '{"formula":["emotion","utility","visual","social"],"title_types":["number","question"],"body":{"domain":"物流知识","content_type":"干货教程","emotion":"生活场景","social":"知识炫耀"},"image_modes":["cover_impact"],"video_modes":[],"notes":"讲解9610的简易申报和汇总申报,退税,以及申报要求"}', + 1, 0, 0, NOW()), +(0, '7dd58bfe4770', '效率工具 · 干货教程(新号)', 'ai_create', 'efficiency_tutorial', + '{"formula":["emotion","utility","visual","social"],"title_types":["number","question"],"body":{"domain":"效率工具","content_type":"干货教程","emotion":"焦虑共鸣","social":"价值传递"},"image_modes":["cover_impact","step_cards"],"video_modes":[],"notes":"话题示例:笔记工具提升效率"}', + 2, 0, 0, NOW()), +(0, '3f6ed9f62afd', '职场发展 · 经验分享', 'ai_create', 'career_experience', + '{"formula":["emotion","utility","social"],"title_types":["story","compare"],"body":{"domain":"职场发展","content_type":"经验分享","emotion":"理想生活","social":"身份标签"},"image_modes":["cover_impact","lifestyle"],"video_modes":[],"notes":"话题示例:职场人际关系"}', + 3, 0, 0, NOW()), +(0, '61a4d4febada', '情感共鸣 · 真实场景', 'ai_create', 'emotional_relatable', + '{"formula":["emotion","social"],"title_types":["story","question"],"body":{"domain":"情感共鸣","content_type":"经验分享","emotion":"生活场景","social":"情感支持"},"image_modes":["lifestyle"],"video_modes":["vlog_clip"],"notes":"让读者觉得这也太真实了"}', + 4, 0, 0, NOW()), +(0, 'ec62cc03eea9', '避坑指南 · 观点表达', 'ai_create', 'pitfall_opinion', + '{"formula":["emotion","utility","visual","social"],"title_types":["number"],"body":{"domain":"职场发展","content_type":"避坑指南","emotion":"吐槽宣泄","social":"观点表达"},"image_modes":["info_chart"],"video_modes":["talking_head"],"notes":"话题示例:避坑指南"}', + 5, 0, 0, NOW()) +ON DUPLICATE KEY UPDATE `Title` = VALUES(`Title`), `RecipeJson` = VALUES(`RecipeJson`); + \ No newline at end of file diff --git a/src/Oms.Application/Dtos/Xhs/XhsDtos.cs b/src/Oms.Application/Dtos/Xhs/XhsDtos.cs new file mode 100644 index 0000000..c1ed96c --- /dev/null +++ b/src/Oms.Application/Dtos/Xhs/XhsDtos.cs @@ -0,0 +1,533 @@ +using System.ComponentModel.DataAnnotations; +using Oms.Application.Dtos.Shared; + +namespace Oms.Application.Dtos.Xhs; + +public class XhsPageInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 30; +} + +public class XhsAgentRegisterInput +{ + [Required] + public string DeviceId { get; set; } = string.Empty; + + public string? DeviceName { get; set; } + + public string? AgentVersion { get; set; } + + public int BridgePort { get; set; } = 9333; +} + +public class XhsAgentRegisterOutput +{ + public int AgentId { get; set; } + + public int CustomerId { get; set; } + + public string DeviceId { get; set; } = string.Empty; + + public int HumanConfigVersion { get; set; } +} + +public class XhsAgentHeartbeatInput +{ + [Required] + public string DeviceId { get; set; } = string.Empty; + + public bool EngineRunning { get; set; } + + public bool ExtensionConnected { get; set; } + + public bool XhsLoggedIn { get; set; } + + public string? LastError { get; set; } +} + +public class XhsAgentHeartbeatOutput +{ + public bool Ok { get; set; } = true; + + public int HumanConfigVersion { get; set; } + + public DateTime ServerTime { get; set; } +} + +public class XhsAgentListInput : XhsPageInput; + +public class XhsAgentOutput +{ + public int Id { get; set; } + + public string DeviceId { get; set; } = string.Empty; + + public string? DeviceName { get; set; } + + public string Status { get; set; } = string.Empty; + + public bool EngineRunning { get; set; } + + public bool ExtensionConnected { get; set; } + + public bool XhsLoggedIn { get; set; } + + public DateTime? LastHeartbeatTime { get; set; } + + public string? AgentVersion { get; set; } + + public int BridgePort { get; set; } + + public string? LastError { get; set; } +} + +public class XhsAgentControlInput +{ + public int AgentId { get; set; } + + [Required] + public string Action { get; set; } = string.Empty; +} + +public class XhsAgentControlOutput +{ + public int TaskId { get; set; } +} + +public class XhsTaskClaimInput +{ + [Required] + public string DeviceId { get; set; } = string.Empty; + + public int Limit { get; set; } = 1; +} + +public class XhsTaskClaimItemOutput +{ + public int Id { get; set; } + + public string TaskType { get; set; } = string.Empty; + + public string? ToolName { get; set; } + + public object? Arguments { get; set; } + + public int HumanConfigVersion { get; set; } +} + +public class XhsTaskClaimOutput +{ + public List Items { get; set; } = []; +} + +public class XhsTaskReportInput +{ + public int TaskId { get; set; } + + [Required] + public string Status { get; set; } = string.Empty; + + public string? ProgressText { get; set; } + + public object? Result { get; set; } + + public string? ErrorMessage { get; set; } +} + +public class XhsTaskListInput : XhsPageInput +{ + public string? Status { get; set; } + + public string? TaskType { get; set; } + + public string? SourceRefId { get; set; } +} + +public class XhsTaskOutput +{ + public int Id { get; set; } + + public string TaskType { get; set; } = string.Empty; + + public string? ToolName { get; set; } + + public string Status { get; set; } = string.Empty; + + public string? ProgressText { get; set; } + + public string? ErrorMessage { get; set; } + + public DateTime CreateTime { get; set; } + + public DateTime? FinishedAt { get; set; } +} + +public class XhsTaskCancelInput +{ + public int TaskId { get; set; } +} + +public class XhsHumanConfigOutput +{ + public object? Behavior { get; set; } + + public object? Guard { get; set; } + + public int Version { get; set; } +} + +public class XhsHumanConfigSaveInput +{ + public object? Behavior { get; set; } + + public object? Guard { get; set; } +} + +public class XhsHumanConfigPullInput +{ + [Required] + public string DeviceId { get; set; } = string.Empty; + + public int KnownVersion { get; set; } +} + +public class XhsHumanConfigPullOutput +{ + public bool Unchanged { get; set; } + + public object? Behavior { get; set; } + + public object? Guard { get; set; } + + public int Version { get; set; } +} + +public class XhsPublishListInput : XhsPageInput +{ + public string? Status { get; set; } + + public string? Keyword { get; set; } +} + +public class XhsPublishRecordOutput +{ + public int Id { get; set; } + + public string ToolName { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string? ContentPreview { get; set; } + + public string Status { get; set; } = string.Empty; + + public bool? Success { get; set; } + + public string? ResultSummary { get; set; } + + public DateTime? ScheduledTime { get; set; } + + public DateTime? PublishedTime { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class XhsPublishCancelInput +{ + public int PublishRecordId { get; set; } +} + +public class XhsPublishRetryInput +{ + public int PublishRecordId { get; set; } + + public int? AgentId { get; set; } +} + +public class XhsCreationPublishInput +{ + public int? AgentId { get; set; } + + [Required] + public string ToolName { get; set; } = "publish_content"; + + [Required] + public string Title { get; set; } = string.Empty; + + [Required] + public string Content { get; set; } = string.Empty; + + public List? MediaUrls { get; set; } + + public List? Tags { get; set; } + + public string? Visibility { get; set; } + + public DateTime? ScheduledTime { get; set; } + + public int? SkillId { get; set; } +} + +public class XhsCreationPublishOutput +{ + public int PublishRecordId { get; set; } + + public int? TaskId { get; set; } +} + +public class XhsSkillListInput +{ + public int? CustomerId { get; set; } +} + +public class XhsSkillOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public string ExternalId { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string SkillType { get; set; } = string.Empty; + + public string? PresetId { get; set; } + + public object? Recipe { get; set; } + + public int SortOrder { get; set; } +} + +public class XhsSkillSaveInput +{ + public int? Id { get; set; } + + [Required] + public string Title { get; set; } = string.Empty; + + public string SkillType { get; set; } = "ai_create"; + + public string? PresetId { get; set; } + + public object? Recipe { get; set; } +} + +public class XhsAiProviderOutput +{ + public int Id { get; set; } + + public string ProviderKey { get; set; } = string.Empty; + + public string ProviderType { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public string BaseUrl { get; set; } = string.Empty; + + public string? ApiKey { get; set; } + + public string? ApiSecret { get; set; } + + public bool IsDefault { get; set; } + + public bool Enabled { get; set; } +} + +public class XhsAiProviderSaveInput +{ + [Required] + public string ProviderKey { get; set; } = string.Empty; + + [Required] + public string ProviderType { get; set; } = string.Empty; + + [Required] + public string DisplayName { get; set; } = string.Empty; + + [Required] + public string BaseUrl { get; set; } = string.Empty; + + public string? ApiKey { get; set; } + + public string? ApiSecret { get; set; } + + public bool IsDefault { get; set; } + + public bool Enabled { get; set; } = true; +} + +public class XhsAiChatInput +{ + [Required] + public string ProviderKey { get; set; } = string.Empty; + + public string? Model { get; set; } + + [Required] + public List Messages { get; set; } = []; + + public string Scene { get; set; } = "assistant"; +} + +public class XhsAiChatMessageInput +{ + public string Role { get; set; } = "user"; + + public string Content { get; set; } = string.Empty; +} + +public class XhsAiChatOutput +{ + public string Content { get; set; } = string.Empty; +} + +public class XhsAnalysisStartInput +{ + [Required] + public string AnalysisType { get; set; } = string.Empty; + + public string? Keyword { get; set; } + + public object? Filters { get; set; } + + public int? AgentId { get; set; } + + public int? SkillId { get; set; } +} + +public class XhsAnalysisStartOutput +{ + public int SessionId { get; set; } + + public int TaskId { get; set; } +} + +public class XhsAnalysisListInput : XhsPageInput; + +public class XhsAnalysisSessionOutput +{ + public int Id { get; set; } + + public string? SessionName { get; set; } + + public string AnalysisType { get; set; } = string.Empty; + + public string? Keyword { get; set; } + + public int? TaskId { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class XhsAnalysisDetailInput +{ + public int SessionId { get; set; } +} + +public class XhsAnalysisDetailOutput : XhsAnalysisSessionOutput +{ + public object? Feeds { get; set; } + + public string? AiAnalysisMarkdown { get; set; } + + public string? TaskStatus { get; set; } +} + +public class XhsAnalysisRunAiInput +{ + public int SessionId { get; set; } + + public int? SkillId { get; set; } + + [Required] + public string ProviderKey { get; set; } = string.Empty; + + public List? SelectedFeedIds { get; set; } +} + +public class XhsAnalysisRunAiOutput +{ + public string Markdown { get; set; } = string.Empty; +} + +public class XhsAnalysisInteractInput +{ + public int? AgentId { get; set; } + + [Required] + public string Action { get; set; } = string.Empty; + + [Required] + public string FeedId { get; set; } = string.Empty; + + public string? XsecToken { get; set; } + + public string? Content { get; set; } +} + +public class XhsAnalysisInteractOutput +{ + public int TaskId { get; set; } +} + +public class XhsSkillRunInput +{ + public int SkillId { get; set; } + + public int? AgentId { get; set; } + + public string RunMode { get; set; } = "analyze_only"; + + public string? ProviderKey { get; set; } + + public string? TopicOverride { get; set; } + + public List? MediaUrls { get; set; } +} + +public class XhsSkillRunOutput +{ + public List TaskIds { get; set; } = []; + + public int? SessionId { get; set; } + + public int? PublishRecordId { get; set; } + + public string? GeneratedTitle { get; set; } + + public string? GeneratedContent { get; set; } + + public List? GeneratedTags { get; set; } +} + +public class XhsCreationGenerateInput +{ + public int? SkillId { get; set; } + + [Required] + public string ProviderKey { get; set; } = string.Empty; + + [Required] + public string Topic { get; set; } = string.Empty; + + public string? ExtraNotes { get; set; } +} + +public class XhsCreationGenerateOutput +{ + public string Title { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; + + public List Tags { get; set; } = []; +} + +public class XhsMediaUploadOutput +{ + public int MediaId { get; set; } + + public string PublicUrl { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index f8ec1ad..e71911c 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -4,6 +4,8 @@ using Fake.Security.Claims; using Microsoft.Extensions.DependencyInjection; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Integrations.Itfx; +using Oms.Application.Shared; +using Oms.Application.Workers; using Oms.Domain; namespace Oms.Application; @@ -26,5 +28,7 @@ public class OmsApplicationModule : FakeModule }); context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration()); context.Services.AddOmsItfxIntegration(context.Services.GetConfiguration()); + context.Services.AddXhsLlmClient(); + context.Services.AddHostedService(); } } diff --git a/src/Oms.Application/Services/Xhs/XhsAgentService.cs b/src/Oms.Application/Services/Xhs/XhsAgentService.cs new file mode 100644 index 0000000..e7eeef0 --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsAgentService.cs @@ -0,0 +1,207 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; +using System.Linq.Expressions; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsAgentService : ApplicationService +{ + private IXhsAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService(); + private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService(); + private IXhsHumanConfigRepository HumanConfigRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/agent/register")] + public async Task RegisterAsync(XhsAgentRegisterInput input) + { + var customerId = await RequireCustomerIdAsync(); + var deviceId = input.DeviceId.Trim(); + if (string.IsNullOrWhiteSpace(deviceId)) + { + throw new BusinessException("deviceId 不能为空"); + } + + var humanVersion = await EnsureHumanConfigVersionAsync(customerId); + var agent = await AgentRepository.GetByDeviceIdAsync(deviceId); + if (agent == null) + { + agent = new XhsAgent + { + CustomerId = customerId, + DeviceId = deviceId, + DeviceName = NormalizeOptional(input.DeviceName), + AgentVersion = NormalizeOptional(input.AgentVersion), + BridgePort = input.BridgePort > 0 ? input.BridgePort : 9333, + Status = XhsAgentStatusCodes.Online, + LastHeartbeatTime = DateTime.Now + }; + await AgentRepository.InsertAsync(agent); + } + else + { + if (agent.CustomerId != customerId) + { + throw new BusinessException("设备已绑定其他客户"); + } + + agent.DeviceName = NormalizeOptional(input.DeviceName) ?? agent.DeviceName; + agent.AgentVersion = NormalizeOptional(input.AgentVersion) ?? agent.AgentVersion; + agent.BridgePort = input.BridgePort > 0 ? input.BridgePort : agent.BridgePort; + agent.Status = XhsAgentStatusCodes.Online; + agent.LastHeartbeatTime = DateTime.Now; + await AgentRepository.UpdateAsync(agent); + } + + return new XhsAgentRegisterOutput + { + AgentId = agent.Id, + CustomerId = customerId, + DeviceId = agent.DeviceId, + HumanConfigVersion = humanVersion + }; + } + + [HttpPost("/xhs/agent/heartbeat")] + public async Task HeartbeatAsync(XhsAgentHeartbeatInput input) + { + var customerId = await RequireCustomerIdAsync(); + var agent = await GetOwnedAgentByDeviceAsync(input.DeviceId.Trim(), customerId); + agent.EngineRunning = input.EngineRunning; + agent.ExtensionConnected = input.ExtensionConnected; + agent.XhsLoggedIn = input.XhsLoggedIn; + agent.LastError = NormalizeOptional(input.LastError); + agent.Status = XhsAgentStatusCodes.Online; + agent.LastHeartbeatTime = DateTime.Now; + await AgentRepository.UpdateAsync(agent); + + var humanVersion = await EnsureHumanConfigVersionAsync(customerId); + return new XhsAgentHeartbeatOutput + { + Ok = true, + HumanConfigVersion = humanVersion, + ServerTime = DateTime.Now + }; + } + + [HttpPost("/xhs/agent/list")] + public async Task> ListAsync(XhsAgentListInput input) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + + Expression> predicate = customerId.HasValue + ? x => x.CustomerId == customerId.Value + : x => true; + + var items = await AgentRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(XhsAgent.Id)] = false }); + var total = await AgentRepository.CountAsync(predicate); + + return new OmsPageResult + { + Items = items.Select(MapAgent).ToList(), + Total = total + }; + } + + [HttpPost("/xhs/agent/control")] + public async Task ControlAsync(XhsAgentControlInput input) + { + var customerId = await RequireCustomerIdAsync(); + var agent = await GetOwnedAgentByIdAsync(input.AgentId, customerId); + var task = XhsTaskFactory.CreateControlTask(customerId, agent.Id, input.Action.Trim()); + await TaskRepository.InsertAsync(task); + return new XhsAgentControlOutput { TaskId = task.Id }; + } + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作,或指定客户上下文"); + } + + return customerId.Value; + } + + private async Task GetOwnedAgentByDeviceAsync(string deviceId, int customerId) + { + if (string.IsNullOrWhiteSpace(deviceId)) + { + throw new BusinessException("deviceId 不能为空"); + } + + var agent = await AgentRepository.GetByDeviceIdAsync(deviceId); + if (agent == null || agent.CustomerId != customerId) + { + throw new BusinessException("Agent 设备不存在"); + } + + return agent; + } + + private async Task GetOwnedAgentByIdAsync(int agentId, int customerId) + { + var agent = await AgentRepository.FirstOrDefaultAsync(x => x.Id == agentId); + if (agent == null || agent.CustomerId != customerId) + { + throw new BusinessException("Agent 不存在"); + } + + return agent; + } + + private async Task EnsureHumanConfigVersionAsync(int customerId) + { + var config = await HumanConfigRepository.GetByCustomerIdAsync(customerId); + if (config != null) + { + return config.Version; + } + + config = new XhsHumanConfig + { + CustomerId = customerId, + BehaviorJson = XhsHumanDefaults.BehaviorJson, + GuardJson = XhsHumanDefaults.GuardJson, + Version = 1 + }; + await HumanConfigRepository.InsertAsync(config); + return config.Version; + } + + private static XhsAgentOutput MapAgent(XhsAgent agent) + { + var online = XhsHelper.IsAgentOnline(agent); + return new XhsAgentOutput + { + Id = agent.Id, + DeviceId = agent.DeviceId, + DeviceName = agent.DeviceName, + Status = online ? XhsAgentStatusCodes.Online : XhsAgentStatusCodes.Offline, + EngineRunning = agent.EngineRunning, + ExtensionConnected = agent.ExtensionConnected, + XhsLoggedIn = agent.XhsLoggedIn, + LastHeartbeatTime = agent.LastHeartbeatTime, + AgentVersion = agent.AgentVersion, + BridgePort = agent.BridgePort, + LastError = agent.LastError + }; + } + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/Oms.Application/Services/Xhs/XhsAiService.cs b/src/Oms.Application/Services/Xhs/XhsAiService.cs new file mode 100644 index 0000000..a433e3c --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsAiService.cs @@ -0,0 +1,137 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsAiService : ApplicationService +{ + private const string SecretMask = "******"; + + private IXhsAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/ai/provider/list")] + public async Task> ListProvidersAsync() + { + var customerId = await ResolveAiCustomerIdAsync(); + var items = await AiProviderRepository.GetListAsync(x => x.CustomerId == customerId || x.CustomerId == 0); + return new OmsItemsResult + { + Items = items.Select(x => MapProvider(x, maskSecret: true)).ToList() + }; + } + + [HttpPost("/xhs/ai/provider/save")] + public async Task SaveProviderAsync(XhsAiProviderSaveInput input) + { + var customerId = await ResolveAiCustomerIdAsync(); + var providerKey = input.ProviderKey.Trim(); + var entity = await AiProviderRepository.GetByProviderKeyAsync(customerId, providerKey); + if (entity == null) + { + entity = new XhsAiProvider { CustomerId = customerId, ProviderKey = providerKey }; + } + + entity.ProviderType = input.ProviderType.Trim(); + entity.DisplayName = input.DisplayName.Trim(); + entity.BaseUrl = input.BaseUrl.Trim(); + entity.IsDefault = input.IsDefault; + entity.Enabled = input.Enabled; + + if (!ShouldKeepExistingSecret(input.ApiKey)) + { + entity.ApiKeyEnc = NormalizeOptional(input.ApiKey); + } + + if (!ShouldKeepExistingSecret(input.ApiSecret)) + { + entity.ApiSecretEnc = NormalizeOptional(input.ApiSecret); + } + + if (entity.Id > 0) + { + await AiProviderRepository.UpdateAsync(entity); + } + else + { + await AiProviderRepository.InsertAsync(entity); + } + + if (input.IsDefault) + { + var others = await AiProviderRepository.GetListAsync(x => + x.CustomerId == customerId && x.Id != entity.Id && x.IsDefault); + foreach (var other in others) + { + other.IsDefault = false; + await AiProviderRepository.UpdateAsync(other); + } + } + + return MapProvider(entity, maskSecret: true); + } + + [HttpPost("/xhs/ai/chat")] + public async Task ChatAsync(XhsAiChatInput input) + { + var customerId = await ResolveAiCustomerIdAsync(); + var provider = await ResolveProviderAsync(customerId, input.ProviderKey.Trim()); + var llm = LazyServiceProvider.GetRequiredService(); + var content = await llm.ChatCompletionAsync( + provider, + input.Messages.Select(x => (x.Role, x.Content)).ToList(), + input.Model); + return new XhsAiChatOutput { Content = content }; + } + + internal async Task ResolveProviderAsync(int customerId, string providerKey) + { + var provider = await AiProviderRepository.GetByProviderKeyAsync(customerId, providerKey); + if (provider == null || !provider.Enabled) + { + provider = await AiProviderRepository.GetByProviderKeyAsync(0, providerKey); + } + + if (provider == null || !provider.Enabled) + { + throw new BusinessException($"AI 提供商 {providerKey} 未配置或未启用"); + } + + return provider; + } + + private async Task ResolveAiCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + return customerId ?? 0; + } + + private static XhsAiProviderOutput MapProvider(XhsAiProvider entity, bool maskSecret) => new() + { + Id = entity.Id, + ProviderKey = entity.ProviderKey, + ProviderType = entity.ProviderType, + DisplayName = entity.DisplayName, + BaseUrl = entity.BaseUrl, + ApiKey = maskSecret ? MaskSecret(entity.ApiKeyEnc) : entity.ApiKeyEnc, + ApiSecret = maskSecret ? MaskSecret(entity.ApiSecretEnc) : entity.ApiSecretEnc, + IsDefault = entity.IsDefault, + Enabled = entity.Enabled + }; + + private static string? MaskSecret(string? secret) => + string.IsNullOrWhiteSpace(secret) ? secret : SecretMask; + + private static bool ShouldKeepExistingSecret(string? secret) => + string.IsNullOrWhiteSpace(secret) || string.Equals(secret.Trim(), SecretMask, StringComparison.Ordinal); + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/Oms.Application/Services/Xhs/XhsAnalysisService.cs b/src/Oms.Application/Services/Xhs/XhsAnalysisService.cs new file mode 100644 index 0000000..239642e --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsAnalysisService.cs @@ -0,0 +1,276 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; +using System.Linq.Expressions; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsAnalysisService : ApplicationService +{ + private IXhsAnalysisSessionRepository AnalysisSessionRepository => LazyServiceProvider.GetRequiredService(); + private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService(); + private IXhsSkillRepository SkillRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/analysis/start")] + public async Task StartAsync(XhsAnalysisStartInput input) + { + var customerId = await RequireCustomerIdAsync(); + return await StartSessionAsync(customerId, input); + } + + [HttpPost("/xhs/analysis/list")] + public async Task> ListAsync(XhsAnalysisListInput input) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + + Expression> predicate = x => + !customerId.HasValue || x.CustomerId == customerId.Value; + + var items = await AnalysisSessionRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(XhsAnalysisSession.CreateTime)] = false }); + var total = await AnalysisSessionRepository.CountAsync(predicate); + + return new OmsPageResult + { + Items = items.Select(MapSession).ToList(), + Total = total + }; + } + + [HttpPost("/xhs/analysis/detail")] + public async Task DetailAsync(XhsAnalysisDetailInput input) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + var session = await AnalysisSessionRepository.FirstOrDefaultAsync(x => x.Id == input.SessionId); + if (session == null || (customerId.HasValue && session.CustomerId != customerId.Value)) + { + throw new BusinessException("分析会话不存在"); + } + + string? taskStatus = null; + if (session.TaskId is > 0) + { + var task = await TaskRepository.FirstOrDefaultAsync(x => x.Id == session.TaskId); + taskStatus = task?.Status; + } + + return new XhsAnalysisDetailOutput + { + Id = session.Id, + SessionName = session.SessionName, + AnalysisType = session.AnalysisType, + Keyword = session.Keyword, + TaskId = session.TaskId, + CreateTime = session.CreateTime, + Feeds = OmsDocHelper.DeserializeJsonObject(session.FeedsJson), + AiAnalysisMarkdown = session.AiAnalysisMarkdown, + TaskStatus = taskStatus + }; + } + + [HttpPost("/xhs/analysis/run-ai")] + public async Task RunAiAsync(XhsAnalysisRunAiInput input) + { + var customerId = await RequireCustomerIdAsync(); + var session = await AnalysisSessionRepository.FirstOrDefaultAsync(x => x.Id == input.SessionId); + if (session == null || session.CustomerId != customerId) + { + throw new BusinessException("分析会话不存在"); + } + + if (string.IsNullOrWhiteSpace(session.FeedsJson)) + { + throw new BusinessException("请先完成笔记采集"); + } + + var aiService = LazyServiceProvider.GetRequiredService(); + var provider = await aiService.ResolveProviderAsync(customerId, input.ProviderKey.Trim()); + var llm = LazyServiceProvider.GetRequiredService(); + + var skillHint = string.Empty; + if (input.SkillId is > 0) + { + var skill = await SkillRepository.FirstOrDefaultAsync(x => x.Id == input.SkillId); + skillHint = skill == null ? string.Empty : XhsHelper.ExtractRecipeNotes(skill.RecipeJson) ?? skill.Title; + } + + var feedsText = XhsHelper.SerializeFeedsForPrompt(OmsDocHelper.DeserializeJsonObject(session.FeedsJson)); + var userPrompt = + "你是小红书内容分析助手。请基于以下采集到的笔记数据,输出 Markdown 格式的分析报告,包含:\n" + + "1. 爆款共性(标题、封面、结构)\n" + + "2. 可借鉴的选题角度\n" + + "3. 3 条可执行的创作建议\n\n" + + $"会话类型:{session.AnalysisType}\n" + + $"关键词:{session.Keyword ?? "无"}\n" + + $"技能参考:{skillHint}\n\n" + + $"采集数据:\n{feedsText}"; + + var markdown = await llm.ChatCompletionAsync( + provider, + [("system", "你擅长小红书爆款分析与内容策划。"), ("user", userPrompt)]); + + session.AiAnalysisMarkdown = markdown; + await AnalysisSessionRepository.UpdateAsync(session); + + return new XhsAnalysisRunAiOutput { Markdown = markdown }; + } + + [HttpPost("/xhs/analysis/interact")] + public async Task InteractAsync(XhsAnalysisInteractInput input) + { + var customerId = await RequireCustomerIdAsync(); + var (toolName, args) = BuildInteractTask(input); + + var task = new XhsTask + { + CustomerId = customerId, + AgentId = input.AgentId, + TaskType = XhsTaskTypeCodes.Interact, + ToolName = toolName, + ArgumentsJson = OmsDocHelper.SerializeJson(args) ?? "{}", + Source = XhsTaskSourceCodes.Web, + SourceRefId = input.FeedId, + Status = XhsTaskStatusCodes.Pending, + Priority = 4 + }; + await TaskRepository.InsertAsync(task); + + return new XhsAnalysisInteractOutput { TaskId = task.Id }; + } + + internal async Task StartSessionAsync(int customerId, XhsAnalysisStartInput input) + { + var analysisType = input.AnalysisType.Trim(); + var (taskType, toolName, args) = BuildBrowseTask(analysisType, input); + + var session = new XhsAnalysisSession + { + CustomerId = customerId, + SessionName = BuildSessionName(analysisType, input.Keyword), + AnalysisType = analysisType, + Keyword = NormalizeOptional(input.Keyword), + SkillId = input.SkillId + }; + await AnalysisSessionRepository.InsertAsync(session); + + var task = new XhsTask + { + CustomerId = customerId, + AgentId = input.AgentId, + TaskType = taskType, + ToolName = toolName, + ArgumentsJson = OmsDocHelper.SerializeJson(args) ?? "{}", + Source = XhsTaskSourceCodes.Web, + SourceRefId = session.Id.ToString(), + Status = XhsTaskStatusCodes.Pending, + Priority = 3 + }; + await TaskRepository.InsertAsync(task); + + session.TaskId = task.Id; + await AnalysisSessionRepository.UpdateAsync(session); + + return new XhsAnalysisStartOutput + { + SessionId = session.Id, + TaskId = task.Id + }; + } + + private static (string ToolName, object Args) BuildInteractTask(XhsAnalysisInteractInput input) + { + var action = input.Action.Trim().ToLowerInvariant(); + return action switch + { + "post_comment" => ("post_comment_to_feed", new + { + feed_id = input.FeedId, + xsec_token = input.XsecToken ?? string.Empty, + content = input.Content ?? string.Empty + }), + "like" => ("like_feed", new + { + feed_id = input.FeedId, + xsec_token = input.XsecToken ?? string.Empty + }), + "favorite" => ("favorite_feed", new + { + feed_id = input.FeedId, + xsec_token = input.XsecToken ?? string.Empty + }), + _ => throw new BusinessException($"不支持的互动动作: {input.Action}") + }; + } + + private static (string TaskType, string ToolName, object Args) BuildBrowseTask( + string analysisType, + XhsAnalysisStartInput input) + { + return analysisType switch + { + XhsAnalysisTypeCodes.HomeFeed => ( + XhsTaskTypeCodes.Browse, + "list_feeds", + new { }), + XhsAnalysisTypeCodes.Search => ( + XhsTaskTypeCodes.Browse, + "search_feeds", + new + { + keyword = input.Keyword ?? string.Empty, + filters = input.Filters + }), + XhsAnalysisTypeCodes.MyFeeds => ( + XhsTaskTypeCodes.Browse, + "my_account_feeds", + new { }), + _ => throw new BusinessException($"不支持的 analysisType: {analysisType}") + }; + } + + private static string BuildSessionName(string analysisType, string? keyword) => + analysisType switch + { + XhsAnalysisTypeCodes.Search => $"搜索:{keyword}", + XhsAnalysisTypeCodes.HomeFeed => "首页推荐", + XhsAnalysisTypeCodes.MyFeeds => "我的笔记", + _ => analysisType + }; + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作"); + } + + return customerId.Value; + } + + private static XhsAnalysisSessionOutput MapSession(XhsAnalysisSession session) => new() + { + Id = session.Id, + SessionName = session.SessionName, + AnalysisType = session.AnalysisType, + Keyword = session.Keyword, + TaskId = session.TaskId, + CreateTime = session.CreateTime + }; + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/Oms.Application/Services/Xhs/XhsCreationService.cs b/src/Oms.Application/Services/Xhs/XhsCreationService.cs new file mode 100644 index 0000000..85c733d --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsCreationService.cs @@ -0,0 +1,130 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsCreationService : ApplicationService +{ + private IXhsPublishRecordRepository PublishRecordRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/creation/publish")] + public async Task PublishAsync(XhsCreationPublishInput input) + { + var customerId = await RequireCustomerIdAsync(); + var toolName = string.IsNullOrWhiteSpace(input.ToolName) ? "publish_content" : input.ToolName.Trim(); + var scheduled = input.ScheduledTime.HasValue && input.ScheduledTime.Value > DateTime.Now; + + var record = new XhsPublishRecord + { + CustomerId = customerId, + ToolName = toolName, + Title = input.Title.Trim(), + ContentFull = input.Content.Trim(), + ContentPreview = XhsHelper.BuildContentPreview(input.Content), + MediaJson = OmsDocHelper.SerializeJson(input.MediaUrls), + TagsJson = OmsDocHelper.SerializeJson(input.Tags), + Visibility = string.IsNullOrWhiteSpace(input.Visibility) ? "公开可见" : input.Visibility.Trim(), + ScheduledTime = input.ScheduledTime, + SkillId = input.SkillId, + Status = scheduled ? XhsPublishStatusCodes.Scheduled : XhsPublishStatusCodes.Draft + }; + await PublishRecordRepository.InsertAsync(record); + + int? taskId = null; + if (!scheduled) + { + var publishService = LazyServiceProvider.GetRequiredService(); + var task = await publishService.CreatePublishTaskAsync(record, input.AgentId); + taskId = task.Id; + record.TaskId = task.Id; + record.Status = XhsPublishStatusCodes.Publishing; + await PublishRecordRepository.UpdateAsync(record); + } + + return new XhsCreationPublishOutput + { + PublishRecordId = record.Id, + TaskId = taskId + }; + } + + [HttpPost("/xhs/creation/generate")] + public async Task GenerateAsync(XhsCreationGenerateInput input) + { + var customerId = await RequireCustomerIdAsync(); + var aiService = LazyServiceProvider.GetRequiredService(); + var provider = await aiService.ResolveProviderAsync(customerId, input.ProviderKey.Trim()); + var llm = LazyServiceProvider.GetRequiredService(); + + var skillHint = string.Empty; + if (input.SkillId is > 0) + { + var skillRepo = LazyServiceProvider.GetRequiredService(); + var skill = await skillRepo.FirstOrDefaultAsync(x => x.Id == input.SkillId); + if (skill != null) + { + skillHint = XhsHelper.ExtractRecipeNotes(skill.RecipeJson) ?? skill.Title; + } + } + + var userPrompt = + "请为小红书创作一篇笔记,严格按 JSON 返回,不要 markdown 代码块:\n" + + "{\"title\":\"标题20字内\",\"content\":\"正文800字内,分段\",\"tags\":[\"标签1\",\"标签2\"]}\n\n" + + $"主题:{input.Topic.Trim()}\n" + + $"补充说明:{input.ExtraNotes ?? skillHint}"; + + var raw = await llm.ChatCompletionAsync( + provider, + [("system", "你是小红书爆款创作助手,输出必须是合法 JSON。"), ("user", userPrompt)]); + + var parsed = OmsDocHelper.DeserializeJson>(ExtractJsonObject(raw)); + if (parsed == null) + { + throw new BusinessException("AI 返回无法解析,请重试"); + } + + return new XhsCreationGenerateOutput + { + Title = parsed.TryGetValue("title", out var title) ? title?.ToString() ?? input.Topic : input.Topic, + Content = parsed.TryGetValue("content", out var content) ? content?.ToString() ?? raw : raw, + Tags = ParseStringList(parsed.GetValueOrDefault("tags")) + }; + } + + private static List ParseStringList(object? value) + { + var json = OmsDocHelper.SerializeJson(value); + return OmsDocHelper.DeserializeStringList(json) ?? []; + } + + private static string ExtractJsonObject(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + if (start >= 0 && end > start) + { + return text[start..(end + 1)]; + } + + return text; + } + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作"); + } + + return customerId.Value; + } +} diff --git a/src/Oms.Application/Services/Xhs/XhsHumanConfigService.cs b/src/Oms.Application/Services/Xhs/XhsHumanConfigService.cs new file mode 100644 index 0000000..b413700 --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsHumanConfigService.cs @@ -0,0 +1,105 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsHumanConfigService : ApplicationService +{ + private IXhsHumanConfigRepository HumanConfigRepository => LazyServiceProvider.GetRequiredService(); + private IXhsAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/human-config/get")] + public async Task GetAsync() + { + var customerId = await RequireCustomerIdAsync(); + var config = await GetOrCreateConfigAsync(customerId); + return MapOutput(config); + } + + [HttpPost("/xhs/human-config/save")] + public async Task SaveAsync(XhsHumanConfigSaveInput input) + { + var customerId = await RequireCustomerIdAsync(); + var config = await GetOrCreateConfigAsync(customerId); + config.BehaviorJson = OmsDocHelper.SerializeJson(input.Behavior) ?? XhsHumanDefaults.BehaviorJson; + config.GuardJson = OmsDocHelper.SerializeJson(input.Guard) ?? XhsHumanDefaults.GuardJson; + config.Version += 1; + await HumanConfigRepository.UpdateAsync(config); + return MapOutput(config); + } + + [HttpPost("/xhs/human-config/pull")] + public async Task PullAsync(XhsHumanConfigPullInput input) + { + var customerId = await RequireCustomerIdAsync(); + _ = await GetOwnedAgentByDeviceAsync(input.DeviceId.Trim(), customerId); + var config = await GetOrCreateConfigAsync(customerId); + if (config.Version == input.KnownVersion) + { + return new XhsHumanConfigPullOutput { Unchanged = true, Version = config.Version }; + } + + return new XhsHumanConfigPullOutput + { + Unchanged = false, + Behavior = OmsDocHelper.DeserializeJsonObject(config.BehaviorJson), + Guard = OmsDocHelper.DeserializeJsonObject(config.GuardJson), + Version = config.Version + }; + } + + private async Task GetOrCreateConfigAsync(int customerId) + { + var config = await HumanConfigRepository.GetByCustomerIdAsync(customerId); + if (config != null) + { + return config; + } + + config = new XhsHumanConfig + { + CustomerId = customerId, + BehaviorJson = XhsHumanDefaults.BehaviorJson, + GuardJson = XhsHumanDefaults.GuardJson, + Version = 1 + }; + await HumanConfigRepository.InsertAsync(config); + return config; + } + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作"); + } + + return customerId.Value; + } + + private async Task GetOwnedAgentByDeviceAsync(string deviceId, int customerId) + { + var agent = await AgentRepository.GetByDeviceIdAsync(deviceId); + if (agent == null || agent.CustomerId != customerId) + { + throw new BusinessException("Agent 设备不存在"); + } + + return agent; + } + + private static XhsHumanConfigOutput MapOutput(XhsHumanConfig config) => new() + { + Behavior = OmsDocHelper.DeserializeJsonObject(config.BehaviorJson), + Guard = OmsDocHelper.DeserializeJsonObject(config.GuardJson), + Version = config.Version + }; +} diff --git a/src/Oms.Application/Services/Xhs/XhsMediaService.cs b/src/Oms.Application/Services/Xhs/XhsMediaService.cs new file mode 100644 index 0000000..5fef530 --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsMediaService.cs @@ -0,0 +1,71 @@ +using Fake.Application; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsMediaService : ApplicationService +{ + private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/media/upload")] + public async Task UploadAsync(IFormFile file) + { + if (file == null || file.Length == 0) + { + throw new BusinessException("请选择文件"); + } + + var customerId = await RequireCustomerIdAsync(); + var uploadsRoot = Path.Combine(WebHostEnvironment.WebRootPath ?? "wwwroot", "uploads", "xhs", customerId.ToString()); + Directory.CreateDirectory(uploadsRoot); + + var ext = Path.GetExtension(file.FileName); + var storedName = $"{Guid.NewGuid():N}{ext}"; + var fullPath = Path.Combine(uploadsRoot, storedName); + await using (var stream = File.Create(fullPath)) + { + await file.CopyToAsync(stream); + } + + var relativePath = Path.Combine("uploads", "xhs", customerId.ToString(), storedName).Replace('\\', '/'); + var publicUrl = $"/{relativePath}"; + + var media = new XhsMedia + { + CustomerId = customerId, + FileName = file.FileName, + StoragePath = relativePath, + PublicUrl = publicUrl, + MimeType = file.ContentType, + FileSizeBytes = file.Length + }; + await MediaRepository.InsertAsync(media); + + return new XhsMediaUploadOutput + { + MediaId = media.Id, + PublicUrl = publicUrl + }; + } + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作"); + } + + return customerId.Value; + } +} diff --git a/src/Oms.Application/Services/Xhs/XhsPublishService.cs b/src/Oms.Application/Services/Xhs/XhsPublishService.cs new file mode 100644 index 0000000..85065ba --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsPublishService.cs @@ -0,0 +1,163 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; +using System.Linq.Expressions; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsPublishService : ApplicationService +{ + private IXhsPublishRecordRepository PublishRecordRepository => LazyServiceProvider.GetRequiredService(); + private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/publish/list")] + public async Task> ListAsync(XhsPublishListInput input) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var status = input.Status?.Trim(); + var keyword = input.Keyword?.Trim(); + + Expression> predicate = x => + (!customerId.HasValue || x.CustomerId == customerId.Value) && + (string.IsNullOrWhiteSpace(status) || x.Status == status) && + (string.IsNullOrWhiteSpace(keyword) || x.Title.Contains(keyword)); + + var items = await PublishRecordRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(XhsPublishRecord.CreateTime)] = false }); + var total = await PublishRecordRepository.CountAsync(predicate); + + return new OmsPageResult + { + Items = items.Select(MapRecord).ToList(), + Total = total + }; + } + + [HttpPost("/xhs/publish/cancel")] + public async Task CancelAsync(XhsPublishCancelInput input) + { + var customerId = await RequireCustomerIdAsync(); + var record = await GetOwnedRecordAsync(input.PublishRecordId, customerId); + if (record.Status != XhsPublishStatusCodes.Scheduled) + { + throw new BusinessException("仅定时发布可取消"); + } + + record.Status = XhsPublishStatusCodes.Cancelled; + await PublishRecordRepository.UpdateAsync(record); + + if (record.TaskId is > 0) + { + var task = await TaskRepository.FirstOrDefaultAsync(x => x.Id == record.TaskId); + if (task != null && task.Status is XhsTaskStatusCodes.Pending or XhsTaskStatusCodes.Claimed) + { + task.Status = XhsTaskStatusCodes.Cancelled; + task.FinishedAt = DateTime.Now; + await TaskRepository.UpdateAsync(task); + } + } + } + + [HttpPost("/xhs/publish/retry")] + public async Task RetryAsync(XhsPublishRetryInput input) + { + var customerId = await RequireCustomerIdAsync(); + var record = await GetOwnedRecordAsync(input.PublishRecordId, customerId); + if (record.Status != XhsPublishStatusCodes.Failed) + { + throw new BusinessException("仅失败记录可重试"); + } + + var task = await CreatePublishTaskAsync(record, input.AgentId); + record.Status = XhsPublishStatusCodes.Publishing; + record.TaskId = task.Id; + record.Success = null; + record.ResultSummary = null; + await PublishRecordRepository.UpdateAsync(record); + + return new XhsCreationPublishOutput + { + PublishRecordId = record.Id, + TaskId = task.Id + }; + } + + internal async Task CreatePublishTaskAsync( + XhsPublishRecord record, + int? agentId, + string? source = null) + { + var args = new Dictionary + { + ["title"] = record.Title, + ["content"] = record.ContentFull ?? record.ContentPreview ?? string.Empty, + ["images"] = OmsDocHelper.DeserializeStringList(record.MediaJson), + ["tags"] = OmsDocHelper.DeserializeStringList(record.TagsJson), + ["visibility"] = record.Visibility ?? "公开可见" + }; + + var task = new XhsTask + { + CustomerId = record.CustomerId, + AgentId = agentId, + TaskType = XhsTaskTypeCodes.Publish, + ToolName = record.ToolName, + ArgumentsJson = OmsDocHelper.SerializeJson(args) ?? "{}", + Source = source ?? XhsTaskSourceCodes.Web, + SourceRefId = record.Id.ToString(), + Status = XhsTaskStatusCodes.Pending, + Priority = 5 + }; + await TaskRepository.InsertAsync(task); + return task; + } + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作"); + } + + return customerId.Value; + } + + private async Task GetOwnedRecordAsync(int id, int customerId) + { + var record = await PublishRecordRepository.FirstOrDefaultAsync(x => x.Id == id); + if (record == null || record.CustomerId != customerId) + { + throw new BusinessException("发布记录不存在"); + } + + return record; + } + + private static XhsPublishRecordOutput MapRecord(XhsPublishRecord record) => new() + { + Id = record.Id, + ToolName = record.ToolName, + Title = record.Title, + ContentPreview = record.ContentPreview, + Status = record.Status, + Success = record.Success, + ResultSummary = record.ResultSummary, + ScheduledTime = record.ScheduledTime, + PublishedTime = record.PublishedTime, + CreateTime = record.CreateTime + }; +} diff --git a/src/Oms.Application/Services/Xhs/XhsSkillService.cs b/src/Oms.Application/Services/Xhs/XhsSkillService.cs new file mode 100644 index 0000000..358ba75 --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsSkillService.cs @@ -0,0 +1,200 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsSkillService : ApplicationService +{ + private IXhsSkillRepository SkillRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/skill/list")] + public async Task> ListAsync(XhsSkillListInput input) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, input.CustomerId); + var listCustomerId = customerId ?? 0; + + var items = await SkillRepository.GetListAsync(x => + x.CustomerId == listCustomerId || x.CustomerId == 0); + items = items.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToList(); + + return new OmsItemsResult + { + Items = items.Select(MapSkill).ToList() + }; + } + + [HttpPost("/xhs/skill/save")] + public async Task SaveAsync(XhsSkillSaveInput input) + { + var customerId = await RequireCustomerIdAsync(); + XhsSkill entity; + if (input.Id is > 0) + { + entity = await SkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value) + ?? throw new BusinessException("技能不存在"); + if (entity.CustomerId != customerId) + { + throw new BusinessException("无权修改该技能"); + } + } + else + { + entity = new XhsSkill + { + CustomerId = customerId, + ExternalId = Guid.NewGuid().ToString("N")[..12] + }; + } + + entity.Title = input.Title.Trim(); + entity.SkillType = string.IsNullOrWhiteSpace(input.SkillType) ? "ai_create" : input.SkillType.Trim(); + entity.PresetId = string.IsNullOrWhiteSpace(input.PresetId) ? null : input.PresetId.Trim(); + entity.RecipeJson = OmsDocHelper.SerializeJson(input.Recipe) ?? "{}"; + + if (input.Id is > 0) + { + await SkillRepository.UpdateAsync(entity); + } + else + { + await SkillRepository.InsertAsync(entity); + } + + return MapSkill(entity); + } + + [HttpPost("/xhs/skill/delete")] + public async Task DeleteAsync(IdInput input) + { + var customerId = await RequireCustomerIdAsync(); + var entity = await SkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id); + if (entity == null || entity.CustomerId != customerId) + { + throw new BusinessException("技能不存在"); + } + + await SkillRepository.DeleteAsync(entity); + } + + [HttpPost("/xhs/skill/run")] + public async Task RunAsync(XhsSkillRunInput input) + { + var customerId = await RequireCustomerIdAsync(); + var entity = await SkillRepository.FirstOrDefaultAsync(x => x.Id == input.SkillId); + if (entity == null || (entity.CustomerId != customerId && entity.CustomerId != 0)) + { + throw new BusinessException("技能不存在"); + } + + var runMode = string.IsNullOrWhiteSpace(input.RunMode) ? "analyze_only" : input.RunMode.Trim(); + var topic = input.TopicOverride?.Trim() + ?? XhsHelper.ExtractRecipeNotes(entity.RecipeJson) + ?? entity.Title; + + if (runMode == "analyze_only") + { + var analysisService = LazyServiceProvider.GetRequiredService(); + var started = await analysisService.StartSessionAsync( + customerId, + new XhsAnalysisStartInput + { + AnalysisType = XhsAnalysisTypeCodes.Search, + Keyword = topic, + AgentId = input.AgentId, + SkillId = entity.Id + }); + + return new XhsSkillRunOutput + { + SessionId = started.SessionId, + TaskIds = [started.TaskId] + }; + } + + if (runMode is "create_only" or "create_and_publish") + { + if (string.IsNullOrWhiteSpace(input.ProviderKey)) + { + throw new BusinessException("create 模式需指定 providerKey"); + } + + var creationService = LazyServiceProvider.GetRequiredService(); + var generated = await creationService.GenerateAsync(new XhsCreationGenerateInput + { + SkillId = entity.Id, + ProviderKey = input.ProviderKey.Trim(), + Topic = topic, + ExtraNotes = XhsHelper.ExtractRecipeNotes(entity.RecipeJson) + }); + + if (runMode == "create_only") + { + return new XhsSkillRunOutput + { + GeneratedTitle = generated.Title, + GeneratedContent = generated.Content, + GeneratedTags = generated.Tags + }; + } + + var mediaUrls = input.MediaUrls ?? []; + if (mediaUrls.Count == 0) + { + throw new BusinessException("create_and_publish 需至少上传一张图片"); + } + + var published = await creationService.PublishAsync(new XhsCreationPublishInput + { + AgentId = input.AgentId, + Title = generated.Title, + Content = generated.Content, + Tags = generated.Tags, + MediaUrls = mediaUrls, + SkillId = entity.Id + }); + + return new XhsSkillRunOutput + { + GeneratedTitle = generated.Title, + GeneratedContent = generated.Content, + GeneratedTags = generated.Tags, + PublishRecordId = published.PublishRecordId, + TaskIds = published.TaskId is > 0 ? [published.TaskId.Value] : [] + }; + } + + throw new BusinessException($"未知 runMode: {runMode}"); + } + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作"); + } + + return customerId.Value; + } + + private static XhsSkillOutput MapSkill(XhsSkill skill) => new() + { + Id = skill.Id, + CustomerId = skill.CustomerId, + ExternalId = skill.ExternalId, + Title = skill.Title, + SkillType = skill.SkillType, + PresetId = skill.PresetId, + Recipe = OmsDocHelper.DeserializeJsonObject(skill.RecipeJson), + SortOrder = skill.SortOrder + }; +} diff --git a/src/Oms.Application/Services/Xhs/XhsTaskService.cs b/src/Oms.Application/Services/Xhs/XhsTaskService.cs new file mode 100644 index 0000000..602a44a --- /dev/null +++ b/src/Oms.Application/Services/Xhs/XhsTaskService.cs @@ -0,0 +1,274 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Xhs; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; +using System.Linq.Expressions; + +namespace Oms.Application.Services.Xhs; + +[ApiExplorerSettings(GroupName = "OMS-Xhs")] +public class XhsTaskService : ApplicationService +{ + private IXhsAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService(); + private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService(); + private IXhsHumanConfigRepository HumanConfigRepository => LazyServiceProvider.GetRequiredService(); + private IXhsPublishRecordRepository PublishRecordRepository => LazyServiceProvider.GetRequiredService(); + private IXhsAnalysisSessionRepository AnalysisSessionRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/xhs/task/claim")] + public async Task ClaimAsync(XhsTaskClaimInput input) + { + var customerId = await RequireCustomerIdAsync(); + var agent = await GetOwnedAgentByDeviceAsync(input.DeviceId.Trim(), customerId); + var limit = input.Limit <= 0 ? 1 : Math.Min(input.Limit, 5); + var tasks = await TaskRepository.ClaimPendingAsync(customerId, agent.Id, limit); + var humanVersion = await GetHumanConfigVersionAsync(customerId); + + return new XhsTaskClaimOutput + { + Items = tasks.Select(x => new XhsTaskClaimItemOutput + { + Id = x.Id, + TaskType = x.TaskType, + ToolName = x.ToolName, + Arguments = OmsDocHelper.DeserializeJsonObject(x.ArgumentsJson), + HumanConfigVersion = humanVersion + }).ToList() + }; + } + + [HttpPost("/xhs/task/report")] + public async Task ReportAsync(XhsTaskReportInput input) + { + var customerId = await RequireCustomerIdAsync(); + var task = await TaskRepository.FirstOrDefaultAsync(x => x.Id == input.TaskId); + if (task == null || task.CustomerId != customerId) + { + throw new BusinessException("任务不存在"); + } + + var status = input.Status.Trim().ToLowerInvariant(); + task.ProgressText = NormalizeOptional(input.ProgressText); + task.ErrorMessage = NormalizeOptional(input.ErrorMessage); + + if (status == XhsTaskStatusCodes.Running) + { + task.Status = XhsTaskStatusCodes.Running; + task.StartedAt ??= DateTime.Now; + } + else if (status == XhsTaskStatusCodes.Succeeded) + { + task.Status = XhsTaskStatusCodes.Succeeded; + task.ResultJson = OmsDocHelper.SerializeJson(input.Result); + task.FinishedAt = DateTime.Now; + } + else if (status == XhsTaskStatusCodes.Failed) + { + task.Status = XhsTaskStatusCodes.Failed; + task.FinishedAt = DateTime.Now; + } + else + { + throw new BusinessException("无效的任务状态"); + } + + await TaskRepository.UpdateAsync(task); + await SyncPublishRecordAsync(task, status, input); + await SyncAnalysisSessionAsync(task, status, input); + } + + [HttpPost("/xhs/task/list")] + public async Task> ListAsync(XhsTaskListInput input) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var status = input.Status?.Trim(); + var taskType = input.TaskType?.Trim(); + var sourceRefId = input.SourceRefId?.Trim(); + + Expression> predicate = x => + (!customerId.HasValue || x.CustomerId == customerId.Value) && + (string.IsNullOrWhiteSpace(status) || x.Status == status) && + (string.IsNullOrWhiteSpace(taskType) || x.TaskType == taskType) && + (string.IsNullOrWhiteSpace(sourceRefId) || x.SourceRefId == sourceRefId); + + var items = await TaskRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(XhsTask.Id)] = false }); + var total = await TaskRepository.CountAsync(predicate); + + return new OmsPageResult + { + Items = items.Select(MapTask).ToList(), + Total = total + }; + } + + [HttpPost("/xhs/task/cancel")] + public async Task CancelAsync(XhsTaskCancelInput input) + { + var customerId = await RequireCustomerIdAsync(); + var task = await TaskRepository.FirstOrDefaultAsync(x => x.Id == input.TaskId); + if (task == null || task.CustomerId != customerId) + { + throw new BusinessException("任务不存在"); + } + + if (task.Status is not (XhsTaskStatusCodes.Pending or XhsTaskStatusCodes.Claimed)) + { + throw new BusinessException("当前状态不可取消"); + } + + task.Status = XhsTaskStatusCodes.Cancelled; + task.FinishedAt = DateTime.Now; + await TaskRepository.UpdateAsync(task); + } + + private async Task SyncAnalysisSessionAsync(XhsTask task, string status, XhsTaskReportInput input) + { + if (task.TaskType is not (XhsTaskTypeCodes.Browse or XhsTaskTypeCodes.FeedDetail)) + { + return; + } + + if (string.IsNullOrWhiteSpace(task.SourceRefId) || + !int.TryParse(task.SourceRefId, out var sessionId)) + { + return; + } + + var session = await AnalysisSessionRepository.FirstOrDefaultAsync(x => x.Id == sessionId); + if (session == null || session.CustomerId != task.CustomerId) + { + return; + } + + if (status == XhsTaskStatusCodes.Succeeded) + { + session.FeedsJson = OmsDocHelper.SerializeJson(input.Result); + await AnalysisSessionRepository.UpdateAsync(session); + } + else if (status == XhsTaskStatusCodes.Failed) + { + session.FeedsJson = OmsDocHelper.SerializeJson(new + { + error = task.ErrorMessage ?? task.ProgressText ?? "采集失败" + }); + await AnalysisSessionRepository.UpdateAsync(session); + } + } + + private async Task SyncPublishRecordAsync(XhsTask task, string status, XhsTaskReportInput input) + { + if (task.TaskType != XhsTaskTypeCodes.Publish || string.IsNullOrWhiteSpace(task.SourceRefId)) + { + return; + } + + if (!int.TryParse(task.SourceRefId, out var publishId)) + { + return; + } + + var record = await PublishRecordRepository.FirstOrDefaultAsync(x => x.Id == publishId); + if (record == null || record.CustomerId != task.CustomerId) + { + return; + } + + record.TaskId = task.Id; + if (status == XhsTaskStatusCodes.Running) + { + record.Status = XhsPublishStatusCodes.Publishing; + } + else if (status == XhsTaskStatusCodes.Succeeded) + { + record.Status = XhsPublishStatusCodes.Succeeded; + record.Success = true; + record.PublishedTime = DateTime.Now; + record.ResultSummary = ExtractResultSummary(input.Result) ?? task.ProgressText; + } + else if (status == XhsTaskStatusCodes.Failed) + { + record.Status = XhsPublishStatusCodes.Failed; + record.Success = false; + record.ResultSummary = task.ErrorMessage ?? task.ProgressText; + } + + await PublishRecordRepository.UpdateAsync(record); + } + + private static string? ExtractResultSummary(object? result) + { + if (result == null) + { + return null; + } + + if (result is string text) + { + return text; + } + + var json = OmsDocHelper.SerializeJson(result); + var obj = OmsDocHelper.DeserializeJson>(json); + if (obj != null && obj.TryGetValue("summary", out var summary)) + { + return summary?.ToString(); + } + + return json; + } + + private async Task RequireCustomerIdAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (!customerId.HasValue) + { + throw new BusinessException("请使用客户账号操作"); + } + + return customerId.Value; + } + + private async Task GetOwnedAgentByDeviceAsync(string deviceId, int customerId) + { + var agent = await AgentRepository.GetByDeviceIdAsync(deviceId); + if (agent == null || agent.CustomerId != customerId) + { + throw new BusinessException("Agent 设备不存在"); + } + + return agent; + } + + private async Task GetHumanConfigVersionAsync(int customerId) + { + var config = await HumanConfigRepository.GetByCustomerIdAsync(customerId); + return config?.Version ?? 1; + } + + private static XhsTaskOutput MapTask(XhsTask task) => new() + { + Id = task.Id, + TaskType = task.TaskType, + ToolName = task.ToolName, + Status = task.Status, + ProgressText = task.ProgressText, + ErrorMessage = task.ErrorMessage, + CreateTime = task.CreateTime, + FinishedAt = task.FinishedAt + }; + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/Oms.Application/Shared/XhsHelper.cs b/src/Oms.Application/Shared/XhsHelper.cs new file mode 100644 index 0000000..3594f44 --- /dev/null +++ b/src/Oms.Application/Shared/XhsHelper.cs @@ -0,0 +1,82 @@ +using Oms.Domain.XhsAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// 小红书模块通用辅助。 +/// +public static class XhsHelper +{ + public const int OnlineThresholdSeconds = 90; + + public static bool IsAgentOnline(XhsAgent agent, DateTime? now = null) + { + if (!string.Equals(agent.Status, "online", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (agent.LastHeartbeatTime == null) + { + return false; + } + + var current = now ?? DateTime.Now; + return (current - agent.LastHeartbeatTime.Value).TotalSeconds <= OnlineThresholdSeconds; + } + + public static string BuildContentPreview(string? content, int maxLen = 200) + { + if (string.IsNullOrWhiteSpace(content)) + { + return string.Empty; + } + + var text = content.Trim(); + return text.Length <= maxLen ? text : text[..maxLen]; + } + + public static string? ExtractRecipeNotes(string? recipeJson) + { + if (string.IsNullOrWhiteSpace(recipeJson)) + { + return null; + } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(recipeJson); + if (doc.RootElement.TryGetProperty("notes", out var notes)) + { + var text = notes.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + { + return text.Trim(); + } + } + + if (doc.RootElement.TryGetProperty("body", out var body) && + body.TryGetProperty("domain", out var domain)) + { + return domain.GetString()?.Trim(); + } + } + catch + { + return null; + } + + return null; + } + + public static string SerializeFeedsForPrompt(object? feeds) + { + if (feeds == null) + { + return string.Empty; + } + + var json = OmsDocHelper.SerializeJson(feeds) ?? string.Empty; + return json.Length <= 12000 ? json : json[..12000]; + } +} diff --git a/src/Oms.Application/Shared/XhsHumanDefaults.cs b/src/Oms.Application/Shared/XhsHumanDefaults.cs new file mode 100644 index 0000000..f444da1 --- /dev/null +++ b/src/Oms.Application/Shared/XhsHumanDefaults.cs @@ -0,0 +1,47 @@ +namespace Oms.Application.Shared; + +/// +/// LIKE AI 拟人配置默认值(与 human.py / human_guard.py 内置默认对齐的简化版)。 +/// +public static class XhsHumanDefaults +{ + public const string BehaviorJson = """ + { + "default_max_attempts": 500, + "human_delay": { "min_ms": 300, "max_ms": 700 }, + "home_browse_plan": { + "scroll_lo": 16, "scroll_hi": 51, + "click_lo": 2, "click_hi": 8 + }, + "search_browse_plan": { + "scroll_lo": 13, "scroll_hi": 39, + "click_lo": 1, "click_hi": 5 + } + } + """; + + public const string GuardJson = """ + { + "publish": { + "max_per_day": 2, + "min_interval_hours": 6, + "pre_delay_min_sec": 60, + "pre_delay_max_sec": 180 + }, + "browse": { + "min_interval_sec": 25, + "max_interval_sec": 45, + "max_per_hour": 20 + }, + "interact": { + "min_interval_sec": 45, + "max_per_day": 10 + }, + "quiet_hours": { + "enabled": true, + "start": "02:00", + "end": "07:00" + } + } + """; +} diff --git a/src/Oms.Application/Shared/XhsLlmClient.cs b/src/Oms.Application/Shared/XhsLlmClient.cs new file mode 100644 index 0000000..9b775d6 --- /dev/null +++ b/src/Oms.Application/Shared/XhsLlmClient.cs @@ -0,0 +1,118 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Fake.Application; +using Microsoft.Extensions.DependencyInjection; +using Oms.Domain.XhsAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。 +/// +public class XhsLlmClient +{ + public const string HttpClientName = nameof(XhsLlmClient); + + private readonly IHttpClientFactory _httpClientFactory; + + public XhsLlmClient(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public async Task ChatCompletionAsync( + XhsAiProvider provider, + IReadOnlyList<(string Role, string Content)> messages, + string? modelOverride = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(provider.ApiKeyEnc)) + { + throw new BusinessException($"请先配置 {provider.DisplayName} 的 API Key"); + } + + var model = modelOverride ?? provider.ApiSecretEnc; + if (string.IsNullOrWhiteSpace(model)) + { + throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)"); + } + + var url = BuildChatUrl(provider.BaseUrl); + var payload = new + { + model, + messages = messages.Select(x => new { role = x.Role, content = x.Content }).ToList(), + temperature = 0.7 + }; + + var client = _httpClientFactory.CreateClient(HttpClientName); + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", provider.ApiKeyEnc.Trim()); + request.Content = JsonContent.Create(payload); + + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new BusinessException($"AI 请求失败 ({(int)response.StatusCode}): {TrimError(body)}"); + } + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.TryGetProperty("choices", out var choices) && + choices.GetArrayLength() > 0 && + choices[0].TryGetProperty("message", out var message) && + message.TryGetProperty("content", out var content)) + { + return content.GetString()?.Trim() ?? string.Empty; + } + + if (root.TryGetProperty("error", out var error) && + error.TryGetProperty("message", out var errorMessage)) + { + throw new BusinessException(errorMessage.GetString() ?? "AI 返回错误"); + } + + throw new BusinessException("AI 响应格式无法解析"); + } + + private static string BuildChatUrl(string baseUrl) + { + var url = baseUrl.Trim().TrimEnd('/'); + if (url.EndsWith("/chat/completions", StringComparison.OrdinalIgnoreCase)) + { + return url; + } + + if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + return url + "/chat/completions"; + } + + return url + "/v1/chat/completions"; + } + + private static string TrimError(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return "空响应"; + } + + return body.Length <= 300 ? body : body[..300]; + } +} + +public static class XhsLlmServiceExtensions +{ + public static IServiceCollection AddXhsLlmClient(this IServiceCollection services) + { + services.AddHttpClient(XhsLlmClient.HttpClientName, client => + { + client.Timeout = TimeSpan.FromMinutes(3); + }); + services.AddTransient(); + return services; + } +} diff --git a/src/Oms.Application/Shared/XhsTaskFactory.cs b/src/Oms.Application/Shared/XhsTaskFactory.cs new file mode 100644 index 0000000..c5cc46c --- /dev/null +++ b/src/Oms.Application/Shared/XhsTaskFactory.cs @@ -0,0 +1,36 @@ +using Fake.Application; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// Agent 远程控制动作 → 任务定义。 +/// +public static class XhsTaskFactory +{ + public static XhsTask CreateControlTask(int customerId, int agentId, string action) + { + var (taskType, toolName, args) = action switch + { + XhsAgentControlActions.EngineStart => (XhsTaskTypeCodes.EngineStart, null, "{}"), + XhsAgentControlActions.EngineStop => (XhsTaskTypeCodes.EngineStop, null, "{}"), + XhsAgentControlActions.BrowserLogin => (XhsTaskTypeCodes.BrowserLogin, "browser_login", "{}"), + XhsAgentControlActions.ClearCookies => (XhsTaskTypeCodes.ClearCookies, "delete_cookies", "{}"), + XhsAgentControlActions.CheckLogin => (XhsTaskTypeCodes.CheckLogin, "check_login_status", "{}"), + _ => throw new BusinessException($"未知控制动作: {action}") + }; + + return new XhsTask + { + CustomerId = customerId, + AgentId = agentId, + TaskType = taskType, + ToolName = toolName, + ArgumentsJson = args, + Source = XhsTaskSourceCodes.Web, + Priority = 10, + Status = XhsTaskStatusCodes.Pending + }; + } +} diff --git a/src/Oms.Application/Workers/XhsScheduledPublishWorker.cs b/src/Oms.Application/Workers/XhsScheduledPublishWorker.cs new file mode 100644 index 0000000..fb1cfa5 --- /dev/null +++ b/src/Oms.Application/Workers/XhsScheduledPublishWorker.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Xhs; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Workers; + +/// +/// 扫描到期定时发布记录并创建 Agent 任务。 +/// +public class XhsScheduledPublishWorker : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1); + private const int BatchSize = 10; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public XhsScheduledPublishWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessDueAsync(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "小红书定时发布调度异常"); + } + + try + { + await Task.Delay(PollInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task ProcessDueAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var publishRecordRepository = scope.ServiceProvider.GetRequiredService(); + var publishService = scope.ServiceProvider.GetRequiredService(); + + var dueRecords = await publishRecordRepository.GetDueScheduledAsync(BatchSize, cancellationToken); + foreach (var record in dueRecords) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (record.Status != XhsPublishStatusCodes.Scheduled) + { + continue; + } + + record.Status = XhsPublishStatusCodes.Publishing; + await publishRecordRepository.UpdateAsync(record); + + var task = await publishService.CreatePublishTaskAsync(record, agentId: null, XhsTaskSourceCodes.Schedule); + record.TaskId = task.Id; + await publishRecordRepository.UpdateAsync(record); + + _logger.LogInformation( + "小红书定时发布已派发 publishRecordId={PublishRecordId} taskId={TaskId}", + record.Id, + task.Id); + } + } +} diff --git a/src/Oms.Domain/XhsAggregate/Consts/XhsCodes.cs b/src/Oms.Domain/XhsAggregate/Consts/XhsCodes.cs new file mode 100644 index 0000000..5fee33b --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Consts/XhsCodes.cs @@ -0,0 +1,65 @@ +namespace Oms.Domain.XhsAggregate.Consts; + +public static class XhsAgentStatusCodes +{ + public const string Online = "online"; + public const string Offline = "offline"; +} + +public static class XhsTaskStatusCodes +{ + public const string Pending = "pending"; + public const string Claimed = "claimed"; + public const string Running = "running"; + public const string Succeeded = "succeeded"; + public const string Failed = "failed"; + public const string Cancelled = "cancelled"; +} + +public static class XhsTaskTypeCodes +{ + public const string EngineStart = "engine_start"; + public const string EngineStop = "engine_stop"; + public const string BrowserLogin = "browser_login"; + public const string ClearCookies = "clear_cookies"; + public const string CheckLogin = "check_login"; + public const string Browse = "browse"; + public const string FeedDetail = "feed_detail"; + public const string Interact = "interact"; + public const string Publish = "publish"; + public const string SkillPipeline = "skill_pipeline"; +} + +public static class XhsTaskSourceCodes +{ + public const string Web = "web"; + public const string Schedule = "schedule"; + public const string Skill = "skill"; +} + +public static class XhsPublishStatusCodes +{ + public const string Draft = "draft"; + public const string Scheduled = "scheduled"; + public const string Publishing = "publishing"; + public const string Succeeded = "succeeded"; + public const string Failed = "failed"; + public const string Cancelled = "cancelled"; +} + +public static class XhsAgentControlActions +{ + public const string EngineStart = "engine_start"; + public const string EngineStop = "engine_stop"; + public const string BrowserLogin = "browser_login"; + public const string ClearCookies = "clear_cookies"; + public const string CheckLogin = "check_login"; +} + +public static class XhsAnalysisTypeCodes +{ + public const string HomeFeed = "home_feed"; + public const string Search = "search"; + public const string MyFeeds = "my_feeds"; + public const string FeedDetail = "feed_detail"; +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsAgent.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsAgent.cs new file mode 100644 index 0000000..43c81ea --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsAgent.cs @@ -0,0 +1,32 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_agent")] +public class XhsAgent : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public string DeviceId { get; set; } = string.Empty; + + public string? DeviceName { get; set; } + + public string? AgentVersion { get; set; } + + public int BridgePort { get; set; } = 9333; + + public string Status { get; set; } = "offline"; + + public bool EngineRunning { get; set; } + + public bool ExtensionConnected { get; set; } + + public bool XhsLoggedIn { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? LastHeartbeatTime { get; set; } + + [SugarColumn(IsNullable = true)] + public string? LastError { get; set; } +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsAiProvider.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsAiProvider.cs new file mode 100644 index 0000000..0504237 --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsAiProvider.cs @@ -0,0 +1,28 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_ai_provider")] +public class XhsAiProvider : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public string ProviderKey { get; set; } = string.Empty; + + public string ProviderType { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public string BaseUrl { get; set; } = string.Empty; + + [SugarColumn(IsNullable = true)] + public string? ApiKeyEnc { get; set; } + + [SugarColumn(IsNullable = true)] + public string? ApiSecretEnc { get; set; } + + public bool IsDefault { get; set; } + + public bool Enabled { get; set; } = true; +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsAnalysisSession.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsAnalysisSession.cs new file mode 100644 index 0000000..e7fd1f1 --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsAnalysisSession.cs @@ -0,0 +1,33 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_analysis_session")] +public class XhsAnalysisSession : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + [SugarColumn(IsNullable = true)] + public string? SessionName { get; set; } + + public string AnalysisType { get; set; } = string.Empty; + + [SugarColumn(IsNullable = true)] + public string? Keyword { get; set; } + + [SugarColumn(IsNullable = true)] + public int? TaskId { get; set; } + + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? FeedsJson { get; set; } + + [SugarColumn(IsNullable = true)] + public string? SelectedFeedId { get; set; } + + [SugarColumn(ColumnDataType = "mediumtext", IsNullable = true)] + public string? AiAnalysisMarkdown { get; set; } + + [SugarColumn(IsNullable = true)] + public int? SkillId { get; set; } +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsHumanConfig.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsHumanConfig.cs new file mode 100644 index 0000000..562d9d2 --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsHumanConfig.cs @@ -0,0 +1,18 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_human_config")] +public class XhsHumanConfig : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + [SugarColumn(ColumnDataType = "json")] + public string BehaviorJson { get; set; } = "{}"; + + [SugarColumn(ColumnDataType = "json")] + public string GuardJson { get; set; } = "{}"; + + public int Version { get; set; } = 1; +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsMedia.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsMedia.cs new file mode 100644 index 0000000..8866b7c --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsMedia.cs @@ -0,0 +1,22 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_media")] +public class XhsMedia : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public string FileName { get; set; } = string.Empty; + + public string StoragePath { get; set; } = string.Empty; + + public string PublicUrl { get; set; } = string.Empty; + + [SugarColumn(IsNullable = true)] + public string? MimeType { get; set; } + + [SugarColumn(IsNullable = true)] + public long? FileSizeBytes { get; set; } +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsPublishRecord.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsPublishRecord.cs new file mode 100644 index 0000000..377f4e5 --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsPublishRecord.cs @@ -0,0 +1,49 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_publish_record")] +public class XhsPublishRecord : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + [SugarColumn(IsNullable = true)] + public int? TaskId { get; set; } + + public string ToolName { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + [SugarColumn(IsNullable = true)] + public string? ContentPreview { get; set; } + + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? ContentFull { get; set; } + + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? MediaJson { get; set; } + + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? TagsJson { get; set; } + + [SugarColumn(IsNullable = true)] + public string? Visibility { get; set; } + + public string Status { get; set; } = "draft"; + + [SugarColumn(IsNullable = true)] + public bool? Success { get; set; } + + [SugarColumn(IsNullable = true)] + public string? ResultSummary { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? ScheduledTime { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? PublishedTime { get; set; } + + [SugarColumn(IsNullable = true)] + public int? SkillId { get; set; } +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsSkill.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsSkill.cs new file mode 100644 index 0000000..d175e8b --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsSkill.cs @@ -0,0 +1,24 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_skill")] +public class XhsSkill : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public string ExternalId { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string SkillType { get; set; } = "ai_create"; + + [SugarColumn(IsNullable = true)] + public string? PresetId { get; set; } + + [SugarColumn(ColumnDataType = "json")] + public string RecipeJson { get; set; } = "{}"; + + public int SortOrder { get; set; } +} diff --git a/src/Oms.Domain/XhsAggregate/Entities/XhsTask.cs b/src/Oms.Domain/XhsAggregate/Entities/XhsTask.cs new file mode 100644 index 0000000..440bbac --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Entities/XhsTask.cs @@ -0,0 +1,51 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.XhsAggregate.Entities; + +[SugarTable("t_xhs_task")] +public class XhsTask : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + [SugarColumn(IsNullable = true)] + public int? AgentId { get; set; } + + public string TaskType { get; set; } = string.Empty; + + [SugarColumn(IsNullable = true)] + public string? ToolName { get; set; } + + public int Priority { get; set; } + + public string Status { get; set; } = "pending"; + + [SugarColumn(ColumnDataType = "json")] + public string ArgumentsJson { get; set; } = "{}"; + + [SugarColumn(IsNullable = true)] + public string? ProgressText { get; set; } + + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? ResultJson { get; set; } + + [SugarColumn(IsNullable = true)] + public string? ErrorMessage { get; set; } + + public string Source { get; set; } = "web"; + + [SugarColumn(IsNullable = true)] + public string? SourceRefId { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? ScheduledTime { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? ClaimedAt { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? StartedAt { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? FinishedAt { get; set; } +} diff --git a/src/Oms.Domain/XhsAggregate/Repositories/IXhsRepositories.cs b/src/Oms.Domain/XhsAggregate/Repositories/IXhsRepositories.cs new file mode 100644 index 0000000..62a69cd --- /dev/null +++ b/src/Oms.Domain/XhsAggregate/Repositories/IXhsRepositories.cs @@ -0,0 +1,42 @@ +using Fake.Domain.Repositories; +using Oms.Domain.XhsAggregate.Entities; + +namespace Oms.Domain.XhsAggregate.Repositories; + +public interface IXhsAgentRepository : IRepository +{ + Task GetByDeviceIdAsync(string deviceId, CancellationToken cancellationToken = default); +} + +public interface IXhsTaskRepository : IRepository +{ + Task> ClaimPendingAsync( + int customerId, + int agentId, + int limit, + CancellationToken cancellationToken = default); +} + +public interface IXhsPublishRecordRepository : IRepository +{ + Task> GetDueScheduledAsync(int limit, CancellationToken cancellationToken = default); +} + +public interface IXhsAnalysisSessionRepository : IRepository; + +public interface IXhsSkillRepository : IRepository +{ + Task GetByExternalIdAsync(int customerId, string externalId, CancellationToken cancellationToken = default); +} + +public interface IXhsHumanConfigRepository : IRepository +{ + Task GetByCustomerIdAsync(int customerId, CancellationToken cancellationToken = default); +} + +public interface IXhsAiProviderRepository : IRepository +{ + Task GetByProviderKeyAsync(int customerId, string providerKey, CancellationToken cancellationToken = default); +} + +public interface IXhsMediaRepository : IRepository; diff --git a/src/Oms.SqlSugarCore/Xhs/XhsRepositories.cs b/src/Oms.SqlSugarCore/Xhs/XhsRepositories.cs new file mode 100644 index 0000000..f1c8ef6 --- /dev/null +++ b/src/Oms.SqlSugarCore/Xhs/XhsRepositories.cs @@ -0,0 +1,96 @@ +using Fake.DependencyInjection; +using Oms.Domain.XhsAggregate.Consts; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; +using SqlSugar; + +namespace Oms.SqlSugarCore.Xhs; + +public class XhsAgentRepository : OmsSqlSugarRepository, IXhsAgentRepository, IScopedDependency +{ + public Task GetByDeviceIdAsync(string deviceId, CancellationToken cancellationToken = default) => + FirstOrDefaultAsync(x => x.DeviceId == deviceId, cancellationToken); +} + +public class XhsTaskRepository : OmsSqlSugarRepository, IXhsTaskRepository, IScopedDependency +{ + public async Task> ClaimPendingAsync( + int customerId, + int agentId, + int limit, + CancellationToken cancellationToken = default) + { + var now = DateTime.Now; + var db = await GetDbContextAsync(); + var query = db.Queryable() + .Where(x => x.CustomerId == customerId) + .Where(x => x.Status == XhsTaskStatusCodes.Pending) + .Where(x => x.AgentId == null || x.AgentId == agentId) + .Where(x => x.ScheduledTime == null || x.ScheduledTime <= now) + .OrderBy(x => x.Priority, OrderByType.Desc) + .OrderBy(x => x.Id, OrderByType.Asc) + .Take(limit); + + var tasks = await query.ToListAsync(cancellationToken); + if (tasks.Count == 0) + { + return tasks; + } + + var claimedAt = DateTime.Now; + foreach (var task in tasks) + { + task.Status = XhsTaskStatusCodes.Claimed; + task.AgentId = agentId; + task.ClaimedAt = claimedAt; + } + + await db.Updateable(tasks).ExecuteCommandAsync(cancellationToken); + return tasks; + } +} + +public class XhsPublishRecordRepository + : OmsSqlSugarRepository, IXhsPublishRecordRepository, IScopedDependency +{ + public async Task> GetDueScheduledAsync(int limit, CancellationToken cancellationToken = default) + { + var now = DateTime.Now; + var db = await GetDbContextAsync(); + return await db.Queryable() + .Where(x => x.Status == XhsPublishStatusCodes.Scheduled) + .Where(x => x.ScheduledTime != null && x.ScheduledTime <= now) + .OrderBy(x => x.ScheduledTime, OrderByType.Asc) + .Take(limit) + .ToListAsync(cancellationToken); + } +} + +public class XhsAnalysisSessionRepository + : OmsSqlSugarRepository, IXhsAnalysisSessionRepository, IScopedDependency; + +public class XhsSkillRepository : OmsSqlSugarRepository, IXhsSkillRepository, IScopedDependency +{ + public Task GetByExternalIdAsync(int customerId, string externalId, CancellationToken cancellationToken = default) => + FirstOrDefaultAsync(x => x.CustomerId == customerId && x.ExternalId == externalId, cancellationToken); +} + +public class XhsHumanConfigRepository + : OmsSqlSugarRepository, IXhsHumanConfigRepository, IScopedDependency +{ + public Task GetByCustomerIdAsync(int customerId, CancellationToken cancellationToken = default) => + FirstOrDefaultAsync(x => x.CustomerId == customerId, cancellationToken); +} + +public class XhsAiProviderRepository + : OmsSqlSugarRepository, IXhsAiProviderRepository, IScopedDependency +{ + public Task GetByProviderKeyAsync( + int customerId, + string providerKey, + CancellationToken cancellationToken = default) => + FirstOrDefaultAsync(x => x.CustomerId == customerId && x.ProviderKey == providerKey, cancellationToken); +} + +public class XhsMediaRepository : OmsSqlSugarRepository, IXhsMediaRepository, IScopedDependency; diff --git a/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs new file mode 100644 index 0000000..86a4f85 --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs @@ -0,0 +1,63 @@ +using Fake.Data.Seeding; +using Fake.Rbac.Application.Services; + +namespace SimpleAdmin.Api.Auth; + +/// +/// 启动时为 Customer / Admin 角色追加小红书权限(幂等合并,不覆盖已有权限)。 +/// +public class XhsRolePermissionSeedContributor(RoleService roleService) : IDataSeedContributor +{ + public const string AdminRoleCode = "Admin"; + + public static readonly string[] CustomerPermissions = + [ + "Xhs.Agent.Query", + "Xhs.Agent.Control", + "Xhs.Analysis.Use", + "Xhs.Creation.Use", + "Xhs.Publish.Query", + "Xhs.Publish.Cancel", + "Xhs.Skills.Query", + "Xhs.Skills.Save", + "Xhs.Skills.Run", + "Xhs.HumanSettings.Save", + "Xhs.Ai.Use", + "Xhs.Ai.Save" + ]; + + public async Task SeedAsync() + { + var roles = await roleService.GetAllRolesAsync(); + var customer = roles.FirstOrDefault(x => + string.Equals(x.Code, CustomerRoleSeedContributor.CustomerRoleCode, StringComparison.OrdinalIgnoreCase)); + if (customer != null) + { + await MergePermissionsAsync(customer.Id, CustomerPermissions); + } + + var admin = roles.FirstOrDefault(x => + string.Equals(x.Code, AdminRoleCode, StringComparison.OrdinalIgnoreCase)); + if (admin != null) + { + await MergePermissionsAsync(admin.Id, CustomerPermissions); + } + } + + private async Task MergePermissionsAsync(Guid roleId, IEnumerable requiredPermissions) + { + var existing = await roleService.GetRolePermissionsAsync(roleId); + var merged = existing + .Concat(requiredPermissions) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (merged.Count == existing.Count && + requiredPermissions.All(p => existing.Contains(p, StringComparer.OrdinalIgnoreCase))) + { + return; + } + + await roleService.AssignPermissionsAsync(roleId, merged); + } +}