This commit is contained in:
李琦
2026-05-28 16:39:51 +08:00
parent 1982d304ca
commit 6030c62e2a
42 changed files with 3360 additions and 100 deletions

View File

@@ -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 最短间隔(默认 10s0 关闭;支持 10s 或纯数字秒)
FILE_UPLOAD_MIN_INTERVAL=10s
# uploadToken Base64padded默认含=| 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

View File

@@ -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")
}

View File

@@ -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
View File

@@ -0,0 +1,52 @@
# FileAuth 上传凭证Ver2.0 §2.3.3
生产仅使用 **§2.3.3**HMAC 对 **encodedPutPolicy** 字符串。FileAuth.java 旧算法HMAC 对 policy JSON**不可用**,不再维护。
## 生产 putPolicyupload profile
```json
{"scope":"<bucket>","deadline":<unix>}
```
- `scope`**仅 bucket 名**(如 `other-doc`**不要** `bucket:filename`
- **无** `returnBody`
- HTTP 上传为 **PDF 二进制体**`Content-Type: application/pdf`**不使用** multipart与 scope 无关
## 五步
| 步 | 说明 |
|----|------|
| 1 | 构造 putPolicyupload 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-goIP / `X-Forward-Token` |
| **`404`(纯文本)+ 403** | 已过 forward**政务云 token 验签失败** |
## Web 调试
- `/fileauth`:生成 upload profile token`?doc=1` 为文档黄金样例
- `/upload`A=Runner 默认 tokenB=本次上传 `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
View 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)));
}
}

View File

@@ -14,27 +14,31 @@ import (
"os"
"strconv"
"strings"
"time"
)
// Config 监管中转运行所需的全部配置项。
type Config struct {
XkAPIBaseURL string // 云端根地址,如 https://api.xiaokang88.com
XkAPIToken string // 对应云端 HY_TRANSIT_API_TOKENHeader: 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 // truePDF 上传经 forward /mng/file/auth/upload不直连政务云
MySQLDSN string // 本机中转库 DSNhy_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_TOKENHeader: X-Hy-Transit-Token
HyAppKey string // 监管平台 AppKey加密上报
HyAppSecret string // 监管平台 Secret签名
HyAesKey string // 监管平台 AES 密钥body 加密)
HyFileBucket string // 文件上传 scopeuploadToken 签名用,云端 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 // truePDF 上传经 forward /mng/file/auth/upload不直连政务云
MySQLDSN string // 本机中转库 DSNhy_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 Base64padded默认| 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
}

View 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
View 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 profilescope=仅 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
}

View 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")
}
}

View 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
View File

@@ -0,0 +1,168 @@
package fileauth
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
)
// UploadTokenParts 上传凭证分步结果(监管 2.3AccessKey:encodedSign:encodedPutPolicy
type UploadTokenParts struct {
AccessKey string
PolicyJSON string
EncodedPutPolicy string
SignInput string // §2.3.3encodedPutPolicy 字符串
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 profilescope 仅 bucket无 returnBody",
UploadTokenSample: DocExampleRegulatory,
AccessKeyPart: ak,
EncodedSignPart: sign,
EncodedPolicyPart: pol,
PolicyJSONDecoded: policyDecoded,
}
}
// BuildUploadTokenParts 分步生成上传凭证§2.3.3 upload profilescope=仅 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:policyB64expectedAccessKey 非空时校验首段。
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 段 encodedSignURL-safe Base64 HMAC非空",
},
{
ID: "encoded_policy_non_empty",
OK: ok && pol != "",
Detail: "第 3 段 encodedPutPolicyURL-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
}

View 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")
}
}

View File

@@ -0,0 +1,9 @@
package fileauth
// PolicyProfile putPolicy 形态upload=生产scope 仅 bucketdoc=监管教学样例单测。
type PolicyProfile string
const (
ProfileUpload PolicyProfile = "upload"
ProfileDoc PolicyProfile = "doc"
)

182
internal/fileauth/ver233.go Normal file
View 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 文档样例完整 uploadTokenAccessKey=MY_ACCESS_KEY
const DocExampleUploadToken = "MY_ACCESS_KEY:wQ4ofysef1R7IKnrziqtomqyDvI=:" + DocExampleEncodedPutPolicy
// MarshalPutPolicyDoc 文档 profilescope 可含文件名 + 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 生产 profilescope=仅 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.3HMAC-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 不参与 scopeHTTP 为 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))
}

View File

View File

View File

View File

View 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

View 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)

View 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()
}

View 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))
}
}

View File

@@ -1,8 +1,8 @@
// Package hyfile 处方监管 PDF 生成与上传。
//
// HtmlToPDFchromedp 将 xk-api 打印 HTML 转为 PDF。
// UploadPDFmultipart POSTHeader X-Authorization 为 uploadToken
// 默认请求地址为 forward-go /mng/file/auth/upload,由 forward 转发至政务云 28211。
// UploadPDFPOST PDF 二进制体Content-Type: application/pdfHeader 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。
//
// uploadURLFILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。
// uploadToken由 xk-api HY_APP_KEY/HY_APP_SECRET + HY_FILE_BUCKET 签名生成,放在 X-Authorization 请求头。
// uploadTokentransit 按 §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_BUCKETpolicy scope 仅 bucket 名,不含文件名)",
"③token=AccessKey:encodedSign:encodedPutPolicysign=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

View File

@@ -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()
}

View 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: "uploadTokenRunner 按 §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 "(未配置)"
}

View 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)
}

View File

@@ -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)
}

View 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
}

View File

@@ -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;
}
/** 右下角 Toasttype: 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();
}

View 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('uploadTokenRunner 本地 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>

View 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>deadlineUnix 秒)<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. putPolicyscope=仅 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>45. 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>

View File

@@ -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 profilescope=仅 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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');
}
};

View 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 默认 tokenscope=仅 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>signInputHMAC 输入 = 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
View 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 重新生成 uploadTokenscope=仅 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] + "…"
}

View File

@@ -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 上传至政务云文件服务,得到 recipeFileIdtoken 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 返回生效的文件 bucketuploadToken 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
}

View 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 tokenscope=仅 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
}

View File

@@ -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、uploadTokenPDF 上传地址由 transit 改走 forward
// Config → GET /api/hy/supervise/config机构 ID、appKey/secret/bucketuploadToken 由 transit 本地 FileAuth 生成
// GetPrescriptionPrintDetail → GET /api/hy/transit/prescription/detailrecipe 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 批量回调单条结果。