From c77813af0d2f7b64387c0d15c8e1261309e1153c Mon Sep 17 00:00:00 2001 From: Timi Date: Tue, 15 Sep 2026 11:00:14 +0800 Subject: [PATCH 1/3] remove ./http --- http/README.md | 163 --------------------------- http/common/attachment.http | 200 ---------------------------------- http/common/multilingual.http | 66 ----------- http/gao/action.http | 60 ---------- http/gao/customer.http | 119 -------------------- http/gao/record.http | 86 --------------- http/http-client.env.json | 8 -- http/object/common.md | 77 ------------- http/object/permission.md | 31 ------ http/object/role.md | 34 ------ http/object/user.md | 73 ------------- http/user/permission.http | 84 -------------- http/user/role.http | 113 ------------------- http/user/user.http | 186 ------------------------------- 14 files changed, 1300 deletions(-) delete mode 100644 http/README.md delete mode 100644 http/common/attachment.http delete mode 100644 http/common/multilingual.http delete mode 100644 http/gao/action.http delete mode 100644 http/gao/customer.http delete mode 100644 http/gao/record.http delete mode 100644 http/http-client.env.json delete mode 100644 http/object/common.md delete mode 100644 http/object/permission.md delete mode 100644 http/object/role.md delete mode 100644 http/object/user.md delete mode 100644 http/user/permission.http delete mode 100644 http/user/role.http delete mode 100644 http/user/user.http diff --git a/http/README.md b/http/README.md deleted file mode 100644 index 01372e0..0000000 --- a/http/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# HTTP 调试与文档规范 - -本目录下的 `.http` 文件同时承担接口调试和接口文档职责,也要保证 AIAgent 能稳定读取 - -## 目录分工 - -### `.http` - -`.http` 只描述接口级信息: - -1. 接口中文名 -2. 权限要求 -3. 前置条件 -4. 请求参数对应的对象说明 -5. 返回参数对应的对象说明 - -`.http` 不重复展开对象字段明细,复杂字段、嵌套结构、枚举值统一写到 `./object` 下 - -### `./object` - -`./object` 只描述对象级信息: - -1. 字段名 -2. 字段类型 -3. 字段说明 -4. 枚举值及含义 - -所有模块都可能复用的通用对象必须写在 `./object/common.md` - -按业务领域拆分对象说明文件,例如: - -1. `./object/common.md` -2. `./object/user.md` -3. `./object/role.md` -4. `./object/permission.md` - -不要把同一个对象的字段说明散落到多个文件里 - -## 全局约定 - -### 返回包装 - -除使用 `IgnoreGlobalReturn` 跳过全局包装的方法外,接口默认都会经过 -[GlobalReturnHandler](E:/IDEAProject/timi-spring/src/main/java/com/imyeyu/spring/util/GlobalReturnHandler.java) -统一包装返回 - -因此 `.http` 里的 `返回参数` 默认只描述业务 `data` 对象,不再重复赘述全局包装结构 - -全局返回包装、通用成功码、错误信息、常见分页壳子等内容统一维护在 `./object/common.md` - -如果接口使用 -[IgnoreGlobalReturn](E:/IDEAProject/timi-spring/src/main/java/com/imyeyu/spring/annotation/IgnoreGlobalReturn.java) -跳过全局包装,必须在对应接口的 `前置条件` 或补充说明里明确写出“该接口不走全局返回包装” - -### 文档真实性 - -1. 文档只记录服务端真实行为 -2. 不写猜测 -3. 不写“看代码” -4. 接口改了就同步更新 `.http` 和 `./object` - -### 变量复用 - -能复用的变量统一放在文件顶部,例如 `@user_token`、`@userId` - -## `.http` 模板 - -每个接口块固定使用下面格式,不要擅自增删字段名: - -```http -### <接口中文名> -### 权限: -### 前置条件: -### 请求参数: -### 返回参数: - -POST {{baseUrl}}/path -Content-Type: application/json -Token: {{user_token}} - -{} -``` - -## `.http` 填写要求 - -### 接口中文名 - -直接写接口用途,别写成控制器方法名 - -### 权限 - -1. 优先直接翻译控制器上的真实注解 -2. 同时存在角色和权限时一起写,例如 `需要 ADMIN 角色 + USER_UPDATE 权限` -3. 只有 Token 要求时写 `需要有效 Token` -4. 无需登录时直接写 `无需登录` - -### 前置条件 - -这里只写接口调用前必须知道的约束,例如: - -1. 需要先登录并回填 `user_token` -2. 需要先创建用户再传入 `userId` -3. 重复调用可能因为唯一约束失败 -4. 接口当前实现为空 -5. 该接口不走全局返回包装 - -没有额外限制时写 `无` - -### 请求参数 - -只写请求对象名和对象说明文件,不在 `.http` 内展开字段 - -推荐格式: - -1. `无` -2. `UserCreateRequest,详见 ./object/user.md` -3. `Page,详见 ./object/common.md 和 ./object/user.md` - -### 返回参数 - -只写业务返回对象名和对象说明文件,不在 `.http` 内展开字段 - -推荐格式: - -1. `无` -2. `User,详见 ./object/user.md` -3. `PageResult,详见 ./object/common.md 和 ./object/user.md` -4. `二进制数据流,该接口不走全局返回包装` - -## `./object` 编写要求 - -对象说明统一使用 Markdown 表格,推荐结构如下 - -```md -## User - -| 字段 | 类型 | 说明 | -|---|---|---| -| name | String | 登录名,系统内唯一 | - -## RoleCode - -| 枚举 | 说明 | -|---|---| -| SYSTEM | 系统 | -| ADMIN | 管理员 | -``` - -### 对象说明规则 - -1. 一个对象一个标题,标题名必须和 `.http` 里引用的对象名一致 -2. 字段说明只写真实存在且调用方需要关心的内容 -3. 枚举单独列出,不要把枚举值混进普通字段表 -4. 嵌套对象、列表元素对象也要有独立标题 -5. 通用壳子对象统一写在 `./object/common.md` - -## 维护要求 - -1. 一个控制器通常对应一个 `.http` 文件 -2. 新增接口时,同步补齐对应 `.http` -3. 新增或修改对象字段时,同步更新对应 `./object/*.md` -4. 注释语言统一用中文 -5. 示例请求优先保证可调试,其次才是看起来完整 diff --git a/http/common/attachment.http b/http/common/attachment.http deleted file mode 100644 index afde9b4..0000000 --- a/http/common/attachment.http +++ /dev/null @@ -1,200 +0,0 @@ -### AttachmentController 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@attachmentId = -@attachmentBizType = TEMP_FILE -@attachmentBizId = demo-biz-id -@attachmentAttachType = SOURCE -@attachmentName = demo.png -@attachmentFilePath = E:/Temp/demo.png -@attachmentTempFileId = - -### 查询附件详情 -### 前置条件:`id` 必须传有效附件 ID -### 请求参数:URL 参数 `id` 为附件 ID -### 返回参数:Attachment,详见 ./object/common.md -POST {{baseUrl}}/attach/detail?id={{attachmentId}} -Token: {{user_token}} - -### 创建附件 -### 前置条件:回填 `attachmentFilePath` 为本地文件绝对路径 -### 请求参数:multipart/form-data -### 返回参数:Attachment,详见 ./object/common.md -POST {{baseUrl}}/attach/create -Content-Type: multipart/form-data; boundary=AttachmentCreateBoundary -Token: {{user_token}} - ---AttachmentCreateBoundary -Content-Disposition: form-data; name="bizType" - -{{attachmentBizType}} ---AttachmentCreateBoundary -Content-Disposition: form-data; name="bizId" - -{{attachmentBizId}} ---AttachmentCreateBoundary -Content-Disposition: form-data; name="attachType" - -{{attachmentAttachType}} ---AttachmentCreateBoundary -Content-Disposition: form-data; name="name" - -{{attachmentName}} ---AttachmentCreateBoundary -Content-Disposition: form-data; name="file"; filename="demo.png" -Content-Type: application/octet-stream - -< {{attachmentFilePath}} ---AttachmentCreateBoundary-- - -### 更新附件元数据 -### 前置条件:`id` 必须传有效附件 ID -### 请求参数:multipart/form-data -### 返回参数:无 -POST {{baseUrl}}/attach/update -Content-Type: multipart/form-data; boundary=AttachmentUpdateMetaBoundary -Token: {{user_token}} - ---AttachmentUpdateMetaBoundary -Content-Disposition: form-data; name="id" - -{{attachmentId}} ---AttachmentUpdateMetaBoundary -Content-Disposition: form-data; name="name" - -{{attachmentName}}-updated ---AttachmentUpdateMetaBoundary -Content-Disposition: form-data; name="attachType" - -{{attachmentAttachType}} ---AttachmentUpdateMetaBoundary-- - -### 更新附件文件 -### 前置条件:`id` 必须传有效附件 ID,`attachmentFilePath` 必须传本地文件绝对路径 -### 请求参数:multipart/form-data -### 返回参数:无 -POST {{baseUrl}}/attach/update -Content-Type: multipart/form-data; boundary=AttachmentUpdateFileBoundary -Token: {{user_token}} - ---AttachmentUpdateFileBoundary -Content-Disposition: form-data; name="id" - -{{attachmentId}} ---AttachmentUpdateFileBoundary -Content-Disposition: form-data; name="name" - -{{attachmentName}}-file-updated ---AttachmentUpdateFileBoundary -Content-Disposition: form-data; name="file"; filename="demo.png" -Content-Type: application/octet-stream - -< {{attachmentFilePath}} ---AttachmentUpdateFileBoundary-- - -### 删除附件 -### 前置条件:`id` 必须传有效附件 ID -### 请求参数:URL 参数 `id` 为附件 ID -### 返回参数:无 -POST {{baseUrl}}/attach/delete?id={{attachmentId}} -Token: {{user_token}} - -### 按业务查询单个附件 -### 前置条件:`bizType` 和 `bizId` 必须传有效值 -### 请求参数:URL 参数 `bizType`、`bizId` -### 返回参数:Attachment,详见 ./object/common.md -GET {{baseUrl}}/attach/detail/byBiz?bizType={{attachmentBizType}}&bizId={{attachmentBizId}} -Token: {{user_token}} - -### 按业务和附件类型查询单个附件 -### 前置条件:`bizType`、`bizId`、`attachType` 必须传有效值 -### 请求参数:URL 参数 `bizType`、`bizId`、`attachType` -### 返回参数:Attachment,详见 ./object/common.md -GET {{baseUrl}}/attach/detail/byAttachType?bizType={{attachmentBizType}}&bizId={{attachmentBizId}}&attachType={{attachmentAttachType}} -Token: {{user_token}} - -### 按业务查询附件列表 -### 前置条件:`bizType` 和 `bizId` 必须传有效值 -### 请求参数:URL 参数 `bizType`、`bizId`,可重复传 `attachTypeList` -### 返回参数:Attachment[] -GET {{baseUrl}}/attach/list/byBiz?bizType={{attachmentBizType}}&bizId={{attachmentBizId}}&attachTypeList={{attachmentAttachType}} -Token: {{user_token}} - -### 按业务统计附件数量 -### 前置条件:`bizType` 和 `bizId` 必须传有效值 -### 请求参数:URL 参数 `bizType`、`bizId`,可重复传 `attachTypeList` -### 返回参数:number -GET {{baseUrl}}/attach/count/byBiz?bizType={{attachmentBizType}}&bizId={{attachmentBizId}}&attachTypeList={{attachmentAttachType}} -Token: {{user_token}} - -### 按业务分页查询附件 -### 前置条件:`bizType` 和 `bizId` 必须传有效值 -### 请求参数:URL 参数 `bizType`、`bizId`,可重复传 `attachTypeList`,请求体为 Page -### 返回参数:PageResult -POST {{baseUrl}}/attach/page/byBiz?bizType={{attachmentBizType}}&bizId={{attachmentBizId}}&attachTypeList={{attachmentAttachType}} -Content-Type: application/json -Token: {{user_token}} - -{ - "index": 0, - "size": 10 -} - -### 按业务差分更新附件 -### 前置条件:新增项需要传有效 `tempFileId` -### 请求参数:BizUpdateReq -### 返回参数:无 -POST {{baseUrl}}/attach/update/byBiz -Content-Type: application/json -Token: {{user_token}} - -{ - "bizType": "{{attachmentBizType}}", - "bizId": "{{attachmentBizId}}", - "items": [ - { - "id": "{{attachmentId}}", - "attachType": "{{attachmentAttachType}}", - "name": "{{attachmentName}}-biz-updated" - }, - { - "tempFileId": "{{attachmentTempFileId}}", - "attachType": "{{attachmentAttachType}}", - "name": "{{attachmentName}}-from-temp" - } - ] -} - -### 按业务删除全部附件 -### 前置条件:`bizType` 和 `bizId` 必须传有效值 -### 请求参数:BizDeleteReq -### 返回参数:无 -POST {{baseUrl}}/attach/delete/byBiz -Content-Type: application/json -Token: {{user_token}} - -{ - "bizType": "{{attachmentBizType}}", - "bizId": "{{attachmentBizId}}" -} - -### 读取附件原图 -### 前置条件:`attachmentId` 必须传有效附件 ID -### 请求参数:路径参数 `id` -### 返回参数:附件二进制流 -GET {{baseUrl}}/attach/read/{{attachmentId}} -Token: {{user_token}} - -### 读取附件缩略图 -### 前置条件:`attachmentId` 必须传有效图片或视频附件 ID -### 请求参数:路径参数 `id`,URL 参数 `thumbWidth`、`thumbHeight` -### 返回参数:附件二进制流 -GET {{baseUrl}}/attach/read/{{attachmentId}}?thumbWidth=200&thumbHeight=200 -Token: {{user_token}} - -### 下载附件 -### 前置条件:`attachmentId` 必须传有效附件 ID -### 请求参数:路径参数 `id` -### 返回参数:附件二进制流 -GET {{baseUrl}}/attach/download/{{attachmentId}} -Token: {{user_token}} diff --git a/http/common/multilingual.http b/http/common/multilingual.http deleted file mode 100644 index 882de25..0000000 --- a/http/common/multilingual.http +++ /dev/null @@ -1,66 +0,0 @@ -### MultilingualController 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@multilingualId = -@multilingualZhCN = 测试简体中文 -@multilingualZhTW = 測試繁體中文 -@multilingualEnUS = Test English -@multilingualRuRU = Тест -@multilingualJaJP = テスト -@multilingualDeDE = Test Deutsch -@multilingualKoKR = 테스트 - -### 查询多语言详情 -### 权限:需要 ADMIN 角色 + MULTILINGUAL_READ 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `id` 必须传有效多语言 ID -### 请求参数:无请求体,URL 参数:`id` 为多语言 ID -### 返回参数:Multilingual,详见 ./object/common.md -POST {{baseUrl}}/system/multilingual/detail?id={{multilingualId}} -Token: {{user_token}} - -### 创建多语言 -### 权限:需要 ADMIN 角色 + MULTILINGUAL_CREATE 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:Multilingual,详见 ./object/common.md -### 返回参数:Multilingual,详见 ./object/common.md -POST {{baseUrl}}/system/multilingual/create -Content-Type: application/json -Token: {{user_token}} - -{ - "zhCN": "{{multilingualZhCN}}", - "zhTW": "{{multilingualZhTW}}", - "enUS": "{{multilingualEnUS}}", - "ruRU": "{{multilingualRuRU}}", - "jaJP": "{{multilingualJaJP}}", - "deDE": "{{multilingualDeDE}}", - "koKR": "{{multilingualKoKR}}" -} - -### 更新多语言 -### 权限:需要 ADMIN 角色 + MULTILINGUAL_UPDATE 权限 -### 前置条件:需要先登录并回填 `user_token`,`id` 必须存在 -### 请求参数:Multilingual,详见 ./object/common.md -### 返回参数:无 -POST {{baseUrl}}/system/multilingual/update -Content-Type: application/json -Token: {{user_token}} - -{ - "id": "{{multilingualId}}", - "zhCN": "{{multilingualZhCN}}-已更新", - "zhTW": "{{multilingualZhTW}}-已更新", - "enUS": "{{multilingualEnUS}}-updated", - "ruRU": "{{multilingualRuRU}}-updated", - "jaJP": "{{multilingualJaJP}}-updated", - "deDE": "{{multilingualDeDE}}-updated", - "koKR": "{{multilingualKoKR}}-updated" -} - -### 删除多语言 -### 权限:需要 ADMIN 角色 + MULTILINGUAL_DELETE 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `id` 必须传有效多语言 ID -### 请求参数:无请求体,URL 参数:`id` 为多语言 ID -### 返回参数:无 -POST {{baseUrl}}/system/multilingual/delete?id={{multilingualId}} -Token: {{user_token}} diff --git a/http/gao/action.http b/http/gao/action.http deleted file mode 100644 index 3f5dd3e..0000000 --- a/http/gao/action.http +++ /dev/null @@ -1,60 +0,0 @@ -### GaoRegisterAction 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@actionId = - -### 分页查询登记行为 -POST {{baseUrl}}/gao/action/list -Content-Type: application/json -Token: {{user_token}} - -{ - "index": 0, - "size": 16 -} - -### 查询启用行为 -POST {{baseUrl}}/gao/action/enabled/list -Token: {{user_token}} - -### 查询行为详情 -POST {{baseUrl}}/gao/action/detail?id={{actionId}} -Token: {{user_token}} - -### 创建登记行为 -POST {{baseUrl}}/gao/action/create -Content-Type: application/json -Token: {{user_token}} - -{ - "name": "签到", - "description": "客户到店签到", - "enabled": true, - "requirePhoto": false, - "sort": 10, - "permissionCodeList": [ - "RECORD:CREATE" - ] -} - -### 更新登记行为 -POST {{baseUrl}}/gao/action/update -Content-Type: application/json -Token: {{user_token}} - -{ - "id": "{{actionId}}", - "name": "物品领取", - "description": "登记客户领取物品", - "enabled": true, - "requirePhoto": true, - "sort": 20, - "permissionCodeList": [ - "RECORD:CREATE", - "STAT:READ" - ] -} - -### 删除登记行为 -POST {{baseUrl}}/gao/action/delete?id={{actionId}} -Token: {{user_token}} diff --git a/http/gao/customer.http b/http/gao/customer.http deleted file mode 100644 index c5166cb..0000000 --- a/http/gao/customer.http +++ /dev/null @@ -1,119 +0,0 @@ -### GaoCustomer 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@customerId = -@introducerCustomerId = -@customerCode = GAO-0001 - -### 分页查询客户 -POST {{baseUrl}}/gao/customer/list -Content-Type: application/json -Token: {{user_token}} - -{ - "index": 0, - "size": 16 -} - -### 搜索客户 -POST {{baseUrl}}/gao/customer/search -Content-Type: application/json -Token: {{user_token}} - -{ - "keyword": "张", - "introducerCustomerId": "{{introducerCustomerId}}" -} - -### 查询客户详情 -POST {{baseUrl}}/gao/customer/detail?id={{customerId}} -Token: {{user_token}} - -### 按客户编码查询 -POST {{baseUrl}}/gao/customer/detail/code?code={{customerCode}} -Token: {{user_token}} - -### 查询某客户邀请了哪些人 -POST {{baseUrl}}/gao/customer/invited/list?introducerCustomerId={{customerId}} -Token: {{user_token}} - -### 创建客户 -POST {{baseUrl}}/gao/customer/create -Content-Type: application/json -Token: {{user_token}} - -{ - "code": "{{customerCode}}", - "name": "张三", - "age": 56, - "telephone": "020-88886666", - "address": "广州市天河区某街道", - "remark": "首诊客户", - "disease": "肩颈不适", - "conditioningPart": [ - { - "code": "neck", - "name": "颈部" - }, - { - "code": "shoulder", - "name": "肩部" - } - ], - "introducerCustomerId": "{{introducerCustomerId}}" -} - -### 更新客户 -POST {{baseUrl}}/gao/customer/update -Content-Type: application/json -Token: {{user_token}} - -{ - "id": "{{customerId}}", - "code": "{{customerCode}}", - "name": "张三", - "age": 57, - "telephone": "020-88886667", - "address": "广州市天河区某街道 88 号", - "remark": "复诊客户", - "disease": "腰背调理", - "conditioningPart": { - "parts": [ - "waist", - "back" - ] - }, - "introducerCustomerId": "{{introducerCustomerId}}" -} - -### 删除客户 -POST {{baseUrl}}/gao/customer/delete?id={{customerId}} -Token: {{user_token}} - -### 创建客户邀请关系 -POST {{baseUrl}}/gao/customer/invite/create -Content-Type: application/json -Token: {{user_token}} - -{ - "customerId": "{{customerId}}", - "introducerCustomerId": "{{introducerCustomerId}}" -} - -### 查询客户邀请关系详情 -POST {{baseUrl}}/gao/customer/invite/detail?customerId={{customerId}} -Token: {{user_token}} - -### 更新客户邀请关系 -POST {{baseUrl}}/gao/customer/invite/update -Content-Type: application/json -Token: {{user_token}} - -{ - "customerId": "{{customerId}}", - "introducerCustomerId": "{{introducerCustomerId}}" -} - -### 删除客户邀请关系 -POST {{baseUrl}}/gao/customer/invite/delete?customerId={{customerId}} -Token: {{user_token}} diff --git a/http/gao/record.http b/http/gao/record.http deleted file mode 100644 index 005981b..0000000 --- a/http/gao/record.http +++ /dev/null @@ -1,86 +0,0 @@ -### GaoRegisterRecord 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@recordId = -@customerId = -@customerCode = GAO-0001 -@actionId = - -### 分页查询登记记录 -POST {{baseUrl}}/gao/record/list -Content-Type: application/json -Token: {{user_token}} - -{ - "index": 0, - "size": 16 -} - -### 查询登记记录详情 -POST {{baseUrl}}/gao/record/detail?id={{recordId}} -Token: {{user_token}} - -### 创建登记记录,直接传客户 ID -POST {{baseUrl}}/gao/record/create -Content-Type: application/json -Token: {{user_token}} - -{ - "customerId": "{{customerId}}", - "actionId": "{{actionId}}", - "remark": "客户到店签到" -} - -### 创建登记记录,扫码后传客户编码 -POST {{baseUrl}}/gao/record/create -Content-Type: application/json -Token: {{user_token}} - -{ - "customerCode": "{{customerCode}}", - "actionId": "{{actionId}}", - "remark": "扫码快速登记" -} - -### 更新登记记录 -POST {{baseUrl}}/gao/record/update -Content-Type: application/json -Token: {{user_token}} - -{ - "id": "{{recordId}}", - "customerId": "{{customerId}}", - "actionId": "{{actionId}}", - "remark": "补充备注", - "registeredAt": 1785110400 -} - -### 删除登记记录 -POST {{baseUrl}}/gao/record/delete?id={{recordId}} -Token: {{user_token}} - -### 按日查询某个行为记录 -POST {{baseUrl}}/gao/record/period/list -Content-Type: application/json -Token: {{user_token}} - -{ - "actionId": "{{actionId}}", - "periodType": "DAY", - "periodValue": 20260727 -} - -### 按月统计某个行为记录 -POST {{baseUrl}}/gao/record/period/stat -Content-Type: application/json -Token: {{user_token}} - -{ - "actionId": "{{actionId}}", - "periodType": "MONTH", - "periodValue": 202607 -} - -### 查询客户所有登记统计 -POST {{baseUrl}}/gao/record/customer/stat?customerId={{customerId}} -Token: {{user_token}} diff --git a/http/http-client.env.json b/http/http-client.env.json deleted file mode 100644 index 22a7d47..0000000 --- a/http/http-client.env.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "dev": { - "baseUrl": "http://localhost:8091" - }, - "prod": { - "baseUrl": "https://api.example.com" - } -} diff --git a/http/object/common.md b/http/object/common.md deleted file mode 100644 index 76ce040..0000000 --- a/http/object/common.md +++ /dev/null @@ -1,77 +0,0 @@ -# 通用对象说明 - -## TimiResponse - -| 字段 | 类型 | 说明 | -|---|---|---| -| code | Integer | 响应码,`20000` 为成功 | -| msg | String | 响应消息 | -| data | T | 业务数据体,具体结构由接口返回对象决定 | - -## Page - -| 字段 | 类型 | 说明 | -|---|---|---| -| index | int | 页码下标,从 `0` 开始 | -| size | long | 每页数量 | -| equalsExample | T | 精确匹配示例对象,非空字段参与精确查询 | -| likesExample | T | 模糊匹配示例对象,非空字段参与模糊查询 | -| equalsLogic | Logic | 精确匹配字段之间的连接逻辑,默认 `AND` | -| likesLogic | Logic | 模糊匹配字段之间的连接逻辑,默认 `OR` | -| orderMap | LinkedHashMap | 排序字段映射,键通常为字段名,值为排序方向 | - -## PageResult - -| 字段 | 类型 | 说明 | -|---|---|---| -| total | long | 总数据量 | -| pages | int | 总页数 | -| list | List | 当前页数据列表 | - -## UUIDEntity - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | String | 主键 ID,UUID 字符串 | -| createdAt | Long | 创建时间,Unix 时间戳毫秒 | -| updatedAt | Long | 更新时间,Unix 时间戳毫秒 | -| deletedAt | Long | 删除时间,Unix 时间戳毫秒,未删除时通常为空 | - -## Multilingual - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | String | 多语言 ID | -| zhCN | String | 简体中文文本 | -| zhTW | String | 繁体中文文本 | -| enUS | String | 英文文本 | -| ruRU | String | 俄文文本 | -| jaJP | String | 日文文本 | -| deDE | String | 德文文本 | -| koKR | String | 韩文文本 | -| lastAccessAt | Long | 最后访问时间,Unix 时间戳毫秒 | -| createdAt | Long | 创建时间,Unix 时间戳毫秒 | -| updatedAt | Long | 更新时间,Unix 时间戳毫秒 | -| deletedAt | Long | 删除时间,Unix 时间戳毫秒,未删除时通常为空 | - -## Logic - -| 枚举 | 说明 | -|---|---| -| AND | 多个条件同时满足 | -| OR | 多个条件满足其一 | - -## OrderType - -| 枚举 | 说明 | -|---|---| -| ASC | 升序 | -| DESC | 降序 | - -## ImageType - -| 枚举 | 说明 | -|---|---| -| AUTO | 双线性 | -| SMOOTH | 模糊 | -| PIXELATED | 像素 | diff --git a/http/object/permission.md b/http/object/permission.md deleted file mode 100644 index 577f384..0000000 --- a/http/object/permission.md +++ /dev/null @@ -1,31 +0,0 @@ -# 权限对象说明 - -## Permission - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | String | 权限 ID | -| createdAt | Long | 创建时间,Unix 时间戳毫秒 | -| updatedAt | Long | 更新时间,Unix 时间戳毫秒 | -| deletedAt | Long | 删除时间,Unix 时间戳毫秒 | -| code | String | 权限代码,需与数据库 `permission.code` 保持一致 | -| nameLangId | String | 权限名称的多语言键 | -| description | String | 权限说明 | -| name | String | 当前语言下的权限名称 | - -## BatchCreateReq - -| 字段 | 类型 | 说明 | -|---|---|---| -| prefixCode | String | 权限代码前缀,例如 `ROLE` | -| prefixName | String | 权限名称前缀,例如 `角色` | -| types | Type[] | 需要批量生成的权限类型列表,详见 `BatchCreateType` | - -## BatchCreateType - -| 枚举 | 说明 | -|---|---| -| CREATE | 创建权限 | -| READ | 读取权限 | -| UPDATE | 更新权限 | -| DELETE | 删除权限 | diff --git a/http/object/role.md b/http/object/role.md deleted file mode 100644 index 5304d45..0000000 --- a/http/object/role.md +++ /dev/null @@ -1,34 +0,0 @@ -# 角色对象说明 - -## Role - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | String | 角色 ID | -| createdAt | Long | 创建时间,Unix 时间戳毫秒 | -| updatedAt | Long | 更新时间,Unix 时间戳毫秒 | -| deletedAt | Long | 删除时间,Unix 时间戳毫秒 | -| code | String | 角色代码,常见值见 `RoleCode` | -| nameLangId | String | 角色名称的多语言键 | -| description | String | 角色说明 | -| name | String | 当前语言下的角色名称 | -| permissionList | List | 角色自身直接绑定的权限列表 | -| allPermissionList | List | 角色全部权限列表,包含继承自子角色的权限 | -| childRoleList | List | 子角色列表 | -| permissionIdList | Set | 权限 ID 列表,创建或更新角色时使用 | -| childRoleIdList | Set | 子角色 ID 列表,创建或更新角色时使用 | - -## UserRoleAuthorizeReq - -| 字段 | 类型 | 说明 | -|---|---|---| -| userId | String | 目标用户 ID | -| roleIdList | List | 需要授权的角色 ID 列表 | -| roleCodeList | List | 需要授权的角色代码列表 | - -## RoleCode - -| 枚举 | 说明 | -|---|---| -| SYSTEM | 系统 | -| ADMIN | 管理员 | diff --git a/http/object/user.md b/http/object/user.md deleted file mode 100644 index 7b9c094..0000000 --- a/http/object/user.md +++ /dev/null @@ -1,73 +0,0 @@ -# 用户对象说明 - -## User - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | String | 用户 ID | -| createdAt | Long | 创建时间,Unix 时间戳毫秒 | -| updatedAt | Long | 更新时间,Unix 时间戳毫秒 | -| deletedAt | Long | 删除时间,Unix 时间戳毫秒 | -| name | String | 登录名,系统内唯一 | -| nick | String | 用户昵称 | -| password | String | 明文密码,请求时填写,响应通常不返回 | -| email | String | 邮箱地址 | -| emailVerifyAt | Long | 邮箱验证时间,Unix 时间戳毫秒 | -| phoneNo | String | 手机号,11 位大陆手机号 | -| phoneNoVerifyAt | Long | 手机号验证时间,Unix 时间戳毫秒 | -| wrapperType | ImageType | 背景图渲染算法,详见 `ImageType` | -| avatarType | ImageType | 头像渲染算法,详见 `ImageType` | -| exp | Integer | 经验值 | -| sex | Integer | 性别,当前代码未定义枚举说明 | -| birthdate | Long | 出生日期,Unix 时间戳毫秒 | -| qq | String | QQ 号 | -| description | String | 个人说明 | -| lastLoginIP | String | 上次登录 IP | -| lastLoginAt | Long | 上次登录时间,Unix 时间戳毫秒 | -| unmuteAt | Long | 解除禁言时间,Unix 时间戳毫秒 | -| unbanAt | Long | 解除封禁时间,Unix 时间戳毫秒 | -| roleList | List | 角色代码列表 | -| permissionList | List | 权限代码列表 | -| attachmentList | List | 附件列表 | -| settingList | List | 设置列表 | - -## LoginRequest - -| 字段 | 类型 | 说明 | -|---|---|---| -| user | String | 用户标识,可能是 UID、邮箱或用户名 | -| password | String | 明文密码 | - -## LoginResponse - -| 字段 | 类型 | 说明 | -|---|---|---| -| token | String | 登录令牌 | -| expireAt | Long | 令牌过期时间,Unix 时间戳毫秒 | -| user | User | 当前登录用户 | - -## UpdatePasswordRequest - -| 字段 | 类型 | 说明 | -|---|---|---| -| oldValue | String | 原密码 | -| newValue | String | 新密码 | - -## PhoneVerifyCodeSendRequest - -| 字段 | 类型 | 说明 | -|---|---|---| -| phoneNo | String | 手机号,必须为 11 位大陆手机号 | - -## PhoneVerifyRequest - -| 字段 | 类型 | 说明 | -|---|---|---| -| phoneNo | String | 手机号,必须为 11 位大陆手机号 | -| verifyCode | String | 6 位数字验证码 | - -## CancelRequest - -| 字段 | 类型 | 说明 | -|---|---|---| -| password | String | 当前密码,用于确认注销 | diff --git a/http/user/permission.http b/http/user/permission.http deleted file mode 100644 index 59d7b24..0000000 --- a/http/user/permission.http +++ /dev/null @@ -1,84 +0,0 @@ -### PermissionController 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@user_token = -@permissionId = -@permissionCode = TEST_PERMISSION -@permissionNameLangId = permission.test.name -@permissionDescription = 测试权限 -@moduleCode = CORE - -### 分页查询权限 -### 权限:需要 PERMISSION_READ 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:Page,详见 ./http/object/permission.md -### 返回参数:PageResult,详见 ./http/object/permission.md -POST {{baseUrl}}/user/permission/list -Content-Type: application/json -Token: {{user_token}} - -{ - "index": 0, - "size": 10, - "likesExample": { - "code": "{{permissionCode}}", - "moduleCode": "{{moduleCode}}" - } -} - -### 创建权限 -### 权限:当前代码要求 PERMISSION_READ 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:Permission,详见 ./http/object/permission.md -### 返回参数:无 -POST {{baseUrl}}/user/permission/create -Content-Type: application/json -Token: {{user_token}} - -{ - "moduleCode": "{{moduleCode}}", - "code": "{{permissionCode}}", - "nameLangId": "{{permissionNameLangId}}", - "description": "{{permissionDescription}}" -} - -### 批量创建权限 -### 权限:需要 PERMISSION_CREATE 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:BatchCreateReq,详见 ./http/object/permission.md -### 返回参数:无 -POST {{baseUrl}}/user/permission/create/batch -Content-Type: application/json -Token: {{user_token}} - -{ - "moduleCode": "{{moduleCode}}", - "prefixCode": "ROLE", - "prefixName": "角色", - "types": ["CREATE", "READ", "UPDATE", "DELETE"] -} - -### 更新权限 -### 权限:需要 PERMISSION_UPDATE 权限 -### 前置条件:需要先登录并回填 `user_token`,`id` 必须存在 -### 请求参数:Permission,详见 ./http/object/permission.md -### 返回参数:无 -POST {{baseUrl}}/user/permission/update -Content-Type: application/json -Token: {{user_token}} - -{ - "id": "{{permissionId}}", - "moduleCode": "{{moduleCode}}", - "code": "{{permissionCode}}", - "nameLangId": "{{permissionNameLangId}}", - "description": "测试权限-已更新" -} - -### 删除权限 -### 权限:需要 PERMISSION_DELETE 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `id` 必须传有效权限 ID -### 请求参数:无请求体,URL 参数:`id` 为权限 ID -### 返回参数:无 -POST {{baseUrl}}/user/permission/delete?id={{permissionId}} -Token: {{user_token}} diff --git a/http/user/role.http b/http/user/role.http deleted file mode 100644 index e48bb71..0000000 --- a/http/user/role.http +++ /dev/null @@ -1,113 +0,0 @@ -### RoleController 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@permissionId = -@roleId = -@roleCode = TEST_ROLE -@roleNameLangId = role.test.name -@roleDescription = 测试角色 -@childRoleId = -@authorizedUserId = -@moduleCode = CORE - -### 分页查询角色 -### 权限:需要 ADMIN 角色 + ROLE_READ 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:Page,详见 ./http/object/role.md -### 返回参数:PageResult,详见 ./http/object/role.md -POST {{baseUrl}}/user/role/list -Content-Type: application/json -Token: {{user_token}} - -{ - "index": 0, - "size": 16, - "likesExample": { - "moduleCode": "{{moduleCode}}" - } -} - -### 查询角色详情 -### 权限:需要 ADMIN 角色 + ROLE_READ 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `id` 必须传有效角色 ID -### 请求参数:无请求体,URL 参数:`id` 为角色 ID -### 返回参数:Role,详见 ./http/object/role.md -POST {{baseUrl}}/user/role/detail?id={{roleId}} -Token: {{user_token}} - -### 创建角色 -### 权限:需要 ADMIN 角色 + ROLE_CREATE 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:Role,详见 ./http/object/role.md -### 返回参数:无 -POST {{baseUrl}}/user/role/create -Content-Type: application/json -Token: {{user_token}} - -{ - "moduleCode": "{{moduleCode}}", - "code": "{{roleCode}}", - "nameLangId": "{{roleNameLangId}}", - "description": "{{roleDescription}}", - "permissionIdList": ["{{permissionId}}"], - "childRoleIdList": [] -} - -### 更新角色 -### 权限:需要 ADMIN 角色 + ROLE_UPDATE 权限 -### 前置条件:需要先登录并回填 `user_token`,`id` 必须存在 -### 请求参数:Role,详见 ./http/object/role.md -### 返回参数:无 -POST {{baseUrl}}/user/role/update -Content-Type: application/json -Token: {{user_token}} - -{ - "id": "{{roleId}}", - "moduleCode": "{{moduleCode}}", - "code": "{{roleCode}}", - "nameLangId": "{{roleNameLangId}}", - "description": "测试角色-已更新", - "permissionIdList": ["{{permissionId}}"], - "childRoleIdList": ["{{childRoleId}}"] -} - -### 删除角色 -### 权限:需要 ADMIN 角色 + ROLE_DELETE 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `id` 必须传有效角色 ID -### 请求参数:无请求体,URL 参数:`id` 为角色 ID -### 返回参数:无 -POST {{baseUrl}}/user/role/delete?id={{roleId}} -Token: {{user_token}} - -### 查询用户角色 -### 权限:需要 ADMIN 角色 + USER_ROLE_READ 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `userId` 必须传有效用户 ID -### 请求参数:无请求体,URL 参数:`userId` 为用户 ID -### 返回参数:List,详见 ./http/object/role.md -POST {{baseUrl}}/user/role/authorized/list?userId={{authorizedUserId}} -Token: {{user_token}} - -### 设置用户角色 -### 权限:需要 ADMIN 角色 + 同时具备 USER_ROLE_CREATE 和 USER_ROLE_DELETE 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:UserRoleAuthorizeReq,详见 ./http/object/role.md -### 返回参数:无 -POST {{baseUrl}}/user/role/authorized/create -Content-Type: application/json -Token: {{user_token}} - -{ - "userId": "{{authorizedUserId}}", - "moduleCode": "{{moduleCode}}", - "roleIdList": ["{{roleId}}"], - "roleCodeList": ["{{roleCode}}"] -} - -### 查询用户已授权权限 -### 权限:需要 ADMIN 角色 + USER_ROLE_READ 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `userId` 必须传有效用户 ID -### 请求参数:无请求体,URL 参数:`userId` 为用户 ID -### 返回参数:List,详见 ./http/object/permission.md -POST {{baseUrl}}/user/role/authorized/permission?userId={{authorizedUserId}} -Token: {{user_token}} diff --git a/http/user/user.http b/http/user/user.http deleted file mode 100644 index baeb74a..0000000 --- a/http/user/user.http +++ /dev/null @@ -1,186 +0,0 @@ -### UserController 接口测试 -### 在 IDEA 中选择 dev 或 prod 环境后执行 - -@userId = -@name = System -@password = u#HA027xq$i9]P}uB]LweJspTb!3oS^. -@newPassword = qwe123 -@nick = 测试用户 -@email = test@example.com - -### 分页查询用户 -### 权限:需要 ADMIN 角色 + USER_READ 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:Page,详见 ./http/object/user.md -### 返回参数:PageResult,详见 ./http/object/user.md -POST {{baseUrl}}/user/list -Content-Type: application/json -Token: {{user_token}} - -{ - "index": 0, - "size": 16 -} - -### 查询用户详情 -### 权限:需要 ADMIN 角色 + USER_READ 权限 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `id` 必须传有效用户 ID -### 请求参数:无请求体,URL 参数:`id` 为用户 ID -### 返回参数:User,详见 ./http/object/user.md -POST {{baseUrl}}/user/detail?id={{userId}} -Token: {{user_token}} - -### 创建用户 -### 权限:需要 ADMIN 角色 + USER_CREATE 权限 -### 前置条件:需要先登录并回填 `user_token`,重复用户名通常会失败 -### 请求参数:User,详见 ./http/object/user.md -### 返回参数:无 -POST {{baseUrl}}/user/create -Content-Type: application/json -Token: {{user_token}} - -{ - "name": "{{name}}", - "nick": "{{nick}}", - "email": "{{email}}", - "phoneNo": "13800138000", - "password": "{{password}}" -} - -### 更新用户 -### 权限:需要 ADMIN 角色 + USER_UPDATE 权限 -### 前置条件:需要先登录并回填 `user_token`,`id` 必须存在 -### 请求参数:User,详见 ./http/object/user.md -### 返回参数:无 -POST {{baseUrl}}/user/update -Content-Type: application/json -Token: {{user_token}} - -{ - "id": "{{userId}}", - "name": "{{name}}", - "nick": "{{nick}}", - "email": "{{email}}", - "phoneNo": "13800138000" -} - -### 删除用户 -### 权限:需要 ADMIN 角色 -### 前置条件:需要先登录并回填 `user_token`,URL 参数 `id` 必须传有效用户 ID -### 请求参数:无请求体,URL 参数:`id` 为用户 ID -### 返回参数:无 -POST {{baseUrl}}/user/delete?id={{userId}} -Token: {{user_token}} - -### 注册 -### 权限:无需登录 -### 前置条件:重复用户名通常会失败 -### 请求参数:User,详见 ./http/object/user.md -### 返回参数:LoginResponse,详见 ./http/object/user.md -POST {{baseUrl}}/user/register -Content-Type: application/json - -{ - "name": "Test", - "nick": "测试用户", - "phoneNo": "13800138000", - "password": "qwe123" -} - -### 登录 -### 权限:无需登录 -### 前置条件:成功后需要把响应中的 token 回填到 `user_token` -### 请求参数:LoginRequest,详见 ./http/object/user.md -### 返回参数:LoginResponse,详见 ./http/object/user.md -POST {{baseUrl}}/user/login -Content-Type: application/json - -{ - "user": "{{name}}", - "password": "{{password}}" -} - -> {% -client.global.set("user_token", response.body?.data?.token) -%} - -### 令牌登录 -### 权限:需要有效 Token -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:无 -### 返回参数:LoginResponse,详见 ./http/object/user.md -GET {{baseUrl}}/user/login/token -Token: {{user_token}} - -### 登出 -### 权限:当前控制器未声明 RequiredToken/权限注解,按服务端实际会话处理 -### 前置条件:当前接口通常用于退出当前登录态 -### 请求参数:无 -### 返回参数:无 -POST {{baseUrl}}/user/logout -Token: {{user_token}} - -### 修改当前用户密码 -### 权限:需要 USER_UPDATE 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:UpdatePasswordRequest,详见 ./http/object/user.md -### 返回参数:无 -POST {{baseUrl}}/user/update/password -Content-Type: application/json -Token: {{user_token}} - -{ - "oldValue": "{{password}}", - "newValue": "{{newPassword}}" -} - -### 修改当前用户邮箱 -### 权限:需要 USER_UPDATE 权限 -### 前置条件:当前方法体为空,接口尚未实现 -### 请求参数:无 -### 返回参数:无 -POST {{baseUrl}}/user/update/email -Content-Type: application/json -Token: {{user_token}} - -{} - -### 发送当前用户手机号验证码 -### 权限:需要 USER_UPDATE 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:PhoneVerifyCodeSendRequest,详见 ./http/object/user.md -### 返回参数:无 -POST {{baseUrl}}/user/phone/verify/send -Content-Type: application/json -Token: {{user_token}} - -{ - "phoneNo": "13800138000" -} - -### 验证当前用户手机号 -### 权限:需要 USER_UPDATE 权限 -### 前置条件:需要先调用手机号验证码发送接口获取验证码 -### 请求参数:PhoneVerifyRequest,详见 ./http/object/user.md -### 返回参数:无 -POST {{baseUrl}}/user/phone/verify -Content-Type: application/json -Token: {{user_token}} - -{ - "phoneNo": "13800138000", - "verifyCode": "123456" -} - -### 注销当前用户 -### 权限:需要 USER_DELETE 权限 -### 前置条件:需要先登录并回填 `user_token` -### 请求参数:CancelRequest,详见 ./http/object/user.md -### 返回参数:无 -POST {{baseUrl}}/user/cancel -Content-Type: application/json -Token: {{user_token}} - -{ - "password": "{{password}}" -} From 1192bee4316400f00c1878416e7cf277ef3b3d7f Mon Sep 17 00:00:00 2001 From: Timi Date: Wed, 16 Sep 2026 01:09:55 +0800 Subject: [PATCH 2/3] add AIAgent --- .../com/imyeyu/api/config/RedisConfig.java | 7 + .../gao/ai/config/GaoAiProperties.java | 48 ++ .../ai/controller/GaoAiChatController.java | 44 + .../modules/gao/ai/entity/GaoAiAuditLog.java | 40 + .../modules/gao/ai/entity/GaoAiSession.java | 39 + .../gao/ai/mcp/GaoAiMcpAuthentication.java | 105 +++ .../gao/ai/mcp/GaoAiMcpController.java | 269 ++++++ .../mcp/GaoAiMcpOAuthDiscoveryController.java | 37 + .../modules/gao/ai/mcp/GaoAiToolNames.java | 46 + .../gao/ai/mcp/tool/BusinessReportTool.java | 35 + .../gao/ai/mcp/tool/CustomerProfileTool.java | 35 + .../modules/gao/ai/mcp/tool/CustomerTool.java | 37 + .../modules/gao/ai/mcp/tool/EventTool.java | 35 + .../modules/gao/ai/mcp/tool/GaoAiTool.java | 51 ++ .../gao/ai/mcp/tool/MetricDefinitionTool.java | 35 + .../modules/gao/ai/mcp/tool/OrderTool.java | 35 + .../modules/gao/ai/mcp/tool/PointTool.java | 35 + .../gao/ai/mcp/tool/ProductInventoryTool.java | 35 + .../gao/ai/mcp/tool/StoreDiaryTool.java | 35 + .../gao/ai/mcp/tool/StoreOverviewTool.java | 35 + .../gao/ai/service/GaoAiAccessException.java | 22 + .../gao/ai/service/GaoAiAuditService.java | 21 + .../gao/ai/service/GaoAiChatService.java | 17 + .../gao/ai/service/GaoAiQueryService.java | 62 ++ .../gao/ai/service/GaoAiSessionService.java | 35 + .../gao/ai/service/GaoAiToolException.java | 22 + .../implement/GaoAiAuditServiceImplement.java | 78 ++ .../implement/GaoAiChatServiceImplement.java | 465 +++++++++++ .../implement/GaoAiQueryServiceImplement.java | 783 ++++++++++++++++++ .../GaoAiSessionServiceImplement.java | 111 +++ .../modules/gao/ai/vo/GaoAiChatRequest.java | 20 + .../modules/gao/ai/vo/GaoAiToolRequest.java | 43 + .../modules/gao/ai/vo/GaoAiToolResult.java | 33 + .../modules/gao/bean/GaoSystemSetting.java | 2 + .../api/modules/system/bean/DifySetting.java | 28 + 35 files changed, 2780 insertions(+) create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiAuditLog.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiSession.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpOAuthDiscoveryController.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiToolNames.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/BusinessReportTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerProfileTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/EventTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/MetricDefinitionTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/OrderTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/PointTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/ProductInventoryTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreDiaryTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreOverviewTool.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAccessException.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAuditService.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiQueryService.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiSessionService.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiToolException.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiAuditServiceImplement.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiQueryServiceImplement.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiSessionServiceImplement.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiChatRequest.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolRequest.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolResult.java create mode 100644 src/main/java/com/imyeyu/api/modules/system/bean/DifySetting.java diff --git a/src/main/java/com/imyeyu/api/config/RedisConfig.java b/src/main/java/com/imyeyu/api/config/RedisConfig.java index c381e7b..120f9b2 100644 --- a/src/main/java/com/imyeyu/api/config/RedisConfig.java +++ b/src/main/java/com/imyeyu/api/config/RedisConfig.java @@ -2,6 +2,7 @@ package com.imyeyu.api.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.imyeyu.api.modules.common.entity.Multilingual; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.bean.GaoUserInviteCache; import com.imyeyu.spring.bean.RedisConfigParams; import com.imyeyu.spring.config.AbstractRedisConfig; @@ -209,4 +210,10 @@ public class RedisConfig extends AbstractRedisConfig { public Redis getGaoUserInviteRedisTemplate() { return getRedis(database, RedisSerializers.STRING, RedisSerializers.jacksonSerializer(jackson, GaoUserInviteCache.class)); } + + /// @return AI 会话,AI 会话 ID: 会话授权内容 + @Bean("redisGaoAiSession") + public Redis getGaoAiSessionRedisTemplate() { + return getRedis(database, RedisSerializers.STRING, RedisSerializers.jacksonSerializer(jackson, GaoAiSession.class)); + } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java b/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java new file mode 100644 index 0000000..c7c4882 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java @@ -0,0 +1,48 @@ +package com.imyeyu.api.modules.gao.ai.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/// GAO 智能体配置 +/// +/// 密钥只从部署环境变量或外部配置读取,不允许写入仓库 +/// +/// @author Codex +/// @since 2026-09-15 +@Getter +@Component +public class GaoAiProperties { + + /// MCP 服务间密钥 + @Value("${gao.ai.mcp-service-key:}") + private String mcpServiceKey; + + /// AI 会话有效期,单位秒 + @Value("${gao.ai.session-ttl-seconds:300}") + private long sessionTtlSeconds; + + /// 工具单页最大条数 + @Value("${gao.ai.max-tool-page-size:20}") + private int maxToolPageSize; + + /// Dify 连接超时,单位秒 + @Value("${gao.ai.connect-timeout-seconds:10}") + private long connectTimeoutSeconds; + + /// Dify 流式读取空闲超时,单位秒 + @Value("${gao.ai.read-timeout-seconds:600}") + private long readTimeoutSeconds; + + /// true 为允许缺少 AI 会话时使用固定测试会话,仅用于本地调试 + @Value("${gao.ai.allow-missing-session:false}") + private boolean allowMissingSession; + + /// 缺少 AI 会话时使用的测试用户 ID + @Value("${gao.ai.debug-user-id:dify-agent-debug}") + private String debugUserId; + + /// 缺少 AI 会话时使用的测试门店 ID + @Value("${gao.ai.debug-store-id:00000000-0000-0000-0000-000000000001}") + private String debugStoreId; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java new file mode 100644 index 0000000..439e858 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java @@ -0,0 +1,44 @@ +package com.imyeyu.api.modules.gao.ai.controller; + +import com.imyeyu.api.modules.gao.ai.service.GaoAiAccessException; +import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.spring.annotation.IgnoreGlobalReturn; +import com.imyeyu.spring.annotation.RequestRateLimit; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/// GAO 智能体聊天接口 +/// +/// 浏览器只携带 Timi 登录令牌访问本接口,不接触 Dify 或 MCP 密钥 +/// +/// @author Codex +/// @since 2026-09-15 +@RestController +@RequiredArgsConstructor +@RequestMapping("/gao/ai") +public class GaoAiChatController { + + private final GaoAiChatService chatService; + + /// 发起门店智能体流式聊天 + /// + /// @param request 聊天请求 + /// @return SSE 发射器 + @RequestRateLimit + @IgnoreGlobalReturn + @PostMapping(value = "/chat", produces = "text/event-stream") + public SseEmitter chat(@RequestBody GaoAiChatRequest request) { + try { + return chatService.chat(request); + } catch (GaoAiAccessException e) { + throw new ResponseStatusException(HttpStatus.valueOf(e.getStatus()), e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiAuditLog.java b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiAuditLog.java new file mode 100644 index 0000000..b8abcb7 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiAuditLog.java @@ -0,0 +1,40 @@ +package com.imyeyu.api.modules.gao.ai.entity; + +import lombok.Data; + +/// GAO 智能体审计记录 +/// +/// 业务审计通过现有系统日志服务落库,本对象只承载脱敏后的结构化内容 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +public class GaoAiAuditLog { + + /// 登录用户 ID + private String userId; + + /// 门店 ID + private String storeId; + + /// 问题摘要哈希,不保存问题原文 + private String questionDigest; + + /// Dify 会话 ID + private String conversationId; + + /// 调用的工具名称 + private String toolName; + + /// 查询耗时,单位毫秒 + private long duration; + + /// 查询状态 + private String status; + + /// 返回条数 + private long resultCount; + + /// 记录时间,单位毫秒 + private long createdAt; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiSession.java b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiSession.java new file mode 100644 index 0000000..0584000 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiSession.java @@ -0,0 +1,39 @@ +package com.imyeyu.api.modules.gao.ai.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashSet; +import java.util.Set; + +/// GAO 智能体短期会话授权 +/// +/// 该对象只存储在服务端,`sessionId` 不是 JWT,也不是用户登录令牌 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +@NoArgsConstructor +public class GaoAiSession { + + /// 不可预测的 AI 会话 ID + private String sessionId; + + /// 登录用户 ID + private String userId; + + /// 固定的门店 ID + private String storeId; + + /// Dify 会话 ID,可为空 + private String difyConversationId; + + /// 允许调用的只读工具 + private Set allowedTools = new LinkedHashSet<>(); + + /// 过期时间,单位毫秒 + private long expiresAt; + + /// 已调用工具次数 + private long usedCount; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java new file mode 100644 index 0000000..1c2e769 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java @@ -0,0 +1,105 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +import com.imyeyu.api.modules.gao.ai.config.GaoAiProperties; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; +import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/// Dify MCP 请求认证 +/// +/// 服务密钥只证明请求来自 Dify,门店和用户身份完全来自 AI 会话 +/// +/// @author Codex +/// @since 2026-09-15 +@Service +@RequiredArgsConstructor +public class GaoAiMcpAuthentication { + + private final GaoAiProperties properties; + private final GaoAiSessionService sessionService; + + /// 校验 Dify 服务密钥和 AI 会话 + /// + /// @param authorization Authorization 请求头 + /// @param sessionId X-Timi-AI-Session 请求头 + /// @param toolName 工具名称,可为空 + /// @return 已认证会话 + public GaoAiSession authenticate(String authorization, String sessionId, String toolName) { + authenticateService(authorization); + if (isBlank(sessionId) && properties.isAllowMissingSession()) { + GaoAiSession session = debugSession(); + if (toolName != null && !session.getAllowedTools().contains(toolName)) { + throw new GaoAiSessionException("AI 会话无权调用该工具", 403); + } + return session; + } + GaoAiSession session = sessionService.require(sessionId); + if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) { + throw new GaoAiSessionException("AI 会话无权调用该工具", 403); + } + return session; + } + + /// 只校验 Dify 服务间密钥 + /// + /// MCP 初始化和工具声明阶段尚未绑定店长会话,只能校验服务间身份 + /// + /// @param authorization Authorization 请求头 + public void authenticateService(String authorization) { + String expectedKey = trim(properties.getMcpServiceKey()); + String actualKey = bearerValue(authorization); + if (expectedKey.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(expectedKey, actualKey)) { + throw new GaoAiSessionException("MCP 服务认证失败", 401); + } + } + + private String bearerValue(String authorization) { + if (authorization == null) { + return ""; + } + String value = authorization.trim(); + if (7 > value.length() || !value.regionMatches(true, 0, "Bearer ", 0, 7)) { + return ""; + } + return value.substring(7).trim(); + } + + private boolean constantTimeEquals(String left, String right) { + return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + /// 创建固定的开发测试会话 + /// + /// @return 开发测试会话 + private GaoAiSession debugSession() { + String userId = trim(properties.getDebugUserId()); + String storeId = trim(properties.getDebugStoreId()); + if (userId.isEmpty() || storeId.isEmpty()) { + throw new GaoAiSessionException("开发测试会话缺少用户或门店配置", 401); + } + GaoAiSession session = new GaoAiSession(); + session.setSessionId("dify-agent-debug-bypass"); + session.setUserId(userId); + session.setStoreId(storeId); + session.setAllowedTools(GaoAiToolNames.orderedReadOnlyTools()); + session.setExpiresAt(Long.MAX_VALUE); + return session; + } + + /// 判断字符串是否为空 + /// + /// @param value 待判断字符串 + /// @return true 为 null、空字符串或空白字符串 + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java new file mode 100644 index 0000000..0d1b58e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java @@ -0,0 +1,269 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.tool.GaoAiTool; +import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiToolException; +import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import com.imyeyu.spring.annotation.IgnoreGlobalReturn; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/// GAO 智能体 MCP Streamable HTTP 接口 +/// +/// Dify 通过 Docker 内网访问本接口,业务查询只允许调用已注册的只读工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Slf4j +@RestController +@RequestMapping("/mcp/gao-ai") +public class GaoAiMcpController { + + private final ObjectMapper jackson; + private final GaoAiMcpAuthentication authentication; + private final GaoAiSessionService sessionService; + private final GaoAiAuditService auditService; + private final Map toolMap; + + public GaoAiMcpController(ObjectMapper jackson, GaoAiMcpAuthentication authentication, + GaoAiSessionService sessionService, GaoAiAuditService auditService, List toolList) { + this.jackson = jackson; + this.authentication = authentication; + this.sessionService = sessionService; + this.auditService = auditService; + this.toolMap = toolList.stream().collect(Collectors.toUnmodifiableMap(GaoAiTool::name, Function.identity())); + } + + /// 处理 MCP JSON-RPC 请求 + /// + /// @param authorization Dify 服务密钥 + /// @param aiSessionId AI 会话头 + /// @param request JSON-RPC 请求 + /// @return JSON-RPC 响应 + @IgnoreGlobalReturn + @PostMapping + public ResponseEntity handle(@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @RequestHeader(value = "X-Timi-AI-Session", required = false) String aiSessionId, + @RequestBody JsonNode request) { + JsonNode id = request == null ? null : request.get("id"); + String method = text(request, "method"); + String toolName = text(request == null ? null : request.path("params"), "name"); + log.info("GAO MCP 请求:method={}, toolName={}, authorizationPresent={}, aiSessionPresent={}, aiSessionLength={}", + method, toolName, hasText(authorization), hasText(aiSessionId), length(aiSessionId)); + try { + if (request == null || !request.isObject()) { + return response(400, error(id, -32600, "MCP 请求格式无效")); + } + if (method == null) { + return response(400, error(id, -32600, "MCP 请求缺少 method")); + } + return switch (method) { + case "initialize" -> { + authentication.authenticateService(authorization); + yield response(200, success(id, initializeResult())); + } + case "notifications/initialized", "ping" -> { + authentication.authenticateService(authorization); + yield response(200, success(id, Map.of())); + } + case "tools/list" -> { + authentication.authenticateService(authorization); + yield response(200, success(id, toolsResult())); + } + case "tools/call" -> { + GaoAiSession session = authentication.authenticate(authorization, aiSessionId, text(request.path("params"), "name")); + if (!hasText(aiSessionId)) { + log.warn("GAO MCP 使用开发测试会话:toolName={}, userId={}, storeId={}", + toolName, session.getUserId(), session.getStoreId()); + } + yield response(200, success(id, executeTool(session, request.path("params"), aiSessionId))); + } + default -> response(400, error(id, -32601, "MCP 方法不支持")); + }; + } catch (GaoAiSessionException e) { + log.warn("GAO MCP 认证失败:method={}, toolName={}, status={}, authorizationPresent={}, aiSessionPresent={}, aiSessionLength={}, reason={}", + method, toolName, e.getStatus(), hasText(authorization), hasText(aiSessionId), length(aiSessionId), e.getMessage()); + return response(e.getStatus(), error(id, -32001, e.getMessage())); + } catch (GaoAiToolException e) { + log.warn("GAO MCP 工具调用失败:method={}, toolName={}, status={}, aiSessionPresent={}, reason={}", + method, toolName, e.getStatus(), hasText(aiSessionId), e.getMessage()); + return response(e.getStatus(), error(id, -32602, e.getMessage())); + } catch (RuntimeException e) { + log.warn("GAO 智能体 MCP 请求失败", e); + return response(500, error(id, -32603, "智能体查询失败,请稍后重试")); + } + } + + private Object executeTool(GaoAiSession session, JsonNode params, String aiSessionId) { + if (params == null || !params.isObject()) { + throw new GaoAiToolException("工具参数格式无效", 400); + } + String toolName = text(params, "name"); + GaoAiTool tool = toolMap.get(toolName); + if (tool == null) { + throw new GaoAiToolException("工具不存在或未启用", 403); + } + JsonNode arguments = params == null || !params.has("arguments") ? jackson.createObjectNode() : params.get("arguments"); + if (arguments == null || !arguments.isObject()) { + throw new GaoAiToolException("工具参数格式无效", 400); + } + if (containsField(arguments, "storeId")) { + throw new GaoAiToolException("工具请求不允许携带 storeId", 403); + } + GaoAiToolRequest request; + try { + request = jackson.treeToValue(arguments, GaoAiToolRequest.class); + } catch (Exception e) { + throw new GaoAiToolException("工具参数格式无效", 400); + } + long startedAt = System.currentTimeMillis(); + try { + GaoAiToolResult result = tool.execute(session, request); + long duration = System.currentTimeMillis() - startedAt; + if (hasText(aiSessionId)) { + sessionService.increaseUsedCount(aiSessionId); + } + recordAudit(session, request, toolName, duration, "SUCCESS", result == null ? 0L : result.getDetails().size()); + Map content = new LinkedHashMap<>(); + content.put("type", "text"); + content.put("text", writeJson(result)); + return Map.of("content", List.of(content), "structuredContent", result); + } catch (GaoAiToolException e) { + recordAudit(session, request, toolName, System.currentTimeMillis() - startedAt, "ERROR", 0L); + throw e; + } catch (RuntimeException e) { + recordAudit(session, request, toolName, System.currentTimeMillis() - startedAt, "ERROR", 0L); + throw e; + } + } + + private Map initializeResult() { + Map result = new LinkedHashMap<>(); + result.put("protocolVersion", "2025-03-26"); + result.put("capabilities", Map.of("tools", Map.of("listChanged", false))); + result.put("serverInfo", Map.of("name", "TimiServerAPI-GAO-AI", "version", "1.0")); + return result; + } + + private Map toolsResult() { + List> tools = toolMap.values().stream() + .map(tool -> { + Map item = new LinkedHashMap<>(); + item.put("name", tool.name()); + item.put("description", tool.description()); + item.put("inputSchema", tool.inputSchema()); + return item; + }).toList(); + return Map.of("tools", tools); + } + + private void recordAudit(GaoAiSession session, GaoAiToolRequest request, String toolName, long duration, + String status, long resultCount) { + GaoAiAuditLog audit = new GaoAiAuditLog(); + audit.setUserId(session.getUserId()); + audit.setStoreId(session.getStoreId()); + audit.setQuestionDigest(auditService.digest(request == null ? null : request.getQuery())); + audit.setConversationId(session.getDifyConversationId()); + audit.setToolName(toolName); + audit.setDuration(Math.max(0L, duration)); + audit.setStatus(status); + audit.setResultCount(resultCount); + audit.setCreatedAt(System.currentTimeMillis()); + auditService.record(audit); + } + + private ResponseEntity response(int status, Object body) { + return ResponseEntity.status(status).body(body); + } + + private Map success(JsonNode id, Object result) { + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + response.put("result", result); + return response; + } + + private Map error(JsonNode id, int code, String message) { + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + response.put("error", Map.of("code", code, "message", message)); + return response; + } + + private String text(JsonNode node, String field) { + if (node == null || !node.hasNonNull(field)) { + return null; + } + String value = node.get(field).asText(); + return value == null || value.trim().isEmpty() ? null : value.trim(); + } + + /// 判断字符串是否有实际内容 + /// + /// @param value 待判断字符串 + /// @return true 为有内容 + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + /// 获取字符串长度 + /// + /// @param value 待计算字符串 + /// @return 字符串长度 + private int length(String value) { + return value == null ? 0 : value.trim().length(); + } + + private boolean containsField(JsonNode node, String field) { + if (node == null) { + return false; + } + if (node.isObject()) { + if (node.has(field)) { + return true; + } + for (JsonNode child : node) { + if (containsField(child, field)) { + return true; + } + } + } + if (node.isArray()) { + for (JsonNode child : node) { + if (containsField(child, field)) { + return true; + } + } + } + return false; + } + + private String writeJson(Object value) { + try { + return jackson.writeValueAsString(value); + } catch (Exception e) { + throw new GaoAiToolException("工具结果序列化失败", 500); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpOAuthDiscoveryController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpOAuthDiscoveryController.java new file mode 100644 index 0000000..f65ca7b --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpOAuthDiscoveryController.java @@ -0,0 +1,37 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +import com.imyeyu.spring.annotation.IgnoreGlobalReturn; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/// GAO MCP OAuth 探测兼容接口 +/// +/// Dify 即使配置固定请求头认证,也会预先探测 OAuth 元数据。本服务不提供 OAuth,必须返回原始 404, +/// 不能落入全局异常处理后被包装为业务 JSON +/// +/// @author Codex +/// @since 2026-09-15 +@RestController +public class GaoAiMcpOAuthDiscoveryController { + + /// 返回未实现的 OAuth 探测结果 + /// + /// @return 原始 HTTP 404 + @IgnoreGlobalReturn + @GetMapping({ + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-authorization-server/mcp/gao-ai", + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp/gao-ai", + "/.well-known/openid-configuration", + "/.well-known/openid-configuration/mcp/gao-ai", + "/mcp/gao-ai/.well-known/oauth-authorization-server", + "/mcp/gao-ai/.well-known/oauth-protected-resource", + "/mcp/gao-ai/.well-known/openid-configuration" + }) + public ResponseEntity notSupported() { + return ResponseEntity.notFound().cacheControl(CacheControl.noStore()).build(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiToolNames.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiToolNames.java new file mode 100644 index 0000000..d6f94d8 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiToolNames.java @@ -0,0 +1,46 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/// GAO 智能体只读工具名称 +/// +/// @author Codex +/// @since 2026-09-15 +public final class GaoAiToolNames { + + public static final String STORE_OVERVIEW = "store_overview"; + public static final String CUSTOMER_SEARCH = "customer_search"; + public static final String CUSTOMER_PROFILE = "customer_profile"; + public static final String ORDER_QUERY = "order_query"; + public static final String EVENT_QUERY = "event_query"; + public static final String PRODUCT_INVENTORY_QUERY = "product_inventory_query"; + public static final String POINT_QUERY = "point_query"; + public static final String BUSINESS_REPORT = "business_report"; + public static final String STORE_DIARY_QUERY = "store_diary_query"; + public static final String METRIC_DEFINITION = "metric_definition"; + + public static final Set READ_ONLY_TOOLS = java.util.Collections.unmodifiableSet(new LinkedHashSet<>(List.of( + STORE_OVERVIEW, + CUSTOMER_SEARCH, + CUSTOMER_PROFILE, + ORDER_QUERY, + EVENT_QUERY, + PRODUCT_INVENTORY_QUERY, + POINT_QUERY, + BUSINESS_REPORT, + STORE_DIARY_QUERY, + METRIC_DEFINITION + ))); + + private GaoAiToolNames() { + } + + /// 生成稳定的工具顺序 + /// + /// @return 工具名称集合 + public static Set orderedReadOnlyTools() { + return new LinkedHashSet<>(READ_ONLY_TOOLS); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/BusinessReportTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/BusinessReportTool.java new file mode 100644 index 0000000..93be855 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/BusinessReportTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 门店经营报表工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class BusinessReportTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.BUSINESS_REPORT; + } + + @Override + public String description() { + return "查询当前门店销售趋势、应收、客户复购和产品排行"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.businessReport(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerProfileTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerProfileTool.java new file mode 100644 index 0000000..a3d9d8a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerProfileTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 客户经营档案工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class CustomerProfileTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.CUSTOMER_PROFILE; + } + + @Override + public String description() { + return "查询当前门店单个客户的套餐、积分、消费和事件档案"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.customerProfile(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerTool.java new file mode 100644 index 0000000..d9d7d96 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerTool.java @@ -0,0 +1,37 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 客户查询工具 +/// +/// 同时承载客户搜索和客户经营档案查询 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class CustomerTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.CUSTOMER_SEARCH; + } + + @Override + public String description() { + return "按客户姓名或编码查询当前门店客户摘要"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.customerSearch(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/EventTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/EventTool.java new file mode 100644 index 0000000..6125c78 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/EventTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 登记事件查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class EventTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.EVENT_QUERY; + } + + @Override + public String description() { + return "查询当前门店登记事件记录及事件统计,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.eventQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java new file mode 100644 index 0000000..ad23182 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java @@ -0,0 +1,51 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; + +import java.util.Map; + +/// GAO 智能体只读工具 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiTool { + + /// @return 工具名称 + String name(); + + /// @return 工具描述 + String description(); + + /// 执行工具查询 + /// + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 脱敏查询结果 + GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request); + + /// @return MCP 输入参数结构 + default Map inputSchema() { + Map timeRange = Map.of( + "type", "object", + "properties", Map.of( + "preset", Map.of("type", "string", "enum", new String[]{"TODAY", "LAST_7_DAYS", "LAST_30_DAYS", "THIS_MONTH", "THIS_YEAR", "CUSTOM"}), + "startDate", Map.of("type", "string", "description", "自定义开始日期,格式 yyyy-MM-dd"), + "endDate", Map.of("type", "string", "description", "自定义结束日期,格式 yyyy-MM-dd") + ), + "additionalProperties", false + ); + return Map.of( + "type", "object", + "properties", Map.of( + "query", Map.of("type", "string", "description", "自然语言关键词"), + "timeRange", timeRange, + "filters", Map.of("type", "object"), + "pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20), + "cursor", Map.of("type", "string") + ), + "additionalProperties", false + ); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/MetricDefinitionTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/MetricDefinitionTool.java new file mode 100644 index 0000000..e46855a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/MetricDefinitionTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 指标口径查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class MetricDefinitionTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.METRIC_DEFINITION; + } + + @Override + public String description() { + return "查询门店经营助手使用的固定指标名称和计算口径"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.metricDefinition(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/OrderTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/OrderTool.java new file mode 100644 index 0000000..46591ea --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/OrderTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 订单和资金查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class OrderTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.ORDER_QUERY; + } + + @Override + public String description() { + return "查询当前门店销售订单、履约和资金摘要,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.orderQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/PointTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/PointTool.java new file mode 100644 index 0000000..66d0bde --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/PointTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 积分查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class PointTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.POINT_QUERY; + } + + @Override + public String description() { + return "查询当前门店积分账户、积分流水、规则和兑换摘要,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.pointQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/ProductInventoryTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/ProductInventoryTool.java new file mode 100644 index 0000000..f808e8e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/ProductInventoryTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 产品库存查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class ProductInventoryTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.PRODUCT_INVENTORY_QUERY; + } + + @Override + public String description() { + return "查询当前门店产品、SKU 和库存余额,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.productInventoryQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreDiaryTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreDiaryTool.java new file mode 100644 index 0000000..138840e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreDiaryTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 门店日记查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class StoreDiaryTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.STORE_DIARY_QUERY; + } + + @Override + public String description() { + return "查询当前门店日记和营业日信息,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.storeDiaryQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreOverviewTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreOverviewTool.java new file mode 100644 index 0000000..86ad099 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreOverviewTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 门店经营总览工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class StoreOverviewTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.STORE_OVERVIEW; + } + + @Override + public String description() { + return "查询当前门店指定周期的营业日、销售、应收和库存健康总览"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.storeOverview(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAccessException.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAccessException.java new file mode 100644 index 0000000..93b5720 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAccessException.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.service; + +/// GAO 智能体访问异常 +/// +/// @author Codex +/// @since 2026-09-15 +public class GaoAiAccessException extends RuntimeException { + + private final int status; + + /// @param message 用户可读消息 + /// @param status HTTP 状态码 + public GaoAiAccessException(String message, int status) { + super(message); + this.status = status; + } + + /// @return HTTP 状态码 + public int getStatus() { + return status; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAuditService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAuditService.java new file mode 100644 index 0000000..788f63f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAuditService.java @@ -0,0 +1,21 @@ +package com.imyeyu.api.modules.gao.ai.service; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; + +/// GAO 智能体审计服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiAuditService { + + /// 记录脱敏后的 AI 查询审计 + /// + /// @param auditLog 审计记录 + void record(GaoAiAuditLog auditLog); + + /// 计算问题摘要哈希 + /// + /// @param question 问题原文 + /// @return SHA-256 摘要 + String digest(String question); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java new file mode 100644 index 0000000..779cf05 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java @@ -0,0 +1,17 @@ +package com.imyeyu.api.modules.gao.ai.service; + +import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/// GAO 智能体聊天服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiChatService { + + /// 创建 AI 会话并转发 Dify 流式响应 + /// + /// @param request 聊天请求 + /// @return SSE 发射器 + SseEmitter chat(GaoAiChatRequest request); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiQueryService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiQueryService.java new file mode 100644 index 0000000..dbac084 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiQueryService.java @@ -0,0 +1,62 @@ +package com.imyeyu.api.modules.gao.ai.service; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; + +/// GAO 智能体只读查询服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiQueryService { + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 门店经营总览 + GaoAiToolResult storeOverview(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 客户搜索结果 + GaoAiToolResult customerSearch(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 客户经营档案 + GaoAiToolResult customerProfile(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 订单查询结果 + GaoAiToolResult orderQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 事件查询结果 + GaoAiToolResult eventQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 产品库存查询结果 + GaoAiToolResult productInventoryQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 积分查询结果 + GaoAiToolResult pointQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 经营报表结果 + GaoAiToolResult businessReport(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 门店日记查询结果 + GaoAiToolResult storeDiaryQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 指标口径 + GaoAiToolResult metricDefinition(GaoAiSession session, GaoAiToolRequest request); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiSessionService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiSessionService.java new file mode 100644 index 0000000..012311f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiSessionService.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.service; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; + +/// GAO 智能体会话服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiSessionService { + + /// 创建绑定用户和门店的 AI 会话 + /// + /// @param userId 用户 ID + /// @param storeId 门店 ID + /// @param difyConversationId Dify 会话 ID,可为空 + /// @return AI 会话 + GaoAiSession create(String userId, String storeId, String difyConversationId); + + /// 获取有效 AI 会话 + /// + /// @param sessionId AI 会话 ID + /// @return AI 会话 + GaoAiSession require(String sessionId); + + /// 增加工具调用次数 + /// + /// @param sessionId AI 会话 ID + /// @return 更新后的会话 + GaoAiSession increaseUsedCount(String sessionId); + + /// 删除 AI 会话 + /// + /// @param sessionId AI 会话 ID + void delete(String sessionId); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiToolException.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiToolException.java new file mode 100644 index 0000000..acf599c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiToolException.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.service; + +/// GAO 智能体工具请求异常 +/// +/// @author Codex +/// @since 2026-09-15 +public class GaoAiToolException extends RuntimeException { + + private final int status; + + /// @param message 异常消息 + /// @param status HTTP 状态码 + public GaoAiToolException(String message, int status) { + super(message); + this.status = status; + } + + /// @return HTTP 状态码 + public int getStatus() { + return status; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiAuditServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiAuditServiceImplement.java new file mode 100644 index 0000000..de1a665 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiAuditServiceImplement.java @@ -0,0 +1,78 @@ +package com.imyeyu.api.modules.gao.ai.service.implement; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; +import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService; +import com.imyeyu.api.modules.system.entity.Logger; +import com.imyeyu.api.modules.system.service.LoggerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashMap; +import java.util.Map; + +/// GAO 智能体审计服务实现 +/// +/// @author Codex +/// @since 2026-09-15 +@Slf4j +@Service +@RequiredArgsConstructor +public class GaoAiAuditServiceImplement implements GaoAiAuditService { + + private final ObjectMapper jackson; + private final LoggerService loggerService; + + @Override + public void record(GaoAiAuditLog auditLog) { + if (auditLog == null) { + return; + } + Map content = new LinkedHashMap<>(); + content.put("storeId", auditLog.getStoreId()); + content.put("questionDigest", auditLog.getQuestionDigest()); + content.put("conversationId", auditLog.getConversationId()); + content.put("toolName", auditLog.getToolName()); + content.put("duration", auditLog.getDuration()); + content.put("status", auditLog.getStatus()); + content.put("resultCount", auditLog.getResultCount()); + content.put("createdAt", auditLog.getCreatedAt()); + try { + Logger logger = new Logger(Logger.Module.GAO, auditLevel(auditLog.getStatus()), "GAO_AI_QUERY"); + logger.setUserId(auditLog.getUserId()); + logger.setContent(jackson.writeValueAsString(content)); + logger.setResult(auditLog.getStatus()); + logger.setDuration((int) Math.min(Integer.MAX_VALUE, Math.max(0L, auditLog.getDuration()))); + loggerService.create(logger); + } catch (JsonProcessingException | RuntimeException e) { + log.warn("写入 GAO 智能体审计日志失败", e); + } + } + + /// 计算问题摘要哈希 + /// + /// @param question 问题原文 + /// @return SHA-256 摘要 + public String digest(String question) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest( + (question == null ? "" : question).getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append("%02x".formatted(value & 0xff)); + } + return result.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("当前 JDK 不支持 SHA-256", e); + } + } + + private Logger.Level auditLevel(String status) { + return "SUCCESS".equals(status) ? Logger.Level.INFO : Logger.Level.WARN; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java new file mode 100644 index 0000000..464c854 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java @@ -0,0 +1,465 @@ +package com.imyeyu.api.modules.gao.ai.service.implement; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.imyeyu.api.bean.ModuleCode; +import com.imyeyu.api.modules.common.service.SettingService; +import com.imyeyu.api.modules.gao.ai.config.GaoAiProperties; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.service.GaoAiAccessException; +import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.api.modules.gao.bean.GaoRoleCode; +import com.imyeyu.api.modules.gao.bean.GaoSystemSetting; +import com.imyeyu.api.modules.gao.entity.GaoStore; +import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.service.GaoStoreService; +import com.imyeyu.api.modules.system.bean.DifySetting; +import com.imyeyu.api.modules.user.entity.User; +import com.imyeyu.api.modules.user.service.RoleChecker; +import com.imyeyu.api.modules.user.service.UserLoginService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/// GAO 智能体聊天服务实现 +/// +/// 使用 JDK HttpClient 读取 Dify SSE,再通过 Spring MVC SseEmitter 输出给前端 +/// +/// @author Codex +/// @since 2026-09-15 +@Slf4j +@Service +@RequiredArgsConstructor +public class GaoAiChatServiceImplement implements GaoAiChatService { + + private static final ZoneId ZONE = ZoneId.systemDefault(); + + private final ObjectMapper jackson; + private final GaoAiProperties properties; + private final UserLoginService userLoginService; + private final RoleChecker roleChecker; + private final GaoStoreScopeService storeScopeService; + private final GaoStoreService storeService; + private final GaoAiSessionService sessionService; + private final GaoAiAuditService auditService; + private final SettingService settingService; + + private HttpClient httpClient; + private ExecutorService streamExecutor; + private ScheduledExecutorService timeoutExecutor; + + @PostConstruct + private void postConstruct() { + this.streamExecutor = Executors.newVirtualThreadPerTaskExecutor(); + this.timeoutExecutor = Executors.newSingleThreadScheduledExecutor(Thread.ofVirtual().factory()); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(positive(properties.getConnectTimeoutSeconds(), 10L))) + .executor(streamExecutor) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + @Override + public SseEmitter chat(GaoAiChatRequest request) { + if (request == null || isBlank(request.getMessage())) { + throw new GaoAiAccessException("问题不能为空", 400); + } + String message = request.getMessage().trim(); + if (256 < message.length()) { + throw new GaoAiAccessException("问题长度不能超过 256 个字符", 400); + } + if (request.getConversationId() != null && 128 < request.getConversationId().length()) { + throw new GaoAiAccessException("会话 ID 无效", 400); + } + User user = userLoginService.getLoginUser(); + if (user == null) { + throw new GaoAiAccessException("登录已过期,请重新登录", 401); + } + if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) { + throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403); + } + String storeId; + try { + storeId = storeScopeService.resolveStoreId(request.getStoreId()); + } catch (RuntimeException e) { + throw new GaoAiAccessException("当前账号没有可用的所属门店", 403); + } + GaoStore store = storeId == null ? null : storeService.get(storeId); + if (store == null || !store.isActivated()) { + throw new GaoAiAccessException("当前账号没有可用的所属门店", 403); + } + GaoAiSession session = sessionService.create(user.getId(), storeId, request.getConversationId()); + return stream(session, message); + } + + private SseEmitter stream(GaoAiSession session, String message) { + long readTimeoutSeconds = positive(properties.getReadTimeoutSeconds(), 600L); + long emitterTimeout = Math.min(Long.MAX_VALUE - 1L, readTimeoutSeconds * 1000L + 30_000L); + SseEmitter emitter = new SseEmitter(emitterTimeout); + AtomicBoolean finished = new AtomicBoolean(); + AtomicBoolean auditRecorded = new AtomicBoolean(); + AtomicReference status = new AtomicReference<>("CANCELLED"); + AtomicReference>> upstreamRef = new AtomicReference<>(); + AtomicReference> timeoutRef = new AtomicReference<>(); + long startedAt = System.currentTimeMillis(); + + Runnable cancel = () -> { + status.compareAndSet("CANCELLED", "CANCELLED"); + finished.set(true); + CompletableFuture> upstream = upstreamRef.get(); + if (upstream != null) { + upstream.cancel(true); + } + cancelTimeout(timeoutRef); + recordAudit(session, message, status.get(), startedAt, auditRecorded); + }; + emitter.onCompletion(cancel); + emitter.onTimeout(cancel); + emitter.onError(error -> cancel.run()); + + try { + HttpRequest httpRequest = buildRequest(session, message, readTimeoutSeconds); + CompletableFuture> upstream = httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + upstreamRef.set(upstream); + if (finished.get()) { + upstream.cancel(true); + return emitter; + } + touchTimeout(timeoutRef, readTimeoutSeconds, () -> timeout(emitter, finished, upstreamRef, timeoutRef, status, auditRecorded, session, message, startedAt)); + upstream.thenAcceptAsync(response -> readResponse(response, emitter, finished, upstreamRef, timeoutRef, + status, auditRecorded, session, message, startedAt, readTimeoutSeconds), streamExecutor) + .exceptionally(error -> { + if (!finished.get()) { + logStreamError("异步请求", session, error); + status.set("ERROR"); + emitError(emitter, "DIFY_UNAVAILABLE", "智能体服务暂时不可用,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + return null; + }); + } catch (RuntimeException e) { + logStreamError("创建请求", session, e); + status.set("ERROR"); + emitError(emitter, "DIFY_REQUEST_ERROR", "智能体服务暂时不可用,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + return emitter; + } + + private HttpRequest buildRequest(GaoAiSession session, String message, long readTimeoutSeconds) { + ObjectNode body = jackson.createObjectNode(); + body.put("query", message); + body.put("response_mode", "streaming"); + body.put("user", "gao-store-manager:" + session.getUserId()); + if (!isBlank(session.getDifyConversationId())) { + body.put("conversation_id", session.getDifyConversationId()); + } + ObjectNode inputs = body.putObject("inputs"); + inputs.put("query", message); + inputs.put("aiSessionId", session.getSessionId()); + GaoStore store = storeService.get(session.getStoreId()); + inputs.put("storeName", store == null ? "当前门店" : store.getName()); + inputs.put("today", LocalDate.now(ZONE).toString()); + String url = settingService.getSystem(DifySetting.BASE_URL).asString(); + String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString(); + return HttpRequest.newBuilder() + .uri(URI.create(url + "/chat-messages")) + .timeout(Duration.ofSeconds(readTimeoutSeconds)) + .header("Authorization", "Bearer " + appApiKey) + .header("X-Timi-AI-Session", session.getSessionId()) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8)) + .build(); + } + + private void readResponse(HttpResponse response, SseEmitter emitter, AtomicBoolean finished, + AtomicReference>> upstreamRef, AtomicReference> timeoutRef, + AtomicReference status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt, + long readTimeoutSeconds) { + try (InputStream stream = response.body()) { + if (response.statusCode() < 200 || 299 < response.statusCode()) { + String responseBody = readErrorResponseBody(stream); + log.error("GAO 智能体返回异常状态:statusCode={}, sessionId={}, userId={}, storeId={}", + response.statusCode(), session.getSessionId(), session.getUserId(), session.getStoreId()); + log.error("GAO 智能体错误响应体:sessionId={}, responseBody={}", session.getSessionId(), responseBody); + status.set("ERROR"); + emitError(emitter, "DIFY_HTTP_ERROR", "智能体服务暂时不可用,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + return; + } + AtomicReference sseEvent = new AtomicReference<>(); + StringBuilder data = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while (!finished.get() && (line = reader.readLine()) != null) { + touchTimeout(timeoutRef, positive(properties.getReadTimeoutSeconds(), 600L), + () -> timeout(emitter, finished, upstreamRef, timeoutRef, status, auditRecorded, session, message, startedAt)); + if (line.startsWith("event:")) { + sseEvent.set(line.substring(6).trim()); + } else if (line.startsWith("data:")) { + data.append(line.substring(5).trim()); + } else if (line.isEmpty() && !data.isEmpty()) { + processData(data.toString(), sseEvent.get(), emitter, status, finished, timeoutRef, auditRecorded, + session, message, startedAt); + data.setLength(0); + sseEvent.set(null); + } + } + if (!finished.get() && !data.isEmpty()) { + processData(data.toString(), sseEvent.get(), emitter, status, finished, timeoutRef, auditRecorded, + session, message, startedAt); + } + } + if (!finished.get()) { + status.compareAndSet("CANCELLED", "SUCCESS"); + emitDone(emitter, session, startedAt, finished, timeoutRef, auditRecorded, message, status); + } + } catch (IOException | RuntimeException e) { + if (!finished.get()) { + logStreamError("读取响应", session, e); + status.set("ERROR"); + emitError(emitter, "DIFY_STREAM_ERROR", "智能体连接中断,请重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + } + } + + /// 读取并压缩 Dify 错误响应体 + /// + /// @param stream Dify 响应流 + /// @return 错误响应内容 + /// @throws IOException 读取响应体失败 + private String readErrorResponseBody(InputStream stream) throws IOException { + int maxLength = 8192; + byte[] bytes = stream.readNBytes(maxLength); + String body = new String(bytes, StandardCharsets.UTF_8) + .replace('\r', ' ') + .replace('\n', ' ') + .replace('\t', ' ') + .trim(); + return maxLength <= bytes.length ? body + "..." : body; + } + + private void processData(String rawData, String sseEvent, SseEmitter emitter, AtomicReference status, + AtomicBoolean finished, AtomicReference> timeoutRef, AtomicBoolean auditRecorded, + GaoAiSession session, String message, long startedAt) { + if ("[DONE]".equals(rawData)) { + return; + } + JsonNode payload; + try { + payload = jackson.readTree(rawData); + } catch (IOException e) { + log.error("GAO 智能体 SSE 数据解析失败:sessionId={}, userId={}, storeId={}, dataLength={}", + session.getSessionId(), session.getUserId(), session.getStoreId(), rawData.length(), e); + return; + } + String event = firstText(text(payload, "event", null), sseEvent, "message"); + String conversationId = firstText(text(payload, "conversation_id", null), text(payload, "conversationId", null)); + if (conversationId != null) { + session.setDifyConversationId(conversationId); + } + switch (event) { + case "message", "agent_message", "text_chunk" -> { + String answer = firstText(text(payload, "answer", null), text(payload, "text", null)); + if (answer != null) { + safeSend(emitter, SseEmitter.event().name("message").data(answer), finished); + } + } + case "message_end" -> emitDone(emitter, session, startedAt, finished, timeoutRef, auditRecorded, message, status); + case "node_started", "node_finished", "workflow_started", "workflow_finished" -> { + Map data = new LinkedHashMap<>(); + data.put("tool", "实时数据查询"); + data.put("status", event.endsWith("finished") ? "finished" : "started"); + safeSend(emitter, SseEmitter.event().name("tool_status").data(data), finished); + } + case "message_file", "retriever_resources" -> { + Map data = new LinkedHashMap<>(); + data.put("source", "实时业务数据或业务知识库"); + safeSend(emitter, SseEmitter.event().name("citation").data(data), finished); + } + case "error" -> { + status.set("ERROR"); + emitError(emitter, "DIFY_ERROR", "智能体查询失败,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + default -> { + // 未识别的 Dify 事件不透传原始内容,避免泄露内部节点和参数 + } + } + } + + private void emitDone(SseEmitter emitter, GaoAiSession session, long startedAt, AtomicBoolean finished, + AtomicReference> timeoutRef, AtomicBoolean auditRecorded, String message, + AtomicReference status) { + if (finished.get()) { + return; + } + Map data = new LinkedHashMap<>(); + data.put("conversationId", session.getDifyConversationId()); + data.put("duration", Math.max(0L, System.currentTimeMillis() - startedAt)); + safeSend(emitter, SseEmitter.event().name("done").data(data), finished); + status.compareAndSet("CANCELLED", "SUCCESS"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + + private void emitError(SseEmitter emitter, String code, String message) { + Map data = new LinkedHashMap<>(); + data.put("code", code); + data.put("message", message); + try { + emitter.send(SseEmitter.event().name("error").data(data)); + } catch (IOException | IllegalStateException ignored) { + // 浏览器已断开时无需再次写入 + } + } + + private boolean safeSend(SseEmitter emitter, SseEmitter.SseEventBuilder event, AtomicBoolean finished) { + if (finished.get()) { + return false; + } + try { + emitter.send(event); + return true; + } catch (IOException | IllegalStateException e) { + finished.set(true); + return false; + } + } + + private void safeComplete(SseEmitter emitter, AtomicBoolean finished, AtomicReference> timeoutRef, + AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt, AtomicReference status) { + if (!finished.compareAndSet(false, true)) { + return; + } + cancelTimeout(timeoutRef); + recordAudit(session, message, status.get(), startedAt, auditRecorded); + try { + emitter.complete(); + } catch (RuntimeException ignored) { + // SSE 已经关闭 + } + } + + private void timeout(SseEmitter emitter, AtomicBoolean finished, + AtomicReference>> upstreamRef, + AtomicReference> timeoutRef, AtomicReference status, + AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt) { + if (finished.get()) { + return; + } + status.set("TIMEOUT"); + CompletableFuture> upstream = upstreamRef.get(); + if (upstream != null) { + upstream.cancel(true); + } + emitError(emitter, "DIFY_TIMEOUT", "数据查询超时,请缩小时间范围后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + + private void touchTimeout(AtomicReference> timeoutRef, long seconds, Runnable action) { + ScheduledFuture old = timeoutRef.getAndSet(timeoutExecutor.schedule(action, seconds, TimeUnit.SECONDS)); + if (old != null) { + old.cancel(false); + } + } + + private void cancelTimeout(AtomicReference> timeoutRef) { + ScheduledFuture timeout = timeoutRef.getAndSet(null); + if (timeout != null) { + timeout.cancel(false); + } + } + + private void recordAudit(GaoAiSession session, String message, String status, long startedAt, AtomicBoolean recorded) { + if (!recorded.compareAndSet(false, true)) { + return; + } + GaoAiAuditLog audit = new GaoAiAuditLog(); + audit.setUserId(session.getUserId()); + audit.setStoreId(session.getStoreId()); + audit.setQuestionDigest(auditService.digest(message)); + audit.setConversationId(session.getDifyConversationId()); + audit.setToolName("dify_chatflow"); + audit.setDuration(Math.max(0L, System.currentTimeMillis() - startedAt)); + audit.setStatus(status); + audit.setResultCount(0L); + audit.setCreatedAt(System.currentTimeMillis()); + auditService.record(audit); + } + + /// 记录智能体流式请求异常 + /// + /// @param phase 异常阶段 + /// @param session 智能体会话 + /// @param error 异常 + /// @return 无 + private void logStreamError(String phase, GaoAiSession session, Throwable error) { + log.error("GAO 智能体流式请求异常:phase={}, sessionId={}, userId={}, storeId={}", + phase, session.getSessionId(), session.getUserId(), session.getStoreId(), error); + } + + private String writeJson(ObjectNode body) { + try { + return jackson.writeValueAsString(body); + } catch (IOException e) { + throw new IllegalStateException("无法生成 Dify 请求", e); + } + } + + private String text(JsonNode node, String field, String fallback) { + if (node != null && node.hasNonNull(field)) { + String value = node.get(field).asText(); + return isBlank(value) ? fallback : value; + } + return fallback; + } + + private String firstText(String... values) { + for (String value : values) { + if (!isBlank(value)) { + return value; + } + } + return null; + } + + private long positive(long value, long fallback) { + return 0 < value ? value : fallback; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiQueryServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiQueryServiceImplement.java new file mode 100644 index 0000000..9c2b36c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiQueryServiceImplement.java @@ -0,0 +1,783 @@ +package com.imyeyu.api.modules.gao.ai.service.implement; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imyeyu.api.modules.common.entity.Article; +import com.imyeyu.api.modules.gao.ai.config.GaoAiProperties; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiToolException; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import com.imyeyu.api.modules.gao.entity.GaoCustomer; +import com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord; +import com.imyeyu.api.modules.gao.entity.GaoCustomerPackage; +import com.imyeyu.api.modules.gao.entity.GaoEvent; +import com.imyeyu.api.modules.gao.entity.GaoEventRecord; +import com.imyeyu.api.modules.gao.entity.GaoFundRecord; +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.entity.GaoProductStock; +import com.imyeyu.api.modules.gao.entity.GaoSalesOrder; +import com.imyeyu.api.modules.gao.entity.GaoStore; +import com.imyeyu.api.modules.gao.entity.GaoStoreBusinessDay; +import com.imyeyu.api.modules.gao.service.GaoCustomerConsumptionRecordService; +import com.imyeyu.api.modules.gao.service.GaoCustomerPackageService; +import com.imyeyu.api.modules.gao.service.GaoCustomerService; +import com.imyeyu.api.modules.gao.service.GaoEventRecordService; +import com.imyeyu.api.modules.gao.service.GaoEventService; +import com.imyeyu.api.modules.gao.service.GaoFundRecordService; +import com.imyeyu.api.modules.gao.service.GaoPointAccountService; +import com.imyeyu.api.modules.gao.service.GaoPointLedgerService; +import com.imyeyu.api.modules.gao.service.GaoPointRedemptionService; +import com.imyeyu.api.modules.gao.service.GaoPointRuleService; +import com.imyeyu.api.modules.gao.service.GaoProductChartService; +import com.imyeyu.api.modules.gao.service.GaoProductStockService; +import com.imyeyu.api.modules.gao.service.GaoSalesOrderChartService; +import com.imyeyu.api.modules.gao.service.GaoSalesOrderService; +import com.imyeyu.api.modules.gao.service.GaoStoreBusinessDayService; +import com.imyeyu.api.modules.gao.service.GaoStoreDiaryService; +import com.imyeyu.api.modules.gao.service.GaoStoreService; +import com.imyeyu.api.modules.gao.vo.GaoCustomerPage; +import com.imyeyu.api.modules.gao.vo.GaoCustomerPackagePage; +import com.imyeyu.api.modules.gao.vo.GaoEventRecordPage; +import com.imyeyu.api.modules.gao.vo.GaoFundRecordPage; +import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionPage; +import com.imyeyu.api.modules.gao.vo.GaoProductChartReq; +import com.imyeyu.api.modules.gao.vo.GaoProductStockPage; +import com.imyeyu.api.modules.gao.vo.GaoSalesOrderChartReq; +import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage; +import com.imyeyu.api.modules.gao.vo.GaoStoreDiaryPage; +import com.imyeyu.spring.bean.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/// GAO 智能体只读查询服务实现 +/// +/// 所有查询都将会话中的门店 ID 写入既有查询请求,不接受工具参数中的门店范围 +/// +/// @author Codex +/// @since 2026-09-15 +@Service +@RequiredArgsConstructor +public class GaoAiQueryServiceImplement implements GaoAiQueryService { + + private static final ZoneId ZONE = ZoneId.systemDefault(); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter DAY_VALUE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final Set SENSITIVE_KEYS = Set.of( + "id", + "storeId", + "userId", + "customerId", + "operatorUserId", + "verifierUserId", + "creatorUserId", + "updatedBy", + "cancelOperatorUserId", + "sourcePayload", + "attachmentList", + "skuSpecSnapshot", + "password", + "token", + "apiKey", + "phone", + "mobile", + "telephone", + "email", + "address" + ); + + private final ObjectMapper jackson; + private final GaoAiProperties properties; + private final GaoStoreService storeService; + private final GaoStoreBusinessDayService businessDayService; + private final GaoCustomerService customerService; + private final GaoCustomerPackageService customerPackageService; + private final GaoCustomerConsumptionRecordService consumptionRecordService; + private final GaoEventService eventService; + private final GaoEventRecordService eventRecordService; + private final GaoSalesOrderService salesOrderService; + private final GaoSalesOrderChartService salesOrderChartService; + private final GaoFundRecordService fundRecordService; + private final GaoProductStockService stockService; + private final GaoProductChartService productChartService; + private final GaoPointAccountService pointAccountService; + private final GaoPointLedgerService pointLedgerService; + private final GaoPointRuleService pointRuleService; + private final GaoPointRedemptionService redemptionService; + private final GaoStoreDiaryService storeDiaryService; + + @Override + public GaoAiToolResult storeOverview(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoStore store = requireStore(session); + + GaoSalesOrderChartReq salesRequest = salesChartRequest(session, window, request); + List daily = salesOrderChartService.daily(salesRequest); + Object receivable = salesOrderChartService.receivable(salesRequest); + GaoProductChartReq productRequest = productChartRequest(session, window, request); + List stockHealth = productChartService.stockHealth(productRequest); + List businessDays = businessDayService.listByDateRange( + session.getStoreId(), window.beginDateValue(), window.endDateValue()); + + Map summary = new LinkedHashMap<>(); + summary.put("store", store.getName()); + summary.put("salesDaily", daily); + summary.put("receivable", receivable); + summary.put("businessDays", businessDays); + summary.put("stockHealth", stockHealth); + List alerts = new ArrayList<>(); + for (Object item : stockHealth) { + if (isLowStock(item)) { + alerts.add(item); + } + } + if (alerts.isEmpty() && !stockHealth.isEmpty()) { + alerts.add("当前查询范围未发现低库存产品"); + } + return buildResult(session, window, summary, List.of(), alerts, "门店营业日、销售订单、应收和库存健康"); + } + + @Override + public GaoAiToolResult customerSearch(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoCustomerPage page = new GaoCustomerPage(); + page.setStoreId(session.getStoreId()); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + page.setBeginCreatedAt(window.begin()); + page.setEndCreatedAt(window.end()); + setPage(page, request); + PageResult result = customerService.pageByQuery(page); + return buildPagedResult(session, window, result, "客户档案", request.getCursor()); + } + + @Override + public GaoAiToolResult customerProfile(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoCustomer customer = findCustomer(session.getStoreId(), request); + if (customer == null) { + return buildResult(session, window, Map.of(), List.of(), List.of("当前门店未查到匹配客户"), "客户、套餐、积分、消费和事件档案"); + } + + GaoCustomerPackagePage packagePage = new GaoCustomerPackagePage(); + packagePage.setStoreId(session.getStoreId()); + packagePage.setCustomerId(customer.getId()); + packagePage.setStatus(enumFilter(request, "status", GaoSalesOrder.Status.class)); + packagePage.setIndex(0); + packagePage.setSize(maxPageSize()); + PageResult packages = customerPackageService.pageByQuery(packagePage); + + GaoPointAccount account = pointAccountService.getByCustomerId(session.getStoreId(), customer.getId()); + GaoEventRecordPage eventPage = eventRecordPage(session, window, request); + eventPage.setCustomerId(customer.getId()); + PageResult events = eventRecordService.pageByRange(eventPage); + if (events != null) { + attachEventDisplay(events.getList()); + } + + List consumptions = consumptionRecordService.listActiveByWindow( + session.getStoreId(), customer.getId(), window.begin(), window.end()); + Map summary = new LinkedHashMap<>(); + summary.put("customer", customer); + summary.put("pointAccount", account); + summary.put("packages", packages == null ? List.of() : packages.getList()); + summary.put("events", events == null ? List.of() : events.getList()); + summary.put("activeConsumptions", consumptions); + return buildResult(session, window, summary, List.of(), List.of(), "客户、套餐、积分、消费和事件档案"); + } + + @Override + public GaoAiToolResult orderQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoSalesOrderPage page = new GaoSalesOrderPage(); + page.setStoreId(session.getStoreId()); + page.setCustomerId(resolveCustomerId(session.getStoreId(), filter(request, "customerId"))); + page.setOrderType(enumFilter(request, "orderType", GaoSalesOrder.OrderType.class)); + page.setStatus(enumFilter(request, "status", GaoSalesOrder.Status.class)); + page.setPaymentStatus(enumFilter(request, "paymentStatus", GaoSalesOrder.PaymentStatus.class)); + page.setRefundStatus(enumFilter(request, "refundStatus", GaoSalesOrder.RefundStatus.class)); + page.setPendingReceive(booleanFilter(request, "pendingReceive")); + page.setFulfilledButUnpaid(booleanFilter(request, "fulfilledButUnpaid")); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + page.setBeginAt(window.begin()); + page.setEndAt(window.end()); + setPage(page, request); + PageResult orders = salesOrderService.pageByQuery(page); + + GaoFundRecordPage fundPage = new GaoFundRecordPage(); + fundPage.setStoreId(session.getStoreId()); + fundPage.setCustomerId(page.getCustomerId()); + fundPage.setBeginAt(window.begin()); + fundPage.setEndAt(window.end()); + fundPage.setIndex(0); + fundPage.setSize(maxPageSize()); + PageResult funds = fundRecordService.pageByQuery(fundPage); + Map summary = new LinkedHashMap<>(); + summary.put("ordersTotal", orders == null ? 0L : orders.getTotal()); + summary.put("fundRecordsTotal", funds == null ? 0L : funds.getTotal()); + summary.put("fundRecords", funds == null ? List.of() : funds.getList()); + return buildResult(session, window, summary, pageDetails(orders), List.of(), "销售订单、履约和资金流水"); + } + + @Override + public GaoAiToolResult eventQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoEventRecordPage page = eventRecordPage(session, window, request); + String eventId = filter(request, "eventId"); + if (!isBlank(eventId)) { + GaoEvent event = eventService.get(eventId); + if (event != null && !session.getStoreId().equals(event.getStoreId())) { + throw new GaoAiToolException("事件不属于当前门店", 403); + } + page.setEventIdList(List.of(eventId)); + } + String eventKeyword = firstText(filter(request, "event"), filter(request, "eventName")); + if (eventKeyword != null && isBlank(eventId)) { + List eventIdList = eventService.listValid(session.getStoreId()).stream() + .filter(item -> containsIgnoreCase(item.getName(), eventKeyword)) + .map(GaoEvent::getId) + .toList(); + page.setEventIdList(eventIdList); + } + PageResult records = eventRecordService.pageByRange(page); + attachEventDisplay(records == null ? null : records.getList()); + List details = pageDetails(records); + Map summary = new LinkedHashMap<>(); + summary.put("total", records == null ? 0L : records.getTotal()); + summary.put("returnedCount", details.size()); + summary.put("numberValueTotal", eventRecordService.sumNumberValueByRange(page)); + summary.put("optionStatistics", eventRecordService.optionStatByRange(page)); + if (records != null && details.size() < records.getTotal()) { + summary.put("hasMore", true); + summary.put("nextCursor", String.valueOf(cursorIndex(request.getCursor()) + 1)); + } + return buildResult(session, window, summary, details, List.of(), "登记事件记录和选项统计"); + } + + @Override + public GaoAiToolResult productInventoryQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoProductStockPage page = new GaoProductStockPage(); + page.setStoreId(session.getStoreId()); + page.setProductId(filter(request, "productId")); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + setPage(page, request); + PageResult stocks = stockService.pageByQuery(page); + GaoProductChartReq productRequest = productChartRequest(session, window, request); + List stockHealth = productChartService.stockHealth(productRequest); + List stockMovementDaily = productChartService.stockMovementDaily(productRequest); + List salesRank = productChartService.salesRank(productRequest); + List details = pageDetails(stocks); + Map summary = new LinkedHashMap<>(); + summary.put("total", stocks == null ? 0L : stocks.getTotal()); + summary.put("returnedCount", details.size()); + summary.put("stockHealth", stockHealth); + summary.put("stockMovementDaily", stockMovementDaily); + summary.put("salesRank", salesRank); + if (stocks != null && details.size() < stocks.getTotal()) { + summary.put("hasMore", true); + summary.put("nextCursor", String.valueOf(cursorIndex(request.getCursor()) + 1)); + } + return buildResult(session, window, summary, details, List.of(), "产品、SKU、库存和销售摘要"); + } + + @Override + public GaoAiToolResult pointQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + String customerId = resolveCustomerId(session.getStoreId(), filter(request, "customerId")); + Map summary = new LinkedHashMap<>(); + List details = new ArrayList<>(); + if (customerId == null && request.getQuery() != null) { + customerId = resolveCustomerId(session.getStoreId(), request.getQuery()); + } + if (customerId != null) { + summary.put("account", pointAccountService.getByCustomerId(session.getStoreId(), customerId)); + } + + GaoPointLedgerPage ledgerPage = new GaoPointLedgerPage(); + ledgerPage.setStoreId(session.getStoreId()); + ledgerPage.setCustomerId(customerId); + ledgerPage.setChangeType(enumFilter(request, "changeType", GaoPointLedger.ChangeType.class)); + ledgerPage.setBeginAt(window.begin()); + ledgerPage.setEndAt(window.end()); + setPage(ledgerPage, request); + PageResult ledgers = pointLedgerService.pageByQuery(ledgerPage); + details.addAll(pageDetails(ledgers)); + summary.put("ledgerTotal", ledgers == null ? 0L : ledgers.getTotal()); + + GaoPointRedemptionPage redemptionPage = new GaoPointRedemptionPage(); + redemptionPage.setStoreId(session.getStoreId()); + redemptionPage.setCustomerId(customerId); + redemptionPage.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + redemptionPage.setBeginAt(window.begin()); + redemptionPage.setEndAt(window.end()); + redemptionPage.setIndex(0); + redemptionPage.setSize(maxPageSize()); + summary.put("redemptions", redemptionService.pageByQuery(redemptionPage)); + summary.put("activeRules", pointRuleService.listActive(session.getStoreId(), null, null, System.currentTimeMillis())); + return buildResult(session, window, summary, details, List.of(), "积分账户、积分流水、规则和兑换"); + } + + @Override + public GaoAiToolResult businessReport(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "THIS_MONTH", true); + GaoSalesOrderChartReq salesRequest = salesChartRequest(session, window, request); + List salesDaily = salesOrderChartService.daily(salesRequest); + Object receivable = salesOrderChartService.receivable(salesRequest); + List customerRank = salesOrderChartService.customerRank(salesRequest); + List repeatDaily = salesOrderChartService.repeatDaily(salesRequest); + List repeatInterval = salesOrderChartService.repeatInterval(salesRequest); + GaoProductChartReq productRequest = productChartRequest(session, window, request); + List productRank = productChartService.salesRank(productRequest); + Map summary = new LinkedHashMap<>(); + summary.put("salesDaily", salesDaily); + summary.put("receivable", receivable); + summary.put("customerRank", customerRank); + summary.put("repeatDaily", repeatDaily); + summary.put("repeatInterval", repeatInterval); + summary.put("productRank", productRank); + return buildResult(session, window, summary, List.of(), List.of(), "销售趋势、排行、复购和应收分析"); + } + + @Override + public GaoAiToolResult storeDiaryQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoStoreDiaryPage page = new GaoStoreDiaryPage(); + page.setStoreId(session.getStoreId()); + page.setBeginCreatedAt(window.begin()); + page.setEndCreatedAt(window.end()); + setPage(page, request); + PageResult
diaries = storeDiaryService.page(page); + return buildPagedResult(session, window, diaries, "门店日记和营业日信息", request.getCursor()); + } + + @Override + public GaoAiToolResult metricDefinition(GaoAiSession session, GaoAiToolRequest request) { + TimeWindow window = resolveWindow(normalize(request), "TODAY", false); + Map summary = new LinkedHashMap<>(); + summary.put("有效销售订单", "状态为 ACTIVE 或 FULFILLED 的销售订单;已取消订单不计入有效销售"); + summary.put("销售额", "有效销售订单的成交金额,单位为分,展示层按金额格式化"); + summary.put("实收金额", "销售订单已确认收款金额,不包含待核销收款"); + summary.put("应收金额", "成交金额减去已确认实收金额"); + summary.put("有效消费", "状态为 ACTIVE 的客户消费事实,仅在订单全额实收后参与累计"); + summary.put("可用库存", "库存现有量减去履约预占量"); + summary.put("复购客户", "统计范围内发生两次及以上有效消费或有效订单的客户,具体以报表工具返回口径为准"); + summary.put("数据边界", "实时业务数据只来自 GAO 查询服务,不包含知识库中的静态规则数据"); + return buildResult(session, window, summary, List.of(), List.of(), "门店经营指标名称和计算口径"); + } + + private GaoAiToolRequest normalize(GaoAiToolRequest request) { + GaoAiToolRequest result = request == null ? new GaoAiToolRequest() : request; + if (result.getFilters() != null && containsStoreId(result.getFilters())) { + throw new GaoAiToolException("工具请求不允许携带 storeId", 403); + } + if (result.getQuery() != null && 256 < result.getQuery().length()) { + throw new GaoAiToolException("查询关键词过长", 400); + } + return result; + } + + private GaoStore requireStore(GaoAiSession session) { + if (session == null || isBlank(session.getStoreId())) { + throw new GaoAiToolException("AI 会话缺少门店范围", 403); + } + GaoStore store = storeService.get(session.getStoreId()); + if (store == null || !store.isActivated()) { + throw new GaoAiToolException("当前门店不存在或未启用", 403); + } + return store; + } + + private GaoCustomer findCustomer(String storeId, GaoAiToolRequest request) { + String customerId = filter(request, "customerId"); + if (!isBlank(customerId)) { + GaoCustomer customer = customerService.get(customerId); + if (customer != null && !storeId.equals(customer.getStoreId())) { + throw new GaoAiToolException("客户不属于当前门店", 403); + } + return customer; + } + String query = request.getQuery(); + if (isBlank(query)) { + return null; + } + GaoCustomer customer = customerService.getByCode(storeId, query); + if (customer == null) { + customer = customerService.getByName(storeId, query); + } + if (customer != null) { + return customer; + } + GaoCustomerPage page = new GaoCustomerPage(); + page.setStoreId(storeId); + page.setKeyword(query); + page.setIndex(0); + page.setSize(1); + PageResult result = customerService.pageByQuery(page); + return result == null || result.getList() == null || result.getList().isEmpty() ? null : result.getList().getFirst(); + } + + private String resolveCustomerId(String storeId, String value) { + if (isBlank(value)) { + return null; + } + GaoCustomer customer = customerService.get(value); + if (customer != null) { + if (!storeId.equals(customer.getStoreId())) { + throw new GaoAiToolException("客户不属于当前门店", 403); + } + return customer.getId(); + } + customer = customerService.getByCode(storeId, value); + if (customer == null) { + customer = customerService.getByName(storeId, value); + } + return customer == null ? null : customer.getId(); + } + + private GaoEventRecordPage eventRecordPage(GaoAiSession session, TimeWindow window, GaoAiToolRequest request) { + GaoEventRecordPage page = new GaoEventRecordPage(); + page.setStoreId(session.getStoreId()); + page.setVerificationStatus(enumFilter(request, "verificationStatus", GaoEventRecord.VerificationStatus.class)); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + page.setBeginAt(window.begin()); + page.setEndAt(window.end()); + setPage(page, request); + return page; + } + + private GaoSalesOrderChartReq salesChartRequest(GaoAiSession session, TimeWindow window, GaoAiToolRequest request) { + GaoSalesOrderChartReq result = new GaoSalesOrderChartReq(); + result.setStoreId(session.getStoreId()); + result.setCustomerId(resolveCustomerId(session.getStoreId(), filter(request, "customerId"))); + result.setProductId(filter(request, "productId")); + result.setSkuId(filter(request, "skuId")); + result.setBeginAt(window.begin()); + result.setEndAt(window.end()); + result.setTopN(maxPageSize()); + return result; + } + + private GaoProductChartReq productChartRequest(GaoAiSession session, TimeWindow window, GaoAiToolRequest request) { + GaoProductChartReq result = new GaoProductChartReq(); + result.setStoreId(session.getStoreId()); + result.setProductId(filter(request, "productId")); + result.setSkuId(filter(request, "skuId")); + result.setCategoryId(filter(request, "categoryId")); + result.setBeginAt(window.begin()); + result.setEndAt(window.end()); + result.setTopN(maxPageSize()); + return result; + } + + private void attachEventDisplay(List list) { + if (list == null || list.isEmpty()) { + return; + } + Map eventMap = eventService.mapByIdList(list.stream().map(GaoEventRecord::getEventId).distinct().toList()); + Map customerMap = customerService.mapByIdList(list.stream().map(GaoEventRecord::getCustomerId).distinct().toList()); + for (GaoEventRecord record : list) { + record.setEvent(eventMap.get(record.getEventId())); + record.setCustomer(customerMap.get(record.getCustomerId())); + } + eventRecordService.attachSelectedOptionGroupList(list); + } + + private GaoAiToolResult buildPagedResult(GaoAiSession session, TimeWindow window, PageResult page, String metric, + String cursor) { + List details = pageDetails(page); + Map summary = new LinkedHashMap<>(); + summary.put("total", page == null ? 0L : page.getTotal()); + summary.put("returnedCount", details.size()); + if (page != null && details.size() < page.getTotal()) { + summary.put("hasMore", true); + summary.put("nextCursor", String.valueOf(cursorIndex(cursor) + 1)); + } + return buildResult(session, window, summary, details, List.of(), metric); + } + + private List pageDetails(PageResult page) { + return page == null || page.getList() == null ? new ArrayList<>() : sanitizeList(page.getList()); + } + + private GaoAiToolResult buildResult(GaoAiSession session, TimeWindow window, Map summary, + Collection details, Collection alerts, String metric) { + GaoAiToolResult result = new GaoAiToolResult(); + Map scope = new LinkedHashMap<>(); + GaoStore store = requireStore(session); + scope.put("storeName", store.getName()); + scope.put("timeRange", window.display()); + result.setScope(scope); + result.setSummary(sanitizeMap(summary)); + result.setDetails(sanitizeList(details)); + result.setAlerts(sanitizeList(alerts)); + Map source = new LinkedHashMap<>(); + source.put("metric", metric); + source.put("generatedAt", Instant.now().atZone(ZONE).toOffsetDateTime().toString()); + result.setSource(source); + return result; + } + + private TimeWindow resolveWindow(GaoAiToolRequest request, String defaultPreset, boolean trend) { + String preset = request.getTimeRange() == null ? defaultPreset : request.getTimeRange().getPreset(); + if (isBlank(preset)) { + preset = defaultPreset; + } + preset = preset.trim().toUpperCase(Locale.ROOT); + LocalDate today = LocalDate.now(ZONE); + LocalDate start; + LocalDate end; + switch (preset) { + case "TODAY" -> { + start = today; + end = today; + } + case "LAST_7_DAYS" -> { + start = today.minusDays(6); + end = today; + } + case "LAST_30_DAYS" -> { + start = today.minusDays(29); + end = today; + } + case "THIS_MONTH" -> { + start = today.withDayOfMonth(1); + end = start.plusMonths(1).minusDays(1); + } + case "THIS_YEAR" -> { + start = today.withDayOfYear(1); + end = start.plusYears(1).minusDays(1); + } + case "CUSTOM" -> { + if (request.getTimeRange() == null || isBlank(request.getTimeRange().getStartDate()) + || isBlank(request.getTimeRange().getEndDate())) { + throw new GaoAiToolException("自定义时间范围必须同时提供开始和结束日期", 400); + } + try { + start = LocalDate.parse(request.getTimeRange().getStartDate(), DATE_FORMATTER); + end = LocalDate.parse(request.getTimeRange().getEndDate(), DATE_FORMATTER); + } catch (RuntimeException e) { + throw new GaoAiToolException("日期格式必须为 yyyy-MM-dd", 400); + } + } + default -> throw new GaoAiToolException("不支持的时间范围预设:" + preset, 400); + } + if (start.isAfter(end)) { + throw new GaoAiToolException("时间范围无效", 400); + } + long days = ChronoUnit.DAYS.between(start, end) + 1L; + long maxDays = trend ? 365L : 31L; + if (maxDays < days) { + throw new GaoAiToolException(trend ? "趋势查询最多支持 365 天" : "明细查询最多支持 31 天", 400); + } + long begin = start.atStartOfDay(ZONE).toInstant().toEpochMilli(); + long finish = end.plusDays(1).atStartOfDay(ZONE).toInstant().toEpochMilli(); + return new TimeWindow(start, end, begin, finish); + } + + private int maxPageSize() { + int max = properties.getMaxToolPageSize(); + if (20 < max) { + max = 20; + } + if (max < 1) { + max = 1; + } + return max; + } + + private void setPage(com.imyeyu.java.bean.BasePage page, GaoAiToolRequest request) { + int size = request.getPageSize() == null ? maxPageSize() : request.getPageSize(); + if (size < 1) { + size = 1; + } + if (maxPageSize() < size) { + size = maxPageSize(); + } + page.setSize(size); + page.setIndex(cursorIndex(request.getCursor())); + } + + private int cursorIndex(String cursor) { + if (isBlank(cursor)) { + return 0; + } + try { + int index = Integer.parseInt(cursor); + return index < 0 ? 0 : index; + } catch (NumberFormatException e) { + throw new GaoAiToolException("分页游标无效", 400); + } + } + + private > E enumFilter(GaoAiToolRequest request, String key, Class type) { + String value = filter(request, key); + if (isBlank(value)) { + return null; + } + try { + return Enum.valueOf(type, value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new GaoAiToolException("过滤条件无效:" + key, 400); + } + } + + private Boolean booleanFilter(GaoAiToolRequest request, String key) { + String value = filter(request, key); + return isBlank(value) ? null : Boolean.valueOf(value); + } + + private String filter(GaoAiToolRequest request, String key) { + if (request == null || request.getFilters() == null) { + return null; + } + Object value = request.getFilters().get(key); + return value == null ? null : String.valueOf(value); + } + + private String firstText(String... values) { + for (String value : values) { + if (!isBlank(value)) { + return value.trim(); + } + } + return null; + } + + private boolean containsIgnoreCase(String value, String keyword) { + return value != null && keyword != null && value.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); + } + + private boolean containsStoreId(Object value) { + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if ("storeId".equals(String.valueOf(entry.getKey())) || containsStoreId(entry.getValue())) { + return true; + } + } + } + if (value instanceof Collection collection) { + for (Object item : collection) { + if (containsStoreId(item)) { + return true; + } + } + } + return false; + } + + private boolean isLowStock(Object value) { + if (value == null) { + return false; + } + Map map = rawMap(value); + Object lowStock = map.get("lowStock"); + return lowStock instanceof Number number && 0 < number.intValue(); + } + + private List sanitizeList(Collection values) { + List result = new ArrayList<>(); + if (values == null) { + return result; + } + for (Object value : values) { + Object sanitized = sanitize(value); + if (sanitized != null) { + result.add(sanitized); + } + } + return result; + } + + private Map sanitizeMap(Map source) { + Map result = new LinkedHashMap<>(); + if (source == null) { + return result; + } + for (Map.Entry entry : source.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (isSensitiveKey(key)) { + continue; + } + Object value = sanitize(entry.getValue()); + if (value != null) { + result.put(key, value); + } + } + return result; + } + + private Object sanitize(Object value) { + if (value == null || value instanceof String || value instanceof Number || value instanceof Boolean || value instanceof Enum) { + return value instanceof Enum enumValue ? enumValue.name() : value; + } + if (value instanceof Map map) { + return sanitizeMap(map); + } + if (value instanceof Collection collection) { + return sanitizeList(collection); + } + try { + return sanitizeMap(jackson.convertValue(value, new TypeReference>() {})); + } catch (IllegalArgumentException e) { + return null; + } + } + + private Map rawMap(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + return jackson.convertValue(value, new TypeReference>() {}); + } + + private boolean isSensitiveKey(String key) { + return SENSITIVE_KEYS.contains(key) || key.endsWith("Id") || key.toLowerCase(Locale.ROOT).contains("password") + || key.toLowerCase(Locale.ROOT).contains("token") || key.toLowerCase(Locale.ROOT).contains("url"); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private record TimeWindow(LocalDate start, LocalDate endDate, long begin, long end) { + + private int beginDateValue() { + return Integer.parseInt(DAY_VALUE_FORMATTER.format(start)); + } + + private int endDateValue() { + return Integer.parseInt(DAY_VALUE_FORMATTER.format(endDate)); + } + + private String display() { + return "%s 至 %s".formatted(start, endDate); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiSessionServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiSessionServiceImplement.java new file mode 100644 index 0000000..74b7742 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiSessionServiceImplement.java @@ -0,0 +1,111 @@ +package com.imyeyu.api.modules.gao.ai.service.implement; + +import com.imyeyu.api.modules.gao.ai.config.GaoAiProperties; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; +import com.imyeyu.spring.util.Redis; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.util.Base64; + +/// GAO 智能体会话服务实现 +/// +/// @author Codex +/// @since 2026-09-15 +@Service +@RequiredArgsConstructor +public class GaoAiSessionServiceImplement implements GaoAiSessionService { + + private static final String KEY_PREFIX = "GAO:AI:SESSION:"; + + private final GaoAiProperties properties; + + @Qualifier("redisGaoAiSession") + private final Redis redis; + + private final SecureRandom secureRandom = new SecureRandom(); + + @Override + public GaoAiSession create(String userId, String storeId, String difyConversationId) { + if (isBlank(userId) || isBlank(storeId)) { + throw new IllegalArgumentException("AI 会话缺少用户或门店"); + } + long ttlSeconds = Math.max(1L, properties.getSessionTtlSeconds()); + GaoAiSession session = new GaoAiSession(); + session.setSessionId(newSessionId()); + session.setUserId(userId); + session.setStoreId(storeId); + session.setDifyConversationId(blankToNull(difyConversationId)); + session.setAllowedTools(GaoAiToolNames.orderedReadOnlyTools()); + session.setExpiresAt(System.currentTimeMillis() + ttlSeconds * 1000L); + session.setUsedCount(0L); + redis.set(key(session.getSessionId()), session, ttlSeconds * 1000L); + return session; + } + + @Override + public GaoAiSession require(String sessionId) { + if (isBlank(sessionId)) { + throw new GaoAiSessionException("缺少 AI 会话", 401); + } + GaoAiSession session = redis.get(key(sessionId.trim())); + if (session == null || session.getExpiresAt() <= System.currentTimeMillis()) { + redis.destroy(key(sessionId.trim())); + throw new GaoAiSessionException("AI 会话不存在或已过期", 401); + } + return session; + } + + @Override + public GaoAiSession increaseUsedCount(String sessionId) { + GaoAiSession session = require(sessionId); + session.setUsedCount(session.getUsedCount() + 1L); + long remain = Math.max(1L, session.getExpiresAt() - System.currentTimeMillis()); + redis.set(key(session.getSessionId()), session, remain); + return session; + } + + @Override + public void delete(String sessionId) { + if (!isBlank(sessionId)) { + redis.destroy(key(sessionId.trim())); + } + } + + private String newSessionId() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String key(String sessionId) { + return KEY_PREFIX + sessionId; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private String blankToNull(String value) { + return isBlank(value) ? null : value.trim(); + } + + /// AI 会话认证异常 + public static class GaoAiSessionException extends RuntimeException { + + private final int status; + + public GaoAiSessionException(String message, int status) { + super(message); + this.status = status; + } + + public int getStatus() { + return status; + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiChatRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiChatRequest.java new file mode 100644 index 0000000..60c8b78 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiChatRequest.java @@ -0,0 +1,20 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +/// GAO 智能体聊天请求 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +public class GaoAiChatRequest { + + /// 店长问题 + private String message; + + /// Dify 会话 ID,可为空 + private String conversationId; + + /// GLOBAL_MANAGER 兼任店长时使用当前选中门店 ID,可为空 + private String storeId; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolRequest.java new file mode 100644 index 0000000..a8f6970 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolRequest.java @@ -0,0 +1,43 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// GAO 智能体 MCP 工具通用请求 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +public class GaoAiToolRequest { + + /// 自然语言关键词 + private String query; + + /// 时间范围 + private TimeRange timeRange; + + /// 业务过滤条件,不允许携带 storeId + private Map filters = new LinkedHashMap<>(); + + /// 请求条数,服务端强制限制为 1 至 20 + private Integer pageSize; + + /// 分页游标,当前实现为不透明页码字符串 + private String cursor; + + /// 工具时间范围 + @Data + public static class TimeRange { + + /// 预设范围,例如 TODAY、THIS_MONTH、CUSTOM + private String preset; + + /// 自定义开始日期,格式为 yyyy-MM-dd + private String startDate; + + /// 自定义结束日期,格式为 yyyy-MM-dd,包含当天 + private String endDate; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolResult.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolResult.java new file mode 100644 index 0000000..52ac6c4 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolResult.java @@ -0,0 +1,33 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// GAO 智能体 MCP 工具统一返回 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +@NoArgsConstructor +public class GaoAiToolResult { + + /// 数据范围 + private Map scope = new LinkedHashMap<>(); + + /// 聚合摘要 + private Map summary = new LinkedHashMap<>(); + + /// 明细,最多返回 20 条 + private List details = new ArrayList<>(); + + /// 风险和数据提示 + private List alerts = new ArrayList<>(); + + /// 来源和生成时间 + private Map source = new LinkedHashMap<>(); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java index 70c0884..cc58962 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java +++ b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java @@ -15,6 +15,8 @@ import lombok.Getter; @AllArgsConstructor public enum GaoSystemSetting implements SettingEnum { + DIFY_APP_API_KEY(null, Setting.ValueType.STRING), + SERVICE_LICENSE(null, Setting.ValueType.STRING), PRIVACY_LICENSE(null, Setting.ValueType.STRING), diff --git a/src/main/java/com/imyeyu/api/modules/system/bean/DifySetting.java b/src/main/java/com/imyeyu/api/modules/system/bean/DifySetting.java new file mode 100644 index 0000000..3345706 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/system/bean/DifySetting.java @@ -0,0 +1,28 @@ +package com.imyeyu.api.modules.system.bean; + +import com.imyeyu.api.annotation.SettingBizType; +import com.imyeyu.api.bean.SettingEnum; +import com.imyeyu.api.modules.common.entity.Setting; +import com.imyeyu.api.modules.common.entity.Setting.ValueType; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/// +/// +/// @author 夜雨 +/// @since 2026-09-15 15:39 +@Getter +@SettingBizType("DIFY") +@AllArgsConstructor +public enum DifySetting implements SettingEnum { + + BASE_URL(null, ValueType.STRING), + + API_KEY(null, ValueType.STRING), + + MCP_URL(null, ValueType.STRING); + + final String defaultValue; + + final Setting.ValueType valueType; +} From 6ac84c11b28d0f3aa54978367b7f3dbd6bd81f40 Mon Sep 17 00:00:00 2001 From: Timi Date: Wed, 16 Sep 2026 01:34:19 +0800 Subject: [PATCH 3/3] v1.0.48 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f4acc82..35e4b0e 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.imyeyu.timiserverapi TimiServerAPI - 1.0.47 + 1.0.48 jar TimiServerAPI imyeyu.com API