Java EE 企业应用 09:REST 资源怎样表达采购操作
HTTP 200 可以掩盖一次失败的审批
采购系统接到“提交申请”“批准”“创建订单”三种指令。若三个操作都用 POST /api/do,响应一律 200 并把失败写成正文字符串,浏览器和自动化客户端就难以区分输入错误、单据不存在、状态冲突及真正成功。HTTP 状态不是数据库状态;但映射清楚的方法、资源路径和响应语义,可以让调用者知道何时重试、何时修正输入,何时查询最终结果。当前隔离工程已有 POST 创建、提交、审批、拒绝和下单,没有 GET 按 ID 查询申请的资源方法;本章不能假装“创建、提交、查询”已经三项全齐。
基线是 Jakarta REST 4.0、Jakarta EE 11、Open Liberty 26.0.0.5、pgJDBC 42.7.7 和 PostgreSQL 16.15。业务数据通过 JDBC 显式 SQL 和 @Transactional 的采购用例访问;先行 JPA 07:本地事务与冲突 使用 RESOURCE_LOCAL 实验,其 EntityManager 事务并不等同于这里的容器受管调用。两个系列可以共享 DRAFT→SUBMITTED→APPROVED→ORDERED 或 SUBMITTED→REJECTED 的业务规则,却不能共享已通过的测试结论。
资源方法解决的是请求匹配问题
在 RestApplication.java 上,@ApplicationPath("/api") 将 Jakarta REST 的应用根接到 WAR 的 /procurement 下。现有 LabRequestsResource.java 以 @Path("/lab/requests") 定义资源集合。POST /procurement/api/lab/requests 创建 DRAFT;POST /{id}/submit、/{id}/approve、/{id}/reject 与 /{id}/order 分别表示特定命令。这些动词路径是教学工程的接口选择,Jakarta REST 4.0 规范 §3.3、§3.7 约束的是资源方法和请求匹配,不替应用选择“审批必须用哪个英文路径”。重复同一 URI 的不同 HTTP 方法仍是不同契约;若错误地把创建改为 GET,会改变缓存与重试预期。
@PathParam("id") long id 从模板中解析路径段;一个非数字值可能由实现的参数转换失败处理而没有进入业务层,不应把它误记成“申请不存在”。缺路径、方法不匹配、参数解析错误与业务状态冲突发生在不同层。新增按 ID 查询时,需要先规定目标路径、只读方法、响应数据类型、404 与跨租户访问策略,然后在应用用例里把身份限定的租户条件传到 JDBC 查询。现有的 JdbcRequestStore.find(id, tenantId) 带两个参数,而 LabRequestsResource 没有 GET 方法;不能单靠 DAO 有 find 就声称 HTTP 查询已部署。缺这条方法时对资源发 GET 的具体响应仍需受控请求记录,不能凭想象填“200/404”。
用申请状态做一次接口设计核对。draft 插入的是 DRAFT,submit 只应接受可提交的 DRAFT,approve 和 reject 面向 SUBMITTED,order 只应接受 APPROVED 或按既定重试约定处理 ORDERED。客户端把“申请不存在”映射成“状态冲突”就会让调用者无从分辨是 ID 错、租户错误还是审批被别人抢先修改;反之把非法转换都返回 404 又掩盖了应刷新当前状态的需求。实现中的用例通过 JdbcRequestStore.find(id, tenantId) 取得对象,再执行领域状态变换;transition 的 SQL 同时比较旧状态与版本,影响行数不是一时抛 RequestConflictException。REST 层映射的是已分类的异常,不可代替持久层的条件更新。这里没有构造两个审批线程同时进入的时间线,因此从顺序脚本不能推出并发请求一定收到某个特定 409。
媒体类型与状态码要逐层对齐
资源类声明 @Consumes(application/json)、@Produces(application/json);创建方法期望 JSON 实体,成功时 Response.created(...) 返回 201、Location 和包含 id 的 JSON。提交与批准成功后 Response.noContent() 返回 204,没有可读响应体;下单返回 200 和 orderId。这些是源码中的意图,只有归档脚本实际断言的那些状态才能标为实测。按 Jakarta REST 4.0 §3.5、§3.7–3.8,Content-Type 与 Accept 在选择资源及消息体提供者时各有作用:不支持的输入媒体类型可导致 415,没有可接受的输出表现可导致 406;某些缺实体/解析错误可能在更早层失败。当前脚本只提交正确 Content-Type,没有对 415、406 或缺 JSON 的原始响应留证,均为 NOT_RUN。
错误映射也按来源区分。ConflictMapper.java 将 RequestConflictException 映射为 409,MissingMapper.java 将 RequestMissingException 映射为 404。draft() 捕捉某些 IllegalArgumentException 并转换成 BadRequestException,但 @Valid 校验触发的异常类型、错误正文及某些 null 明细分支未在当前场景脚本覆盖。绝不能由“有 ExceptionMapper”推断应用所有异常都安全地返回 400/404/409;未捕获的服务器异常须避免把 SQL、服务器路径或凭证写进外部响应。对于资源不存在与跨租户无权限,可选择对外统一 404 避免存在性泄漏,但必须保证租户来源可信,否则伪造演示头的人仍可切换视角。
即便返回 201,服务器是否已经在数据库里提交两条明细,需要查实际事务与最终行;201 的 Location 不代表审批通过。POST /{id}/order 重试返回相同 orderId,与重复 POST 创建一张新申请是两种行为:前者在当前实现中有“已 ORDERED 则查已有订单”分支及数据库 UNIQUE(request_id),后者没有幂等请求键。客户端遇网络超时,无法仅凭“自己没收到 201”判定服务端没创建申请;随后盲目重复 POST 可能产生两张 DRAFT。要为创建提供稳定操作 ID 或查询结果的恢复协议,还需在应用/数据库层设计并验收,第 22–23 篇再处理完整的不确定提交与重试。
返回码还要和客户端的恢复动作配对。收到 400,客户端应修正语法或字段,而不是盲重试;404 对外隐藏不存在/无权对象时,客户端不应凭错误正文探查另一个租户;409 提示状态或版本冲突,应查询最新状态后由业务决定能否重新发起。收到 201,客户端可按 Location 进一步查询,但当前工程未实现 GET,因此现有 Location 虽由源码设置,也不是可用的后续恢复入口。收到 204 只说明 HTTP 层没有返回正文;真正的提交终态要查询数据库或经过可信的读取接口。若网关与服务器之间超时,客户端甚至连返回码都拿不到,必须依赖稳定的请求标识和数据库结果查询,不能套用“没见到 200 就再发 POST”。
Response.created(URI.create(...)) 形成 Location 路径时还要核对部署的 context root:示例硬编码了 /procurement/api/lab/requests/,若换一个部署名,引用可能指向旧路径。验收不能只看创建状态 201,还应检查 Location 实际可用、是否含不可信输入、返回 id 与查到的对象一致。未实现 GET 前,即使 Location 合法也无法按它读取申请。将来加查询接口应优先用经过验证的租户上下文和只读投影,不把采购明细的成本价或内部异常堆栈透给浏览器。硬编码路径是当前源码行为,不是 Jakarta REST 强制所有应用使用的布局。
现有顺序业务实验证明了哪些分支
在 loopback 单实例、专用 javaee_lab、JAVAEE_DEMO_MODE=true 且专用账号名为 javaee_lab 时,09-lab-procurement.sh 使用随机实验租户。提交两项 SKU 生成的申请返回 201;未批准先下单得 409;更换 X-Lab-Tenant 再提交得 404;正确租户提交 204、审批 204、下单 200,再下单 200 且返回相同订单 ID,已下单后拒绝得到 409。脚本另起 PostgreSQL 连接检查 ORDERED|13.75|1。这证明该次顺序路径与指定演示头参数在当时部署中符合预期,不能说明请求头是经过认证的身份,也不说明跨运行时部署、并发双订单及错误媒体类型通过了验证。
该结果的原始依据是 writing-plans/javaee-enterprise/verification/20261004T062900Z-pg16-business/RUN.md、scenario.stdout.txt、scenario-exit-code.txt(0)和 code-sha256.txt。后续 20261004T-a-batch-recheck 的 after-fix 场景再次退出 0,保存构建/部署 WAR 同摘要及独立数据库终态 ORDERED|13.75|1;两个通过记录仍只有受控顺序。归档基线提交当时不含新增工程,逐文件 SHA 对应那轮源码,不能用当前未提交的文件名反推 GitLab master 具有同样内容。本篇现有顺序 REST 操作:PASS(限上述场景);GET 查询、输入 400、媒体类型 415/406、同申请并发下单、正式授权:NOT_RUN。其中“输入 400”指针对本篇接口实发坏 JSON/空输入的失败实验,不否认源码中已有的坏请求分支。
待补的契约负例
如果要把这个接口扩展为可供外部使用的申请 API,先在隔离环境为三个层级分别留证:发错误媒体类型/不可接受的 Accept,记录容器选择结果;发缺失字段、非法数量、错误路径 ID,记录是否进入资源方法及实际状态;让目标状态已改变或租户条件不成立,记录领域异常到 HTTP 的映射与独立数据库终态。用 curl -i --max-time 20 -H 'Content-Type: application/json' -H 'X-Lab-Tenant: ...' 采集状态行、Location、Content-Type、响应体,不要在请求日志写入真实租户凭证。服务运行后可调用:
1 | |
命令中的口令是读者自备占位,不是仓库提供的凭据。当前脚本只断言顺序响应码和订单最终行数,既没对 Location 作具体断言,也未额外采集跨租户 404 时对外错误正文的一致性。若加入 GET,先让用例/适配层按可信身份查申请,然后用已知存在的 ID 与另一个租户分别请求;成功应只返回允许字段,拒绝不应在错误正文泄漏 SKU 或内部 SQL。任何新接口或失败场景必须有代码和原始输出才可升为 PASS。边界矩阵见 09实验说明。
可以再把失败样本映射到责任层:把 Content-Type 改为纯文本却发送 JSON,是请求媒体类型/提供者匹配问题;发送 {"items":[]},形状有效但不满足 @NotEmpty,须走校验集成;发已存在且已 ORDERED 的申请 ID 到拒绝入口,是领域状态冲突;换一个不可信演示头进入别的租户申请,当前 DAO 谓词返回缺失。四条路径不应依靠一条 404/409 覆盖所有结果。先针对每条输入在隔离库建立随机测试数据,然后记录是否进入资源方法、最终 HTTP 状态、错误正文是否泄漏内部信息和数据库行数;再删除随机数据或使用独立一次性库。这些负例中仅“已下单再拒绝 409”和“换演示租户提交 404”有归档输出,不能把同类的其它输入也标为 PASS。
构造协议错误时不应先复制旧实验的租户 ID;09-lab-procurement.sh 每次生成随机租户,负例最好为每条测试建立新的申请,必要时读取其状态再请求目标动作。若多个负例共用一条 SUBMITTED 申请,前一个 approve 可能改变状态,后一个非法 JSON 或错误媒体类型返回的 409 就难以归因。每轮保存“先决状态→单一改变项→HTTP 输出→独立数据库终态”;对未到达资源方法的请求,还应验证数据库没有任何新申请行。对超时或响应丢失,应保留客户端的稳定操作标识并重查而不是只看 curl 的退出码。这个故障矩阵尚未在脚本中实现,属于协议层与业务层联动的待办,不影响原有顺序场景在其自身范围内的 PASS。
还有两个容易被误写成“默认成功”的行为:没有 Accept 时服务器可能按自己的提供者选择 JSON,不能把它作为 406 实验;发 Accept: text/plain 得到何种错误需要真实响应。@Consumes(application/json) 标注在资源类上,对各个带实体的 POST 有效,但不等于 Content-Type 缺失时所有方法都返回相同状态。分别发送错误媒体类型、无效请求体和不存在的路径,记录资源方法是否进入。一条 400 日志不足以判断是哪一层拒绝:JSON 解码、Bean Validation 或采购规则。这个区分会决定客户端究竟修复请求头、修复字段还是刷新申请状态。
两道练习与答案
练习一: 客户端发 POST /requests 创建两条明细,网络超时且没有收到 201,随后重新发相同的请求;客户端看到第二次返回 201。能据此判断数据库只增加了一张申请吗?POST /{id}/order 返回相同订单号又说明什么?
答: 不能。创建没有持久化的稳定幂等键,第一次可能已经提交,第二次也可能创建新 DRAFT;必须借稳定操作 ID 和新事务查询终态。顺序重复下单在当前实验中返回相同 orderId 且终态一张订单,属于已知申请的特定操作,不保证未知第一次创建结果可恢复,更不能证明同时下单的交错已覆盖。
练习二: 对不存在的申请 ID 发 POST /{id}/approve,另对已有申请使用错误租户头。期望怎样设计 HTTP 状态?若换用真实用户身份,映射器中加 404 是否就足够?
答: 在当前演示应用中,两条路径都由带租户条件的 find 返回 RequestMissingException,设计目标是统一 404,避免暴露其他租户对象是否存在;仅有演示换头的 404 实测,不存在 ID 的请求需单独执行。不够:请求头是调用方可伪造的,真实身份必须由认证层提供并由用例/DAO 对对象与租户一起授权。错误映射器只控制对外表现,不赋予原本缺失的权限规则。
对接口状态码进行版本化管理同样必要:若未来把 POST /{id}/order 的首次下单改为 201、重试改为 200,客户端要能区分是否新建订单,而数据库“最多一单”的断言仍保持不变;若继续一律返回 200,也须文档化返回体及 Location 约定。这里的状态码选择属于服务合同,不能凭某个 Jakarta REST 默认响应自动决定重试策略。
限制与官方资料
本章是受控接口实验,不是已认证、支持完整 GET、可抗并发重试的采购 API。JPA 的事务与查询不参与这些 JDBC 实验,不能混用代码 SHA 或数据库结果。GitLab 源码链接需要在远端同步后核对;页面的相对站点地址不能替代仓库源码定位。






