项目:ERP-CRM | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇给出 10 组核心接口的完整请求/响应示例,是可直接照抄的联调契约:登录、当前用户信息、客户 CRUD(含列表分页契约)、转移与入公海、审批提交/审批/列表、看板统计、文件上传与 Excel 导出。全部 JSON 字段取自真实实体类与 Controller 代码(字段名逐一核对,出处标注在每节开头),字段值为示意值——标注"示意"的值仅为可读性构造,生产含义见表后说明。接口风格总约定与端点全量清单见《03-接口设计》与《15-接口全量参考》,本篇不重复端点总表,只给契约细节。
通用约定(本篇所有示例共同遵守,依据《03-接口设计》第 1 节):每个请求必须带 tenant 请求头(TenantInterceptor 强制);认证头 Authorization: Bearer <JWT>;响应包裹统一 {code, msg, ...},成功 code=200;401/403 都是 HTTP 200 + 业务码(AuthenticationEntryPointImpl 硬编码 setStatus(200);GlobalExceptionHandler 把 AccessDeniedException 转 code 403);列表响应是 {code, msg, rows[], total}(TableDataInfo);分页参数 pageNum/pageSize(query),pageSize 钳制 ≤500。curl 示例统一假设后端 http://localhost:9090、租户 tenant1、账号 admin/admin123(测试口径,见《00-项目总览》第 5 节);<TOKEN> 为登录返回的 JWT 占位。
依据:erp-crm/src/main/java/com/ec/web/controller/system/SysLoginController.java(body 只收 LoginBody 两字段)、com/ec/common/core/domain/model/LoginBody.java。注意没有 code/uuid 字段(验证码已移除,见《16-决策记录ADR》ADR-006)。
请求:
curl -s -X POST http://localhost:9090/login \
-H "Content-Type: application/json" \
-H "tenant: tenant1" \
-d '{"username":"admin","password":"admin123"}'成功响应(token 为 HS512 JWT,示意):
{ "msg": "操作成功", "code": 200, "token": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjZm..." }四类错误响应(均为 HTTP 200):
{ "msg": "租户ID不能为空", "code": 500 }
{ "msg": "用户名或密码错误,还可尝试 2 次", "code": 500 }
{ "msg": "密码输入错误达 5 次,账号已锁定 10 分钟", "code": 500 }
{ "msg": "密码错误次数过多,账号已锁定,请约 9 分钟后再试", "code": 500 }说明:第 2 条是密码错误当次的响应(SysLoginService 第 148 行格式化);第 3 条是错误达 5 次触发锁定的当次响应(第 143 行);第 4 条是锁定窗口内再请求的响应(第 123 行,剩余分钟数动态计算,示意值 9)。
说明:缺 tenant 头在 Controller 直接拦截(SysLoginController.login 第 55-57 行);密码错误计数由 SysLoginService 维护(Redis 键 pwd_err_cnt:{tenant}:{username},错 5 次锁 10 分钟);租户不存在/停用/过期分别有专属消息。登录成功后需把 token 与 tenant 头一并用于后续所有请求;未认证访问受保护接口返回 { "msg": "请求访问:/xxx,认证失败,无法访问系统资源", "code": 401 }。
联调时还有四个不体现在单个响应里的行为需要预知。其一,/login 挂着 IP 限流注解(30 次/分/IP),脚本化压测会先撞限流——响应是限流类消息(RateLimiterAspect 产出,示意文案 ⚠️ 待确认精确措辞),要与密码错误类消息区分开。其二,登录是少数同时查主库与租户库的接口:先校验租户状态(主库 master_tenant,finally 重锚租户键),再认证用户(租户库 sys_user BCrypt 比对),"租户停用"与"密码错误"的报错时序有先后,排障时按报错消息即可判断断在哪一段(《05-核心流程》流程二)。其三,登录成功/失败都会打点 erp.login.total{tenant,result},联调环境可用 curl http://localhost:9091/actuator/prometheus 观察 erp_login 系列指标验证埋点生效。其四,token 有效期 120 分钟滑动续期(剩余 ≤20 分钟时任一请求刷新),curl 脚本长跑要么处理中途 401 重登,要么每次请求前重新登录换新 token(联调期最省事)。
依据:SysLoginController.getInfo/getRouters(第 78-102 行)。getInfo 返回四键(user/roles/permissions/额外无),登录后前端第一跳即调用它:
{
"msg": "操作成功",
"code": 200,
"user": {
"userId": 1, "deptId": 100, "userName": "admin", "nickName": "若依管理员",
"email": "admin@example.com", "phonenumber": "15888888888", "sex": "0",
"avatar": "/profile/avatar/2026/09/28/admin.png", "status": "0",
"loginIp": "127.0.0.1", "loginDate": "2026-10-06 09:30:12",
"dept": { "deptId": 100, "deptName": "tenant1部门", "ancestors": "0" },
"roles": [{ "roleId": 1, "roleName": "超级管理员", "roleKey": "admin", "dataScope": "1", "status": "0" }],
"admin": true, "createTime": "2026-09-01 00:00:00"
},
"roles": ["admin"],
"permissions": ["*:*:*"],
"isDefaultModifyPwd": false
}说明:user 结构即 com/ec/common/core/domain/entity/SysUser.java(含 dept/roles 聚合);管理员 permissions 为通配 *:*:*,普通用户是权限串数组(如 "crm:customer:list")。字段值均为示意。getRouters 返回 data: RouterVo[](RuoYi 菜单树,经 src/api/routes.ts 转 pure-admin 路由,转换规则见《07-模块详解》5.4)。
对接这两个接口有三处容易踩空。第一,getInfo 的鉴权是"登录即可"(无 @PreAuthorize),但它返回的 roles/permissions 正是前端 v-hasPermi 指令的判定数据源——权限串在这里被截断(比如给前端只回部分串)会直接改变按钮可见性,修改返回逻辑前先读《08-安全与权限设计》4.1 的判定链。第二,前端并不直接消费这两个原始响应:src/api/user.ts 的 getLogin 先 POST /login 拿 token,再用裸 axios(绕过请求实例,避免此时还没有 token 注入)调 getInfo 组装成 pure-admin 期望的 UserResult,roles/permissions 单项失败降级空数组不阻断登录——联调若发现"登录成功但页面空白",先查这个组装层而不是后端。第三,getRouters 的菜单树来自租户库 sys_menu(按角色过滤),新建租户若发现菜单缺失,问题在租户模板的种子数据而非本接口(排查路径见《11-扩展开发指南》SOP-3)。
依据:CrmCustomerController.list(强制 status=1 客户态)、CrmCustomer.java 字段、CrmCustomerMapper.xml 的 resultMap。本接口是全站分页契约的基准范式。
curl -s "http://localhost:9090/crm/customer/list?pageNum=1&pageSize=10&name=%E5%8D%8E" \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1"响应(TableDataInfo;rows 元素字段与 CrmCustomer 实体一一对应,值为示意):
{
"total": 42,
"rows": [
{
"id": 1, "code": "KH20260901001", "name": "华信集团", "linkman": "王总",
"phone": "13800000000", "region": "华东", "customerIndustry": "制造业",
"customerRank": "1", "customerStatus": "1",
"status": "1", "dealStatus": "0", "owner": "admin", "preOwner": null,
"delFlag": "0", "version": 0,
"lastUpdateRecord": "电话沟通报价", "lastFollowupTime": "2026-09-30 10:12:00",
"nextFollowupTime": "2026-10-08 14:00:00",
"toPoolTime": null, "toCustomerTime": "2026-09-15 09:00:00", "poolType": null,
"createBy": "admin", "createTime": "2026-09-10 15:20:00",
"updateBy": "admin", "updateTime": "2026-09-30 10:12:00",
"remark": "重点客户", "params": {}
}
],
"code": 200,
"msg": "查询成功"
}说明:status(0 线索/1 客户/2 公海)由 Controller 强制置 "1",前端传了也无效——同一张表的三种视图是三个端点(/crm/clues/list、/crm/pool/list);cluesName/cluesSource/cluesStatus 仅线索态有值,示意中省略;params 是 BaseEntity 的查询参数容器(跟进统计用它传 beginNextFollowupTime 等,见《07-模块详解》2.3),响应里恒为空对象。前端 useTable 直接消费 rows/total。
列表契约还有三条附加语义,写自动化断言前必须知道。第一,筛选条件的匹配方式分两档(CrmCustomerMapper.xml 的 selectCrmCustomerList <where> 段):code/name/linkman/phone/cluesName 走 like concat('%', ?, '%') 模糊匹配(示例 name=华 即命中所有名字含"华"的行),其余字段(region/customerRank/customerStatus/owner 等)走等值匹配——断言用例按字段对号入座。第二,时间范围筛选走 params 容器的 beginLastFollowupTime/endLastFollowupTime 等成对键(SQL 用 between 并对结束日期 date_add 1 day 含当日),键不成对传则条件不生效。第三,分页的 total 是 PageHelper 先发 count 语句统计的全量行数,pageSize 超过 500 被钳制为 500(响应正常但数据被截断),压测时不要用大 pageSize 探边界。导出接口与列表共用同一组筛选条件(第 10 节),断言口径可以复用。
依据:CrmCustomerController.add(status/owner/createBy 三字段服务端强制)、CrmCustomerServiceImpl.insertCrmCustomer(createTime 服务端取 now)。请求体只需传业务字段:
curl -s -X POST http://localhost:9090/crm/customer \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1" \
-H "Content-Type: application/json" \
-d '{"name":"华信集团","code":"KH20261006001","linkman":"王总","phone":"13800000000","region":"华东","customerIndustry":"制造业","customerRank":"1","customerStatus":"1","remark":"展会获客"}'响应(toAjax 包装,影响行数 1):
{ "msg": "操作成功", "code": 200 }说明:服务端强制覆盖 status="1"(客户态)、owner=当前用户名、createBy/createTime——前端传 owner 会被覆盖;无 JSR-303 校验,字段长度由数据库列宽兜底(超长触发 500"系统内部错误")。修改 PUT /crm/customer 同形态(updateBy/updateTime 服务端注入),删除 DELETE /crm/customer/{ids}(逗号分隔多 id),非管理员删除他人名下记录得到:
新增环节还有两条对接注意。其一,insertCrmCustomer 的 XML 会回填 useGeneratedKeys 主键,但响应体不回传实体——需要新记录 id 的客户端(如新增后立刻跳详情)要自行再查一次列表或用唯一 code 检索。其二,crm_customer 表的 code 列没有唯一约束(idx_name 只是普通索引),重复编号不会被数据库拒绝,编号唯一性靠前端表单或录入规范保证 ⚠️ 待确认是否需要后端补校验;批量造数时如果依赖 code 反查会因此产生歧义。删除接口是物理删除(RuoYi 惯例,del_flag 列存在但业务代码未使用),误删恢复只能靠备份(ops/backup.sh 产物),高危操作前建议先在操作日志页确认影响面(@Log 已把 ids 落进 sys_oper_log)。
{ "msg": "无权删除他人名下的记录", "code": 500 }依据:CrmCustomerController.transfer/toPool(第 141-156 行,@RequestParam 形态)、CrmCustomerMapper.xml 两个 update。两个动作都是 query 参数、非 JSON body:
curl -s -X POST "http://localhost:9090/crm/customer/transfer?ids=1&ids=2&owner=xiaohong" \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1"{ "msg": "操作成功", "code": 200 }成功后的数据语义:命中行 pre_owner=旧负责人、owner=xiaohong(SQL 顺序依赖见《18-代码走读实录》3.3);非管理员转移他人名下记录时静默影响 0 行,仍返回 code 200——排查"转移没生效"先查操作者身份与 owner 归属。入公海:
curl -s -X POST "http://localhost:9090/crm/customer/to-pool?ids=1&poolType=1" \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1"数据语义:status 1→2、owner 置空、pre_owner 留痕、pool_type=1、to_pool_time=now;where status='1' 使线索/已入池行不受影响(同样返回 0 行成功)。公海领取 POST /crm/pool/receive/{id} 是反向动作(status 2→1、owner=当前用户、to_pool_time 清空,仅 status=2 行可领取)。
依据:CrmApprovalController.submit(权限串 crm:contract:edit)、CrmApprovalServiceImpl.submitContractApproval(见《18-代码走读实录》1.4)。无请求体,路径参数即合同主键:
curl -s -X POST http://localhost:9090/crm/approval/submit/5 \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1"成功响应与三类拒绝响应:
{ "msg": "操作成功", "code": 200 }
{ "msg": "该合同已有待审批单,请勿重复提交", "code": 500 }
{ "msg": "该合同已审批通过,无需再次提交", "code": 500 }
{ "msg": "审批流程启动失败,已回滚业务数据,请重试", "code": 500 }说明:成功时租户库 crm_contract.review_status 置 "0"、crm_approval 新增留痕行(biz_type="contract"、biz_id=5、status="0"、applicant=当前用户)、主库 Flowable 启动流程实例(businessKey=tenant1:5);最后一类错误表示引擎启动异常且租户库已自动补偿回退(可安全重试,回退机制见走读一 1.4)。提交后合同页 review_status 字典标签显示"待审批"。
依据:CrmApprovalController.list/approve、CrmApprovalServiceImpl.selectApprovalList 组装逻辑、CrmApproval.java 字段。待办列表(status=0):
{
"total": 1,
"rows": [
{
"taskId": "8f14e45f-3b1a-11ef-9c1b-0242ac120002",
"title": "合同审批:华信集团年度框架合同",
"applicant": "xiaoming",
"status": "0",
"customerName": "华信集团",
"contractAmount": 158000,
"createTime": "2026-10-05 16:40:00",
"opinion": null,
"approveBy": null,
"approveTime": null
}
],
"code": 200,
"msg": "查询成功"
}说明:taskId 是 Flowable 任务 ID(UUID 字符串)而非 crm_approval.id 自增主键——后续审批动作必须用这个值;customerName/contractAmount 由 fillContract 从租户库合同表回填。已办列表传 status=1(通过)或 status=2(驳回),行内多 approveTime/opinion、approveBy。
列表接口的分页与筛选有三点契约细节。第一,本接口不走 PageHelper(分页参数是显式 @RequestParam,在 Flowable 查询里用 listPage(offset, limit) 实现),因此 pageSize 不受 500 钳制约束、但换算逻辑不同(见《18-代码走读实录》1.3)。第二,title 筛选作用在流程变量 contractName 上(variableValueLike("%" + title + "%")),不是 SQL like——所以它只匹配提交审批时灌入引擎的合同名快照,合同后来改名不会影响已启动实例的筛选结果。第三,列表返回的租户过滤依赖当前会话租户(businessKey 前缀),用 tenant1 的令牌查不到 tenant2 的待办是设计行为而非缺陷(LoginAuthChainIntegrationTest 锚定的一致性不变量在审批域的体现)。审批动作的幂等性边界也要记录:同一 taskId 重复 approve,第一次后任务已 complete,第二次会命中"审批任务不存在或已处理"——前端按钮防抖与该消息互为双保险。审批动作:
curl -s -X POST http://localhost:9090/crm/approval/approve \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1" \
-H "Content-Type: application/json" \
-d '{"taskId":"8f14e45f-3b1a-11ef-9c1b-0242ac120002","status":"1","opinion":"同意,金额已核对"}'{ "msg": "操作成功", "code": 200 }错误分支:驳回必填意见({"msg": "驳回时请填写审批意见", "code": 500});任务不存在/已处理({"msg": "审批任务不存在或已处理", "code": 500});留痕不匹配({"msg": "未找到待审批的留痕记录,请刷新后重试", "code": 500});引擎失败自动双回退({"msg": "审批引擎处理失败,已回滚业务状态,请重试", "code": 500})。成功后合同 review_status 置 "1"/"2",留痕行 status 同步。
依据:IndexDataController.indexData/stats(第 35-60 行)、CrmStatisticServiceImpl 四方法。/info(权限串 crm:index:data):
{
"msg": "操作成功",
"code": 200,
"data": {
"customer_info": { "my_clues": 3, "clues_total": 25, "my_customers": 12, "customer_total": 80, "my_pools": 15, "pool_total": 15 },
"followup_info": { "today_followup": 4, "no_followup": 2, "followup_rate": 66 },
"today_followup_list": [
{ "id": 7, "name": "华信集团", "nextFollowupTime": "2026-10-06 14:00:00", "owner": "admin", "phone": "13800000000" }
],
"today_updates": [
{ "id": 1, "owner": "admin", "name": "华信集团", "content": "电话沟通报价", "createTime": "2026-10-06 10:02:00" }
]
}
}说明:六个 customer_info 键与 CrmStatisticServiceImpl.customerInfo 的 map.put 一一对应(my_pools 恒等于 pool_total,公海无归属语义);followup_rate 是整数百分数;today_updates 元素是 TodayUpdatesVO(客户名+最新跟进内容,limit 10)。/stats 无按钮权限(登录即可):
{
"msg": "操作成功",
"code": 200,
"data": {
"customer_status": [ { "name": "意向客户", "value": 30 }, { "name": "成交客户", "value": 45 }, { "name": "未分类", "value": 5 } ],
"clues_source": [ { "name": "展会", "value": 12 }, { "name": "转介绍", "value": 9 }, { "name": "未分类", "value": 4 } ],
"months": ["2026-05", "2026-06", "2026-07", "2026-08", "2026-09", "2026-10"],
"order_amounts": [12000, 28000, 15000, 43000, 36000, 8000],
"order_counts": [2, 4, 3, 6, 5, 1],
"new_customers": [6, 9, 7, 12, 10, 2]
}
}说明:分布由客户状态/线索来源字典值聚合(空值归"未分类",CrmStatisticServiceImpl.dashboardStats 第 131-150 行);趋势恒为近 6 个月窗口、空月补零;order_amounts 由 BigDecimal 转 int(分位以下被截断 ⚠️ 待确认产品口径)。性能提示:这两个接口全量拉表进内存聚合(D11),数据量增长后优先优化。
从消费端视角补充三点。第一,/stats 的图表数据结构是 ECharts 饼图的 name/value 数组原样适配(views/welcome/index.vue 直接喂给 plugins/echarts.ts 注册的 Pie/Bar 实例),对接自有图表库时注意"未分类"是兜底键而非枚举值,不要把它当字典项回查。第二,/info 的 customer_info 中 my_pools 与 pool_total 恒相等——公海行没有 owner 语义(入池即置空),"我的公海"本质是公海全量;前端卡片文案若暗示"我的"会误导用户,这是已知的语义瑕疵(见《07-模块详解》2.3 对统计实现的描述)。第三,两个接口都是"当前用户"维度的实时计算(owner 取自会话),无缓存无快照,刷新即最新——对比租户库字典缓存(改字典要 refreshCache)不存在缓存一致性问题,但高频刷新会给全表查询加压,压测脚本请控制频率(性能视角见《10-性能设计与优化》3.3)。
依据:erp-crm/src/main/java/com/ec/web/controller/common/CommonController.java 第 62-77 行、FileUploadUtils(单文件 10MB/请求 20MB,扩展名白名单 MimeTypeUtils)。
curl -s -X POST http://localhost:9090/common/upload \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1" \
-F "file=@./合同扫描件.pdf"{
"msg": "操作成功",
"code": 200,
"fileName": "/profile/upload/2026/10/06/contract_20261006103000123.pdf",
"url": "http://localhost:9090/profile/upload/2026/10/06/contract_20261006103000123.pdf"
}说明:fileName 是入库/回显用的相对路径(profile 前缀),url 是拼了服务器地址的绝对地址;上传产物经 /profile/** 匿名 GET 直出(安全边界见《08-安全与权限设计》6 与 D02);非法扩展名返回 {"msg": "不允许上传jpg等格式文件"} 类白名单错误(示意,具体文案随扩展名)。下载走 GET /common/download?fileName=xxx&delete=false(二进制流);上传目录由 ec.profile 配置(application.yml)。
对接文件接口要记住三个边界。其一,上传大小双闸门:后端 Spring multipart 限制单文件 10MB/请求 20MB(application.yml servlet.multipart 段),生产 nginx 还有 client_max_body_size 50m(erpcrm-ui/nginx.conf),超限报错形态不同(后端 500 业务消息、nginx 413),前端报错文案会因此不一致。其二,返回的 url 不保证可长期解析:它拼的是请求到达时的 ServerConfig.getUrl()(协议+host+端口),经反代部署时客户端拿到的可能是内网地址,回显应优先用相对路径 fileName 拼前端代理前缀。其三,/common/download 的 delete=true 是"下载即删"(CommonController.fileDownload 第 45-53 行,取时间戳重命名后删除源文件),用于一次性导出文件;误把它当普通下载会丢文件——上传目录没有回收站,删除不可恢复。
依据:CrmCustomerController.export(第 85-93 行,POST + HttpServletResponse 直写流)、ExcelUtil 按 @Excel 注解列头。请求即列表查询条件(无分页语义,全量导出):
curl -s -X POST "http://localhost:9090/crm/customer/export" \
-H "Authorization: Bearer <TOKEN>" -H "tenant: tenant1" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "name=华" \
-o 客户数据.xlsx响应是 xlsx 二进制流(非 JSON),列头顺序与 CrmCustomer.java 的 @Excel(name=...) 注解一致(客户编号/客户名称/联系人/联系电话/地区/客户行业/客户级别/客户状态/线索名称/线索来源/线索状态/负责人)。前端不走 axios,走全局 download()(60 秒超时 + blobValidate 区分"包成 JSON 的错误响应"——导出失败时后端 msg 能弹出,见《07-模块详解》5.1)。同形态导出还有 /crm/clues/export、/crm/comment/export、/monitor/operlog/export 等;ERP 主数据的导出是另一种形态(GET + AjaxResult 包裹的 ajax 拼装结果),对接时先对齐形态(《15-接口全量参考》第 7 节的形态对照)。
全部错误都是 HTTP 200 + 业务码(依据 GlobalExceptionHandler 各 @ExceptionHandler 分支),本篇出现过的分支汇总如下:
| 业务码 | 产生条件 | msg 示例(源码可全文搜索定位) |
|---|---|---|
| 401 | 未认证/token 失效 | "请求访问:/xxx,认证失败,无法访问系统资源" |
| 403 | 已认证但无权限(AccessDeniedException) | "没有权限,请联系管理员授权" |
| 500 | 业务校验(ServiceException,msg 原样透传) | 见各节错误示例(如"仅待入库状态的采购单可执行入库") |
| 500 | 未知运行时异常 | "系统内部错误,请联系管理员" |
| 500 | 参数绑定/校验失败(BindException 等) | 首条字段错误消息 |
前端按业务码分流的行为(401 弹重登确认框、非 200 统一 ElMessage)在 erpcrm-ui/src/utils/request.js 响应拦截器与 src/utils/errorHandler.js。用 curl 联调时建议先 POST /login 拿 token、再带着双头(Authorization + tenant)逐接口过一遍本篇示例;若某接口响应与本篇契约不符,以源码为准并按《13-技术债务与演进建议》的维护方式更新本篇——契约示例是"活文档",漂移即债务。