feat: 接口测试工具 - @ApiMeta 注解扫描生成接口清单
- 新增 @ApiMeta/@ApiParam 注解,OrderApi 每个方法标注 desc+参数描述 - 新增 ApiScanner 反射扫描器:从 @Mapping+方法签名自动提取 method/path/params - AdminApi /apis 改为 ApiScanner.scan(OrderApi.class),替代手写清单 - 新增接口自动同步:OrderApi 加 @ApiMeta 即可,无需维护写死列表 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -273,4 +273,20 @@ public class AdminApi {
|
||||
t.start();
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// ================= 5. 接口清单(供 B 端接口测试工具加载) =================
|
||||
|
||||
/**
|
||||
* 返回 /order 业务接口元数据清单。
|
||||
*
|
||||
* 反射扫描 OrderApi 的 @ApiMeta 注解自动生成,无需手写维护。
|
||||
* B 端因前置机白名单无法直连,通过 Agent 通道拉取本接口清单,
|
||||
* 再经 Agent HTTP_PROXY 代理调用具体接口。
|
||||
* 走 X-Admin-Token 鉴权(与其它管理接口一致)。
|
||||
*/
|
||||
@Mapping("/apis")
|
||||
public Result<List<Map<String, Object>>> apis(Context ctx) {
|
||||
checkToken(ctx);
|
||||
return Result.success(com.hospital.front.infra.ApiScanner.scan(com.hospital.front.api.order.OrderApi.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import org.noear.solon.Solon;
|
||||
import org.noear.snack.ONode;
|
||||
|
||||
import com.hospital.front.dto.Result;
|
||||
import com.hospital.front.infra.annotation.ApiMeta;
|
||||
import com.hospital.front.infra.annotation.ApiParam;
|
||||
import com.hospital.front.infra.exception.BusinessException;
|
||||
import com.hospital.front.service.OrderService;
|
||||
|
||||
@@ -29,6 +31,8 @@ import com.hospital.front.service.OrderService;
|
||||
* 鉴权:对齐原程序 Sa-Token 的 SaBasicUtil.check(),对 /order/** 做 HTTP Basic 认证。
|
||||
* 配置 order.basic(格式 "user:password",未配置时放行——与原程序 authFlag=true 且
|
||||
* basic 为空时行为一致,仅建议生产环境必配)。
|
||||
*
|
||||
* 每个业务方法标注 @ApiMeta,供 /api/admin/apis 反射扫描自动生成接口清单(B 端接口测试工具用)。
|
||||
*/
|
||||
@Controller
|
||||
@Mapping("/order")
|
||||
@@ -83,6 +87,9 @@ public class OrderApi {
|
||||
// ================= 1. 预约提交 / 取消 =================
|
||||
|
||||
/** 4. 提交预约保存 */
|
||||
@ApiMeta(desc = "提交预约保存", params = {
|
||||
@ApiParam(name = "body", desc = "预约 JSON 体")
|
||||
})
|
||||
@Mapping(value = "/saveOrder", method = {MethodType.POST})
|
||||
public Result<String> saveOrder(Context ctx, @Body String body) {
|
||||
checkBasic(ctx);
|
||||
@@ -93,6 +100,11 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 5. 取消体检预约 */
|
||||
@ApiMeta(desc = "取消体检预约", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号"),
|
||||
@ApiParam(name = "name", desc = "姓名"),
|
||||
@ApiParam(name = "idcard", desc = "身份证号")
|
||||
})
|
||||
@Mapping(value = "/cancelOrder/{peId}", method = {MethodType.POST})
|
||||
public Result<?> cancelOrder(
|
||||
Context ctx,
|
||||
@@ -107,6 +119,9 @@ public class OrderApi {
|
||||
// ================= 2. 体检状态 =================
|
||||
|
||||
/** 3. 体检状态查询(多个 peId 逗号分隔) */
|
||||
@ApiMeta(desc = "体检状态查询(多个 peId 逗号分隔)", params = {
|
||||
@ApiParam(name = "peIds", desc = "体检编号逗号分隔")
|
||||
})
|
||||
@Mapping("/getPeStatus/{peIds}")
|
||||
public Result<List<Map<String, String>>> getPeStatus(Context ctx, @Path("peIds") String peIds) {
|
||||
checkBasic(ctx);
|
||||
@@ -114,6 +129,10 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 13. 根据身份证号及年份获取当前体检状态及体检项状态 */
|
||||
@ApiMeta(desc = "体检状态及体检项状态", params = {
|
||||
@ApiParam(name = "idNo", desc = "身份证号"),
|
||||
@ApiParam(name = "year", desc = "年份")
|
||||
})
|
||||
@Mapping("/getPeStatusAndItemList/{idNo}/{year}")
|
||||
public Result<Map<String, Object>> getPeStatusAndItemList(Context ctx, @Path("idNo") String idNo, @Path("year") String year) {
|
||||
checkBasic(ctx);
|
||||
@@ -123,6 +142,9 @@ public class OrderApi {
|
||||
// ================= 3. 体检项目 =================
|
||||
|
||||
/** 1. 查询员工体检项目 */
|
||||
@ApiMeta(desc = "员工体检项目", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getEmpItem/{peId}")
|
||||
public Result<List<Map<String, Object>>> getEmpItem(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -130,6 +152,7 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 2. 体检项目查询 */
|
||||
@ApiMeta(desc = "体检项目查询")
|
||||
@Mapping("/getItemList")
|
||||
public Result<List<Map<String, Object>>> getItemList(Context ctx) {
|
||||
checkBasic(ctx);
|
||||
@@ -137,6 +160,7 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 6. 体检指标项数据获取 */
|
||||
@ApiMeta(desc = "体检指标项数据获取")
|
||||
@Mapping("/getPeItemList")
|
||||
public Result<List<Map<String, Object>>> getPeItemList(Context ctx) {
|
||||
checkBasic(ctx);
|
||||
@@ -144,6 +168,7 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 6.1 体检指标项及体检组合数据获取 */
|
||||
@ApiMeta(desc = "体检指标项及体检组合数据获取")
|
||||
@Mapping("/getAllItemList")
|
||||
public Result<List<Map<String, Object>>> getAllItemList(Context ctx) {
|
||||
checkBasic(ctx);
|
||||
@@ -151,6 +176,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 7. 根据体检指标项查询所属体检组合项 */
|
||||
@ApiMeta(desc = "按体检指标项查所属组合", params = {
|
||||
@ApiParam(name = "peItemCode", desc = "体检指标项编码")
|
||||
})
|
||||
@Mapping("/getItemInfo/{peItemCode}")
|
||||
public Result<List<Map<String, Object>>> getItemInfo(Context ctx, @Path("peItemCode") String peItemCode) {
|
||||
checkBasic(ctx);
|
||||
@@ -160,6 +188,9 @@ public class OrderApi {
|
||||
// ================= 4. 体检结果 / 结论 / 建议 =================
|
||||
|
||||
/** 8. 根据体检编号获取员工体检结果 */
|
||||
@ApiMeta(desc = "按体检编号查员工体检结果", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getResult/{peId}")
|
||||
public Result<List<Map<String, Object>>> getResult(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -167,6 +198,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 9. 体检结论(汇总) */
|
||||
@ApiMeta(desc = "体检结论(汇总)", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getTotalConclusion/{peId}")
|
||||
public Result<Map<String, String>> getTotalConclusion(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -174,6 +208,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 10. 体检建议(汇总) */
|
||||
@ApiMeta(desc = "体检建议(汇总)", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getTotalSuggest/{peId}")
|
||||
public Result<Map<String, String>> getTotalSuggest(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -181,6 +218,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 11. 体检结论(列表) */
|
||||
@ApiMeta(desc = "体检结论(列表)", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getConclusion/{peId}")
|
||||
public Result<List<Map<String, Object>>> getConclusion(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -188,6 +228,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 12. 体检建议(列表) */
|
||||
@ApiMeta(desc = "体检建议(列表)", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getSuggest/{peId}")
|
||||
public Result<List<Map<String, Object>>> getSuggest(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -197,6 +240,10 @@ public class OrderApi {
|
||||
// ================= 5. 按身份证 / 日期查询 =================
|
||||
|
||||
/** 14. 根据身份证号及年份获取体检结果 */
|
||||
@ApiMeta(desc = "按身份证及年份查体检结果", params = {
|
||||
@ApiParam(name = "idNo", desc = "身份证号"),
|
||||
@ApiParam(name = "year", desc = "年份")
|
||||
})
|
||||
@Mapping("/getResultByIdcardAndYear/{idNo}/{year}")
|
||||
public Result<List<Map<String, Object>>> getResultByIdcardAndYear(Context ctx, @Path("idNo") String idNo, @Path("year") String year) {
|
||||
checkBasic(ctx);
|
||||
@@ -204,6 +251,10 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 14.1 根据身份证号及年份获取体检结果(全量) */
|
||||
@ApiMeta(desc = "按身份证及年份查全量结果", params = {
|
||||
@ApiParam(name = "idNo", desc = "身份证号"),
|
||||
@ApiParam(name = "year", desc = "年份")
|
||||
})
|
||||
@Mapping("/getResultByIdcardAndYearAll/{idNo}/{year}")
|
||||
public Result<List<Map<String, Object>>> getResultByIdcardAndYearAll(Context ctx, @Path("idNo") String idNo, @Path("year") String year) {
|
||||
checkBasic(ctx);
|
||||
@@ -211,6 +262,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 15.1 根据体检编号获取体检结果 */
|
||||
@ApiMeta(desc = "按体检编号查体检结果", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getResultByPeId/{peId}")
|
||||
public Result<List<Map<String, Object>>> getResultByPeId(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -218,6 +272,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 15.2 根据体检编号获取体检状态(简) */
|
||||
@ApiMeta(desc = "按体检编号查体检状态(简)", params = {
|
||||
@ApiParam(name = "peId", desc = "体检编号")
|
||||
})
|
||||
@Mapping("/getResultStatus/{peId}")
|
||||
public Result<List<Map<String, Object>>> getResultStatus(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
@@ -225,6 +282,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 15.3 根据 audit_date 查询当天的体检报告 */
|
||||
@ApiMeta(desc = "按审查日期查体检报告", params = {
|
||||
@ApiParam(name = "auditDate", desc = "审查日期 yyyy-MM-dd")
|
||||
})
|
||||
@Mapping("/getResultByAuditDate/{auditDate}")
|
||||
public Result<List<Map<String, Object>>> getResultByAuditDate(Context ctx, @Path("auditDate") String auditDate) {
|
||||
checkBasic(ctx);
|
||||
@@ -232,6 +292,11 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 16. 根据 id_code 或 pe_id 和审查年份查询体检报告 */
|
||||
@ApiMeta(desc = "按身份证或体检编号及年份查报告", params = {
|
||||
@ApiParam(name = "idNo", desc = "身份证号(与 peId 二选一)"),
|
||||
@ApiParam(name = "peId", desc = "体检编号(与 idNo 二选一)"),
|
||||
@ApiParam(name = "auditYear", desc = "审查年份")
|
||||
})
|
||||
@Mapping(value = "/getResultByIdNoOrPeId", method = {MethodType.POST})
|
||||
public Result<List<Map<String, Object>>> getResultByIdNoOrPeId(
|
||||
Context ctx,
|
||||
@@ -246,6 +311,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 17. 根据身份证号查询当前年的 peId 和预约日期 */
|
||||
@ApiMeta(desc = "按身份证查当年 peId 和预约日期", params = {
|
||||
@ApiParam(name = "idNo", desc = "身份证号")
|
||||
})
|
||||
@Mapping("/getPeIdAndDateByIdcard/{idNo}")
|
||||
public Result<Map<String, Object>> getPeIdAndDateByIdcard(Context ctx, @Path("idNo") String idNo) {
|
||||
checkBasic(ctx);
|
||||
@@ -253,6 +321,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 17.1 同上(数组) */
|
||||
@ApiMeta(desc = "按身份证查 peId 和预约日期(数组)", params = {
|
||||
@ApiParam(name = "idNo", desc = "身份证号")
|
||||
})
|
||||
@Mapping("/getPeIdAndDateByIdcardList/{idNo}")
|
||||
public Result<List<Map<String, Object>>> getPeIdAndDateByIdcardList(Context ctx, @Path("idNo") String idNo) {
|
||||
checkBasic(ctx);
|
||||
@@ -262,6 +333,9 @@ public class OrderApi {
|
||||
// ================= 6. 单位 / 人员列表 =================
|
||||
|
||||
/** 18. 添加修改单位信息 */
|
||||
@ApiMeta(desc = "添加修改单位信息", params = {
|
||||
@ApiParam(name = "body", desc = "单位 JSON 数组")
|
||||
})
|
||||
@Mapping(value = "/addUnit", method = {MethodType.POST})
|
||||
public Result<?> addUnit(Context ctx, @Body String body) {
|
||||
checkBasic(ctx);
|
||||
@@ -279,6 +353,9 @@ public class OrderApi {
|
||||
}
|
||||
|
||||
/** 19. 根据日期查询当天体检人员列表(去重),日期为空则查当天 */
|
||||
@ApiMeta(desc = "按日期查当天体检人员列表(去重)", params = {
|
||||
@ApiParam(name = "auditDate", desc = "审查日期(空则查当天)")
|
||||
})
|
||||
@Mapping("/getExamListByDate")
|
||||
public Result<List<Map<String, Object>>> getExamListByDate(Context ctx, @Param("auditDate") String auditDate) {
|
||||
checkBasic(ctx);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.hospital.front.infra;
|
||||
|
||||
import com.hospital.front.infra.annotation.ApiMeta;
|
||||
import com.hospital.front.infra.annotation.ApiParam;
|
||||
import org.noear.solon.annotation.Body;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Param;
|
||||
import org.noear.solon.annotation.Path;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.MethodType;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 接口元数据扫描器。
|
||||
*
|
||||
* 反射扫描指定 Controller 类的所有 @ApiMeta 标注方法,自动提取:
|
||||
* - method:从 @Mapping.method()(空数组视为 GET)
|
||||
* - path:类 @Mapping 前缀 + 方法 @Mapping.value()
|
||||
* - params:从方法签名 @Path/@Param/@Body 提取 name/in/required/type,@ApiMeta.params 补充 desc
|
||||
*
|
||||
* 替代手写接口清单,新增/调整接口时只需维护 @ApiMeta 注解即可自动同步。
|
||||
*/
|
||||
public final class ApiScanner {
|
||||
|
||||
private ApiScanner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描指定 Controller 类,返回带 @ApiMeta 的接口元数据清单。
|
||||
*
|
||||
* @param controllerClass Controller 类(如 OrderApi.class)
|
||||
* @return 接口元数据列表(不可变)
|
||||
*/
|
||||
public static List<Map<String, Object>> scan(Class<?> controllerClass) {
|
||||
// 类级路径前缀(如 /order)
|
||||
String prefix = "";
|
||||
Mapping classMapping = controllerClass.getAnnotation(Mapping.class);
|
||||
if (classMapping != null && !classMapping.value().isEmpty()) {
|
||||
prefix = classMapping.value();
|
||||
}
|
||||
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (Method method : controllerClass.getDeclaredMethods()) {
|
||||
ApiMeta meta = method.getAnnotation(ApiMeta.class);
|
||||
Mapping methodMapping = method.getAnnotation(Mapping.class);
|
||||
if (meta == null || methodMapping == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 拼接完整路径
|
||||
String path = prefix + (methodMapping.value() == null ? "" : methodMapping.value());
|
||||
|
||||
// HTTP 方法:method() 空数组视为 GET
|
||||
String httpMethod = "GET";
|
||||
MethodType[] mtypes = methodMapping.method();
|
||||
if (mtypes != null && mtypes.length > 0) {
|
||||
httpMethod = mtypes[0].name();
|
||||
}
|
||||
|
||||
// @ApiMeta.params 转 name→desc 映射,供参数补充描述
|
||||
Map<String, String> descMap = new LinkedHashMap<>();
|
||||
for (ApiParam ap : meta.params()) {
|
||||
descMap.put(ap.name(), ap.desc());
|
||||
}
|
||||
|
||||
// 扫描方法参数(跳过 Context)
|
||||
List<Map<String, Object>> params = new ArrayList<>();
|
||||
for (Parameter p : method.getParameters()) {
|
||||
if (p.getType() == Context.class) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> param = extractParam(p, descMap);
|
||||
if (param != null) {
|
||||
params.add(param);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("method", httpMethod);
|
||||
item.put("path", path);
|
||||
item.put("desc", meta.desc());
|
||||
item.put("params", params);
|
||||
list.add(item);
|
||||
}
|
||||
return Collections.unmodifiableList(list);
|
||||
}
|
||||
|
||||
/** 从单个方法参数提取元数据 */
|
||||
private static Map<String, Object> extractParam(Parameter p, Map<String, String> descMap) {
|
||||
Map<String, Object> param = new LinkedHashMap<>();
|
||||
Path pathAnno = p.getAnnotation(Path.class);
|
||||
Param paramAnno = p.getAnnotation(Param.class);
|
||||
Body bodyAnno = p.getAnnotation(Body.class);
|
||||
|
||||
if (pathAnno != null) {
|
||||
String name = pathAnno.value().isEmpty() ? p.getName() : pathAnno.value();
|
||||
param.put("name", name);
|
||||
param.put("in", "path");
|
||||
param.put("type", "string");
|
||||
param.put("required", true);
|
||||
} else if (bodyAnno != null) {
|
||||
param.put("name", "body");
|
||||
param.put("in", "body");
|
||||
param.put("type", "json");
|
||||
param.put("required", true);
|
||||
} else if (paramAnno != null) {
|
||||
String name = paramAnno.value().isEmpty() ? p.getName() : paramAnno.value();
|
||||
param.put("name", name);
|
||||
param.put("in", "query");
|
||||
param.put("type", "string");
|
||||
param.put("required", paramAnno.required());
|
||||
} else {
|
||||
// 无 Solon 参数注解,跳过(如注入的 bean 等)
|
||||
return null;
|
||||
}
|
||||
param.put("desc", descMap.getOrDefault((String) param.get("name"), ""));
|
||||
return param;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hospital.front.infra.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 接口元数据注解(标注在 Controller 方法上)。
|
||||
*
|
||||
* 用于 /api/admin/apis 反射扫描自动生成接口清单,供 B 端接口测试工具加载。
|
||||
* 标注后无需手写清单,新增/调整接口时自动同步。
|
||||
*
|
||||
* 参数的 name/in/required/type 由方法签名(@Path/@Param/@Body)反射自动提取,
|
||||
* 此处仅补充方法描述与参数描述(按 name 匹配)。
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ApiMeta {
|
||||
/** 接口描述 */
|
||||
String desc();
|
||||
|
||||
/** 参数描述(按 name 匹配方法参数补充 desc) */
|
||||
ApiParam[] params() default {};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.hospital.front.infra.annotation;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.lang.annotation.ElementType;
|
||||
|
||||
/**
|
||||
* 接口参数描述注解(仅作为 {@link ApiMeta#params()} 数组成员,不单独标注)。
|
||||
*
|
||||
* 扫描时按 name 匹配方法参数,补充其 desc。
|
||||
*/
|
||||
@Target(ElementType.ANNOTATION_TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ApiParam {
|
||||
/** 参数名(须与方法签名 @Path/@Param/@Body 的参数名一致) */
|
||||
String name();
|
||||
|
||||
/** 参数描述 */
|
||||
String desc() default "";
|
||||
}
|
||||
Reference in New Issue
Block a user