From 6030c62e2ac9ccb18f725e637a06dc7fdfa439aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 28 May 2026 16:39:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 +- cmd/transit/main.go | 3 + docs/COMMANDS.md | 18 +- docs/FILEAUTH.md | 52 +++ docs/FileAuth.java | 153 +++++++++ internal/config/config.go | 59 +++- internal/config/config_duration_test.go | 23 ++ internal/fileauth/auth.go | 26 ++ internal/fileauth/auth_ver233_test.go | 112 +++++++ internal/fileauth/b64_config.go | 42 +++ internal/fileauth/parts.go | 168 ++++++++++ internal/fileauth/parts_test.go | 85 +++++ internal/fileauth/profile.go | 9 + internal/fileauth/ver233.go | 182 +++++++++++ internal/hyfile/app-2026-05-27.log | 0 internal/hyfile/app-2026-05-28.log | 0 internal/hyfile/pull-2026-05-27.log | 0 internal/hyfile/pull-2026-05-28.log | 0 internal/hyfile/push-2026-05-27.log | 6 + internal/hyfile/push-2026-05-28.log | 32 ++ internal/hyfile/throttle.go | 67 ++++ internal/hyfile/throttle_test.go | 70 ++++ internal/hyfile/upload.go | 101 ++++-- internal/hyfile/upload_test.go | 80 ++++- internal/logweb/configview.go | 332 +++++++++++++++++++ internal/logweb/fileauth_handlers.go | 146 +++++++++ internal/logweb/server.go | 147 +++++++++ internal/logweb/upload_handlers.go | 50 +++ internal/logweb/web/app.js | 67 +++- internal/logweb/web/config.html | 212 +++++++++++++ internal/logweb/web/fileauth.html | 403 ++++++++++++++++++++++++ internal/logweb/web/index.html | 20 +- internal/logweb/web/logs_view.html | 2 +- internal/logweb/web/runs.html | 33 +- internal/logweb/web/runs_record.html | 35 +- internal/logweb/web/static.css | 48 +++ internal/logweb/web/test.html | 4 +- internal/logweb/web/upload.html | 164 ++++++++++ internal/sync/config.go | 257 +++++++++++++++ internal/sync/sync.go | 163 ++++++++-- internal/sync/upload_test_result.go | 72 +++++ internal/xkapi/client.go | 5 +- 42 files changed, 3360 insertions(+), 100 deletions(-) create mode 100644 docs/FILEAUTH.md create mode 100644 docs/FileAuth.java create mode 100644 internal/config/config_duration_test.go create mode 100644 internal/fileauth/auth.go create mode 100644 internal/fileauth/auth_ver233_test.go create mode 100644 internal/fileauth/b64_config.go create mode 100644 internal/fileauth/parts.go create mode 100644 internal/fileauth/parts_test.go create mode 100644 internal/fileauth/profile.go create mode 100644 internal/fileauth/ver233.go create mode 100644 internal/hyfile/app-2026-05-27.log create mode 100644 internal/hyfile/app-2026-05-28.log create mode 100644 internal/hyfile/pull-2026-05-27.log create mode 100644 internal/hyfile/pull-2026-05-28.log create mode 100644 internal/hyfile/push-2026-05-27.log create mode 100644 internal/hyfile/push-2026-05-28.log create mode 100644 internal/hyfile/throttle.go create mode 100644 internal/hyfile/throttle_test.go create mode 100644 internal/logweb/configview.go create mode 100644 internal/logweb/fileauth_handlers.go create mode 100644 internal/logweb/upload_handlers.go create mode 100644 internal/logweb/web/config.html create mode 100644 internal/logweb/web/fileauth.html create mode 100644 internal/logweb/web/upload.html create mode 100644 internal/sync/config.go create mode 100644 internal/sync/upload_test_result.go diff --git a/.env.example b/.env.example index abbd31c..12a6767 100644 --- a/.env.example +++ b/.env.example @@ -3,11 +3,13 @@ XK_API_BASE_URL=https://api.xiaokang88.com # 与云端 HY_TRANSIT_API_TOKEN 一致 XK_API_TOKEN= -# 监管平台凭证(与 xk-api config/xk.php hy 段一致) -# HY_AES_KEY 对应 xk-api 的 HY_APP_ENCODING_ASE_KEY +# 监管平台凭证:生产环境以 xk-api GET /supervise/config 为准(serve 启动与 /config 页可刷新) +# 下列 HY_* 仅作离线兜底;HY_AES_KEY 对应 xk-api 的 HY_APP_ENCODING_ASE_KEY HY_APP_KEY= HY_APP_SECRET= HY_AES_KEY= +# 文件上传 scope(与 xk-api HY_FILE_BUCKET 一致;云端 config 优先) +# HY_FILE_BUCKET= # 内网 xk-hy-forward-go 地址(监管 JSON + PDF 上传均经此转发) FORWARD_BASE_URL=http://127.0.0.1:8080 @@ -40,6 +42,12 @@ CRON_EXPR="0 22 * * *" # 监管文件上传 HTTPS(政务网自签证书,默认 true) FILE_UPLOAD_INSECURE_SKIP_VERIFY=true +# 政务云文件上传限频:两次 POST 最短间隔(默认 10s;0 关闭;支持 10s 或纯数字秒) +FILE_UPLOAD_MIN_INTERVAL=10s + +# uploadToken Base64:padded(默认,含=)| raw(无填充,联调仍 403 可试) +# FILEAUTH_B64=padded + # 应用日志(默认 {程序目录}/log/transit/,含 pull/push/app 按日文件;每次执行有 BEGIN/END 分隔符) # LOG_DIR 可覆盖根目录,实际路径为 {LOG_DIR}/transit/ # LOG_DIR=D:\worker\code\xk-hy-transit-go\log diff --git a/cmd/transit/main.go b/cmd/transit/main.go index 3ec1ca2..b9164f7 100644 --- a/cmd/transit/main.go +++ b/cmd/transit/main.go @@ -215,6 +215,9 @@ func runUploadTest(args []string) { } runner, _ := loadRunner() defer runner.StoreClose() + if err := runner.RegenerateUploadToken(); err != nil { + log.Fatalf("生成 uploadToken 失败: %v", err) + } if runner.UploadToken() == "" { log.Fatal("uploadToken 为空,请在 xk-api 配置 HY_APP_KEY / HY_APP_SECRET / HY_FILE_BUCKET") } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 2ea616d..23a762e 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -160,7 +160,7 @@ go run ./cmd/transit pdf-test --html-file=./test.html --patient=张三 --store= ### 单独测监管文件上传 -需 xk-api 配置 `HY_APP_KEY`、`HY_APP_SECRET`、`HY_FILE_BUCKET`(否则 `uploadToken` 为空): +需 xk-api 配置 `HY_APP_KEY`、`HY_APP_SECRET`、`HY_FILE_BUCKET`(transit 按 FileAuth 算法本地生成 `uploadToken`,每次上传前自动刷新)。算法说明见 [`docs/FILEAUTH.md`](FILEAUTH.md)。 ```bash go run ./cmd/transit upload-test --pdf=./test.pdf @@ -207,10 +207,22 @@ xk-hy-transit-go\log\transit\ | 交互菜单不出现 | 非 TTY 环境(CI/重定向)会只打印用法;请用 `sync`/`serve` 子命令 | | PDF 未生成 / chromedp 报错 | 安装 Chrome 或设置 `CHROME_PATH`;首次需 `go mod tidy` 拉取 chromedp 依赖 | | 样式与 PC 不一致 | 对比 `GET /api/hy/transit/prescription/detail` 的 `recipe_file_html` 与后台处方打印预览 | -| `upload token empty` | xk-api `.env` 配置 `HY_APP_KEY` / `HY_APP_SECRET` / `HY_FILE_BUCKET` | +| `upload token empty` / `appKey/appSecret/fileBucket 未配置` | xk-api 或 transit `.env` 配置 `HY_APP_KEY` / `HY_APP_SECRET` / `HY_FILE_BUCKET`;`/config` 点「重新拉取」 | | PDF 上传连接超时 | 确认 `FILE_UPLOAD_VIA_FORWARD=true` 且 forward 已监听 `/mng/file/auth/upload`;检查 forward `.env` 中 `FILE_TARGET_URL` | | 推送显示 success 但监管未入库 | 若响应含 `msgCode` 须为 200;无 `msgCode` 时看 `code` 与 HTTP | -| `上传凭证无效或过期` | 文件上传 HTTP 403,重新拉取 config 或检查 token | +| 文件上传 HTTP 403 body=`forbidden` | **forward-go 本地拒绝**:检查 `ALLOW_IPS`、`FORWARD_SHARED_SECRET` 与 transit `X-Forward-Token` | +| 文件上传 HTTP 403 含 JSON `message` | **政务云拒绝 token**:核对 `HY_FILE_BUCKET` scope、密钥;`/config` 重新拉取;Web `/upload` 查看完整 `detail` | +| xk-api 改了 HY_APP_* 但 transit 仍是旧值 | transit `serve` 长进程需 Web「重新拉取」或重启;云端凭证优先于本地 `.env`;uploadToken 每次上传前自动重生 | + +### Web 控制台页面(`transit serve`) + +| 路径 | 说明 | +|------|------| +| `/config` | 查看云端 / 本地 / 生效互医配置;对比云端预生成 token 与 Runner 本地 FileAuth token | +| `/upload` | 测试 PDF 上传(失败时展示完整 upstream 错误,Toast 提示) | +| `/runs` | 同步流水;失败记录可「整条重试」 | + +改 xk-api `.env` 后:`php artisan config:clear` + 重启 PHP/Laravel-S,再在 transit `/config` 点重新拉取。 --- diff --git a/docs/FILEAUTH.md b/docs/FILEAUTH.md new file mode 100644 index 0000000..63f3c3a --- /dev/null +++ b/docs/FILEAUTH.md @@ -0,0 +1,52 @@ +# FileAuth 上传凭证(Ver2.0 §2.3.3) + +生产仅使用 **§2.3.3**(HMAC 对 **encodedPutPolicy** 字符串)。FileAuth.java 旧算法(HMAC 对 policy JSON)**不可用**,不再维护。 + +## 生产 putPolicy(upload profile) + +```json +{"scope":"","deadline":} +``` + +- `scope`:**仅 bucket 名**(如 `other-doc`),**不要** `bucket:filename` +- **无** `returnBody` +- HTTP 上传为 **PDF 二进制体**(`Content-Type: application/pdf`),**不使用** multipart;与 scope 无关 + +## 五步 + +| 步 | 说明 | +|----|------| +| 1 | 构造 putPolicy(upload profile 见上) | +| 2 | `encodedPutPolicy = urlsafe_base64(putPolicy)` | +| 3 | `sign = HMAC-SHA1(secret, UTF-8(encodedPutPolicy))` | +| 4 | `encodedSign = urlsafe_base64(sign)` | +| 5 | `uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy` | + +## Base64 填充 + +| 环境变量 | 说明 | +|----------|------| +| `FILEAUTH_B64=padded`(默认) | URL-safe Base64 **含 `=`**(与文档教学样例一致) | +| `FILEAUTH_B64=raw` | 无填充(联调仍 403 时可试) | + +## 文档样例(doc profile,仅单测) + +教学样例含 `returnBody` 与 `scope=my-bucket:sunflower.jpg`,见 `auth_ver233_test.go` / `FileAuthServiceTest::test_create_upload_token_doc_golden`。 + +## 错误对照 + +| HTTP body | 含义 | +|-----------|------| +| `forbidden` | forward-go(IP / `X-Forward-Token`) | +| **`404`(纯文本)+ 403** | 已过 forward,**政务云 token 验签失败** | + +## Web 调试 + +- `/fileauth`:生成 upload profile token;`?doc=1` 为文档黄金样例 +- `/upload`:A=Runner 默认 token;B=本次上传 `tokenInfo`(`scope` 应为 bucket) + +实现:[`internal/fileauth`](../internal/fileauth/ver233.go) + +## 上传频率 + +见 [`internal/hyfile/throttle.go`](../internal/hyfile/throttle.go),`FILE_UPLOAD_MIN_INTERVAL`(默认 `10s`)。 diff --git a/docs/FileAuth.java b/docs/FileAuth.java new file mode 100644 index 0000000..1e2f261 --- /dev/null +++ b/docs/FileAuth.java @@ -0,0 +1,153 @@ +/** + * bianque.com + * Copyright (C) 2013-2023 All Rights Reserved. + */ +package com.bqhealth.nethospital.supervise.common.utils; + +import org.apache.commons.lang3.StringUtils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * + * @author geyin + * @version FileAuth.java, v 0.1 2023-06-28 10:27 + */ +public class FileAuth { + + private static FileAuth instance; + + private final SecretKeySpec secretKey; + + private String accessKey; + + public FileAuth(String accessKey, String secret) { + if (isNullOrEmpty(accessKey) || isNullOrEmpty(secret)) + throw new IllegalArgumentException("empty key or secret"); + byte[] sk = utf8Bytes(secret); + SecretKeySpec secretKeySpec = new SecretKeySpec(sk, "HmacSHA1"); + this.accessKey = accessKey; + this.secretKey = secretKeySpec; + instance = this; + } + + public static FileAuth instance() { + if (instance == null) + throw new IllegalStateException("FileAuth not setup,please check"); + return instance; + } + + private static String encodeToString(byte[] data) { + return Base64.encodeToString(data, 10); + } + + public static byte[] decode(String data) { + return Base64.decode(data, 10); + } + + private static boolean isNullOrEmpty(String s) { + return (s == null || "".equals(s)); + } + + private static byte[] utf8Bytes(String data) { + return data.getBytes(StandardCharsets.UTF_8); + } + + public static void main(String[] args) { + FileAuth fileAuth = new FileAuth("accessKey", "secret"); + String uptk = fileAuth.createUploadToken("other-doc"); + System.out.println(uptk); + String token = fileAuth.createToken("5dbf93147826c67027c438bf", 3600L); + System.out.println(token); + System.out.println(fileAuth.createTokens("a,b,c", 3600L)); + System.out.println(fileAuth.createTokensByListStr("[\"a\",\"b\"]", 3600L)); + System.out.println(fileAuth.createTokensByListStr("[1,2]", 3600L)); + } + + public String getAccessKey() { + return this.accessKey; + } + + private Mac createMac() { + Mac mac; + try { + mac = Mac.getInstance("HmacSHA1"); + mac.init(this.secretKey); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + throw new IllegalArgumentException(e); + } + return mac; + } + + public String sign(byte[] data) { + Mac mac = createMac(); + return encodeToString(mac.doFinal(data)); + } + + public String sign(String data) { + return sign(utf8Bytes(data)); + } + + private boolean isTimeExpired(long expireTime) { + long nowTime = System.currentTimeMillis() / 1000L; + return (nowTime > expireTime); + } + + public String createToken(String fileId, long expires) { + if (instance == null) + return null; + long deadline = System.currentTimeMillis() / 1000L + expires; + String sign = sign(fileId + "?" + deadline); + return StringUtils.join(new Object[] { Long.valueOf(deadline), this.accessKey, sign }, ":"); + } + + private String createTokens(String fileIds, long expires) { + String[] keys = StringUtils.split(fileIds, ','); + String[] tokens = new String[keys.length]; + for (int i = 0; i < keys.length; i++) + tokens[i] = createToken(keys[i], expires); + return StringUtils.join((Object[])tokens, ","); + } + + private String createTokensByListStr(String fileIdListStr, long expires) { + List fileIdList = JSONUtils.>parse(fileIdListStr, (Class)List.class); + List strList = new ArrayList<>(); + for (Object o : fileIdList) + strList.add(String.valueOf(o)); + return JSONUtils.toString(createTokens(strList, expires)); + } + + private List createTokens(List fileIdList, long expires) { + List list = new ArrayList<>(); + for (String s : fileIdList) + list.add(createToken(s, expires)); + return list; + } + + public String createUploadToken(String bucket) { + Map policy = new HashMap<>(); + policy.put("scope", bucket); + policy.put("deadline", Long.valueOf(System.currentTimeMillis() / 1000L + 3600L)); + return createUploadToken(bucket, policy); + } + + public String createUploadToken(String bucket, long expires) { + Map policy = new HashMap<>(); + policy.put("scope", bucket); + policy.put("deadline", Long.valueOf(System.currentTimeMillis() / 1000L + expires)); + return createUploadToken(bucket, policy); + } + + public String createUploadToken(String bucket, Map policy) { + String sign = sign(JSONUtils.toString(policy)); + return this.accessKey + ':' + sign + ':' + encodeToString(utf8Bytes(JSONUtils.toString(policy))); + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9612ee0..a3f41b3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,27 +14,31 @@ import ( "os" "strconv" "strings" + "time" ) // Config 监管中转运行所需的全部配置项。 type Config struct { - XkAPIBaseURL string // 云端根地址,如 https://api.xiaokang88.com - XkAPIToken string // 对应云端 HY_TRANSIT_API_TOKEN,Header: X-Hy-Transit-Token - HyAppKey string // 监管平台 AppKey(加密上报) - HyAppSecret string // 监管平台 Secret(签名) - HyAesKey string // 监管平台 AES 密钥(body 加密) - ForwardBaseURL string // xk-hy-forward-go 地址,如 http://192.168.1.10:8080 - ForwardSharedSecret string // 与 forward-go FORWARD_SHARED_SECRET 一致,Header X-Forward-Token - FileUploadViaForward bool // true:PDF 上传经 forward /mng/file/auth/upload,不直连政务云 - MySQLDSN string // 本机中转库 DSN(hy_transit_schema.sql) - AnchorOffsetDays int // 锚定日 = 今天 - N 天,默认 1 - XkAPICallbackPath string // 批量回调路径,默认 /api/hy/transit/batch/callback - CronExpr string // serve 用,默认 0 22 * * * - ScheduleTime string // HH:MM,仅当未设 CRON_EXPR 时转为 cron - ChromePath string // 可选:Chrome/Edge 路径,未设则自动探测 - FileUploadInsecureSkipVerify bool // 文件上传 HTTPS 跳过证书校验(政务网) - LogWebAddr string // Web 控制台监听,默认 127.0.0.1:8765 - LogWebAllowTest bool // 是否允许 Web 触发测试 sync + XkAPIBaseURL string // 云端根地址,如 https://api.xiaokang88.com + XkAPIToken string // 对应云端 HY_TRANSIT_API_TOKEN,Header: X-Hy-Transit-Token + HyAppKey string // 监管平台 AppKey(加密上报) + HyAppSecret string // 监管平台 Secret(签名) + HyAesKey string // 监管平台 AES 密钥(body 加密) + HyFileBucket string // 文件上传 scope(uploadToken 签名用,云端 fileBucket 优先) + ForwardBaseURL string // xk-hy-forward-go 地址,如 http://192.168.1.10:8080 + ForwardSharedSecret string // 与 forward-go FORWARD_SHARED_SECRET 一致,Header X-Forward-Token + FileUploadViaForward bool // true:PDF 上传经 forward /mng/file/auth/upload,不直连政务云 + MySQLDSN string // 本机中转库 DSN(hy_transit_schema.sql) + AnchorOffsetDays int // 锚定日 = 今天 - N 天,默认 1 + XkAPICallbackPath string // 批量回调路径,默认 /api/hy/transit/batch/callback + CronExpr string // serve 用,默认 0 22 * * * + ScheduleTime string // HH:MM,仅当未设 CRON_EXPR 时转为 cron + ChromePath string // 可选:Chrome/Edge 路径,未设则自动探测 + FileUploadInsecureSkipVerify bool // 文件上传 HTTPS 跳过证书校验(政务网) + FileUploadMinInterval time.Duration // 两次文件上传 HTTP 最短间隔,默认 10s(政务云限频) + FileAuthB64 string // uploadToken Base64:padded(默认)| raw + LogWebAddr string // Web 控制台监听,默认 127.0.0.1:8765 + LogWebAllowTest bool // 是否允许 Web 触发测试 sync } // Load 读取环境变量;未设置时使用默认值。 @@ -45,6 +49,7 @@ func Load() Config { HyAppKey: env("HY_APP_KEY", ""), HyAppSecret: env("HY_APP_SECRET", ""), HyAesKey: env("HY_AES_KEY", ""), + HyFileBucket: env("HY_FILE_BUCKET", ""), ForwardBaseURL: strings.TrimRight(env("FORWARD_BASE_URL", "http://127.0.0.1:8080"), "/"), ForwardSharedSecret: env("FORWARD_SHARED_SECRET", ""), FileUploadViaForward: envBool("FILE_UPLOAD_VIA_FORWARD", true), @@ -55,6 +60,8 @@ func Load() Config { ScheduleTime: env("SCHEDULE_TIME", ""), ChromePath: env("CHROME_PATH", ""), FileUploadInsecureSkipVerify: envBool("FILE_UPLOAD_INSECURE_SKIP_VERIFY", true), + FileUploadMinInterval: envDuration("FILE_UPLOAD_MIN_INTERVAL", 10*time.Second), + FileAuthB64: env("FILEAUTH_B64", "padded"), LogWebAddr: env("LOG_WEB_ADDR", ":8765"), LogWebAllowTest: envBool("LOG_WEB_ALLOW_TEST", true), } @@ -93,3 +100,21 @@ func envInt(key string, def int) int { } return n } + +// envDuration 读取时长:支持 Go duration(如 10s);纯整数视为秒。 +func envDuration(key string, def time.Duration) time.Duration { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + if d, err := time.ParseDuration(v); err == nil { + if d < 0 { + return 0 + } + return d + } + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return time.Duration(n) * time.Second + } + return def +} diff --git a/internal/config/config_duration_test.go b/internal/config/config_duration_test.go new file mode 100644 index 0000000..b9092ee --- /dev/null +++ b/internal/config/config_duration_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestEnvDuration(t *testing.T) { + t.Setenv("TEST_DURATION", "15s") + if got := envDuration("TEST_DURATION", time.Second); got != 15*time.Second { + t.Fatalf("got %v", got) + } + t.Setenv("TEST_DURATION", "20") + if got := envDuration("TEST_DURATION", time.Second); got != 20*time.Second { + t.Fatalf("got %v", got) + } + t.Setenv("TEST_DURATION", "") + if got := envDuration("TEST_DURATION", 10*time.Second); got != 10*time.Second { + t.Fatalf("got %v", got) + } + os.Unsetenv("TEST_DURATION") +} diff --git a/internal/fileauth/auth.go b/internal/fileauth/auth.go new file mode 100644 index 0000000..6408934 --- /dev/null +++ b/internal/fileauth/auth.go @@ -0,0 +1,26 @@ +// Package fileauth 实现监管文件服务上传凭证(Ver2.0 §2.3.3,生产 scope=仅 bucket)。 +package fileauth + +import ( + "time" +) + +const defaultUploadExpires = 3600 * time.Second + +// CreateUploadToken 生成上传凭证(§2.3.3 upload profile,scope=仅 bucket)。 +func CreateUploadToken(accessKey, secret, bucket string, expires time.Duration) (string, error) { + if expires <= 0 { + expires = defaultUploadExpires + } + deadline := time.Now().Unix() + int64(expires.Seconds()) + return CreateUploadTokenAt(accessKey, secret, bucket, deadline) +} + +// CreateUploadTokenAt 在指定 deadline 生成上传凭证。 +func CreateUploadTokenAt(accessKey, secret, bucket string, deadlineUnix int64) (string, error) { + parts, err := BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) + if err != nil { + return "", err + } + return parts.UploadToken, nil +} diff --git a/internal/fileauth/auth_ver233_test.go b/internal/fileauth/auth_ver233_test.go new file mode 100644 index 0000000..c8a030a --- /dev/null +++ b/internal/fileauth/auth_ver233_test.go @@ -0,0 +1,112 @@ +package fileauth + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + SetB64Mode(B64ModePadded) + os.Exit(m.Run()) +} + +func TestMarshalPutPolicyDoc_docExample(t *testing.T) { + got, err := MarshalPutPolicyDoc("my-bucket:sunflower.jpg", 1451491200) + if err != nil { + t.Fatal(err) + } + if string(got) != DocExamplePolicyJSON { + t.Fatalf("putPolicy mismatch\ngot: %s\nwant: %s", got, DocExamplePolicyJSON) + } +} + +func TestMarshalPutPolicyUpload_scopeBucketOnly(t *testing.T) { + got, err := MarshalPutPolicyUpload("other-doc", 1735689600) + if err != nil { + t.Fatal(err) + } + want := `{"scope":"other-doc","deadline":1735689600}` + if string(got) != want { + t.Fatalf("upload policy mismatch\ngot: %s\nwant: %s", got, want) + } +} + +func TestVer233_encodedPutPolicy_docExample(t *testing.T) { + got := urlSafeBase64Ver233([]byte(DocExamplePolicyJSON)) + if got != DocExampleEncodedPutPolicy { + t.Fatalf("encodedPutPolicy mismatch\ngot: %s\nwant: %s", got, DocExampleEncodedPutPolicy) + } +} + +func TestVer233_signHex_docExample(t *testing.T) { + hex := HMACSignHex("MY_SECRET_KEY", DocExampleEncodedPutPolicy) + if hex != DocExampleSignHex { + t.Fatalf("sign hex mismatch\ngot: %s\nwant: %s", hex, DocExampleSignHex) + } +} + +func TestVer233_encodedSign_docExample(t *testing.T) { + sign, err := signPolicyVer233("MY_SECRET_KEY", DocExampleEncodedPutPolicy) + if err != nil { + t.Fatal(err) + } + if sign != DocExampleEncodedSign { + t.Fatalf("encodedSign mismatch\ngot: %s\nwant: %s", sign, DocExampleEncodedSign) + } +} + +func TestVer233_fullToken_docExample(t *testing.T) { + parts, err := BuildUploadTokenFromPolicy("MY_ACCESS_KEY", "MY_SECRET_KEY", []byte(DocExamplePolicyJSON), "ver233-doc") + if err != nil { + t.Fatal(err) + } + if parts.UploadToken != DocExampleUploadToken { + t.Fatalf("token mismatch\ngot: %s\nwant: %s", parts.UploadToken, DocExampleUploadToken) + } + if parts.SignInput != DocExampleEncodedPutPolicy { + t.Fatal("signInput should be encodedPutPolicy") + } +} + +func TestBuildUploadTokenPartsForUpload_productionShape(t *testing.T) { + parts, err := BuildUploadTokenPartsForUpload("ak", "sec", "other-doc", 1735689600) + if err != nil { + t.Fatal(err) + } + if parts.Algorithm != "ver233-upload" { + t.Fatalf("algorithm=%q", parts.Algorithm) + } + if parts.SignInput != parts.EncodedPutPolicy { + t.Fatal("ver233 signInput must be encodedPutPolicy") + } + if parts.PolicyJSON != `{"scope":"other-doc","deadline":1735689600}` { + t.Fatalf("policy=%q", parts.PolicyJSON) + } +} + +func TestCreateUploadTokenAtFile_ignoresFilenameInScope(t *testing.T) { + parts, err := BuildUploadTokenPartsAtFile("ak", "sec", "other-doc", "different.pdf", 1735689600) + if err != nil { + t.Fatal(err) + } + if parts.PolicyJSON != `{"scope":"other-doc","deadline":1735689600}` { + t.Fatalf("filename must not affect scope: %q", parts.PolicyJSON) + } +} + +func TestVer233_uploadRawB64_differsFromPadded(t *testing.T) { + SetB64Mode(B64ModePadded) + padded, err := BuildUploadTokenPartsForUpload("ak", "sec", "b", 1735689600) + if err != nil { + t.Fatal(err) + } + SetB64Mode(B64ModeRaw) + raw, err := BuildUploadTokenPartsForUpload("ak", "sec", "b", 1735689600) + if err != nil { + t.Fatal(err) + } + SetB64Mode(B64ModePadded) + if padded.UploadToken == raw.UploadToken { + t.Fatal("padded vs raw token should differ") + } +} diff --git a/internal/fileauth/b64_config.go b/internal/fileauth/b64_config.go new file mode 100644 index 0000000..92c71f4 --- /dev/null +++ b/internal/fileauth/b64_config.go @@ -0,0 +1,42 @@ +package fileauth + +import ( + "encoding/base64" + "strings" +) + +// B64 编码模式:padded=URL-safe 含 =(文档样例);raw=无填充(联调可切换)。 +const ( + B64ModePadded = "padded" + B64ModeRaw = "raw" +) + +var fileAuthB64Mode = B64ModePadded + +// SetB64Mode 设置全局 URL-safe Base64 模式(由 config.FILEAUTH_B64 注入)。 +func SetB64Mode(mode string) { + m := strings.ToLower(strings.TrimSpace(mode)) + if m == B64ModeRaw { + fileAuthB64Mode = B64ModeRaw + return + } + fileAuthB64Mode = B64ModePadded +} + +// B64Mode 返回当前 Base64 模式。 +func B64Mode() string { return fileAuthB64Mode } + +func encodeURLSafe(data []byte) string { + if fileAuthB64Mode == B64ModeRaw { + return base64RawURL(data) + } + return base64URLPadded(data) +} + +func base64URLPadded(data []byte) string { + return base64.URLEncoding.EncodeToString(data) +} + +func base64RawURL(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} diff --git a/internal/fileauth/parts.go b/internal/fileauth/parts.go new file mode 100644 index 0000000..e6080c7 --- /dev/null +++ b/internal/fileauth/parts.go @@ -0,0 +1,168 @@ +package fileauth + +import ( + "encoding/base64" + "encoding/json" + "errors" + "strings" +) + +// UploadTokenParts 上传凭证分步结果(监管 2.3:AccessKey:encodedSign:encodedPutPolicy)。 +type UploadTokenParts struct { + AccessKey string + PolicyJSON string + EncodedPutPolicy string + SignInput string // §2.3.3:encodedPutPolicy 字符串 + EncodedSign string + UploadToken string + DeadlineUnix int64 + ExpiresSec int64 + Algorithm string +} + +// FormatCheck 单条 uploadToken 格式校验结果。 +type FormatCheck struct { + ID string `json:"id"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +// DocExample 监管 2.3 教学示例(含 returnBody,仅格式对照,非 transit 生产 policy)。 +type DocExample struct { + Note string `json:"note"` + UploadTokenSample string `json:"uploadTokenSample"` + AccessKeyPart string `json:"accessKeyPart"` + EncodedSignPart string `json:"encodedSignPart"` + EncodedPolicyPart string `json:"encodedPolicyPart"` + PolicyJSONDecoded string `json:"policyJsonDecoded"` +} + +// DocExampleRegulatory 内置监管文档第 5 步示例 token(含 returnBody 的完整 putPolicy)。 +const DocExampleRegulatory = "MY_ACCESS_KEY:wQ4ofysef1R7IKnrziqtomqyDvI=:eyJzY29wZSI6Im15LWJ1Y2tldDpzdW5mbG93ZXIuanBnIiwiZGVhZGxpbmUiOjE0NTE0OTEyMDAsInJldHVybkJvZHkiOiJ7XCJuYW1lXCI6JChmbmFtZSksXCJzaXplXCI6JChmc2l6ZSksXCJ3XCI6JChpbWFnZUluZm8ud2lkdGgpLFwiaFwiOiQoaW1hZ2VJbmZvLmhlaWdodCksXCJoYXNoXCI6JChldGFnKX0ifQ==" + +const docExamplePolicyJSON = `{"scope":"my-bucket:sunflower.jpg","deadline":1451491200,"returnBody":"{\"name\":$(fname),\"size\":$(fsize),\"w\":$(imageInfo.width),\"h\":$(imageInfo.height),\"hash\":$(etag)}"}` + +// RegulatoryDocExample 返回文档教学示例的拆分与解码(只读对照)。 +func RegulatoryDocExample() DocExample { + ak, sign, pol, ok := SplitUploadToken(DocExampleRegulatory) + policyDecoded := "" + if ok { + if s, err := DecodePutPolicyB64(pol); err == nil { + policyDecoded = s + } + } + return DocExample{ + Note: "监管 §2.3.3 教学示例(doc profile);生产为 upload profile(scope 仅 bucket,无 returnBody)", + UploadTokenSample: DocExampleRegulatory, + AccessKeyPart: ak, + EncodedSignPart: sign, + EncodedPolicyPart: pol, + PolicyJSONDecoded: policyDecoded, + } +} + +// BuildUploadTokenParts 分步生成上传凭证(§2.3.3 upload profile,scope=仅 bucket)。 +func BuildUploadTokenParts(accessKey, secret, bucket string, deadlineUnix int64) (UploadTokenParts, error) { + return BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) +} + +// SplitUploadToken 按前两处英文冒号拆分为 accessKey、encodedSign、encodedPutPolicy。 +func SplitUploadToken(token string) (accessKey, encodedSign, encodedPutPolicy string, ok bool) { + token = strings.TrimSpace(token) + i := strings.Index(token, ":") + if i <= 0 { + return "", "", "", false + } + rest := token[i+1:] + j := strings.Index(rest, ":") + if j <= 0 { + return "", "", "", false + } + ak := token[:i] + sign := rest[:j] + pol := rest[j+1:] + if ak == "" || sign == "" || pol == "" { + return "", "", "", false + } + return ak, sign, pol, true +} + +// ValidateUploadTokenFormat 校验 token 是否符合 AccessKey:sign:policyB64;expectedAccessKey 非空时校验首段。 +func ValidateUploadTokenFormat(token, expectedAccessKey string) (allOK bool, checks []FormatCheck) { + ak, sign, pol, ok := SplitUploadToken(token) + checks = []FormatCheck{ + { + ID: "three_parts", + OK: ok, + Detail: "uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy(按前两处冒号拆分)", + }, + { + ID: "access_key_non_empty", + OK: ok && ak != "", + Detail: "第 1 段 AccessKey 非空", + }, + { + ID: "encoded_sign_non_empty", + OK: ok && sign != "", + Detail: "第 2 段 encodedSign(URL-safe Base64 HMAC)非空", + }, + { + ID: "encoded_policy_non_empty", + OK: ok && pol != "", + Detail: "第 3 段 encodedPutPolicy(URL-safe Base64 policy JSON)非空", + }, + } + if expectedAccessKey != "" { + exp := strings.TrimSpace(expectedAccessKey) + match := ok && ak == exp + checks = append(checks, FormatCheck{ + ID: "access_key_match", + OK: match, + Detail: "第 1 段与当前 AccessKey 一致", + }) + } + _, decErr := DecodePutPolicyB64(pol) + checks = append(checks, FormatCheck{ + ID: "policy_b64_decodable", + OK: ok && decErr == nil, + Detail: "第 3 段可 URL-safe Base64 解码为 JSON", + }) + allOK = true + for _, c := range checks { + if !c.OK { + allOK = false + break + } + } + return allOK, checks +} + +// DecodePutPolicyB64 解码 encodedPutPolicy 为 JSON 字符串(美化缩进)。 +func DecodePutPolicyB64(encoded string) (string, error) { + encoded = strings.TrimSpace(encoded) + if encoded == "" { + return "", errors.New("empty encoded policy") + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + // 兼容带 padding 的标准 url-safe + raw, err = base64.URLEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + } + var pretty interface{} + if err := json.Unmarshal(raw, &pretty); err != nil { + return string(raw), nil + } + out, err := json.MarshalIndent(pretty, "", " ") + if err != nil { + return string(raw), nil + } + return string(out), nil +} + +// DecodeDocExamplePolicy 返回内置文档示例 policy 明文(便于测试)。 +func DecodeDocExamplePolicy() string { + return docExamplePolicyJSON +} diff --git a/internal/fileauth/parts_test.go b/internal/fileauth/parts_test.go new file mode 100644 index 0000000..d69c15c --- /dev/null +++ b/internal/fileauth/parts_test.go @@ -0,0 +1,85 @@ +package fileauth + +import ( + "strings" + "testing" +) + +func TestBuildUploadTokenParts_matchesCreateUploadTokenAt(t *testing.T) { + const ( + accessKey = "accessKey" + secret = "secret" + bucket = "other-doc" + deadline = int64(1735689600) + ) + want, err := CreateUploadTokenAt(accessKey, secret, bucket, deadline) + if err != nil { + t.Fatal(err) + } + parts, err := BuildUploadTokenParts(accessKey, secret, bucket, deadline) + if err != nil { + t.Fatal(err) + } + if parts.UploadToken != want { + t.Fatalf("token mismatch\ngot: %s\nwant: %s", parts.UploadToken, want) + } + if parts.AccessKey != accessKey { + t.Fatalf("accessKey=%q", parts.AccessKey) + } + if parts.PolicyJSON != `{"scope":"other-doc","deadline":1735689600}` { + t.Fatalf("policy=%q", parts.PolicyJSON) + } + if strings.Contains(parts.PolicyJSON, "returnBody") { + t.Fatalf("upload profile must not include returnBody: %q", parts.PolicyJSON) + } + if parts.Algorithm != "ver233-upload" { + t.Fatalf("algorithm=%q", parts.Algorithm) + } + if parts.SignInput != parts.EncodedPutPolicy { + t.Fatal("ver233 signInput must be encodedPutPolicy") + } +} + +func TestValidateUploadTokenFormat_golden(t *testing.T) { + parts, err := BuildUploadTokenParts("accessKey", "secret", "other-doc", 1735689600) + if err != nil { + t.Fatal(err) + } + ok, checks := ValidateUploadTokenFormat(parts.UploadToken, "accessKey") + if !ok { + t.Fatalf("validate failed: %+v", checks) + } +} + +func TestSplitUploadToken_docExample(t *testing.T) { + ak, sign, pol, ok := SplitUploadToken(DocExampleRegulatory) + if !ok { + t.Fatal("split failed") + } + if ak != "MY_ACCESS_KEY" { + t.Fatalf("ak=%q", ak) + } + if sign != "wQ4ofysef1R7IKnrziqtomqyDvI=" { + t.Fatalf("sign=%q", sign) + } + if !strings.HasPrefix(pol, "eyJ") { + t.Fatalf("policy=%q", pol) + } + decoded, err := DecodePutPolicyB64(pol) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(decoded, "returnBody") { + t.Fatalf("decoded missing returnBody: %s", decoded) + } +} + +func TestRegulatoryDocExample(t *testing.T) { + ex := RegulatoryDocExample() + if ex.AccessKeyPart != "MY_ACCESS_KEY" { + t.Fatalf("ak=%q", ex.AccessKeyPart) + } + if ex.PolicyJSONDecoded == "" { + t.Fatal("empty decoded policy") + } +} diff --git a/internal/fileauth/profile.go b/internal/fileauth/profile.go new file mode 100644 index 0000000..492fc6a --- /dev/null +++ b/internal/fileauth/profile.go @@ -0,0 +1,9 @@ +package fileauth + +// PolicyProfile putPolicy 形态:upload=生产(scope 仅 bucket);doc=监管教学样例单测。 +type PolicyProfile string + +const ( + ProfileUpload PolicyProfile = "upload" + ProfileDoc PolicyProfile = "doc" +) diff --git a/internal/fileauth/ver233.go b/internal/fileauth/ver233.go new file mode 100644 index 0000000..ab70921 --- /dev/null +++ b/internal/fileauth/ver233.go @@ -0,0 +1,182 @@ +package fileauth + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// ReturnBodyTemplate 监管 Ver2.0 §2.3.3 文档示例中的 returnBody 固定模板。 +const ReturnBodyTemplate = `{"name":$(fname),"size":$(fsize),"w":$(imageInfo.width),"h":$(imageInfo.height),"hash":$(etag)}` + +// DocExamplePolicyJSON 文档教学样例 putPolicy(固定向量单测用)。 +const DocExamplePolicyJSON = `{"scope":"my-bucket:sunflower.jpg","deadline":1451491200,"returnBody":"{\"name\":$(fname),\"size\":$(fsize),\"w\":$(imageInfo.width),\"h\":$(imageInfo.height),\"hash\":$(etag)}"}` + +// DocExampleEncodedPutPolicy 文档 §2.3.3 步骤 2 给出的 encodedPutPolicy。 +const DocExampleEncodedPutPolicy = "eyJzY29wZSI6Im15LWJ1Y2tldDpzdW5mbG93ZXIuanBnIiwiZGVhZGxpbmUiOjE0NTE0OTEyMDAsInJldHVybkJvZHkiOiJ7XCJuYW1lXCI6JChmbmFtZSksXCJzaXplXCI6JChmc2l6ZSksXCJ3XCI6JChpbWFnZUluZm8ud2lkdGgpLFwiaFwiOiQoaW1hZ2VJbmZvLmhlaWdodCksXCJoYXNoXCI6JChldGFnKX0ifQ==" + +// DocExampleSignHex 文档样例 HMAC 原始字节十六进制(SecretKey=MY_SECRET_KEY)。 +const DocExampleSignHex = "c10e287f2b1e7f547b20a9ebce2aada26ab20ef2" + +// DocExampleEncodedSign 文档样例 encodedSign。 +const DocExampleEncodedSign = "wQ4ofysef1R7IKnrziqtomqyDvI=" + +// DocExampleUploadToken 文档样例完整 uploadToken(AccessKey=MY_ACCESS_KEY)。 +const DocExampleUploadToken = "MY_ACCESS_KEY:wQ4ofysef1R7IKnrziqtomqyDvI=:" + DocExampleEncodedPutPolicy + +// MarshalPutPolicyDoc 文档 profile:scope 可含文件名 + returnBody。 +func MarshalPutPolicyDoc(scope string, deadlineUnix int64) ([]byte, error) { + scope = strings.TrimSpace(scope) + if scope == "" { + return nil, errors.New("empty scope") + } + if deadlineUnix <= 0 { + return nil, errors.New("invalid deadline") + } + scopeJSON, err := json.Marshal(scope) + if err != nil { + return nil, err + } + rbJSON, err := json.Marshal(ReturnBodyTemplate) + if err != nil { + return nil, err + } + return []byte(`{"scope":` + string(scopeJSON) + `,"deadline":` + strconv.FormatInt(deadlineUnix, 10) + `,"returnBody":` + string(rbJSON) + `}`), nil +} + +// MarshalPutPolicyUpload 生产 profile:scope=仅 bucket,无 returnBody。 +func MarshalPutPolicyUpload(bucket string, deadlineUnix int64) ([]byte, error) { + bucket = strings.TrimSpace(bucket) + if bucket == "" { + return nil, errors.New("empty bucket") + } + if deadlineUnix <= 0 { + return nil, errors.New("invalid deadline") + } + scopeJSON, err := json.Marshal(bucket) + if err != nil { + return nil, err + } + return []byte(`{"scope":` + string(scopeJSON) + `,"deadline":` + strconv.FormatInt(deadlineUnix, 10) + `}`), nil +} + +// MarshalPutPolicyVer233 兼容旧名;等同 doc profile。 +func MarshalPutPolicyVer233(scope string, deadlineUnix int64) ([]byte, error) { + return MarshalPutPolicyDoc(scope, deadlineUnix) +} + +// signPolicyVer233 §2.3.3:HMAC-SHA1(secret, UTF-8(encodedPutPolicy)) → urlSafeB64。 +func signPolicyVer233(secret, encodedPutPolicy string) (string, error) { + mac := hmac.New(sha1.New, []byte(secret)) + if _, err := mac.Write([]byte(encodedPutPolicy)); err != nil { + return "", fmt.Errorf("hmac: %w", err) + } + return encodeURLSafe(mac.Sum(nil)), nil +} + +// urlSafeBase64Ver233 供单测;使用当前 B64 模式(doc 单测应 SetB64Mode(padded))。 +func urlSafeBase64Ver233(data []byte) string { + return encodeURLSafe(data) +} + +// BuildUploadTokenFromPolicy 按 §2.3.3 五步由 putPolicy JSON 生成 token。 +func BuildUploadTokenFromPolicy(accessKey, secret string, putPolicyJSON []byte, algorithm string) (UploadTokenParts, error) { + accessKey = strings.TrimSpace(accessKey) + secret = strings.TrimSpace(secret) + if accessKey == "" || secret == "" { + return UploadTokenParts{}, errors.New("empty key or secret") + } + if len(putPolicyJSON) == 0 { + return UploadTokenParts{}, errors.New("empty putPolicy") + } + if algorithm == "" { + algorithm = "ver233" + } + + encodedPutPolicy := encodeURLSafe(putPolicyJSON) + sign, err := signPolicyVer233(secret, encodedPutPolicy) + if err != nil { + return UploadTokenParts{}, err + } + token := accessKey + ":" + sign + ":" + encodedPutPolicy + + var policy struct { + Deadline int64 `json:"deadline"` + } + _ = json.Unmarshal(putPolicyJSON, &policy) + + return UploadTokenParts{ + AccessKey: accessKey, + PolicyJSON: string(putPolicyJSON), + EncodedPutPolicy: encodedPutPolicy, + SignInput: encodedPutPolicy, + EncodedSign: sign, + UploadToken: token, + DeadlineUnix: policy.Deadline, + Algorithm: algorithm, + }, nil +} + +// BuildUploadTokenPartsForUpload 生产路径:scope=仅 bucket,§2.3.3 验签。 +func BuildUploadTokenPartsForUpload(accessKey, secret, bucket string, deadlineUnix int64) (UploadTokenParts, error) { + accessKey = strings.TrimSpace(accessKey) + secret = strings.TrimSpace(secret) + bucket = strings.TrimSpace(bucket) + if accessKey == "" || secret == "" { + return UploadTokenParts{}, errors.New("empty key or secret") + } + if bucket == "" { + return UploadTokenParts{}, errors.New("empty bucket") + } + if deadlineUnix <= 0 { + return UploadTokenParts{}, errors.New("invalid deadline") + } + jsonPolicy, err := MarshalPutPolicyUpload(bucket, deadlineUnix) + if err != nil { + return UploadTokenParts{}, err + } + parts, err := BuildUploadTokenFromPolicy(accessKey, secret, jsonPolicy, "ver233-upload") + if err != nil { + return UploadTokenParts{}, err + } + now := time.Now().Unix() + expires := deadlineUnix - now + if expires < 0 { + expires = 0 + } + parts.ExpiresSec = expires + return parts, nil +} + +// BuildUploadTokenPartsAtFile filename 不参与 scope(HTTP 为 PDF 二进制体,非 multipart)。 +func BuildUploadTokenPartsAtFile(accessKey, secret, bucket, filename string, deadlineUnix int64) (UploadTokenParts, error) { + _ = filename + return BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) +} + +// CreateUploadTokenAtFile 生产上传凭证。 +func CreateUploadTokenAtFile(accessKey, secret, bucket, filename string, deadlineUnix int64) (string, error) { + parts, err := BuildUploadTokenPartsAtFile(accessKey, secret, bucket, filename, deadlineUnix) + if err != nil { + return "", err + } + return parts.UploadToken, nil +} + +// BuildUploadTokenPartsDoc 文档黄金样例(固定 scope/deadline)。 +func BuildUploadTokenPartsDoc(accessKey, secret string) (UploadTokenParts, error) { + return BuildUploadTokenFromPolicy(accessKey, secret, []byte(DocExamplePolicyJSON), "ver233-doc") +} + +// HMACSignHex 返回 HMAC-SHA1 原始字节的十六进制(调试用)。 +func HMACSignHex(secret, encodedPutPolicy string) string { + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(encodedPutPolicy)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/hyfile/app-2026-05-27.log b/internal/hyfile/app-2026-05-27.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/hyfile/app-2026-05-28.log b/internal/hyfile/app-2026-05-28.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/hyfile/pull-2026-05-27.log b/internal/hyfile/pull-2026-05-27.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/hyfile/pull-2026-05-28.log b/internal/hyfile/pull-2026-05-28.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/hyfile/push-2026-05-27.log b/internal/hyfile/push-2026-05-27.log new file mode 100644 index 0000000..4487b20 --- /dev/null +++ b/internal/hyfile/push-2026-05-27.log @@ -0,0 +1,6 @@ +[push] 2026/05/27 11:20:07 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/27 11:20:07 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/27 11:20:08 chromedp: pdf bytes=5205 +[push] 2026/05/27 15:01:23 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/27 15:01:23 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/27 15:01:24 chromedp: pdf bytes=5205 diff --git a/internal/hyfile/push-2026-05-28.log b/internal/hyfile/push-2026-05-28.log new file mode 100644 index 0000000..f63e469 --- /dev/null +++ b/internal/hyfile/push-2026-05-28.log @@ -0,0 +1,32 @@ +[push] 2026/05/28 10:15:46 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 10:15:46 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 10:15:47 chromedp: pdf bytes=5205 +[push] 2026/05/28 10:15:47 file upload throttle: wait 80ms (min interval 80ms) +[push] 2026/05/28 10:43:14 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 10:43:14 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 10:43:15 chromedp: pdf bytes=5205 +[push] 2026/05/28 10:43:15 file upload throttle: wait 79ms (min interval 80ms) +[push] 2026/05/28 14:20:27 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 14:20:27 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 14:20:28 chromedp: pdf bytes=5205 +[push] 2026/05/28 14:20:28 file upload throttle: wait 78ms (min interval 80ms) +[push] 2026/05/28 14:41:20 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 14:41:20 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 14:41:21 chromedp: pdf bytes=5205 +[push] 2026/05/28 14:41:21 file upload throttle: wait 80ms (min interval 80ms) +[push] 2026/05/28 14:41:32 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 14:41:32 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 14:41:33 chromedp: pdf bytes=5205 +[push] 2026/05/28 14:41:33 file upload throttle: wait 80ms (min interval 80ms) +[push] 2026/05/28 15:11:57 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 15:11:57 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 15:11:58 chromedp: pdf bytes=5205 +[push] 2026/05/28 15:11:58 file upload throttle: wait 79ms (min interval 80ms) +[push] 2026/05/28 15:12:14 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 15:12:14 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 15:12:15 chromedp: pdf bytes=5205 +[push] 2026/05/28 15:12:15 file upload throttle: wait 79ms (min interval 80ms) +[push] 2026/05/28 15:14:40 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 15:14:40 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 15:14:41 chromedp: pdf bytes=5205 +[push] 2026/05/28 15:14:41 file upload throttle: wait 79ms (min interval 80ms) diff --git a/internal/hyfile/throttle.go b/internal/hyfile/throttle.go new file mode 100644 index 0000000..e1e10ae --- /dev/null +++ b/internal/hyfile/throttle.go @@ -0,0 +1,67 @@ +package hyfile + +import ( + "sync" + "time" + + "xk-hy-transit-go/internal/applog" +) + +const defaultUploadMinInterval = 10 * time.Second + +var ( + uploadThrottleMu sync.Mutex + uploadMinInterval = defaultUploadMinInterval + lastUploadStart time.Time +) + +// SetUploadMinInterval 设置两次文件上传 HTTP 请求之间的最短间隔(0 表示不限频)。 +func SetUploadMinInterval(d time.Duration) { + uploadThrottleMu.Lock() + defer uploadThrottleMu.Unlock() + if d < 0 { + d = 0 + } + uploadMinInterval = d +} + +// UploadMinInterval 返回当前配置的上传最短间隔。 +func UploadMinInterval() time.Duration { + uploadThrottleMu.Lock() + defer uploadThrottleMu.Unlock() + return uploadMinInterval +} + +// ResetUploadThrottleForTest 清空上次上传时间(仅单测使用)。 +func ResetUploadThrottleForTest() { + uploadThrottleMu.Lock() + defer uploadThrottleMu.Unlock() + lastUploadStart = time.Time{} +} + +// acquireUploadSlot 在发起上传 HTTP 前等待,满足政务云「10 秒 1 次」限制。 +// 计时基准为上一次请求的开始时刻;失败请求同样占用间隔。 +func acquireUploadSlot() { + uploadThrottleMu.Lock() + interval := uploadMinInterval + if interval <= 0 { + lastUploadStart = time.Now() + uploadThrottleMu.Unlock() + return + } + + var wait time.Duration + if !lastUploadStart.IsZero() { + if d := interval - time.Since(lastUploadStart); d > 0 { + wait = d + } + } + if wait > 0 { + applog.Pushf("file upload throttle: wait %s (min interval %s)", wait.Round(time.Millisecond), interval) + uploadThrottleMu.Unlock() + time.Sleep(wait) + uploadThrottleMu.Lock() + } + lastUploadStart = time.Now() + uploadThrottleMu.Unlock() +} diff --git a/internal/hyfile/throttle_test.go b/internal/hyfile/throttle_test.go new file mode 100644 index 0000000..566e21f --- /dev/null +++ b/internal/hyfile/throttle_test.go @@ -0,0 +1,70 @@ +package hyfile + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAcquireUploadSlot_respectsInterval(t *testing.T) { + SetUploadMinInterval(80 * time.Millisecond) + t.Cleanup(func() { + SetUploadMinInterval(defaultUploadMinInterval) + ResetUploadThrottleForTest() + }) + ResetUploadThrottleForTest() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "record": map[string]string{"fileId": "ok"}, + }) + })) + defer srv.Close() + + pdf := []byte("%PDF-1.4") + token := "ak:sign:policy" + + start := time.Now() + if _, err := UploadPDF(pdf, "a.pdf", srv.URL, token, ""); err != nil { + t.Fatalf("first upload: %v", err) + } + if _, err := UploadPDF(pdf, "b.pdf", srv.URL, token, ""); err != nil { + t.Fatalf("second upload: %v", err) + } + elapsed := time.Since(start) + if elapsed < 75*time.Millisecond { + t.Fatalf("expected throttle wait >= 75ms, elapsed=%s", elapsed) + } +} + +func TestAcquireUploadSlot_zeroIntervalNoWait(t *testing.T) { + SetUploadMinInterval(0) + t.Cleanup(func() { + SetUploadMinInterval(defaultUploadMinInterval) + ResetUploadThrottleForTest() + }) + ResetUploadThrottleForTest() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "record": map[string]string{"fileId": "ok"}, + }) + })) + defer srv.Close() + + start := time.Now() + pdf := []byte("x") + token := "ak:sign:policy" + for i := 0; i < 2; i++ { + if _, err := UploadPDF(pdf, "x.pdf", srv.URL, token, ""); err != nil { + t.Fatalf("upload %d: %v", i, err) + } + } + if time.Since(start) > 200*time.Millisecond { + t.Fatalf("zero interval should not sleep noticeably, elapsed=%s", time.Since(start)) + } +} diff --git a/internal/hyfile/upload.go b/internal/hyfile/upload.go index 57d5aee..27868e5 100644 --- a/internal/hyfile/upload.go +++ b/internal/hyfile/upload.go @@ -1,8 +1,8 @@ // Package hyfile 处方监管 PDF 生成与上传。 // // HtmlToPDF:chromedp 将 xk-api 打印 HTML 转为 PDF。 -// UploadPDF:multipart POST,Header X-Authorization 为 uploadToken; -// 默认请求地址为 forward-go /mng/file/auth/upload,由 forward 转发至政务云 28211。 +// UploadPDF:POST PDF 二进制体(Content-Type: application/pdf),Header X-Authorization 为 uploadToken; +// 不使用 multipart/form-data。默认经 forward-go /mng/file/auth/upload 转发至政务云 28211。 package hyfile import ( @@ -11,8 +11,8 @@ import ( "encoding/json" "fmt" "io" - "mime/multipart" "net/http" + "strings" "sync" "time" ) @@ -50,9 +50,11 @@ func uploadHTTPClient() *http.Client { // UploadPDF 上传 PDF 至监管文件服务(通常经 forward-go),成功时返回 record.fileId。 // // uploadURL:FILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。 -// uploadToken:由 xk-api HY_APP_KEY/HY_APP_SECRET + HY_FILE_BUCKET 签名生成,放在 X-Authorization 请求头。 +// uploadToken:transit 按 §2.3.3 本地生成(scope=仅 bucket),放在 X-Authorization 请求头。 +// filename 仅用于本机落盘命名,不参与 HTTP 请求体。 // forwardToken:非空时设置 X-Forward-Token(与 forward-go FORWARD_SHARED_SECRET 对应)。 func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string) (string, error) { + _ = filename if len(pdf) == 0 { return "", fmt.Errorf("empty pdf") } @@ -62,33 +64,18 @@ func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string if uploadToken == "" { return "", fmt.Errorf("upload token empty") } - if filename == "" { - filename = "prescription.pdf" - } - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("file", filename) + req, err := http.NewRequest(http.MethodPost, uploadURL, bytes.NewReader(pdf)) if err != nil { return "", err } - if _, err := part.Write(pdf); err != nil { - return "", err - } - if err := writer.Close(); err != nil { - return "", err - } - - req, err := http.NewRequest(http.MethodPost, uploadURL, body) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Content-Type", "application/pdf") req.Header.Set("X-Authorization", uploadToken) if forwardToken != "" { req.Header.Set("X-Forward-Token", forwardToken) } + acquireUploadSlot() resp, err := uploadHTTPClient().Do(req) if err != nil { return "", err @@ -96,11 +83,8 @@ func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode == http.StatusForbidden { - return "", fmt.Errorf("上传凭证无效或过期 (http 403)") - } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("upload http %d: %s", resp.StatusCode, truncate(raw, 512)) + return "", formatUploadHTTPError(resp.StatusCode, raw, uploadURL) } var parsed struct { @@ -127,6 +111,71 @@ func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string return fileID, nil } +func formatUploadHTTPError(status int, raw []byte, uploadURL string) error { + body := strings.TrimSpace(string(truncate(raw, 512))) + msg := fmt.Sprintf("upload http %d", status) + if body != "" { + msg += ": " + body + } + if status == http.StatusForbidden { + switch { + case strings.EqualFold(body, "forbidden"): + msg += ";可能为 forward-go 拒绝:检查 ALLOW_IPS、FORWARD_SHARED_SECRET 与 X-Forward-Token" + case body == "404": + msg += ";政务云 uploadToken 验签失败(非 forward forbidden):核对 §2.3.3 scope 是否为仅 bucket 名、HMAC 输入为 encodedPutPolicy" + msg += upload403Hints(body) + default: + if extra := jsonMessageField(raw); extra != "" { + msg += ";政务云: " + extra + } else { + msg += ";可能为 uploadToken 无效/过期或 fileBucket(scope) 错误" + } + msg += upload403Hints(body) + } + } + if uploadURL != "" { + msg += fmt.Sprintf(" (url=%s)", uploadURL) + } + return fmt.Errorf("%s", msg) +} + +func upload403Hints(body string) string { + lower := strings.ToLower(body) + var hints []string + if looksLikeUploadRateLimit(lower) { + hints = append(hints, "政务云文件上传约 10 秒 1 次,transit 已全局限频;若仍 403 请拉长 FILE_UPLOAD_MIN_INTERVAL 或稍后重试") + } + hints = append(hints, + "按 §2.3.3 排查:①NTP 时钟(deadline 为上传完成校验时间)", + "②HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKET(policy scope 仅 bucket 名,不含文件名)", + "③token=AccessKey:encodedSign:encodedPutPolicy;sign=HMAC-SHA1(secret, UTF-8(encodedPutPolicy))", + "④仍失败可设 FILEAUTH_B64=raw 后重启 transit", + "⑤/config 重新拉取", + ) + return ";" + strings.Join(hints, ";") +} + +func looksLikeUploadRateLimit(lower string) bool { + for _, kw := range []string{ + "频率", "频繁", "too many", "rate limit", "ratelimit", "throttle", "limit exceeded", + } { + if strings.Contains(lower, kw) { + return true + } + } + return false +} + +func jsonMessageField(raw []byte) string { + var v struct { + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return "" + } + return strings.TrimSpace(v.Message) +} + func truncate(b []byte, n int) []byte { if len(b) <= n { return b diff --git a/internal/hyfile/upload_test.go b/internal/hyfile/upload_test.go index 46d451c..c83cc8a 100644 --- a/internal/hyfile/upload_test.go +++ b/internal/hyfile/upload_test.go @@ -10,6 +10,13 @@ import ( ) func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { + SetUploadMinInterval(0) + t.Cleanup(func() { + SetUploadMinInterval(defaultUploadMinInterval) + ResetUploadThrottleForTest() + }) + ResetUploadThrottleForTest() + const wantToken = "accessKey:sign:policyB64" var gotAuth string @@ -18,12 +25,12 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { if r.Method != http.MethodPost { t.Errorf("method=%s", r.Method) } - if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { - t.Errorf("content-type=%s", r.Header.Get("Content-Type")) + if r.Header.Get("Content-Type") != "application/pdf" { + t.Errorf("content-type=%q want application/pdf", r.Header.Get("Content-Type")) } - _ = r.ParseMultipartForm(1 << 20) - if r.MultipartForm == nil || len(r.MultipartForm.File["file"]) == 0 { - t.Error("missing multipart file field") + body, _ := io.ReadAll(r.Body) + if !strings.HasPrefix(string(body), "%PDF") { + t.Errorf("body should be raw pdf, got %q", truncateStr(string(body), 32)) } _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, @@ -32,7 +39,7 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { })) defer srv.Close() - fileID, err := UploadPDF([]byte("%PDF-1.4 test"), "test.pdf", srv.URL, wantToken, "") + fileID, err := UploadPDF([]byte("%PDF-1.4 test"), "", srv.URL, wantToken, "") if err != nil { t.Fatalf("UploadPDF: %v", err) } @@ -44,10 +51,12 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { } } -func TestUploadPDF_forbidden(t *testing.T) { +func TestUploadPDF_forbiddenForward(t *testing.T) { + disableUploadThrottle(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.Copy(io.Discard, r.Body) - w.WriteHeader(http.StatusForbidden) + http.Error(w, "forbidden", http.StatusForbidden) })) defer srv.Close() @@ -55,4 +64,59 @@ func TestUploadPDF_forbidden(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "403") { t.Fatalf("err=%v", err) } + if !strings.Contains(err.Error(), "forward-go") { + t.Fatalf("want forward hint, got %v", err) + } +} + +func TestUploadPDF_forbiddenGovMessage(t *testing.T) { + disableUploadThrottle(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"token expired"}`)) + })) + defer srv.Close() + + _, err := UploadPDF([]byte("x"), "x.pdf", srv.URL, "token", "") + if err == nil || !strings.Contains(err.Error(), "token expired") { + t.Fatalf("err=%v", err) + } + if !strings.Contains(err.Error(), "2.3") { + t.Fatalf("want 2.3 troubleshooting hints, got %v", err) + } +} + +func TestUploadPDF_forbiddenGovRateLimitHint(t *testing.T) { + disableUploadThrottle(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"请求过于频繁"}`)) + })) + defer srv.Close() + + _, err := UploadPDF([]byte("x"), "x.pdf", srv.URL, "token", "") + if err == nil || !strings.Contains(err.Error(), "10 秒 1 次") { + t.Fatalf("err=%v", err) + } +} + +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +func disableUploadThrottle(t *testing.T) { + t.Helper() + SetUploadMinInterval(0) + t.Cleanup(func() { + SetUploadMinInterval(defaultUploadMinInterval) + ResetUploadThrottleForTest() + }) + ResetUploadThrottleForTest() } diff --git a/internal/logweb/configview.go b/internal/logweb/configview.go new file mode 100644 index 0000000..f9ca0d5 --- /dev/null +++ b/internal/logweb/configview.go @@ -0,0 +1,332 @@ +package logweb + +import ( + "strings" + + "time" + + "xk-hy-transit-go/internal/config" + + syncer "xk-hy-transit-go/internal/sync" + + "xk-hy-transit-go/internal/xkapi" +) + +// ConfigViewResponse Web 配置页 API 响应(敏感字段已脱敏)。 + +type ConfigViewResponse struct { + FetchedAt string `json:"fetchedAt"` + + XkAPIBaseURL string `json:"xkApiBaseUrl"` + + Cloud cloudConfigView `json:"cloud"` + + LocalEnv localEnvView `json:"localEnv"` + + Effective effectiveConfigView `json:"effective"` + + Diagnostics []string `json:"diagnostics"` + + CloudRaw map[string]any `json:"cloudRaw,omitempty"` +} + +type cloudConfigView struct { + OrganID string `json:"organID"` + + UnitID string `json:"unitID"` + + OrganName string `json:"organName"` + + HosCode string `json:"hosCode"` + + HosName string `json:"hosName"` + + FileBucket string `json:"fileBucket"` + + FileUploadURL string `json:"fileUploadUrl"` + + AppKeyPreview string `json:"appKeyPreview"` + + AppSecretSet bool `json:"appSecretSet"` + + AesKeySet bool `json:"aesKeySet"` + + UploadTokenSet bool `json:"uploadTokenSet"` + + UploadTokenPreview string `json:"uploadTokenPreview"` + + RawError string `json:"rawError"` +} + +type localEnvView struct { + HyAppKeyPreview string `json:"hyAppKeyPreview"` + + HyAppSecretSet bool `json:"hyAppSecretSet"` + + HyAesKeySet bool `json:"hyAesKeySet"` + + HyFileBucket string `json:"hyFileBucket"` + + FileUploadViaForward bool `json:"fileUploadViaForward"` + + ForwardBaseURL string `json:"forwardBaseUrl"` + + ForwardSharedSecretSet bool `json:"forwardSharedSecretSet"` + + XkAPITokenSet bool `json:"xkApiTokenSet"` +} + +type effectiveConfigView struct { + OrganID string `json:"organID"` + + UnitID string `json:"unitID"` + + OrganName string `json:"organName"` + + AppKeyPreview string `json:"appKeyPreview"` + + AppSecretSet bool `json:"appSecretSet"` + + AesKeySet bool `json:"aesKeySet"` + + FileBucket string `json:"fileBucket"` + + UploadTokenSet bool `json:"uploadTokenSet"` + + UploadTokenPreview string `json:"uploadTokenPreview"` + + UploadTokenSource string `json:"uploadTokenSource"` + + FileUploadURL string `json:"fileUploadURL"` + + SourceNote string `json:"sourceNote"` + + ConfigRefreshed string `json:"configRefreshedAt"` +} + +func maskPreview(s string) string { + + s = strings.TrimSpace(s) + + if s == "" { + + return "" + + } + + if len(s) <= 12 { + + return s + + } + + return s[:8] + "****" + s[len(s)-4:] + +} + +func BuildConfigView(snap syncer.ConfigSnapshot, cfg config.Config) ConfigViewResponse { + + cloud := snap.Cloud + + localToken := strings.TrimSpace(snap.UploadToken) + + cloudToken := strings.TrimSpace(snap.CloudUploadToken) + + if cloudToken == "" { + + cloudToken = strings.TrimSpace(cloud.UploadToken) + + } + + effectiveBucket := strings.TrimSpace(snap.EffectiveFileBucket) + + view := ConfigViewResponse{ + + FetchedAt: time.Now().Format(time.RFC3339), + + XkAPIBaseURL: cfg.XkAPIBaseURL, + + Cloud: cloudConfigView{ + + OrganID: cloud.OrganID, + + UnitID: cloud.UnitID, + + OrganName: cloud.OrganName, + + HosCode: cloud.HosCode, + + HosName: cloud.HosName, + + FileBucket: cloud.FileBucket, + + FileUploadURL: cloud.FileUploadURL, + + AppKeyPreview: maskPreview(cloud.AppKey), + + AppSecretSet: strings.TrimSpace(cloud.AppSecret) != "", + + AesKeySet: strings.TrimSpace(cloud.AesKey) != "", + + UploadTokenSet: cloudToken != "", + + UploadTokenPreview: maskPreview(cloudToken), + + RawError: snap.CloudFetchError, + }, + + LocalEnv: localEnvView{ + + HyAppKeyPreview: maskPreview(cfg.HyAppKey), + + HyAppSecretSet: strings.TrimSpace(cfg.HyAppSecret) != "", + + HyAesKeySet: strings.TrimSpace(cfg.HyAesKey) != "", + + HyFileBucket: cfg.HyFileBucket, + + FileUploadViaForward: cfg.FileUploadViaForward, + + ForwardBaseURL: cfg.ForwardBaseURL, + + ForwardSharedSecretSet: strings.TrimSpace(cfg.ForwardSharedSecret) != "", + + XkAPITokenSet: strings.TrimSpace(cfg.XkAPIToken) != "", + }, + + Effective: effectiveConfigView{ + + OrganID: snap.EffectiveOrgan.OrganID, + + UnitID: snap.EffectiveOrgan.UnitID, + + OrganName: snap.EffectiveOrgan.OrganName, + + AppKeyPreview: maskPreview(snap.EffectiveAppKey), + + AppSecretSet: strings.TrimSpace(snap.EffectiveSecret) != "", + + AesKeySet: strings.TrimSpace(snap.EffectiveAesKey) != "", + + FileBucket: effectiveBucket, + + UploadTokenSet: localToken != "", + + UploadTokenPreview: maskPreview(localToken), + + UploadTokenSource: snap.UploadTokenSource, + + FileUploadURL: snap.FileUploadURL, + + SourceNote: "uploadToken:Runner 按 §2.3.3 本地生成;appKey/secret/bucket 云端非空优先", + + ConfigRefreshed: formatTime(snap.RefreshedAt), + }, + } + + if !snap.CloudOK { + + view.Diagnostics = append(view.Diagnostics, "无法连接 xk-api /supervise/config:"+snap.CloudFetchError) + + } + + if effectiveBucket == "" { + + view.Diagnostics = append(view.Diagnostics, "fileBucket 为空:请在 xk-api 配置 HY_FILE_BUCKET(或 transit .env HY_FILE_BUCKET)") + + } + + if !view.Effective.UploadTokenSet { + + view.Diagnostics = append(view.Diagnostics, "本地 uploadToken 未生成:检查 HY_APP_KEY、HY_APP_SECRET、HY_FILE_BUCKET 后点「重新拉取」") + + } + + if cloudToken != "" && localToken != "" && cloudToken != localToken { + + view.Diagnostics = append(view.Diagnostics, "云端预生成 uploadToken 与 Runner 本地 token 不一致(已使用本地 §2.3.3,以 Runner 为准)") + + } + + if cloud.AppKey != "" && cfg.HyAppKey != "" && cloud.AppKey != cfg.HyAppKey { + + view.Diagnostics = append(view.Diagnostics, "云端 appKey 与 transit 本地 HY_APP_KEY 不一致,生效值以云端为准,请点「重新拉取」") + + } + + if view.Effective.AppKeyPreview == "" { + + view.Diagnostics = append(view.Diagnostics, "生效 appKey 为空:请在 xk-api 或 transit .env 配置 HY_APP_KEY") + + } + + if cfg.FileUploadViaForward { + + if view.LocalEnv.ForwardSharedSecretSet { + + view.Diagnostics = append(view.Diagnostics, "PDF 经 forward 上传:请确认 forward .env 的 FORWARD_SHARED_SECRET 与 transit 一致") + + } + + view.Diagnostics = append(view.Diagnostics, "PDF 经 forward 上传:请确认 forward ALLOW_IPS 包含中转机 IP(否则 HTTP 403 body=forbidden)") + + } + + view.CloudRaw = redactCloudRaw(cloud) + + return view + +} + +func formatTime(t time.Time) string { + + if t.IsZero() { + + return "-" + + } + + return t.Format(time.RFC3339) + +} + +func redactCloudRaw(c xkapi.ConfigResponse) map[string]any { + + return map[string]any{ + + "organID": c.OrganID, + + "unitID": c.UnitID, + + "organName": c.OrganName, + + "hosCode": c.HosCode, + + "hosName": c.HosName, + + "fileUploadUrl": c.FileUploadURL, + + "fileBucket": c.FileBucket, + + "appKey": maskPreview(c.AppKey), + + "appSecret": boolLabel(c.AppSecret != ""), + + "aesKey": boolLabel(c.AesKey != ""), + + "uploadToken": maskPreview(c.UploadToken), + } + +} + +func boolLabel(set bool) string { + + if set { + + return "(已配置)" + + } + + return "(未配置)" + +} diff --git a/internal/logweb/fileauth_handlers.go b/internal/logweb/fileauth_handlers.go new file mode 100644 index 0000000..754ba3a --- /dev/null +++ b/internal/logweb/fileauth_handlers.go @@ -0,0 +1,146 @@ +package logweb + +import ( + "net/http" + "strconv" + "strings" + "time" + + "xk-hy-transit-go/internal/fileauth" + syncer "xk-hy-transit-go/internal/sync" +) + +// FileAuthGenerateResponse Web 上传凭证调试 API。 +type FileAuthGenerateResponse struct { + AccessKey string `json:"accessKey"` + SecretSet bool `json:"secretSet"` + Bucket string `json:"bucket"` + Scope string `json:"scope"` + B64Mode string `json:"b64Mode"` + PolicyJSON string `json:"policyJson"` + EncodedPutPolicy string `json:"encodedPutPolicy"` + SignInput string `json:"signInput"` + EncodedSign string `json:"encodedSign"` + SignHex string `json:"signHex,omitempty"` + UploadToken string `json:"uploadToken"` + UploadTokenMasked string `json:"uploadTokenMasked"` + DeadlineUnix int64 `json:"deadlineUnix"` + ExpiresSec int64 `json:"expiresSec"` + Algorithm string `json:"algorithm"` + FormatOK bool `json:"formatOk"` + FormatChecks []fileauth.FormatCheck `json:"formatChecks"` + RunnerTokenMatch bool `json:"runnerTokenMatch"` + RunnerTokenPreview string `json:"runnerTokenPreview"` + DocGoldenMatch bool `json:"docGoldenMatch"` + DocExample fileauth.DocExample `json:"docExample"` +} + +func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + writeErr(w, 405, "GET or POST only") + return + } + sr := asSyncRunner(s.deps.Runner) + if sr == nil { + writeErr(w, 503, "未配置 Runner(请使用 transit serve)") + return + } + + refresh := r.URL.Query().Get("refresh") == "1" || r.URL.Query().Get("refresh") == "true" + if refresh { + if err := sr.RegenerateUploadToken(); err != nil { + writeErr(w, 502, "刷新 Runner uploadToken 失败: "+err.Error()) + return + } + } + + snap := sr.ConfigSnapshot() + accessKey := strings.TrimSpace(snap.EffectiveAppKey) + secret := strings.TrimSpace(snap.EffectiveSecret) + bucket := strings.TrimSpace(r.URL.Query().Get("bucket")) + if bucket == "" { + bucket = strings.TrimSpace(snap.EffectiveFileBucket) + } + + isDoc := r.URL.Query().Get("doc") == "1" + if isDoc { + accessKey = "MY_ACCESS_KEY" + secret = "MY_SECRET_KEY" + } + + if accessKey == "" || secret == "" { + writeErr(w, 400, "生效 appKey/appSecret 未配置,请在 /config 重新拉取或配置 HY_APP_KEY/HY_APP_SECRET") + return + } + if !isDoc && bucket == "" { + writeErr(w, 400, "fileBucket(scope) 为空,请配置 HY_FILE_BUCKET") + return + } + + deadlineUnix := time.Now().Unix() + 3600 + if isDoc { + deadlineUnix = 1451491200 + } + if ds := strings.TrimSpace(r.URL.Query().Get("deadline")); ds != "" && !isDoc { + n, err := strconv.ParseInt(ds, 10, 64) + if err != nil || n <= 0 { + writeErr(w, 400, "invalid deadline (unix seconds)") + return + } + deadlineUnix = n + } + + var parts fileauth.UploadTokenParts + var err error + if isDoc { + parts, err = fileauth.BuildUploadTokenPartsDoc(accessKey, secret) + } else { + parts, err = fileauth.BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) + } + if err != nil { + writeErr(w, 500, err.Error()) + return + } + + scope := bucket + if isDoc { + scope = "my-bucket:sunflower.jpg" + } + + formatOK, checks := fileauth.ValidateUploadTokenFormat(parts.UploadToken, accessKey) + runnerTok := strings.TrimSpace(sr.UploadToken()) + runnerMatch := !isDoc && runnerTok != "" && runnerTok == parts.UploadToken + + docGolden := isDoc && parts.UploadToken == fileauth.DocExampleUploadToken + + writeJSON(w, FileAuthGenerateResponse{ + AccessKey: parts.AccessKey, + SecretSet: secret != "", + Bucket: bucket, + Scope: scope, + B64Mode: fileauth.B64Mode(), + PolicyJSON: parts.PolicyJSON, + EncodedPutPolicy: parts.EncodedPutPolicy, + SignInput: parts.SignInput, + EncodedSign: parts.EncodedSign, + SignHex: fileauth.HMACSignHex(secret, parts.SignInput), + UploadToken: parts.UploadToken, + UploadTokenMasked: maskPreview(parts.UploadToken), + DeadlineUnix: parts.DeadlineUnix, + ExpiresSec: parts.ExpiresSec, + Algorithm: parts.Algorithm, + FormatOK: formatOK, + FormatChecks: checks, + RunnerTokenMatch: runnerMatch, + RunnerTokenPreview: maskPreview(runnerTok), + DocGoldenMatch: docGolden, + DocExample: fileauth.RegulatoryDocExample(), + }) +} + +// RunnerCredentialsForFileAuth 供测试或扩展:从快照读取凭证。 +func RunnerCredentialsForFileAuth(snap syncer.ConfigSnapshot) (accessKey, secret, bucket string) { + return strings.TrimSpace(snap.EffectiveAppKey), + strings.TrimSpace(snap.EffectiveSecret), + strings.TrimSpace(snap.EffectiveFileBucket) +} diff --git a/internal/logweb/server.go b/internal/logweb/server.go index 811f837..654b873 100644 --- a/internal/logweb/server.go +++ b/internal/logweb/server.go @@ -3,6 +3,7 @@ package logweb import ( "encoding/json" "fmt" + "io" "io/fs" "log" "net/http" @@ -22,6 +23,13 @@ type Runner interface { Run(step, anchorDate string) error } +func asSyncRunner(r Runner) *syncer.Runner { + if sr, ok := r.(*syncer.Runner); ok { + return sr + } + return nil +} + // Deps Web 控制台依赖。 type Deps struct { LogDir string @@ -59,13 +67,21 @@ func (s *Server) routes() { s.mux.HandleFunc("/runs", s.handlePage("runs.html")) s.mux.HandleFunc("/runs/record", s.handlePage("runs_record.html")) s.mux.HandleFunc("/test", s.handlePage("test.html")) + s.mux.HandleFunc("/config", s.handlePage("config.html")) + s.mux.HandleFunc("/upload", s.handlePage("upload.html")) + s.mux.HandleFunc("/fileauth", s.handlePage("fileauth.html")) s.mux.HandleFunc("/api/meta", s.handleAPIMeta) + s.mux.HandleFunc("/api/fileauth/generate", s.handleAPIFileAuthGenerate) + s.mux.HandleFunc("/api/config/view", s.handleAPIConfigView) + s.mux.HandleFunc("/api/config/refresh", s.handleAPIConfigRefresh) + s.mux.HandleFunc("/api/upload/test", s.handleAPIUploadTest) s.mux.HandleFunc("/api/dates", s.handleAPIDates) s.mux.HandleFunc("/api/log", s.handleAPILog) s.mux.HandleFunc("/api/runs", s.handleAPIRuns) s.mux.HandleFunc("/api/runs/record", s.handleAPIRecord) s.mux.HandleFunc("/api/runs/truncate", s.handleAPIRunsTruncate) + s.mux.HandleFunc("/api/runs/retry", s.handleAPIRunsRetry) s.mux.HandleFunc("/api/test/status", s.handleAPITestStatus) s.mux.HandleFunc("/api/test/sync", s.handleAPITestSync) @@ -192,6 +208,113 @@ func (s *Server) handleAPITestStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, enrichTestStatus(syncgate.GetStatus())) } +func (s *Server) handleAPIConfigView(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErr(w, 405, "GET only") + return + } + sr := asSyncRunner(s.deps.Runner) + if sr == nil { + writeErr(w, 503, "未配置 Runner(请使用 transit serve)") + return + } + writeJSON(w, BuildConfigView(sr.ConfigSnapshot(), s.deps.Cfg)) +} + +func (s *Server) handleAPIConfigRefresh(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErr(w, 405, "POST only") + return + } + sr := asSyncRunner(s.deps.Runner) + if sr == nil { + writeErr(w, 503, "未配置 Runner(请使用 transit serve)") + return + } + if err := sr.ReloadCloudConfig(); err != nil { + writeErr(w, 502, err.Error()) + return + } + writeJSON(w, BuildConfigView(sr.ConfigSnapshot(), s.deps.Cfg)) +} + +func (s *Server) handleAPIUploadTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErr(w, 405, "POST only") + return + } + sr := asSyncRunner(s.deps.Runner) + if sr == nil { + writeErr(w, 503, "未配置 Runner(请使用 transit serve)") + return + } + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeErr(w, 400, "invalid multipart") + return + } + file, hdr, err := r.FormFile("file") + if err != nil { + writeErr(w, 400, "missing file field") + return + } + defer file.Close() + pdf, err := io.ReadAll(file) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + name := hdr.Filename + if name == "" { + name = "test.pdf" + } + res, err := sr.UploadTestPDF(pdf, name) + if err != nil { + writeUploadErr(w, 502, err.Error(), sr.FileUploadURL(), uploadTestPayload(res, sr.FileUploadURL(), false, "")) + return + } + writeJSON(w, uploadTestPayload(res, sr.FileUploadURL(), true, res.FileID)) +} + +func (s *Server) handleAPIRunsRetry(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErr(w, 405, "POST only") + return + } + if s.deps.Store == nil || !s.deps.MySQLOK { + writeErr(w, 503, "MySQL 未连接") + return + } + sr := asSyncRunner(s.deps.Runner) + if sr == nil { + writeErr(w, 503, "未配置 Runner(请使用 transit serve)") + return + } + if syncgate.IsRunning() { + writeErr(w, 409, "已有同步任务在执行中") + return + } + var body struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID <= 0 { + writeErr(w, 400, "body: {\"id\": }") + return + } + var runErr error + if !syncgate.TryRun("retry", fmt.Sprintf("record-%d", body.ID), func() error { + runErr = sr.RetryRecord(body.ID) + return runErr + }) { + writeErr(w, 409, "已有同步任务在执行中") + return + } + if runErr != nil { + writeErr(w, 502, runErr.Error()) + return + } + writeJSON(w, map[string]any{"ok": true, "id": body.ID}) +} + func (s *Server) handleAPITestSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, 405, "POST only") @@ -253,3 +376,27 @@ func writeErr(w http.ResponseWriter, code int, msg string) { w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } + +func writeUploadErr(w http.ResponseWriter, code int, detail, uploadURL string, extra map[string]any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + summary := detail + if idx := strings.IndexByte(summary, '\n'); idx >= 0 { + summary = summary[:idx] + } + if len(summary) > 200 { + summary = summary[:200] + "…" + } + body := map[string]any{ + "error": summary, + "detail": detail, + "uploadUrl": uploadURL, + } + for k, v := range extra { + if v == nil { + continue + } + body[k] = v + } + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/logweb/upload_handlers.go b/internal/logweb/upload_handlers.go new file mode 100644 index 0000000..4e048c7 --- /dev/null +++ b/internal/logweb/upload_handlers.go @@ -0,0 +1,50 @@ +package logweb + +import ( + syncer "xk-hy-transit-go/internal/sync" +) + +// UploadTokenInfo 本次上传使用的 §2.3.3 凭证(Web 调试展示)。 +type UploadTokenInfo struct { + Algorithm string `json:"algorithm"` + PolicyJSON string `json:"policyJson"` + EncodedPutPolicy string `json:"encodedPutPolicy"` + SignInput string `json:"signInput"` + EncodedSign string `json:"encodedSign"` + UploadToken string `json:"uploadToken"` + UploadTokenMasked string `json:"uploadTokenMasked"` + DeadlineUnix int64 `json:"deadlineUnix"` +} + +func uploadTokenInfoFromResult(res *syncer.UploadTestResult) UploadTokenInfo { + if res == nil { + return UploadTokenInfo{} + } + p := res.Parts + return UploadTokenInfo{ + Algorithm: p.Algorithm, + PolicyJSON: p.PolicyJSON, + EncodedPutPolicy: p.EncodedPutPolicy, + SignInput: p.SignInput, + EncodedSign: p.EncodedSign, + UploadToken: p.UploadToken, + UploadTokenMasked: maskPreview(p.UploadToken), + DeadlineUnix: p.DeadlineUnix, + } +} + +func uploadTestPayload(res *syncer.UploadTestResult, uploadURL string, ok bool, fileID string) map[string]any { + out := map[string]any{ + "ok": ok, + "url": uploadURL, + "tokenInfo": uploadTokenInfoFromResult(res), + } + if res != nil { + out["filename"] = res.Filename + out["scope"] = res.Scope + } + if fileID != "" { + out["fileId"] = fileID + } + return out +} diff --git a/internal/logweb/web/app.js b/internal/logweb/web/app.js index e5a1875..11caab2 100644 --- a/internal/logweb/web/app.js +++ b/internal/logweb/web/app.js @@ -1,10 +1,47 @@ async function api(path, opts) { const r = await fetch(path, opts); const j = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(j.error || r.statusText); + if (!r.ok) { + const err = new Error(j.error || r.statusText); + if (j.detail) err.detail = j.detail; + if (j.uploadUrl) err.uploadUrl = j.uploadUrl; + throw err; + } return j; } +/** 右下角 Toast;type: ok | err | warn | info */ +function showToast(message, type) { + if (!message) return; + let root = document.getElementById('toast-root'); + if (!root) { + root = document.createElement('div'); + root.id = 'toast-root'; + root.className = 'toast-root'; + document.body.appendChild(root); + } + const el = document.createElement('div'); + el.className = 'toast ' + (type || 'info'); + el.textContent = message; + root.appendChild(el); + requestAnimationFrame(() => el.classList.add('show')); + setTimeout(() => { + el.classList.remove('show'); + setTimeout(() => el.remove(), 300); + }, 4500); +} + +function showInlineError(el, message) { + if (!el) return; + el.innerHTML = '
' + esc(message) + '
'; +} + +function firstLine(s) { + if (!s) return ''; + const i = s.indexOf('\n'); + return i >= 0 ? s.slice(0, i) : s; +} + function qs(name) { return new URLSearchParams(location.search).get(name) || ''; } @@ -59,3 +96,31 @@ function fillStepSelect(sel, options, includeAll) { `` ).join(''); } + +const MAIN_NAV = [ + { href: '/', id: 'home', label: '首页' }, + { href: '/config', id: 'config', label: '配置' }, + { href: '/fileauth', id: 'fileauth', label: '上传凭证' }, + { href: '/upload', id: 'upload', label: '上传' }, + { href: '/runs', id: 'runs', label: '流水' }, + { href: '/logs', id: 'logs', label: '日志' }, + { href: '/test', id: 'test', label: '测试' }, +]; + +function renderMainNav(activeId) { + const el = document.getElementById('main-nav'); + if (!el) return; + el.innerHTML = MAIN_NAV.map(n => { + const cur = n.id === activeId ? ' style="border-color:var(--accent)"' : ''; + return `${esc(n.label)}`; + }).join(''); +} + +async function retryRecord(id, onOk) { + await api('/api/runs/retry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: Number(id) }), + }); + if (onOk) onOk(); +} diff --git a/internal/logweb/web/config.html b/internal/logweb/web/config.html new file mode 100644 index 0000000..75279d3 --- /dev/null +++ b/internal/logweb/web/config.html @@ -0,0 +1,212 @@ + + + + + + + + + 互医配置 + + + + + + + +
+ +
+ +

互医配置

+ + + +
+ +
+ + + + + + 上传凭证 + 文件上传 + +
+ +
+ +
+ +

三栏对比

+ +
+ + + + + +
字段云端 xk-api本地 .envRunner 生效
+ +
+ +
+ +
+ +

运行环境

+ +

+
+  
+ +
+ +

诊断 diagnostics

+ +

+
+  
+ +
+ + 展开云端原始响应(脱敏) + +

+
+  
+ +
+ + + + + + + + + + diff --git a/internal/logweb/web/fileauth.html b/internal/logweb/web/fileauth.html new file mode 100644 index 0000000..3f48c0d --- /dev/null +++ b/internal/logweb/web/fileauth.html @@ -0,0 +1,403 @@ + + + + + + + + + 上传凭证 §2.3.3 + + + + + + + + + +
+ +
+ +

上传凭证(Ver2.0 §2.3.3)

+ + + +
+ + + +

+ + 五步:putPolicyencodedPutPolicy(URL-safe Base64)→ + + HMAC-SHA1(secret, encodedPutPolicy)encodedSign → + + uploadToken = AccessKey:encodedSign:encodedPutPolicy + +

+ + + +
+ + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ +

A. §2.3.3 生产算法(transit / xk-api)

+ +
+ +

1. putPolicy(scope=仅 bucket,无 returnBody)

+ +
+ +
+ +
+ +

2. encodedPutPolicy = urlsafe_base64(putPolicy)

+ +
+ +
+ +
+ +

3. sign = HMAC-SHA1(secret, encodedPutPolicy UTF-8)

+ +

signInput(签名字节串): · hex:

+ +
+ +
+ +
+ +

4–5. uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy

+ +
+ +
+ +

算法: · B64: · AccessKey: · scope: · deadline: + + ·

+ +
+ + + +
+ +

B. 格式校验

+ +

+ +
    + +

    与 Runner 当前 token(默认 prescription.pdf):

    + +
    + + + +
    + +

    C. 监管文档教学示例(doc profile,只读)

    + +

    + +
    + +

    示例 token 三段

    + +
    + +
    + +
    + +

    encodedPutPolicy 解码

    + +
    + +
    + +
    + +
    + + + + + + + + + + diff --git a/internal/logweb/web/index.html b/internal/logweb/web/index.html index 1a00716..6112555 100644 --- a/internal/logweb/web/index.html +++ b/internal/logweb/web/index.html @@ -11,13 +11,25 @@

    互联网医院监管中转 · Web 控制台

    - -

    文件日志

    -

    查看 app / pull / push 按日滚动日志,支持搜索与高亮。

    +
    +

    互医配置

    +

    查看 xk-api 云端与本地 .env、Runner 生效值;本地 §2.3.3 生成 uploadToken。

    +
    + +

    上传凭证

    +

    §2.3.3 upload profile(scope=仅 bucket),文档黄金样例校验。

    +
    + +

    文件上传

    +

    测试监管 PDF 上传(经 forward-go),排查 403 / token 问题。

    同步流水

    -

    三表关联:任务、拉取 payload、上传 HTTP、推断回调。

    +

    三表关联:任务、拉取 payload、上传 HTTP;失败记录可整条重试。

    +
    + +

    文件日志

    +

    查看 app / pull / push 按日滚动日志,支持搜索与高亮。

    测试执行

    diff --git a/internal/logweb/web/logs_view.html b/internal/logweb/web/logs_view.html index 756b866..1169bd7 100644 --- a/internal/logweb/web/logs_view.html +++ b/internal/logweb/web/logs_view.html @@ -54,7 +54,7 @@ document.getElementById('auto').onchange = e => { clearInterval(timer); if (e.target.checked) timer = setInterval(() => load().catch(() => {}), 5000); }; -load().catch(e => alert(e.message)); +load().catch(e => showToast(e.message, 'err')); diff --git a/internal/logweb/web/runs.html b/internal/logweb/web/runs.html index 96d4485..3f8fa67 100644 --- a/internal/logweb/web/runs.html +++ b/internal/logweb/web/runs.html @@ -9,7 +9,7 @@

    同步流水

    -
    +
    @@ -29,12 +29,13 @@
    - +
    ID日期类型批次biz_key拉取校验上报推送明文HTTP回调trace上报重试推送明文HTTP回调trace
    diff --git a/internal/logweb/web/runs_record.html b/internal/logweb/web/runs_record.html index 399ff54..71c07f1 100644 --- a/internal/logweb/web/runs_record.html +++ b/internal/logweb/web/runs_record.html @@ -9,9 +9,11 @@

    记录详情 #

    - +
    -
    +
    + +

    ① 拉取(云端组包 payload_json)

    云端拉下的原始明文,可能尚未含本机机构字段。

    @@ -41,6 +43,7 @@
    diff --git a/internal/logweb/web/static.css b/internal/logweb/web/static.css index 2c46f24..8762bd0 100644 --- a/internal/logweb/web/static.css +++ b/internal/logweb/web/static.css @@ -145,3 +145,51 @@ tr:hover td { background: #1c2128; } .footer { margin-top: 2rem; color: var(--muted); font-size: 0.85rem; } .hidden { display: none; } .status-msg { color: var(--muted); font-size: 0.9rem; } +.toast-root { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: min(420px, calc(100vw - 2rem)); + pointer-events: none; +} +.toast { + padding: 0.65rem 1rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-size: 0.9rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s, transform 0.2s; + pointer-events: auto; + word-break: break-word; +} +.toast.show { opacity: 1; transform: translateY(0); } +.toast.ok { border-color: var(--ok); background: rgba(63, 185, 80, 0.12); } +.toast.err { border-color: var(--err); background: rgba(248, 81, 73, 0.12); } +.toast.warn { border-color: var(--warn); background: rgba(210, 153, 34, 0.12); } +.toast.info { border-color: var(--accent); background: rgba(88, 166, 255, 0.1); } +.flash { + margin: 0; + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-all; +} +.flash.err { + background: rgba(248, 81, 73, 0.12); + border: 1px solid rgba(248, 81, 73, 0.35); + color: #ffb1ac; +} +.flash.ok { + background: rgba(63, 185, 80, 0.12); + border: 1px solid rgba(63, 185, 80, 0.35); + color: #7ee787; +} diff --git a/internal/logweb/web/test.html b/internal/logweb/web/test.html index 4e6b01e..c743046 100644 --- a/internal/logweb/web/test.html +++ b/internal/logweb/web/test.html @@ -76,11 +76,11 @@ document.getElementById('run').onclick = async () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ step, date }), }); - alert('已启动: ' + (r.step_label || r.step) + ' 锚定日=' + r.date); + showToast('已启动: ' + (r.step_label || r.step) + ' 锚定日=' + r.date, 'ok'); pollTimer = null; refreshStatus(); } catch (e) { - alert(e.message); + showToast(e.message, 'err'); } }; diff --git a/internal/logweb/web/upload.html b/internal/logweb/web/upload.html new file mode 100644 index 0000000..b5d5cfb --- /dev/null +++ b/internal/logweb/web/upload.html @@ -0,0 +1,164 @@ + + + + + 文件上传测试 + + + + +
    +
    +

    监管文件上传测试

    + +
    + +

    §2.3.3 生产 token:scope=仅 bucket 名。上传为 PDF 二进制体(Content-Type: application/pdf),不用 multipart。403 且 body 为 404 表示 token 验签失败。

    + +
    + + + 互医配置 + 上传凭证调试 +
    + +
    +

    A. Runner 默认 token(scope=仅 bucket)

    +

    加载中…

    +
    + +
    +

    B. 本次上传凭证(§2.3.3)

    +

    上传成功或失败后显示本次请求使用的 token 信息。

    + +
    + +
    +

    上传结果

    +
    +
    +
    + + + + diff --git a/internal/sync/config.go b/internal/sync/config.go new file mode 100644 index 0000000..5849517 --- /dev/null +++ b/internal/sync/config.go @@ -0,0 +1,257 @@ +package syncer + +import ( + "fmt" + + "strings" + + "time" + + "xk-hy-transit-go/internal/applog" + + "xk-hy-transit-go/internal/config" + + "xk-hy-transit-go/internal/fileauth" + + "xk-hy-transit-go/internal/hy" + + "xk-hy-transit-go/internal/xkapi" +) + +// ConfigSnapshot 当前 Runner 配置快照(明文仅进程内使用,Web 层需脱敏)。 + +type ConfigSnapshot struct { + Cloud xkapi.ConfigResponse + + CloudOK bool + + CloudFetchError string + + RefreshedAt time.Time + + Local config.Config + + EffectiveOrgan hy.OrganConfig + + EffectiveAppKey string + + EffectiveSecret string + + EffectiveAesKey string + + EffectiveFileBucket string + + UploadToken string + + UploadTokenSource string + + CloudUploadToken string + + FileUploadURL string +} + +func firstNonEmpty(values ...string) string { + + for _, v := range values { + + if strings.TrimSpace(v) != "" { + + return strings.TrimSpace(v) + + } + + } + + return "" + +} + +func (r *Runner) applyCloudConfig(conf *xkapi.ConfigResponse) { + + if conf == nil { + + return + + } + + r.organ = hy.OrganConfig{ + + UnitID: conf.UnitID, + + OrganID: conf.OrganID, + + OrganName: conf.OrganName, + } + + r.cloudUploadToken = conf.UploadToken + + r.fileUploadURL = resolveFileUploadURL(r.cfg, conf.FileUploadURL) + + r.hyAppKey = firstNonEmpty(conf.AppKey, r.cfg.HyAppKey) + + r.hyAppSecret = firstNonEmpty(conf.AppSecret, r.cfg.HyAppSecret) + + r.hyAesKey = firstNonEmpty(conf.AesKey, r.cfg.HyAesKey) + + r.fileBucket = firstNonEmpty(conf.FileBucket, r.cfg.HyFileBucket) + + r.lastCloud = *conf + + r.lastCloudOK = true + + r.lastConfigErr = nil + + r.configRefreshedAt = time.Now() + + if err := r.refreshUploadTokenLocked(); err != nil { + + r.uploadToken = "" + + applog.Appf("warning: generate uploadToken failed: %v", err) + + } + +} + +// refreshUploadToken 按 §2.3.3 upload profile 重新生成 uploadToken(scope=仅 bucket)。 + +func (r *Runner) refreshUploadToken() error { + + r.configMu.Lock() + + defer r.configMu.Unlock() + + return r.refreshUploadTokenLocked() + +} + +func (r *Runner) refreshUploadTokenLocked() error { + + key := strings.TrimSpace(r.hyAppKey) + + secret := strings.TrimSpace(r.hyAppSecret) + + bucket := strings.TrimSpace(r.fileBucket) + + if key == "" || secret == "" || bucket == "" { + + return fmt.Errorf("appKey/appSecret/fileBucket 未配置(检查 xk-api HY_APP_KEY、HY_APP_SECRET、HY_FILE_BUCKET)") + + } + + tok, err := fileauth.CreateUploadToken(key, secret, bucket, 0) + + if err != nil { + + return err + + } + + r.uploadToken = tok + + return nil + +} + +// ReloadCloudConfig 从 xk-api GET /supervise/config 刷新机构、凭证与 uploadToken。 + +func (r *Runner) ReloadCloudConfig() error { + + r.configMu.Lock() + + defer r.configMu.Unlock() + + conf, err := r.xk.Config() + + if err != nil { + + r.lastCloudOK = false + + r.lastConfigErr = err + + applog.Appf("reload cloud config failed: %v", err) + + return err + + } + + r.applyCloudConfig(conf) + + if strings.TrimSpace(r.uploadToken) == "" { + + applog.Appf("warning: uploadToken empty after config reload") + + } + + applog.Appf("cloud config reloaded organID=%s appKey=%s…", conf.OrganID, previewKey(r.hyAppKey)) + + return nil + +} + +// ConfigSnapshot 返回当前配置快照(读锁)。 + +func (r *Runner) ConfigSnapshot() ConfigSnapshot { + + r.configMu.RLock() + + defer r.configMu.RUnlock() + + snap := ConfigSnapshot{ + + Cloud: r.lastCloud, + + CloudOK: r.lastCloudOK, + + RefreshedAt: r.configRefreshedAt, + + Local: r.cfg, + + EffectiveOrgan: r.organ, + + EffectiveAppKey: r.hyAppKey, + + EffectiveSecret: r.hyAppSecret, + + EffectiveAesKey: r.hyAesKey, + + EffectiveFileBucket: r.fileBucket, + + UploadToken: r.uploadToken, + + UploadTokenSource: "local-ver233-upload", + + CloudUploadToken: r.cloudUploadToken, + + FileUploadURL: r.fileUploadURL, + } + + if r.lastConfigErr != nil { + + snap.CloudFetchError = r.lastConfigErr.Error() + + } + + return snap + +} + +func previewKey(s string) string { + + s = strings.TrimSpace(s) + + if len(s) <= 12 { + + if s == "" { + + return "(empty)" + + } + + return s + + } + + return s[:8] + "…" + +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 7d36b30..db2da4c 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -15,11 +15,13 @@ import ( "encoding/json" "fmt" "strings" + "sync" "time" "xk-hy-transit-go/internal/applog" "xk-hy-transit-go/internal/config" "xk-hy-transit-go/internal/db" + "xk-hy-transit-go/internal/fileauth" "xk-hy-transit-go/internal/hy" "xk-hy-transit-go/internal/hyfile" "xk-hy-transit-go/internal/xkapi" @@ -32,8 +34,18 @@ type Runner struct { store *db.Store organ hy.OrganConfig fileUploadURL string // 实际上传地址(经 forward 或直连政务云) - uploadToken string // 由 xk-api config 接口生成的 uploadToken + uploadToken string // 本地按 §2.3.3 生成(默认 prescription.pdf) + cloudUploadToken string // xk-api 预生成值,仅诊断对比 + fileBucket string fileUploadViaForward bool + hyAppKey string + hyAppSecret string + hyAesKey string + lastCloud xkapi.ConfigResponse + lastCloudOK bool + lastConfigErr error + configRefreshedAt time.Time + configMu sync.RWMutex } // StoreClose 关闭本机 MySQL 连接。 @@ -50,36 +62,36 @@ func (r *Runner) Store() *db.Store { // NewRunner 加载机构配置并创建 Runner(含 forward 文件上传地址解析)。 func NewRunner(cfg config.Config, store *db.Store) (*Runner, error) { + fileauth.SetB64Mode(cfg.FileAuthB64) if p := strings.TrimSpace(cfg.ChromePath); p != "" { hyfile.SetChromePath(p) applog.Appf("chrome path from config: %s", p) } hyfile.SetUploadInsecureSkipVerify(cfg.FileUploadInsecureSkipVerify) + hyfile.SetUploadMinInterval(cfg.FileUploadMinInterval) + if cfg.FileUploadMinInterval > 0 { + applog.Appf("file upload min interval: %s", cfg.FileUploadMinInterval) + } xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath) - conf, err := xk.Config() - if err != nil { + r := &Runner{ + cfg: cfg, + xk: xk, + store: store, + fileUploadViaForward: cfg.FileUploadViaForward, + hyAppKey: cfg.HyAppKey, + hyAppSecret: cfg.HyAppSecret, + hyAesKey: cfg.HyAesKey, + } + if err := r.ReloadCloudConfig(); err != nil { return nil, fmt.Errorf("load organ config: %w", err) } - if strings.TrimSpace(conf.UploadToken) == "" { + if strings.TrimSpace(r.uploadToken) == "" { applog.Appf("warning: uploadToken empty, recipe file upload will fail (check xk-api HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKET)") } - uploadURL := resolveFileUploadURL(cfg, conf.FileUploadURL) if cfg.FileUploadViaForward { - applog.Appf("file upload via forward: %s (cloud fileUploadUrl ignored)", uploadURL) + applog.Appf("file upload via forward: %s (cloud fileUploadUrl ignored)", r.fileUploadURL) } - return &Runner{ - cfg: cfg, - xk: xk, - store: store, - organ: hy.OrganConfig{ - UnitID: conf.UnitID, - OrganID: conf.OrganID, - OrganName: conf.OrganName, - }, - fileUploadURL: uploadURL, - uploadToken: conf.UploadToken, - fileUploadViaForward: cfg.FileUploadViaForward, - }, nil + return r, nil } // resolveFileUploadURL 决定 PDF 实际上传地址:默认经 forward-go,避免外网机直连政务云 28211。 @@ -217,7 +229,10 @@ func (r *Runner) processItem(step, anchorDate, method string, batchID int, item payloadJSON, _ := json.Marshal(payload) _ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", string(payloadJSON), nil) - req, err := hy.BuildUpload(method, payload, r.organ, r.cfg.HyAppKey, r.cfg.HyAppSecret, r.cfg.HyAesKey) + r.configMu.RLock() + appKey, appSecret, aesKey := r.hyAppKey, r.hyAppSecret, r.hyAesKey + r.configMu.RUnlock() + req, err := hy.BuildUpload(method, payload, r.organ, appKey, appSecret, aesKey) if err != nil { _ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error()) cb.PushStatus = "failed" @@ -310,9 +325,16 @@ func (r *Runner) ensureRecipeFileID(item xkapi.PullItem, payload map[string]any) applog.Pushf("saved local pdf: %s", saved) } - // 步骤 4:经 forward-go 上传至政务云文件服务,得到 recipeFileId + // 步骤 4:经 forward-go 上传至政务云文件服务,得到 recipeFileId(token scope 含实际上传文件名) filename := hyfile.BuildPDFFilename(pdfName) - fileID, err := hyfile.UploadPDF(pdf, filename, r.fileUploadURL, r.uploadToken, r.cfg.ForwardSharedSecret) + token, err := r.uploadTokenForFile(filename) + if err != nil { + return err + } + r.configMu.RLock() + url := r.fileUploadURL + r.configMu.RUnlock() + fileID, err := hyfile.UploadPDF(pdf, filename, url, token, r.cfg.ForwardSharedSecret) if err != nil { applog.Pushf("upload pdf fail prescription_no=%s local=%s err=%v", pdfName.PrescriptionNo, localPath, err) return err @@ -437,3 +459,100 @@ func (r *Runner) UploadToken() string { return r.uploadToken } // ForwardSharedSecret 与 forward-go FORWARD_SHARED_SECRET 一致(可选)。 func (r *Runner) ForwardSharedSecret() string { return r.cfg.ForwardSharedSecret } + +// Cfg 返回本地环境配置(只读副本)。 +func (r *Runner) Cfg() config.Config { return r.cfg } + +// FileBucket 返回生效的文件 bucket(uploadToken scope)。 +func (r *Runner) FileBucket() string { + r.configMu.RLock() + defer r.configMu.RUnlock() + return r.fileBucket +} + +// CloudUploadToken 返回 xk-api 预生成的 uploadToken(仅诊断对比)。 +func (r *Runner) CloudUploadToken() string { + r.configMu.RLock() + defer r.configMu.RUnlock() + return r.cloudUploadToken +} + +// UploadTokenSource 说明 uploadToken 生成方式。 +func (r *Runner) UploadTokenSource() string { return "local-ver233-upload" } + +// RegenerateUploadToken 供诊断:用当前凭证重新生成 token(与 refreshUploadToken 相同)。 +func (r *Runner) RegenerateUploadToken() error { + return r.refreshUploadToken() +} + +// RetryRecord 对本机 hy_push_record 整条重试:刷新配置 → 处理 → 回调云端。 +func (r *Runner) RetryRecord(localRecordID int64) error { + if r.store == nil { + return fmt.Errorf("no local store") + } + if err := r.ReloadCloudConfig(); err != nil { + return fmt.Errorf("reload config: %w", err) + } + detail, err := r.store.GetRecordDetail(localRecordID) + if err != nil { + return err + } + rec := detail.Record + step := db.StepForMethod(rec.Method) + if step == "" { + return fmt.Errorf("unknown method: %s", rec.Method) + } + method := rec.Method + batchID := int(rec.JobID) + if batchID <= 0 { + return fmt.Errorf("invalid batch_id (job_id) on record %d", localRecordID) + } + + var payload map[string]any + if rec.PayloadJSON != "" { + if err := json.Unmarshal([]byte(rec.PayloadJSON), &payload); err != nil { + return fmt.Errorf("parse payload_json: %w", err) + } + } + var validationErrors []string + if rec.ValidationErrors.Valid && rec.ValidationErrors.String != "" { + _ = json.Unmarshal([]byte(rec.ValidationErrors.String), &validationErrors) + } + + meta := buildRetryMeta(step, payload) + item := xkapi.PullItem{ + RecordID: 0, + BizKey: rec.BizKey, + Payload: payload, + Meta: meta, + ValidationErrors: validationErrors, + } + + cb := r.processItem(step, rec.AnchorDate, method, batchID, item) + if err := r.xk.BatchCallback(batchID, []xkapi.CallbackItem{cb}); err != nil { + applog.Appf("retry batch callback warning record_id=%d err=%v", localRecordID, err) + } + _ = r.store.IncRetry(rec.BizKey) + if cb.PushStatus != "success" { + if cb.ErrorMessage != "" { + return fmt.Errorf("%s", cb.ErrorMessage) + } + return fmt.Errorf("retry failed push_status=%s", cb.PushStatus) + } + return nil +} + +func buildRetryMeta(step string, payload map[string]any) map[string]any { + meta := map[string]any{} + if step != "recipe" || payload == nil { + return meta + } + rid := metaIntFromPayload(payload, "recipeID") + if rid > 0 { + meta["prescription_id"] = rid + } + if v, ok := payload["recipeFileId"]; !ok || v == nil || fmt.Sprintf("%v", v) == "" { + meta["needs_recipe_upload"] = true + } + return meta +} diff --git a/internal/sync/upload_test_result.go b/internal/sync/upload_test_result.go new file mode 100644 index 0000000..e608b3c --- /dev/null +++ b/internal/sync/upload_test_result.go @@ -0,0 +1,72 @@ +package syncer + +import ( + "fmt" + "strings" + "time" + + "xk-hy-transit-go/internal/fileauth" + "xk-hy-transit-go/internal/hyfile" +) + +// UploadTestResult Web 测试上传结果(含本次 §2.3.3 token 分步信息)。 +type UploadTestResult struct { + FileID string + Filename string + Scope string + Parts fileauth.UploadTokenParts +} + +// uploadTokenPartsForUpload 生成 §2.3.3 upload profile token(scope=仅 bucket)。 +func (r *Runner) uploadTokenPartsForUpload() (fileauth.UploadTokenParts, error) { + r.configMu.RLock() + key := strings.TrimSpace(r.hyAppKey) + secret := strings.TrimSpace(r.hyAppSecret) + bucket := strings.TrimSpace(r.fileBucket) + r.configMu.RUnlock() + if key == "" || secret == "" || bucket == "" { + return fileauth.UploadTokenParts{}, fmt.Errorf("appKey/appSecret/fileBucket 未配置") + } + deadline := time.Now().Unix() + 3600 + return fileauth.BuildUploadTokenPartsForUpload(key, secret, bucket, deadline) +} + +// UploadTestPDF 刷新配置后上传测试 PDF,返回 fileId 与本次 token 分步信息。 +func (r *Runner) UploadTestPDF(pdf []byte, filename string) (*UploadTestResult, error) { + if err := r.ReloadCloudConfig(); err != nil { + return nil, err + } + if strings.TrimSpace(filename) == "" { + filename = "prescription.pdf" + } + parts, err := r.uploadTokenPartsForUpload() + if err != nil { + return nil, err + } + r.configMu.RLock() + url := r.fileUploadURL + bucket := strings.TrimSpace(r.fileBucket) + r.configMu.RUnlock() + + res := &UploadTestResult{ + Filename: filename, + Scope: bucket, + Parts: parts, + } + fileID, err := hyfile.UploadPDF(pdf, filename, url, parts.UploadToken, r.cfg.ForwardSharedSecret) + if err != nil { + return res, err + } + res.FileID = fileID + return res, nil +} + +// uploadTokenForFile 生产上传凭证(scope=仅 bucket)。 +func (r *Runner) uploadTokenForFile(filename string) (string, error) { + _ = filename + parts, err := r.uploadTokenPartsForUpload() + if err != nil { + return "", err + } + return parts.UploadToken, nil +} diff --git a/internal/xkapi/client.go b/internal/xkapi/client.go index 5551d5a..54dc494 100644 --- a/internal/xkapi/client.go +++ b/internal/xkapi/client.go @@ -6,7 +6,7 @@ // Pull → GET /api/hy/supervise/{step}?batch_id=&date= // BatchCallback→ POST /api/hy/transit/batch/callback(路径可配 XK_API_CALLBACK_PATH) // BatchFinish → POST /api/hy/transit/batch/finish -// Config → GET /api/hy/supervise/config(机构 ID、uploadToken;PDF 上传地址由 transit 改走 forward) +// Config → GET /api/hy/supervise/config(机构 ID、appKey/secret/bucket;uploadToken 由 transit 本地 FileAuth 生成) // GetPrescriptionPrintDetail → GET /api/hy/transit/prescription/detail(recipe PDF HTML) // SaveRecipeFile → POST /api/hy/transit/prescription/recipe-file(回写 recipeFileId) // @@ -72,6 +72,9 @@ type ConfigResponse struct { FileUploadURL string `json:"fileUploadUrl"` FileBucket string `json:"fileBucket"` UploadToken string `json:"uploadToken"` + AppKey string `json:"appKey"` + AppSecret string `json:"appSecret"` + AesKey string `json:"aesKey"` } // CallbackItem 批量回调单条结果。