feat: 新增小红书模块后端(Agent/任务/发布/分析/技能/AI)及 SQL 种子与 RBAC GUID 修复脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-30 09:43:09 +08:00
parent 42b41e68c7
commit af53c48f7f
38 changed files with 3981 additions and 0 deletions

181
db.sql
View File

@ -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;

View File

@ -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';

View File

@ -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 确认无脏行

View File

@ -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}$');

219
sql/install_xhs_module.sql Normal file
View File

@ -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`);

181
sql/migrate_t_xhs.sql Normal file
View File

@ -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='小红书创作上传媒体表';

View File

@ -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`);

View File

@ -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 自动合并)

View File

@ -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`
);

47
sql/seed_xhs_skills.sql Normal file
View File

@ -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`);

View File

@ -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<XhsTaskClaimItemOutput> 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<string>? MediaUrls { get; set; }
public List<string>? 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<XhsAiChatMessageInput> 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<string>? 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<string>? MediaUrls { get; set; }
}
public class XhsSkillRunOutput
{
public List<int> 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<string>? 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<string> Tags { get; set; } = [];
}
public class XhsMediaUploadOutput
{
public int MediaId { get; set; }
public string PublicUrl { get; set; } = string.Empty;
}

View File

@ -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<XhsScheduledPublishWorker>();
}
}

View File

@ -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<IXhsAgentRepository>();
private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService<IXhsTaskRepository>();
private IXhsHumanConfigRepository HumanConfigRepository => LazyServiceProvider.GetRequiredService<IXhsHumanConfigRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/agent/register")]
public async Task<XhsAgentRegisterOutput> 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<XhsAgentHeartbeatOutput> 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<OmsPageResult<XhsAgentOutput>> ListAsync(XhsAgentListInput input)
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
Expression<Func<XhsAgent, bool>> predicate = customerId.HasValue
? x => x.CustomerId == customerId.Value
: x => true;
var items = await AgentRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool> { [nameof(XhsAgent.Id)] = false });
var total = await AgentRepository.CountAsync(predicate);
return new OmsPageResult<XhsAgentOutput>
{
Items = items.Select(MapAgent).ToList(),
Total = total
};
}
[HttpPost("/xhs/agent/control")]
public async Task<XhsAgentControlOutput> 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<int> RequireCustomerIdAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
if (!customerId.HasValue)
{
throw new BusinessException("请使用客户账号操作,或指定客户上下文");
}
return customerId.Value;
}
private async Task<XhsAgent> 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<XhsAgent> 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<int> 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();
}

View File

@ -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<IXhsAiProviderRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/ai/provider/list")]
public async Task<OmsItemsResult<XhsAiProviderOutput>> ListProvidersAsync()
{
var customerId = await ResolveAiCustomerIdAsync();
var items = await AiProviderRepository.GetListAsync(x => x.CustomerId == customerId || x.CustomerId == 0);
return new OmsItemsResult<XhsAiProviderOutput>
{
Items = items.Select(x => MapProvider(x, maskSecret: true)).ToList()
};
}
[HttpPost("/xhs/ai/provider/save")]
public async Task<XhsAiProviderOutput> 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<XhsAiChatOutput> ChatAsync(XhsAiChatInput input)
{
var customerId = await ResolveAiCustomerIdAsync();
var provider = await ResolveProviderAsync(customerId, input.ProviderKey.Trim());
var llm = LazyServiceProvider.GetRequiredService<XhsLlmClient>();
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<XhsAiProvider> 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<int> 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();
}

View File

@ -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<IXhsAnalysisSessionRepository>();
private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService<IXhsTaskRepository>();
private IXhsSkillRepository SkillRepository => LazyServiceProvider.GetRequiredService<IXhsSkillRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/analysis/start")]
public async Task<XhsAnalysisStartOutput> StartAsync(XhsAnalysisStartInput input)
{
var customerId = await RequireCustomerIdAsync();
return await StartSessionAsync(customerId, input);
}
[HttpPost("/xhs/analysis/list")]
public async Task<OmsPageResult<XhsAnalysisSessionOutput>> ListAsync(XhsAnalysisListInput input)
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
Expression<Func<XhsAnalysisSession, bool>> predicate = x =>
!customerId.HasValue || x.CustomerId == customerId.Value;
var items = await AnalysisSessionRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool> { [nameof(XhsAnalysisSession.CreateTime)] = false });
var total = await AnalysisSessionRepository.CountAsync(predicate);
return new OmsPageResult<XhsAnalysisSessionOutput>
{
Items = items.Select(MapSession).ToList(),
Total = total
};
}
[HttpPost("/xhs/analysis/detail")]
public async Task<XhsAnalysisDetailOutput> 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<XhsAnalysisRunAiOutput> 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<XhsAiService>();
var provider = await aiService.ResolveProviderAsync(customerId, input.ProviderKey.Trim());
var llm = LazyServiceProvider.GetRequiredService<XhsLlmClient>();
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<XhsAnalysisInteractOutput> 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<XhsAnalysisStartOutput> 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<int> 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();
}

View File

@ -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<IXhsPublishRecordRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/creation/publish")]
public async Task<XhsCreationPublishOutput> 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<XhsPublishService>();
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<XhsCreationGenerateOutput> GenerateAsync(XhsCreationGenerateInput input)
{
var customerId = await RequireCustomerIdAsync();
var aiService = LazyServiceProvider.GetRequiredService<XhsAiService>();
var provider = await aiService.ResolveProviderAsync(customerId, input.ProviderKey.Trim());
var llm = LazyServiceProvider.GetRequiredService<XhsLlmClient>();
var skillHint = string.Empty;
if (input.SkillId is > 0)
{
var skillRepo = LazyServiceProvider.GetRequiredService<IXhsSkillRepository>();
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<Dictionary<string, object>>(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<string> 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<int> RequireCustomerIdAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
if (!customerId.HasValue)
{
throw new BusinessException("请使用客户账号操作");
}
return customerId.Value;
}
}

View File

@ -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<IXhsHumanConfigRepository>();
private IXhsAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService<IXhsAgentRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/human-config/get")]
public async Task<XhsHumanConfigOutput> GetAsync()
{
var customerId = await RequireCustomerIdAsync();
var config = await GetOrCreateConfigAsync(customerId);
return MapOutput(config);
}
[HttpPost("/xhs/human-config/save")]
public async Task<XhsHumanConfigOutput> 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<XhsHumanConfigPullOutput> 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<XhsHumanConfig> 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<int> RequireCustomerIdAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
if (!customerId.HasValue)
{
throw new BusinessException("请使用客户账号操作");
}
return customerId.Value;
}
private async Task<XhsAgent> 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
};
}

View File

@ -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<IXhsMediaRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
[HttpPost("/xhs/media/upload")]
public async Task<XhsMediaUploadOutput> 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<int> RequireCustomerIdAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
if (!customerId.HasValue)
{
throw new BusinessException("请使用客户账号操作");
}
return customerId.Value;
}
}

View File

@ -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<IXhsPublishRecordRepository>();
private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService<IXhsTaskRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/publish/list")]
public async Task<OmsPageResult<XhsPublishRecordOutput>> 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<Func<XhsPublishRecord, bool>> 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<string, bool> { [nameof(XhsPublishRecord.CreateTime)] = false });
var total = await PublishRecordRepository.CountAsync(predicate);
return new OmsPageResult<XhsPublishRecordOutput>
{
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<XhsCreationPublishOutput> 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<XhsTask> CreatePublishTaskAsync(
XhsPublishRecord record,
int? agentId,
string? source = null)
{
var args = new Dictionary<string, object?>
{
["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<int> RequireCustomerIdAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
if (!customerId.HasValue)
{
throw new BusinessException("请使用客户账号操作");
}
return customerId.Value;
}
private async Task<XhsPublishRecord> 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
};
}

View File

@ -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<IXhsSkillRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/skill/list")]
public async Task<OmsItemsResult<XhsSkillOutput>> 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<XhsSkillOutput>
{
Items = items.Select(MapSkill).ToList()
};
}
[HttpPost("/xhs/skill/save")]
public async Task<XhsSkillOutput> 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<XhsSkillRunOutput> 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<XhsAnalysisService>();
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<XhsCreationService>();
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<int> 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
};
}

View File

@ -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<IXhsAgentRepository>();
private IXhsTaskRepository TaskRepository => LazyServiceProvider.GetRequiredService<IXhsTaskRepository>();
private IXhsHumanConfigRepository HumanConfigRepository => LazyServiceProvider.GetRequiredService<IXhsHumanConfigRepository>();
private IXhsPublishRecordRepository PublishRecordRepository => LazyServiceProvider.GetRequiredService<IXhsPublishRecordRepository>();
private IXhsAnalysisSessionRepository AnalysisSessionRepository => LazyServiceProvider.GetRequiredService<IXhsAnalysisSessionRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
[HttpPost("/xhs/task/claim")]
public async Task<XhsTaskClaimOutput> 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<OmsPageResult<XhsTaskOutput>> 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<Func<XhsTask, bool>> 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<string, bool> { [nameof(XhsTask.Id)] = false });
var total = await TaskRepository.CountAsync(predicate);
return new OmsPageResult<XhsTaskOutput>
{
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<Dictionary<string, object>>(json);
if (obj != null && obj.TryGetValue("summary", out var summary))
{
return summary?.ToString();
}
return json;
}
private async Task<int> RequireCustomerIdAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null);
if (!customerId.HasValue)
{
throw new BusinessException("请使用客户账号操作");
}
return customerId.Value;
}
private async Task<XhsAgent> 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<int> 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();
}

View File

@ -0,0 +1,82 @@
using Oms.Domain.XhsAggregate.Entities;
namespace Oms.Application.Shared;
/// <summary>
/// 小红书模块通用辅助。
/// </summary>
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];
}
}

View File

@ -0,0 +1,47 @@
namespace Oms.Application.Shared;
/// <summary>
/// LIKE AI 拟人配置默认值(与 human.py / human_guard.py 内置默认对齐的简化版)。
/// </summary>
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"
}
}
""";
}

View File

@ -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;
/// <summary>
/// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。
/// </summary>
public class XhsLlmClient
{
public const string HttpClientName = nameof(XhsLlmClient);
private readonly IHttpClientFactory _httpClientFactory;
public XhsLlmClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> 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<XhsLlmClient>();
return services;
}
}

View File

@ -0,0 +1,36 @@
using Fake.Application;
using Oms.Domain.XhsAggregate.Consts;
using Oms.Domain.XhsAggregate.Entities;
namespace Oms.Application.Shared;
/// <summary>
/// Agent 远程控制动作 → 任务定义。
/// </summary>
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
};
}
}

View File

@ -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;
/// <summary>
/// 扫描到期定时发布记录并创建 Agent 任务。
/// </summary>
public class XhsScheduledPublishWorker : BackgroundService
{
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
private const int BatchSize = 10;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<XhsScheduledPublishWorker> _logger;
public XhsScheduledPublishWorker(
IServiceScopeFactory scopeFactory,
ILogger<XhsScheduledPublishWorker> 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<IXhsPublishRecordRepository>();
var publishService = scope.ServiceProvider.GetRequiredService<XhsPublishService>();
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);
}
}
}

View File

@ -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";
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,42 @@
using Fake.Domain.Repositories;
using Oms.Domain.XhsAggregate.Entities;
namespace Oms.Domain.XhsAggregate.Repositories;
public interface IXhsAgentRepository : IRepository<XhsAgent>
{
Task<XhsAgent?> GetByDeviceIdAsync(string deviceId, CancellationToken cancellationToken = default);
}
public interface IXhsTaskRepository : IRepository<XhsTask>
{
Task<List<XhsTask>> ClaimPendingAsync(
int customerId,
int agentId,
int limit,
CancellationToken cancellationToken = default);
}
public interface IXhsPublishRecordRepository : IRepository<XhsPublishRecord>
{
Task<List<XhsPublishRecord>> GetDueScheduledAsync(int limit, CancellationToken cancellationToken = default);
}
public interface IXhsAnalysisSessionRepository : IRepository<XhsAnalysisSession>;
public interface IXhsSkillRepository : IRepository<XhsSkill>
{
Task<XhsSkill?> GetByExternalIdAsync(int customerId, string externalId, CancellationToken cancellationToken = default);
}
public interface IXhsHumanConfigRepository : IRepository<XhsHumanConfig>
{
Task<XhsHumanConfig?> GetByCustomerIdAsync(int customerId, CancellationToken cancellationToken = default);
}
public interface IXhsAiProviderRepository : IRepository<XhsAiProvider>
{
Task<XhsAiProvider?> GetByProviderKeyAsync(int customerId, string providerKey, CancellationToken cancellationToken = default);
}
public interface IXhsMediaRepository : IRepository<XhsMedia>;

View File

@ -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<XhsAgent>, IXhsAgentRepository, IScopedDependency
{
public Task<XhsAgent?> GetByDeviceIdAsync(string deviceId, CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.DeviceId == deviceId, cancellationToken);
}
public class XhsTaskRepository : OmsSqlSugarRepository<XhsTask>, IXhsTaskRepository, IScopedDependency
{
public async Task<List<XhsTask>> ClaimPendingAsync(
int customerId,
int agentId,
int limit,
CancellationToken cancellationToken = default)
{
var now = DateTime.Now;
var db = await GetDbContextAsync();
var query = db.Queryable<XhsTask>()
.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<XhsPublishRecord>, IXhsPublishRecordRepository, IScopedDependency
{
public async Task<List<XhsPublishRecord>> GetDueScheduledAsync(int limit, CancellationToken cancellationToken = default)
{
var now = DateTime.Now;
var db = await GetDbContextAsync();
return await db.Queryable<XhsPublishRecord>()
.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<XhsAnalysisSession>, IXhsAnalysisSessionRepository, IScopedDependency;
public class XhsSkillRepository : OmsSqlSugarRepository<XhsSkill>, IXhsSkillRepository, IScopedDependency
{
public Task<XhsSkill?> GetByExternalIdAsync(int customerId, string externalId, CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.CustomerId == customerId && x.ExternalId == externalId, cancellationToken);
}
public class XhsHumanConfigRepository
: OmsSqlSugarRepository<XhsHumanConfig>, IXhsHumanConfigRepository, IScopedDependency
{
public Task<XhsHumanConfig?> GetByCustomerIdAsync(int customerId, CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.CustomerId == customerId, cancellationToken);
}
public class XhsAiProviderRepository
: OmsSqlSugarRepository<XhsAiProvider>, IXhsAiProviderRepository, IScopedDependency
{
public Task<XhsAiProvider?> GetByProviderKeyAsync(
int customerId,
string providerKey,
CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.CustomerId == customerId && x.ProviderKey == providerKey, cancellationToken);
}
public class XhsMediaRepository : OmsSqlSugarRepository<XhsMedia>, IXhsMediaRepository, IScopedDependency;

View File

@ -0,0 +1,63 @@
using Fake.Data.Seeding;
using Fake.Rbac.Application.Services;
namespace SimpleAdmin.Api.Auth;
/// <summary>
/// 启动时为 Customer / Admin 角色追加小红书权限(幂等合并,不覆盖已有权限)。
/// </summary>
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<string> 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);
}
}