测试
This commit is contained in:
12
.env.example
12
.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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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` 点重新拉取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
52
docs/FILEAUTH.md
Normal file
52
docs/FILEAUTH.md
Normal file
@@ -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":"<bucket>","deadline":<unix>}
|
||||
```
|
||||
|
||||
- `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`)。
|
||||
153
docs/FileAuth.java
Normal file
153
docs/FileAuth.java
Normal file
@@ -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<Object> fileIdList = JSONUtils.<List<Object>>parse(fileIdListStr, (Class)List.class);
|
||||
List<String> strList = new ArrayList<>();
|
||||
for (Object o : fileIdList)
|
||||
strList.add(String.valueOf(o));
|
||||
return JSONUtils.toString(createTokens(strList, expires));
|
||||
}
|
||||
|
||||
private List<String> createTokens(List<String> fileIdList, long expires) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (String s : fileIdList)
|
||||
list.add(createToken(s, expires));
|
||||
return list;
|
||||
}
|
||||
|
||||
public String createUploadToken(String bucket) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> policy) {
|
||||
String sign = sign(JSONUtils.toString(policy));
|
||||
return this.accessKey + ':' + sign + ':' + encodeToString(utf8Bytes(JSONUtils.toString(policy)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
23
internal/config/config_duration_test.go
Normal file
23
internal/config/config_duration_test.go
Normal file
@@ -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")
|
||||
}
|
||||
26
internal/fileauth/auth.go
Normal file
26
internal/fileauth/auth.go
Normal file
@@ -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
|
||||
}
|
||||
112
internal/fileauth/auth_ver233_test.go
Normal file
112
internal/fileauth/auth_ver233_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
42
internal/fileauth/b64_config.go
Normal file
42
internal/fileauth/b64_config.go
Normal file
@@ -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)
|
||||
}
|
||||
168
internal/fileauth/parts.go
Normal file
168
internal/fileauth/parts.go
Normal file
@@ -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
|
||||
}
|
||||
85
internal/fileauth/parts_test.go
Normal file
85
internal/fileauth/parts_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
9
internal/fileauth/profile.go
Normal file
9
internal/fileauth/profile.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package fileauth
|
||||
|
||||
// PolicyProfile putPolicy 形态:upload=生产(scope 仅 bucket);doc=监管教学样例单测。
|
||||
type PolicyProfile string
|
||||
|
||||
const (
|
||||
ProfileUpload PolicyProfile = "upload"
|
||||
ProfileDoc PolicyProfile = "doc"
|
||||
)
|
||||
182
internal/fileauth/ver233.go
Normal file
182
internal/fileauth/ver233.go
Normal file
@@ -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))
|
||||
}
|
||||
0
internal/hyfile/app-2026-05-27.log
Normal file
0
internal/hyfile/app-2026-05-27.log
Normal file
0
internal/hyfile/app-2026-05-28.log
Normal file
0
internal/hyfile/app-2026-05-28.log
Normal file
0
internal/hyfile/pull-2026-05-27.log
Normal file
0
internal/hyfile/pull-2026-05-27.log
Normal file
0
internal/hyfile/pull-2026-05-28.log
Normal file
0
internal/hyfile/pull-2026-05-28.log
Normal file
6
internal/hyfile/push-2026-05-27.log
Normal file
6
internal/hyfile/push-2026-05-27.log
Normal file
@@ -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
|
||||
32
internal/hyfile/push-2026-05-28.log
Normal file
32
internal/hyfile/push-2026-05-28.log
Normal file
@@ -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)
|
||||
67
internal/hyfile/throttle.go
Normal file
67
internal/hyfile/throttle.go
Normal file
@@ -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()
|
||||
}
|
||||
70
internal/hyfile/throttle_test.go
Normal file
70
internal/hyfile/throttle_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
332
internal/logweb/configview.go
Normal file
332
internal/logweb/configview.go
Normal file
@@ -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 "(未配置)"
|
||||
|
||||
}
|
||||
146
internal/logweb/fileauth_handlers.go
Normal file
146
internal/logweb/fileauth_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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\": <hy_push_record.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)
|
||||
}
|
||||
|
||||
50
internal/logweb/upload_handlers.go
Normal file
50
internal/logweb/upload_handlers.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 = '<div class="flash err">' + esc(message) + '</div>';
|
||||
}
|
||||
|
||||
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) {
|
||||
`<option value="${esc(o.value)}">${esc(o.label)}</option>`
|
||||
).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 `<a href="${n.href}"${cur}>${esc(n.label)}</a>`;
|
||||
}).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();
|
||||
}
|
||||
|
||||
212
internal/logweb/web/config.html
Normal file
212
internal/logweb/web/config.html
Normal file
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
|
||||
<title>互医配置</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/static.css">
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
|
||||
<h1>互医配置</h1>
|
||||
|
||||
<nav class="nav" id="main-nav"></nav>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
|
||||
<button class="primary" id="refresh">从 xk-api 重新拉取</button>
|
||||
|
||||
<button type="button" id="copy-diag">复制 diagnostics</button>
|
||||
|
||||
<a href="/fileauth">上传凭证</a>
|
||||
<a href="/upload">文件上传</a>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="summary" class="panel" style="margin-bottom:1rem"></div>
|
||||
|
||||
<div class="panel">
|
||||
|
||||
<h3>三栏对比</h3>
|
||||
|
||||
<div style="overflow:auto">
|
||||
|
||||
<table id="compare"><thead><tr>
|
||||
|
||||
<th>字段</th><th>云端 xk-api</th><th>本地 .env</th><th>Runner 生效</th>
|
||||
|
||||
</tr></thead><tbody></tbody></table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
|
||||
<h3>运行环境</h3>
|
||||
|
||||
<pre id="runtime"></pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
|
||||
<h3>诊断 diagnostics</h3>
|
||||
|
||||
<pre id="diagnostics"></pre>
|
||||
|
||||
</div>
|
||||
|
||||
<details class="panel">
|
||||
|
||||
<summary>展开云端原始响应(脱敏)</summary>
|
||||
|
||||
<pre id="cloud-raw"></pre>
|
||||
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
<script>
|
||||
|
||||
if (typeof renderMainNav === 'function') renderMainNav('config');
|
||||
|
||||
|
||||
|
||||
function row(label, cloud, local, eff) {
|
||||
|
||||
return `<tr><td>${esc(label)}</td><td>${esc(cloud)}</td><td>${esc(local)}</td><td>${esc(eff)}</td></tr>`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function render(v) {
|
||||
|
||||
const sum = document.getElementById('summary');
|
||||
|
||||
const okCloud = !v.cloud.rawError;
|
||||
|
||||
const okToken = v.effective && v.effective.uploadTokenSet;
|
||||
|
||||
const mismatch = (v.diagnostics || []).some(d => d.includes('不一致'));
|
||||
|
||||
let cls = 'badge ok';
|
||||
|
||||
let msg = 'xk-api 已连通';
|
||||
|
||||
if (!okCloud) { cls = 'badge err'; msg = 'xk-api 未连通'; }
|
||||
|
||||
else if (!okToken) { cls = 'badge warn'; msg = '本地 uploadToken 未生成'; }
|
||||
|
||||
else if (mismatch) { cls = 'badge warn'; msg = '配置存在不一致项'; }
|
||||
|
||||
sum.innerHTML = `<span class="${cls}">${esc(msg)}</span> · 刷新于 ${esc(v.effective.configRefreshedAt)} · ${esc(v.xkApiBaseUrl)}`;
|
||||
|
||||
|
||||
|
||||
const c = v.cloud, l = v.localEnv, e = v.effective;
|
||||
|
||||
document.querySelector('#compare tbody').innerHTML =
|
||||
|
||||
row('organID', c.organID, '-', e.organID) +
|
||||
|
||||
row('unitID', c.unitID, '-', e.unitID) +
|
||||
|
||||
row('organName', c.organName, '-', e.organName) +
|
||||
|
||||
row('appKey', c.appKeyPreview, l.hyAppKeyPreview, e.appKeyPreview) +
|
||||
|
||||
row('appSecret', c.appSecretSet ? '已配置' : '未配置', l.hyAppSecretSet ? '已配置' : '未配置', e.appSecretSet ? '已配置' : '未配置') +
|
||||
|
||||
row('aesKey', c.aesKeySet ? '已配置' : '未配置', l.hyAesKeySet ? '已配置' : '未配置', e.aesKeySet ? '已配置' : '未配置') +
|
||||
|
||||
row('fileBucket', c.fileBucket, l.hyFileBucket || '-', e.fileBucket) +
|
||||
|
||||
row('uploadToken(云端预生成)', c.uploadTokenSet ? c.uploadTokenPreview : '空', '-', '-') +
|
||||
|
||||
row('uploadToken(Runner 本地 FileAuth)', '-', '-', e.uploadTokenSet ? e.uploadTokenPreview + ' [' + e.uploadTokenSource + ']' : '空') +
|
||||
|
||||
row('fileUploadURL', c.fileUploadUrl, l.forwardBaseUrl + (l.fileUploadViaForward ? ' (经 forward)' : ''), e.fileUploadURL) +
|
||||
|
||||
row('FORWARD_SHARED_SECRET', '-', l.forwardSharedSecretSet ? '已配置' : '未配置', l.forwardSharedSecretSet ? '已配置' : '未配置');
|
||||
|
||||
|
||||
|
||||
document.getElementById('runtime').textContent =
|
||||
|
||||
`FILE_UPLOAD_VIA_FORWARD: ${l.fileUploadViaForward}\n` +
|
||||
|
||||
`FORWARD_BASE_URL: ${l.forwardBaseUrl}\n` +
|
||||
|
||||
`FORWARD_SHARED_SECRET: ${l.forwardSharedSecretSet ? '已配置' : '未配置'}\n` +
|
||||
|
||||
`XK_API_TOKEN: ${l.xkApiTokenSet ? '已配置' : '未配置'}\n` +
|
||||
|
||||
`生效说明: ${e.sourceNote}`;
|
||||
|
||||
|
||||
|
||||
document.getElementById('diagnostics').textContent =
|
||||
|
||||
(v.diagnostics && v.diagnostics.length) ? v.diagnostics.join('\n') : '(无)';
|
||||
|
||||
document.getElementById('cloud-raw').textContent = JSON.stringify(v.cloudRaw || {}, null, 2);
|
||||
|
||||
window._lastConfigView = v;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function load(refresh) {
|
||||
|
||||
const path = refresh ? '/api/config/refresh' : '/api/config/view';
|
||||
|
||||
const opts = refresh ? { method: 'POST' } : {};
|
||||
|
||||
const v = await api(path, opts);
|
||||
|
||||
render(v);
|
||||
|
||||
if (refresh) showToast('配置已重新拉取', 'ok');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
document.getElementById('refresh').onclick = () => load(true).catch(e => showToast(e.message, 'err'));
|
||||
|
||||
document.getElementById('copy-diag').onclick = () => {
|
||||
|
||||
const t = document.getElementById('diagnostics').textContent;
|
||||
|
||||
navigator.clipboard.writeText(t).then(() => showToast('已复制 diagnostics', 'ok')).catch(() => showToast(t, 'info'));
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
load(false).catch(e => showToast(e.message, 'err'));
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
403
internal/logweb/web/fileauth.html
Normal file
403
internal/logweb/web/fileauth.html
Normal file
@@ -0,0 +1,403 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
|
||||
<title>上传凭证 §2.3.3</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/static.css">
|
||||
|
||||
<style>
|
||||
|
||||
.step { margin-bottom: 1rem; }
|
||||
|
||||
.step h4 { margin: 0 0 0.35rem; color: var(--accent); font-size: 0.95rem; }
|
||||
|
||||
.step pre { margin: 0; max-height: 12rem; overflow: auto; }
|
||||
|
||||
.formula { color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
|
||||
.check-ok { color: var(--ok); }
|
||||
|
||||
.check-fail { color: var(--err); }
|
||||
|
||||
label.chk { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.9rem; }
|
||||
|
||||
.diff { border-left: 3px solid var(--warn); padding-left: 0.75rem; margin-top: 0.5rem; }
|
||||
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
|
||||
<h1>上传凭证(Ver2.0 §2.3.3)</h1>
|
||||
|
||||
<nav class="nav" id="main-nav"></nav>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
|
||||
<p class="formula">
|
||||
|
||||
五步:<code>putPolicy</code> → <code>encodedPutPolicy</code>(URL-safe Base64)→
|
||||
|
||||
<code>HMAC-SHA1(secret, encodedPutPolicy)</code> → <code>encodedSign</code> →
|
||||
|
||||
<code>uploadToken = AccessKey:encodedSign:encodedPutPolicy</code>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<div class="toolbar">
|
||||
|
||||
<label>deadline(Unix 秒)<input type="text" id="deadline" placeholder="默认 now+3600" style="width:11rem"></label>
|
||||
|
||||
<label>bucket <input type="text" id="bucket" placeholder="Runner fileBucket" style="width:10rem"></label>
|
||||
|
||||
<button class="primary" id="btn-gen">生成(upload profile)</button>
|
||||
|
||||
<button id="btn-doc">文档黄金样例</button>
|
||||
|
||||
<button id="btn-refresh">刷新 Runner 并对比</button>
|
||||
|
||||
<label class="chk"><input type="checkbox" id="show-full"> 显示完整 token</label>
|
||||
|
||||
<button type="button" id="btn-copy">复制完整 token</button>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="err-box"></div>
|
||||
|
||||
|
||||
|
||||
<div class="panel" id="panel-ver233">
|
||||
|
||||
<h3>A. §2.3.3 生产算法(transit / xk-api)</h3>
|
||||
|
||||
<div class="step">
|
||||
|
||||
<h4>1. putPolicy(scope=仅 bucket,无 returnBody)</h4>
|
||||
|
||||
<pre id="policy-json">—</pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
|
||||
<h4>2. encodedPutPolicy = urlsafe_base64(putPolicy)</h4>
|
||||
|
||||
<pre id="encoded-policy">—</pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
|
||||
<h4>3. sign = HMAC-SHA1(secret, <strong>encodedPutPolicy</strong> UTF-8)</h4>
|
||||
|
||||
<p class="formula" style="margin:0 0 0.35rem">signInput(签名字节串):<code id="sign-input">—</code> · hex:<code id="sign-hex">—</code></p>
|
||||
|
||||
<pre id="encoded-sign">—</pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
|
||||
<h4>4–5. uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy</h4>
|
||||
|
||||
<pre id="upload-token">—</pre>
|
||||
|
||||
</div>
|
||||
|
||||
<p>算法:<code id="algorithm">—</code> · B64:<code id="b64-mode">—</code> · AccessKey:<code id="access-key">—</code> · scope:<code id="scope-val">—</code> · deadline:<code id="deadline-val">—</code>
|
||||
|
||||
· <span id="doc-golden">—</span></p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="panel">
|
||||
|
||||
<h3>B. 格式校验</h3>
|
||||
|
||||
<p id="format-summary">—</p>
|
||||
|
||||
<ul id="format-checks"></ul>
|
||||
|
||||
<p>与 Runner 当前 token(默认 prescription.pdf):<span id="runner-match">—</span>(<code id="runner-preview">—</code>)</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="panel">
|
||||
|
||||
<h3>C. 监管文档教学示例(doc profile,只读)</h3>
|
||||
|
||||
<p id="doc-note" class="formula">—</p>
|
||||
|
||||
<div class="step">
|
||||
|
||||
<h4>示例 token 三段</h4>
|
||||
|
||||
<pre id="doc-token-parts">—</pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
|
||||
<h4>encodedPutPolicy 解码</h4>
|
||||
|
||||
<pre id="doc-policy">—</pre>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
<script>
|
||||
|
||||
if (typeof renderMainNav === 'function') renderMainNav('fileauth');
|
||||
|
||||
|
||||
|
||||
let lastData = null;
|
||||
|
||||
|
||||
|
||||
function tokenDisplay(data) {
|
||||
|
||||
if (!data) return '—';
|
||||
|
||||
const show = document.getElementById('show-full').checked;
|
||||
|
||||
return show ? data.uploadToken : (data.uploadTokenMasked || data.uploadToken);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function renderChecks(checks) {
|
||||
|
||||
const ul = document.getElementById('format-checks');
|
||||
|
||||
ul.innerHTML = (checks || []).map(c => {
|
||||
|
||||
const cls = c.ok ? 'check-ok' : 'check-fail';
|
||||
|
||||
const mark = c.ok ? '✓' : '✗';
|
||||
|
||||
return `<li class="${cls}">${mark} <strong>${esc(c.id)}</strong> — ${esc(c.detail)}</li>`;
|
||||
|
||||
}).join('');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function render(data) {
|
||||
|
||||
lastData = data;
|
||||
|
||||
document.getElementById('err-box').innerHTML = '';
|
||||
|
||||
document.getElementById('policy-json').textContent = data.policyJson || '—';
|
||||
|
||||
document.getElementById('encoded-policy').textContent = data.encodedPutPolicy || '—';
|
||||
|
||||
document.getElementById('sign-input').textContent = data.signInput || '—';
|
||||
|
||||
document.getElementById('sign-hex').textContent = data.signHex || '—';
|
||||
|
||||
document.getElementById('encoded-sign').textContent = data.encodedSign || '—';
|
||||
|
||||
document.getElementById('upload-token').textContent = tokenDisplay(data);
|
||||
|
||||
document.getElementById('access-key').textContent = data.accessKey || '—';
|
||||
|
||||
document.getElementById('scope-val').textContent = data.scope || data.bucket || '—';
|
||||
|
||||
document.getElementById('b64-mode').textContent = data.b64Mode || '—';
|
||||
|
||||
document.getElementById('deadline-val').textContent = data.deadlineUnix != null ? String(data.deadlineUnix) : '—';
|
||||
|
||||
document.getElementById('algorithm').textContent = data.algorithm || '—';
|
||||
|
||||
document.getElementById('doc-golden').innerHTML = data.docGoldenMatch
|
||||
|
||||
? '<span class="badge ok">与文档黄金样例一致</span>'
|
||||
|
||||
: '<span class="badge info">非文档固定向量(点「文档黄金样例」可对照)</span>';
|
||||
|
||||
|
||||
|
||||
const sum = document.getElementById('format-summary');
|
||||
|
||||
sum.innerHTML = data.formatOk
|
||||
|
||||
? '<span class="badge ok">格式符合 AccessKey:encodedSign:encodedPutPolicy</span>'
|
||||
|
||||
: '<span class="badge err">格式校验未全部通过</span>';
|
||||
|
||||
renderChecks(data.formatChecks);
|
||||
|
||||
|
||||
|
||||
const matchEl = document.getElementById('runner-match');
|
||||
|
||||
if (data.runnerTokenMatch) {
|
||||
|
||||
matchEl.innerHTML = '<span class="badge ok">一致</span>';
|
||||
|
||||
} else {
|
||||
|
||||
matchEl.innerHTML = '<span class="badge warn">不一致</span>(自定义 deadline 或需重新拉取配置)';
|
||||
|
||||
}
|
||||
|
||||
document.getElementById('runner-preview').textContent = data.runnerTokenPreview || '—';
|
||||
|
||||
|
||||
|
||||
const doc = data.docExample || {};
|
||||
|
||||
document.getElementById('doc-note').textContent = doc.note || '—';
|
||||
|
||||
document.getElementById('doc-token-parts').textContent =
|
||||
|
||||
'AccessKey: ' + (doc.accessKeyPart || '') + '\n' +
|
||||
|
||||
'encodedSign: ' + (doc.encodedSignPart || '') + '\n' +
|
||||
|
||||
'encodedPutPolicy: ' + (doc.encodedPolicyPart || '');
|
||||
|
||||
document.getElementById('doc-policy').textContent = doc.policyJsonDecoded || '—';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function load(refresh) {
|
||||
|
||||
const errBox = document.getElementById('err-box');
|
||||
|
||||
errBox.innerHTML = '';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const dl = document.getElementById('deadline').value.trim();
|
||||
|
||||
const bk = document.getElementById('bucket').value.trim();
|
||||
|
||||
if (dl) params.set('deadline', dl);
|
||||
|
||||
if (bk) params.set('bucket', bk);
|
||||
|
||||
if (refresh) params.set('refresh', '1');
|
||||
|
||||
try {
|
||||
|
||||
const data = await api('/api/fileauth/generate?' + params.toString());
|
||||
|
||||
render(data);
|
||||
|
||||
showToast('已生成', 'ok');
|
||||
|
||||
} catch (e) {
|
||||
|
||||
showInlineError(errBox, e.message || String(e));
|
||||
|
||||
showToast(firstLine(e.message) || '生成失败', 'err');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function loadDocGolden() {
|
||||
|
||||
document.getElementById('deadline').value = '1451491200';
|
||||
|
||||
document.getElementById('bucket').value = 'my-bucket';
|
||||
|
||||
try {
|
||||
|
||||
const data = await api('/api/fileauth/generate?doc=1');
|
||||
|
||||
render(data);
|
||||
|
||||
showToast('文档黄金样例', 'ok');
|
||||
|
||||
} catch (e) {
|
||||
|
||||
showInlineError(document.getElementById('err-box'), e.message || String(e));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
document.getElementById('btn-gen').onclick = () => load(false);
|
||||
|
||||
document.getElementById('btn-doc').onclick = () => loadDocGolden();
|
||||
|
||||
document.getElementById('btn-refresh').onclick = () => load(true);
|
||||
|
||||
document.getElementById('show-full').onchange = () => {
|
||||
|
||||
if (lastData) document.getElementById('upload-token').textContent = tokenDisplay(lastData);
|
||||
|
||||
};
|
||||
|
||||
document.getElementById('btn-copy').onclick = async () => {
|
||||
|
||||
if (!lastData || !lastData.uploadToken) {
|
||||
|
||||
showToast('请先生成 token', 'warn');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
await navigator.clipboard.writeText(lastData.uploadToken);
|
||||
|
||||
showToast('已复制完整 uploadToken', 'ok');
|
||||
|
||||
} catch {
|
||||
|
||||
showToast('复制失败', 'err');
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
load(false);
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
@@ -11,13 +11,25 @@
|
||||
<h1>互联网医院监管中转 · Web 控制台</h1>
|
||||
</header>
|
||||
<div class="cards">
|
||||
<a class="card" href="/logs">
|
||||
<h2>文件日志</h2>
|
||||
<p>查看 app / pull / push 按日滚动日志,支持搜索与高亮。</p>
|
||||
<a class="card" href="/config">
|
||||
<h2>互医配置</h2>
|
||||
<p>查看 xk-api 云端与本地 .env、Runner 生效值;本地 §2.3.3 生成 uploadToken。</p>
|
||||
</a>
|
||||
<a class="card" href="/fileauth">
|
||||
<h2>上传凭证</h2>
|
||||
<p>§2.3.3 upload profile(scope=仅 bucket),文档黄金样例校验。</p>
|
||||
</a>
|
||||
<a class="card" href="/upload">
|
||||
<h2>文件上传</h2>
|
||||
<p>测试监管 PDF 上传(经 forward-go),排查 403 / token 问题。</p>
|
||||
</a>
|
||||
<a class="card" href="/runs">
|
||||
<h2>同步流水</h2>
|
||||
<p>三表关联:任务、拉取 payload、上传 HTTP、推断回调。</p>
|
||||
<p>三表关联:任务、拉取 payload、上传 HTTP;失败记录可整条重试。</p>
|
||||
</a>
|
||||
<a class="card" href="/logs">
|
||||
<h2>文件日志</h2>
|
||||
<p>查看 app / pull / push 按日滚动日志,支持搜索与高亮。</p>
|
||||
</a>
|
||||
<a class="card" href="/test">
|
||||
<h2>测试执行</h2>
|
||||
|
||||
@@ -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'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>同步流水</h1>
|
||||
<nav class="nav"><a href="/">首页</a><a href="/logs">文件日志</a><a href="/test">测试</a></nav>
|
||||
<nav class="nav" id="main-nav"></nav>
|
||||
</header>
|
||||
<div class="toolbar">
|
||||
<label>锚定日 <input type="date" id="anchor"></label>
|
||||
@@ -29,12 +29,13 @@
|
||||
<div style="overflow:auto">
|
||||
<table><thead><tr>
|
||||
<th>ID</th><th>日期</th><th>类型</th><th>批次</th><th>biz_key</th><th>拉取校验</th>
|
||||
<th>上报</th><th>推送明文</th><th>HTTP</th><th>回调</th><th>trace</th><th></th>
|
||||
<th>上报</th><th>重试</th><th>推送明文</th><th>HTTP</th><th>回调</th><th>trace</th><th></th>
|
||||
</tr></thead><tbody id="items"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
<script>
|
||||
renderMainNav('runs');
|
||||
const anchorFromUrl = qs('anchor_date');
|
||||
if (anchorFromUrl) document.getElementById('anchor').value = anchorFromUrl;
|
||||
|
||||
@@ -96,6 +97,10 @@ async function load() {
|
||||
const ve = it.errors && it.errors.valid ? (it.errors.formatted || it.errors.raw) : (it.errors && it.errors.raw) || '';
|
||||
const veShort = ve.length > 80 ? ve.slice(0, 80) + '…' : ve;
|
||||
const ll = r.LastLog;
|
||||
const canRetry = r.PushStatus === 'failed' || r.PushStatus === 'skipped';
|
||||
const retryBtn = canRetry
|
||||
? `<button type="button" class="primary retry-btn" data-id="${r.ID}">重试</button>`
|
||||
: '-';
|
||||
return `<tr>
|
||||
<td>${r.ID}</td>
|
||||
<td>${esc(r.AnchorDate)}</td>
|
||||
@@ -104,35 +109,47 @@ async function load() {
|
||||
<td><code>${esc(r.BizKey)}</code></td>
|
||||
<td>${veShort ? '<span class="badge warn">有</span> ' + esc(veShort) : badge('success', '通过')}</td>
|
||||
<td>${badge(r.PushStatus, it.pushStatusLabel)}</td>
|
||||
<td>${r.RetryCount || 0} ${retryBtn}</td>
|
||||
<td>${it.hasPushPlain ? '<span class="badge ok">有</span>' : '<span class="badge warn">无</span>'}</td>
|
||||
<td>${ll ? ll.HTTPCode + ' / ' + (ll.MsgCode && ll.MsgCode.Valid ? ll.MsgCode.Int64 : '-') + ' / ' + ll.DurationMs + 'ms' : '-'}</td>
|
||||
<td>${badge(cb.callback_status, it.callbackStatusLabel)} ${esc(cb.error_message || '')}</td>
|
||||
<td>${ll ? esc(ll.TraceID) : '-'}</td>
|
||||
<td><a href="/runs/record?id=${r.ID}">详情</a></td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="12">无记录</td></tr>';
|
||||
}).join('') || '<tr><td colspan="13">无记录</td></tr>';
|
||||
document.querySelectorAll('.retry-btn').forEach(btn => {
|
||||
btn.onclick = async () => {
|
||||
if (!confirm('整条重试该记录(刷新配置 → 上传/推送 → 回调云端)?')) return;
|
||||
try {
|
||||
await retryRecord(btn.dataset.id, () => load());
|
||||
showToast('重试完成', 'ok');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'err');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('load').onclick = () => load().catch(e => alert(e.message));
|
||||
document.getElementById('load').onclick = () => load().catch(e => showToast(e.message, 'err'));
|
||||
|
||||
truncateBtn.onclick = async () => {
|
||||
const tables = 'hy_push_log、hy_push_record、hy_sync_job';
|
||||
if (!confirm('将清空本机审计库三表(' + tables + '),不可恢复。\n仅影响本机 xk_hy_transit,不动云端数据。\n\n确定继续?')) return;
|
||||
const typed = prompt('请输入 TRUNCATE 以确认清空:');
|
||||
if (typed !== 'TRUNCATE') {
|
||||
if (typed !== null) alert('已取消:确认词不正确');
|
||||
if (typed !== null) showToast('已取消:确认词不正确', 'warn');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('/api/runs/truncate', { method: 'POST' });
|
||||
alert('三表已清空');
|
||||
showToast('三表已清空', 'ok');
|
||||
await load();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
showToast(e.message, 'err');
|
||||
}
|
||||
};
|
||||
|
||||
load().catch(e => alert(e.message));
|
||||
load().catch(e => showToast(e.message, 'err'));
|
||||
refreshSyncGate();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>记录详情 #<span id="rid"></span></h1>
|
||||
<nav class="nav"><a href="/runs">返回流水</a><a href="/">首页</a></nav>
|
||||
<nav class="nav" id="main-nav"></nav>
|
||||
</header>
|
||||
<div class="toolbar" id="summary"></div>
|
||||
<div class="toolbar" id="summary">
|
||||
<button type="button" class="primary" id="retry" style="display:none">整条重试</button>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>① 拉取(云端组包 payload_json)</h3>
|
||||
<p class="status-msg">云端拉下的原始明文,可能尚未含本机机构字段。</p>
|
||||
@@ -41,6 +43,7 @@
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
<script>
|
||||
renderMainNav('runs');
|
||||
const id = qs('id');
|
||||
document.getElementById('rid').textContent = id;
|
||||
|
||||
@@ -55,9 +58,31 @@ function showJSON(el, block, emptyHint) {
|
||||
async function load() {
|
||||
const d = await api('/api/runs/record?id=' + id);
|
||||
const r = d.record;
|
||||
document.getElementById('summary').innerHTML =
|
||||
const sumEl = document.getElementById('summary');
|
||||
const retryBtn = document.getElementById('retry');
|
||||
let info = document.getElementById('summary-info');
|
||||
if (!info) {
|
||||
info = document.createElement('span');
|
||||
info.id = 'summary-info';
|
||||
sumEl.insertBefore(info, retryBtn);
|
||||
}
|
||||
info.innerHTML =
|
||||
`锚定日 <b>${esc(r.AnchorDate)}</b> | step <b>${esc(d.step)}</b> | batch_id <b>${r.JobID}</b> | ` +
|
||||
`biz_key <code>${esc(r.BizKey)}</code> | 上传 ${badge(r.PushStatus)} | 回调 ${badge(d.callback.CallbackStatus)}`;
|
||||
`biz_key <code>${esc(r.BizKey)}</code> | 重试次数 <b>${r.RetryCount || 0}</b> | 上报 ${badge(r.PushStatus)} | 回调 ${badge(d.callback.callback_status, d.callbackStatusLabel)}`;
|
||||
if (r.PushStatus === 'failed' || r.PushStatus === 'skipped') {
|
||||
retryBtn.style.display = '';
|
||||
retryBtn.onclick = async () => {
|
||||
if (!confirm('整条重试该记录?')) return;
|
||||
try {
|
||||
await retryRecord(id, load);
|
||||
showToast('重试完成', 'ok');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'err');
|
||||
}
|
||||
};
|
||||
} else {
|
||||
retryBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
showJSON(document.getElementById('payload'), d.payload);
|
||||
showJSON(document.getElementById('errors'), d.errors);
|
||||
@@ -97,7 +122,7 @@ async function load() {
|
||||
document.getElementById('callback').textContent = JSON.stringify(d.callback, null, 2);
|
||||
}
|
||||
|
||||
load().catch(e => alert(e.message));
|
||||
load().catch(e => showToast(e.message, 'err'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
164
internal/logweb/web/upload.html
Normal file
164
internal/logweb/web/upload.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>文件上传测试</title>
|
||||
<link rel="stylesheet" href="/static/static.css">
|
||||
<style>
|
||||
.token-panel h4 { margin: 0.5rem 0 0.25rem; font-size: 0.9rem; color: var(--accent); }
|
||||
.token-panel pre { margin: 0 0 0.75rem; max-height: 8rem; overflow: auto; font-size: 0.85rem; }
|
||||
.muted-note { color: var(--muted); font-size: 0.88rem; margin: 0.35rem 0 0.75rem; }
|
||||
label.chk { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.9rem; margin-right: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>监管文件上传测试</h1>
|
||||
<nav class="nav" id="main-nav"></nav>
|
||||
</header>
|
||||
|
||||
<p class="muted-note">§2.3.3 生产 token:<strong>scope=仅 bucket 名</strong>。上传为 PDF 二进制体(<code>Content-Type: application/pdf</code>),不用 multipart。403 且 body 为 <code>404</code> 表示 token 验签失败。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<label>PDF 文件 <input type="file" id="file" accept=".pdf,application/pdf"></label>
|
||||
<button class="primary" id="upload">上传</button>
|
||||
<a href="/config">互医配置</a>
|
||||
<a href="/fileauth">上传凭证调试</a>
|
||||
</div>
|
||||
|
||||
<div id="status-default" class="panel">
|
||||
<h3>A. Runner 默认 token(scope=仅 bucket)</h3>
|
||||
<p id="runner-status">加载中…</p>
|
||||
</div>
|
||||
|
||||
<div id="status-upload" class="panel token-panel">
|
||||
<h3>B. 本次上传凭证(§2.3.3)</h3>
|
||||
<p id="upload-token-placeholder" class="muted-note">上传成功或失败后显示本次请求使用的 token 信息。</p>
|
||||
<div id="upload-token-detail" style="display:none">
|
||||
<p>filename: <code id="t-filename">—</code> · scope: <code id="t-scope">—</code> · algorithm: <code id="t-algo">—</code> · deadline: <code id="t-deadline">—</code></p>
|
||||
<h4>signInput(HMAC 输入 = encodedPutPolicy)</h4>
|
||||
<pre id="t-sign-input">—</pre>
|
||||
<h4>putPolicy JSON</h4>
|
||||
<pre id="t-policy">—</pre>
|
||||
<h4>encodedPutPolicy</h4>
|
||||
<pre id="t-encoded-policy">—</pre>
|
||||
<h4>encodedSign</h4>
|
||||
<pre id="t-encoded-sign">—</pre>
|
||||
<h4>uploadToken</h4>
|
||||
<pre id="t-upload-token">—</pre>
|
||||
<label class="chk"><input type="checkbox" id="show-full"> 显示完整 uploadToken</label>
|
||||
<button type="button" id="btn-copy">复制完整 token</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>上传结果</h3>
|
||||
<pre id="result" style="min-height:3rem">—</pre>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
<script>
|
||||
if (typeof renderMainNav === 'function') renderMainNav('upload');
|
||||
|
||||
let lastTokenInfo = null;
|
||||
|
||||
async function showRunnerStatus() {
|
||||
try {
|
||||
const v = await api('/api/config/view');
|
||||
const eff = v.effective || {};
|
||||
const el = document.getElementById('runner-status');
|
||||
el.innerHTML = `uploadToken: ${eff.uploadTokenSet ? '<span class="badge ok">有效</span> ' + esc(eff.uploadTokenPreview) + ' <span class="badge info">' + esc(eff.uploadTokenSource || 'local-ver233-upload') + '</span>' : '<span class="badge err">空</span>'}
|
||||
· fileBucket: <code>${esc(eff.fileBucket || '-')}</code>
|
||||
· 上传地址: <code>${esc(eff.fileUploadURL)}</code>`;
|
||||
} catch (e) {
|
||||
showInlineError(document.getElementById('runner-status'), e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenDisplay(info) {
|
||||
if (!info) return '—';
|
||||
const show = document.getElementById('show-full').checked;
|
||||
return show ? (info.uploadToken || '—') : (info.uploadTokenMasked || info.uploadToken || '—');
|
||||
}
|
||||
|
||||
function renderUploadTokenInfo(data) {
|
||||
const info = data && data.tokenInfo;
|
||||
const ph = document.getElementById('upload-token-placeholder');
|
||||
const detail = document.getElementById('upload-token-detail');
|
||||
if (!info || !info.uploadToken) {
|
||||
ph.style.display = '';
|
||||
detail.style.display = 'none';
|
||||
lastTokenInfo = null;
|
||||
return;
|
||||
}
|
||||
lastTokenInfo = info;
|
||||
ph.style.display = 'none';
|
||||
detail.style.display = '';
|
||||
document.getElementById('t-filename').textContent = data.filename || '—';
|
||||
document.getElementById('t-scope').textContent = data.scope || '—';
|
||||
document.getElementById('t-algo').textContent = info.algorithm || 'ver233';
|
||||
document.getElementById('t-deadline').textContent = info.deadlineUnix != null ? String(info.deadlineUnix) : '—';
|
||||
document.getElementById('t-sign-input').textContent = info.signInput || '—';
|
||||
document.getElementById('t-policy').textContent = info.policyJson || '—';
|
||||
document.getElementById('t-encoded-policy').textContent = info.encodedPutPolicy || '—';
|
||||
document.getElementById('t-encoded-sign').textContent = info.encodedSign || '—';
|
||||
document.getElementById('t-upload-token').textContent = tokenDisplay(info);
|
||||
}
|
||||
|
||||
document.getElementById('show-full').onchange = () => {
|
||||
if (lastTokenInfo) {
|
||||
document.getElementById('t-upload-token').textContent = tokenDisplay(lastTokenInfo);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('btn-copy').onclick = async () => {
|
||||
if (!lastTokenInfo || !lastTokenInfo.uploadToken) {
|
||||
showToast('请先上传以生成 token', 'warn');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastTokenInfo.uploadToken);
|
||||
showToast('已复制完整 uploadToken', 'ok');
|
||||
} catch {
|
||||
showToast('复制失败', 'err');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('upload').onclick = async () => {
|
||||
const input = document.getElementById('file');
|
||||
const resultEl = document.getElementById('result');
|
||||
const btn = document.getElementById('upload');
|
||||
if (!input.files || !input.files[0]) {
|
||||
showToast('请选择 PDF', 'warn');
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('file', input.files[0]);
|
||||
btn.disabled = true;
|
||||
resultEl.textContent = '上传中…';
|
||||
try {
|
||||
const r = await fetch('/api/upload/test', { method: 'POST', body: fd });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
renderUploadTokenInfo(j);
|
||||
if (!r.ok) {
|
||||
let text = j.detail || j.error || r.statusText;
|
||||
if (j.uploadUrl) text += '\n\nuploadUrl: ' + j.uploadUrl;
|
||||
resultEl.textContent = text;
|
||||
showToast(firstLine(j.error || text), 'err');
|
||||
return;
|
||||
}
|
||||
resultEl.textContent = JSON.stringify(j, null, 2);
|
||||
showToast('上传成功 fileId=' + (j.fileId || ''), 'ok');
|
||||
} catch (e) {
|
||||
resultEl.textContent = e.message;
|
||||
showToast(e.message, 'err');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
showRunnerStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
257
internal/sync/config.go
Normal file
257
internal/sync/config.go
Normal file
@@ -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] + "…"
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
72
internal/sync/upload_test_result.go
Normal file
72
internal/sync/upload_test_result.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 批量回调单条结果。
|
||||
|
||||
Reference in New Issue
Block a user