diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenApiController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenApiController.java
new file mode 100644
index 0000000..13d9e68
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenApiController.java
@@ -0,0 +1,194 @@
+package cn.tianbaotong.fill.module.open.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.exception.BizException;
+import cn.tianbaotong.fill.security.PublicApi;
+import cn.tianbaotong.fill.module.open.entity.CollectOpenApp;
+import cn.tianbaotong.fill.module.open.service.OpenDataService;
+import cn.tianbaotong.fill.module.open.service.OpenPushService;
+import cn.tianbaotong.fill.module.open.service.OpenSignService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 开放接口(《03-接口设计》§14)—— 供认定机构对接。
+ *
+ *
鉴权:两条路都支持
+ *
+ *
+ * - HMAC 头(契约的鉴权方式):{@code X-App-Key} + {@code X-Timestamp} +
+ * {@code X-Nonce} + {@code X-Sign = HMAC-SHA256(appSecret, appKey+timestamp+nonce+body)};
+ * - 访问令牌:先调 {@code /auth/token} 换一个 2 小时的 token,
+ * 之后 {@code Authorization: Bearer }。
+ *
+ *
+ * 契约同时定义了这两种凭据,所以两种都实现。为什么要两种:
+ * HMAC 每次请求都要算签名(更安全,但批量对接时对方要写更多代码),
+ * 令牌适合"先换一次、后面连调几十次"。机构那边的实现习惯两种都有。
+ *
+ *
⚠️ 它是 {@code @PublicApi}:匿名、靠 HMAC 自证
+ *
+ * 这个标注意味着**不检查登录态**。放行的理由不是"外部系统没有账号"这种方便说法,
+ * 而是:它的鉴权强度**不低于**登录态(HMAC 覆盖 appKey+时间戳+nonce+报文体,
+ * 且带 5 分钟时间窗与 nonce 防重放),并且每一步都要显式校验 scope。
+ * 换句话说,这里的"公开"是**换了一套凭据**,而不是"不要凭据"。
+ *
+ *
⚠️ 这里的 Body 必须读"原文"
+ *
+ * 签名覆盖的是**原始 JSON 字符串**(未格式化、未排序)。所以校验必须在
+ * Controller 里拿 {@code @RequestBody String}(原文)来做,**不能**先反序列化成对象
+ * 再序列化回去算签名 —— 字段顺序与空白一变签名就不一样,而错误表现只是"签名不通过",
+ * 排查方向会完全跑偏。
+ *
+ *
⚠️ 路径前缀为什么是 {@code /api/v1/open/v1/...}
+ *
+ * 契约 §14 的详情节写的是 {@code /open/v1/auth/token}(不带 {@code /api/v1}),
+ * 而 §1 声明全部接口在生产是 {@code https://api.tianbaotong.cn/api/v1/**}。
+ * 两处不一致,这里按 §1 走(与其它 100 多个接口同一个前缀,网关只需一条规则),
+ * 差异记在 F-65。
+ */
+@Slf4j
+@PublicApi
+@Tag(name = "开放接口(认定机构对接)")
+@RestController
+@RequestMapping("/api/v1/open/v1")
+@RequiredArgsConstructor
+public class OpenApiController {
+
+ private final OpenSignService signService;
+ private final OpenDataService dataService;
+ private final OpenPushService pushService;
+
+ // ── §14.1 换令牌 ──────────────────────────────
+
+ @PostMapping("/auth/token")
+ @Operation(summary = "换取访问令牌(§14.1)",
+ description = "请求体 `{appKey, appSecret}`,返回 2 小时的 accessToken 与 scope 列表。"
+ + "**这个接口不需要签名头**:它本身就是用密钥换令牌的地方 —— "
+ + "要求它再带一次 HMAC 会让第一次对接多写一层代码,而安全性并没有提升"
+ + "(密钥已经在请求体里了)")
+ public ApiResult token(@RequestBody(required = false) String rawBody) {
+ Map body = parseBody(rawBody);
+ String appKey = str(body.get("appKey"));
+ String appSecret = str(body.get("appSecret"));
+ return ApiResult.ok(signService.issueToken(appKey, appSecret));
+ }
+
+ // ── §14.2 填报单 ──────────────────────────────
+
+ @GetMapping("/tasks/{id}/submissions")
+ @Operation(summary = "拉取任务的填报单(§14.2)",
+ description = "默认只返回**已通过**(status=3)的单子;`since` 支持增量拉取"
+ + "(ISO 时间或毫秒时间戳)。"
+ + "⚠️ **一律不返回明文证件号** —— 库里 `id_card_no` 是 AES 密文、"
+ + "另有 `id_card_mask`,这里只用脱敏值,连解密都不做")
+ public ApiResult submissions(
+ @PathVariable Long id,
+ @RequestParam(required = false) String since,
+ @RequestParam(required = false) Integer status,
+ @RequestParam(defaultValue = "1") int pageNum,
+ @RequestParam(defaultValue = "100") int pageSize,
+ HttpServletRequest req) {
+ CollectOpenApp app = auth(req, "");
+ signService.requireScope(app, CollectOpenApp.SCOPE_SUBMISSION_READ);
+ return ApiResult.ok(dataService.submissions(app, id, since, status, pageNum, pageSize));
+ }
+
+ // ── §14.3 文件 ────────────────────────────────
+
+ @GetMapping("/tasks/{id}/files")
+ @Operation(summary = "拉取材料文件(§14.3)",
+ description = "`submissionIds` 逗号分隔、**最多 200 份**。返回的是**签名 URL**"
+ + "(有效期 30 分钟):七牛空间是私有的,链接不能入库、不能长期有效,"
+ + "过期请重新获取。"
+ + "⚠️ 会校验这些填报单确实属于 path 里那个任务 —— "
+ + "不校验就能拿 A 任务的路径去拉 B 任务的文件")
+ public ApiResult