token生成成功

This commit is contained in:
李琦
2026-05-28 17:03:08 +08:00
parent 6030c62e2a
commit 6219166465
22 changed files with 302 additions and 723 deletions

View File

@@ -45,7 +45,7 @@ FILE_UPLOAD_INSECURE_SKIP_VERIFY=true
# 政务云文件上传限频:两次 POST 最短间隔(默认 10s0 关闭;支持 10s 或纯数字秒)
FILE_UPLOAD_MIN_INTERVAL=10s
# uploadToken Base64padded默认含=| raw无填充联调仍 403 可试)
# uploadToken Base64FileAuth.javapadded默认含=| raw无填充联调仍 403 可试)
# FILEAUTH_B64=padded
# 应用日志(默认 {程序目录}/log/transit/,含 pull/push/app 按日文件;每次执行有 BEGIN/END 分隔符)

View File

@@ -1,8 +1,8 @@
# FileAuth 上传凭证(Ver2.0 §2.3.3
# FileAuth 上传凭证(FileAuth.java
生产仅使用 **§2.3.3**HMAC 对 **encodedPutPolicy** 字符串。FileAuth.java 旧算法HMAC 对 policy JSON**不可用**,不再维护
生产与 [`docs/FileAuth.java`](FileAuth.java) 中 `createUploadToken` **完全一致**HMAC 对 **policy JSON 明文**
## 生产 putPolicyupload profile
## 生产 putPolicy
```json
{"scope":"<bucket>","deadline":<unix>}
@@ -10,29 +10,24 @@
- `scope`**仅 bucket 名**(如 `other-doc`**不要** `bucket:filename`
- **无** `returnBody`
- HTTP 上传为 **PDF 二进制体**`Content-Type: application/pdf`**不使用** multipart;与 scope 无关
- HTTP 上传为 **PDF 二进制体**`Content-Type: application/pdf`**不使用** multipart
## 五步
## 生成步骤
| 步 | 说明 |
|----|------|
| 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` |
| 1 | 构造 putPolicy JSON字段顺序`scope``deadline` |
| 2 | `sign = urlsafe_base64( HMAC-SHA1(secret, UTF-8(policyJSON)) )` |
| 3 | `policyB64 = urlsafe_base64( UTF-8(policyJSON) )` |
| 4 | `uploadToken = AccessKey + ':' + sign + ':' + policyB64` |
## Base64 填充
| 环境变量 | 说明 |
|----------|------|
| `FILEAUTH_B64=padded`(默认) | URL-safe Base64 **含 `=`**与文档教学样例一致 |
| `FILEAUTH_B64=padded`(默认) | URL-safe Base64 **含 `=`**对应 Java `Base64.URL_SAFE \| NO_WRAP` |
| `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 | 含义 |
@@ -42,10 +37,10 @@
## Web 调试
- `/fileauth`:生成 upload profile token`?doc=1` 为文档黄金样例
- `/fileauth`:生成 upload profile token
- `/upload`A=Runner 默认 tokenB=本次上传 `tokenInfo``scope` 应为 bucket
实现:[`internal/fileauth`](../internal/fileauth/ver233.go)
实现:[`internal/fileauth/upload.go`](../internal/fileauth/upload.go)
## 上传频率

View File

@@ -1,4 +1,4 @@
// Package fileauth 实现监管文件服务上传凭证(Ver2.0 §2.3.3,生产 scope=仅 bucket
// Package fileauth 实现监管文件服务上传凭证(与 docs/FileAuth.java 一致,生产 scope=仅 bucket
package fileauth
import (
@@ -7,7 +7,7 @@ import (
const defaultUploadExpires = 3600 * time.Second
// CreateUploadToken 生成上传凭证(§2.3.3 upload profilescope=仅 bucket
// CreateUploadToken 生成上传凭证(FileAuth.java upload profilescope=仅 bucket
func CreateUploadToken(accessKey, secret, bucket string, expires time.Duration) (string, error) {
if expires <= 0 {
expires = defaultUploadExpires

View File

@@ -0,0 +1,88 @@
package fileauth
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"os"
"testing"
)
func TestMain(m *testing.M) {
SetB64Mode(B64ModePadded)
os.Exit(m.Run())
}
func TestMarshalPutPolicyUpload_productionShape(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 TestBuildUploadTokenParts_javaAlgorithm(t *testing.T) {
const (
accessKey = "accessKey"
secret = "secret"
bucket = "other-doc"
deadline = int64(1735689600)
policyJSON = `{"scope":"other-doc","deadline":1735689600}`
)
mac := hmac.New(sha1.New, []byte(secret))
_, _ = mac.Write([]byte(policyJSON))
wantSign := base64.URLEncoding.EncodeToString(mac.Sum(nil))
wantPolicyB64 := base64.URLEncoding.EncodeToString([]byte(policyJSON))
wantToken := accessKey + ":" + wantSign + ":" + wantPolicyB64
parts, err := BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadline)
if err != nil {
t.Fatal(err)
}
if parts.UploadToken != wantToken {
t.Fatalf("token mismatch\ngot: %s\nwant: %s", parts.UploadToken, wantToken)
}
if parts.SignInput != policyJSON {
t.Fatalf("signInput=%q want policy JSON", parts.SignInput)
}
if parts.EncodedSign != wantSign {
t.Fatalf("encodedSign=%q want %q", parts.EncodedSign, wantSign)
}
if parts.EncodedPutPolicy != wantPolicyB64 {
t.Fatalf("policyB64=%q want %q", parts.EncodedPutPolicy, wantPolicyB64)
}
if parts.Algorithm != algorithmJavaUpload {
t.Fatalf("algorithm=%q", parts.Algorithm)
}
}
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 TestUploadToken_paddedVsRawB64(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

@@ -1,112 +0,0 @@
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

@@ -7,12 +7,12 @@ import (
"strings"
)
// UploadTokenParts 上传凭证分步结果(监管 2.3AccessKey:encodedSign:encodedPutPolicy
// UploadTokenParts 上传凭证分步结果(FileAuth.javaAccessKey:encodedSign:policyB64)。
type UploadTokenParts struct {
AccessKey string
PolicyJSON string
EncodedPutPolicy string
SignInput string // §2.3.3encodedPutPolicy 字符串
EncodedPutPolicy string // 第三段policy JSON 的 URL-safe Base64
SignInput string // HMAC 输入policy JSON 明文
EncodedSign string
UploadToken string
DeadlineUnix int64
@@ -27,41 +27,7 @@ type FormatCheck struct {
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
// BuildUploadTokenParts 分步生成上传凭证(生产 upload profilescope=仅 bucket)。
func BuildUploadTokenParts(accessKey, secret, bucket string, deadlineUnix int64) (UploadTokenParts, error) {
return BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix)
}
@@ -94,7 +60,7 @@ func ValidateUploadTokenFormat(token, expectedAccessKey string) (allOK bool, che
{
ID: "three_parts",
OK: ok,
Detail: "uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy按前两处冒号拆分",
Detail: "uploadToken = AccessKey + ':' + encodedSign + ':' + policyB64(按前两处冒号拆分)",
},
{
ID: "access_key_non_empty",
@@ -109,7 +75,7 @@ func ValidateUploadTokenFormat(token, expectedAccessKey string) (allOK bool, che
{
ID: "encoded_policy_non_empty",
OK: ok && pol != "",
Detail: "第 3 段 encodedPutPolicyURL-safe Base64 policy JSON非空",
Detail: "第 3 段 policyB64URL-safe Base64 policy JSON非空",
},
}
if expectedAccessKey != "" {
@@ -137,7 +103,7 @@ func ValidateUploadTokenFormat(token, expectedAccessKey string) (allOK bool, che
return allOK, checks
}
// DecodePutPolicyB64 解码 encodedPutPolicy 为 JSON 字符串(美化缩进)。
// DecodePutPolicyB64 解码 policyB64 为 JSON 字符串(美化缩进)。
func DecodePutPolicyB64(encoded string) (string, error) {
encoded = strings.TrimSpace(encoded)
if encoded == "" {
@@ -145,7 +111,6 @@ func DecodePutPolicyB64(encoded string) (string, error) {
}
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
// 兼容带 padding 的标准 url-safe
raw, err = base64.URLEncoding.DecodeString(encoded)
if err != nil {
return "", err
@@ -161,8 +126,3 @@ func DecodePutPolicyB64(encoded string) (string, error) {
}
return string(out), nil
}
// DecodeDocExamplePolicy 返回内置文档示例 policy 明文(便于测试)。
func DecodeDocExamplePolicy() string {
return docExamplePolicyJSON
}

View File

@@ -32,15 +32,15 @@ func TestBuildUploadTokenParts_matchesCreateUploadTokenAt(t *testing.T) {
if strings.Contains(parts.PolicyJSON, "returnBody") {
t.Fatalf("upload profile must not include returnBody: %q", parts.PolicyJSON)
}
if parts.Algorithm != "ver233-upload" {
if parts.Algorithm != algorithmJavaUpload {
t.Fatalf("algorithm=%q", parts.Algorithm)
}
if parts.SignInput != parts.EncodedPutPolicy {
t.Fatal("ver233 signInput must be encodedPutPolicy")
if parts.SignInput != parts.PolicyJSON {
t.Fatal("signInput must be policy JSON")
}
}
func TestValidateUploadTokenFormat_golden(t *testing.T) {
func TestValidateUploadTokenFormat_production(t *testing.T) {
parts, err := BuildUploadTokenParts("accessKey", "secret", "other-doc", 1735689600)
if err != nil {
t.Fatal(err)
@@ -50,36 +50,3 @@ func TestValidateUploadTokenFormat_golden(t *testing.T) {
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

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

132
internal/fileauth/upload.go Normal file
View File

@@ -0,0 +1,132 @@
package fileauth
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
const algorithmJavaUpload = "fileauth-java-upload"
// MarshalPutPolicyUpload 生产 putPolicyscope=仅 bucket无 returnBody与 FileAuth.java HashMap 字段顺序一致)。
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
}
// signPolicyJava 与 FileAuth.java sign(byte[]) 一致HMAC-SHA1(secret, policyJSON) → URL-safe Base64。
func signPolicyJava(secret string, policyJSON []byte) (string, error) {
mac := hmac.New(sha1.New, []byte(secret))
if _, err := mac.Write(policyJSON); err != nil {
return "", fmt.Errorf("hmac: %w", err)
}
return encodeURLSafe(mac.Sum(nil)), nil
}
// BuildUploadTokenFromPolicy 按 FileAuth.java createUploadToken 生成 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 = algorithmJavaUpload
}
policyJSON := string(putPolicyJSON)
sign, err := signPolicyJava(secret, putPolicyJSON)
if err != nil {
return UploadTokenParts{}, err
}
policyB64 := encodeURLSafe(putPolicyJSON)
token := accessKey + ":" + sign + ":" + policyB64
var policy struct {
Deadline int64 `json:"deadline"`
}
_ = json.Unmarshal(putPolicyJSON, &policy)
return UploadTokenParts{
AccessKey: accessKey,
PolicyJSON: policyJSON,
EncodedPutPolicy: policyB64,
SignInput: policyJSON,
EncodedSign: sign,
UploadToken: token,
DeadlineUnix: policy.Deadline,
Algorithm: algorithm,
}, nil
}
// BuildUploadTokenPartsForUpload 生产路径scope=仅 bucket。
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, algorithmJavaUpload)
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
}
// HMACSignHex 返回 HMAC-SHA1(policyJSON) 原始字节的十六进制(调试用)。
func HMACSignHex(secret, policyJSON string) string {
mac := hmac.New(sha1.New, []byte(secret))
_, _ = mac.Write([]byte(policyJSON))
return hex.EncodeToString(mac.Sum(nil))
}

View File

@@ -1,182 +0,0 @@
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

@@ -30,3 +30,11 @@
[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)
[push] 2026/05/28 16:42:27 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe
[push] 2026/05/28 16:42:27 chromedp: html_len=87 data_url_len=152
[push] 2026/05/28 16:42:28 chromedp: pdf bytes=5205
[push] 2026/05/28 16:42:28 file upload throttle: wait 80ms (min interval 80ms)
[push] 2026/05/28 16:42:39 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe
[push] 2026/05/28 16:42:39 chromedp: html_len=87 data_url_len=152
[push] 2026/05/28 16:42:39 chromedp: pdf bytes=5205
[push] 2026/05/28 16:42:40 file upload throttle: wait 79ms (min interval 80ms)

View File

@@ -50,7 +50,7 @@ func uploadHTTPClient() *http.Client {
// UploadPDF 上传 PDF 至监管文件服务(通常经 forward-go成功时返回 record.fileId。
//
// uploadURLFILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。
// uploadTokentransit 按 §2.3.3 本地生成scope=仅 bucket放在 X-Authorization 请求头。
// uploadTokentransit 按 FileAuth.java 本地生成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) {
@@ -122,7 +122,7 @@ func formatUploadHTTPError(status int, raw []byte, uploadURL string) error {
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 += ";政务云 uploadToken 验签失败(非 forward forbidden核对 FileAuth.java scope 是否为仅 bucket 名、HMAC 输入为 policy JSON"
msg += upload403Hints(body)
default:
if extra := jsonMessageField(raw); extra != "" {
@@ -146,9 +146,9 @@ func upload403Hints(body string) string {
hints = append(hints, "政务云文件上传约 10 秒 1 次transit 已全局限频;若仍 403 请拉长 FILE_UPLOAD_MIN_INTERVAL 或稍后重试")
}
hints = append(hints,
"按 §2.3.3 排查①NTP 时钟deadline 为上传完成校验时间)",
"按 FileAuth.java 排查①NTP 时钟deadline 为上传完成校验时间)",
"②HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKETpolicy scope 仅 bucket 名,不含文件名)",
"③token=AccessKey:encodedSign:encodedPutPolicysign=HMAC-SHA1(secret, UTF-8(encodedPutPolicy))",
"③token=AccessKey:encodedSign:policyB64sign=HMAC-SHA1(secret, UTF-8(policyJSON))",
"④仍失败可设 FILEAUTH_B64=raw 后重启 transit",
"⑤/config 重新拉取",
)

View File

@@ -83,8 +83,8 @@ func TestUploadPDF_forbiddenGovMessage(t *testing.T) {
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)
if !strings.Contains(err.Error(), "FileAuth.java") {
t.Fatalf("want FileAuth.java troubleshooting hints, got %v", err)
}
}

View File

@@ -218,7 +218,7 @@ func BuildConfigView(snap syncer.ConfigSnapshot, cfg config.Config) ConfigViewRe
FileUploadURL: snap.FileUploadURL,
SourceNote: "uploadTokenRunner 按 §2.3.3 本地生成appKey/secret/bucket 云端非空优先",
SourceNote: "uploadTokenRunner 按 FileAuth.java 本地生成appKey/secret/bucket 云端非空优先",
ConfigRefreshed: formatTime(snap.RefreshedAt),
},
@@ -244,7 +244,7 @@ func BuildConfigView(snap syncer.ConfigSnapshot, cfg config.Config) ConfigViewRe
if cloudToken != "" && localToken != "" && cloudToken != localToken {
view.Diagnostics = append(view.Diagnostics, "云端预生成 uploadToken 与 Runner 本地 token 不一致(已使用本地 §2.3.3,以 Runner 为准)")
view.Diagnostics = append(view.Diagnostics, "云端预生成 uploadToken 与 Runner 本地 token 不一致(已使用本地 FileAuth.java,以 Runner 为准)")
}

View File

@@ -31,8 +31,6 @@ type FileAuthGenerateResponse struct {
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) {
@@ -62,26 +60,17 @@ func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Reques
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 == "" {
if 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 {
if ds := strings.TrimSpace(r.URL.Query().Get("deadline")); ds != "" {
n, err := strconv.ParseInt(ds, 10, 64)
if err != nil || n <= 0 {
writeErr(w, 400, "invalid deadline (unix seconds)")
@@ -90,34 +79,21 @@ func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Reques
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)
}
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
runnerMatch := runnerTok != "" && runnerTok == parts.UploadToken
writeJSON(w, FileAuthGenerateResponse{
AccessKey: parts.AccessKey,
SecretSet: secret != "",
Bucket: bucket,
Scope: scope,
Scope: bucket,
B64Mode: fileauth.B64Mode(),
PolicyJSON: parts.PolicyJSON,
EncodedPutPolicy: parts.EncodedPutPolicy,
@@ -133,8 +109,6 @@ func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Reques
FormatChecks: checks,
RunnerTokenMatch: runnerMatch,
RunnerTokenPreview: maskPreview(runnerTok),
DocGoldenMatch: docGolden,
DocExample: fileauth.RegulatoryDocExample(),
})
}

View File

@@ -4,7 +4,7 @@ import (
syncer "xk-hy-transit-go/internal/sync"
)
// UploadTokenInfo 本次上传使用的 §2.3.3 凭证Web 调试展示)。
// UploadTokenInfo 本次上传使用的 FileAuth.java 凭证Web 调试展示)。
type UploadTokenInfo struct {
Algorithm string `json:"algorithm"`
PolicyJSON string `json:"policyJson"`

View File

@@ -1,403 +1,161 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>上传凭证 §2.3.3</title>
<title>上传凭证 FileAuth</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>
<h1>上传凭证FileAuth.java</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>
<code>docs/FileAuth.java</code> 一致:
<code>sign = HMAC-SHA1(secret, policyJSON)</code>
<code>uploadToken = AccessKey:encodedSign:policyB64</code>policyB64 = urlsafe_base64(policyJSON)
</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 class="primary" id="btn-gen">生成</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="panel" id="panel-upload">
<h3>生产 upload profilescope=仅 bucket</h3>
<div class="step">
<h4>1. putPolicyscope=仅 bucket无 returnBody</h4>
<h4>1. putPolicy</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>
<h4>2. sign = HMAC-SHA1(secret, <strong>policyJSON</strong> UTF-8) → encodedSign</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>
<h4>3. policyB64 = urlsafe_base64(putPolicy)</h4>
<pre id="encoded-policy"></pre>
</div>
<div class="step">
<h4>4. uploadToken = AccessKey + ':' + encodedSign + ':' + policyB64</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>
<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></p>
</div>
<div class="panel">
<h3>B. 格式校验</h3>
<h3>格式校验</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>
<p>与 Runner 当前 token<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 ok">格式符合 AccessKey:encodedSign:policyB64</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

@@ -13,11 +13,11 @@
<div class="cards">
<a class="card" href="/config">
<h2>互医配置</h2>
<p>查看 xk-api 云端与本地 .env、Runner 生效值;本地 §2.3.3 生成 uploadToken。</p>
<p>查看 xk-api 云端与本地 .env、Runner 生效值;本地 FileAuth.java 生成 uploadToken。</p>
</a>
<a class="card" href="/fileauth">
<h2>上传凭证</h2>
<p>§2.3.3 upload profilescope=仅 bucket文档黄金样例校验。</p>
<p>FileAuth.java upload profilescope=仅 bucket分步生成与格式校验。</p>
</a>
<a class="card" href="/upload">
<h2>文件上传</h2>

View File

@@ -18,7 +18,7 @@
<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>
<p class="muted-note">FileAuth.java 生产 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>
@@ -33,15 +33,15 @@
</div>
<div id="status-upload" class="panel token-panel">
<h3>B. 本次上传凭证(§2.3.3</h3>
<h3>B. 本次上传凭证(FileAuth.java</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>
<h4>signInputHMAC 输入 = policy JSON</h4>
<pre id="t-sign-input"></pre>
<h4>putPolicy JSON</h4>
<pre id="t-policy"></pre>
<h4>encodedPutPolicy</h4>
<h4>policyB64第三段</h4>
<pre id="t-encoded-policy"></pre>
<h4>encodedSign</h4>
<pre id="t-encoded-sign"></pre>
@@ -68,7 +68,7 @@ async function showRunnerStatus() {
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>'}
el.innerHTML = `uploadToken: ${eff.uploadTokenSet ? '<span class="badge ok">有效</span> ' + esc(eff.uploadTokenPreview) + ' <span class="badge info">' + esc(eff.uploadTokenSource || 'local-fileauth-java') + '</span>' : '<span class="badge err">空</span>'}
· fileBucket: <code>${esc(eff.fileBucket || '-')}</code>
· 上传地址: <code>${esc(eff.fileUploadURL)}</code>`;
} catch (e) {
@@ -97,7 +97,7 @@ function renderUploadTokenInfo(data) {
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-algo').textContent = info.algorithm || 'fileauth-java-upload';
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 || '—';

View File

@@ -113,7 +113,7 @@ func (r *Runner) applyCloudConfig(conf *xkapi.ConfigResponse) {
}
// refreshUploadToken 按 §2.3.3 upload profile 重新生成 uploadTokenscope=仅 bucket
// refreshUploadToken 按 FileAuth.java upload profile 重新生成 uploadTokenscope=仅 bucket
func (r *Runner) refreshUploadToken() error {
@@ -219,7 +219,7 @@ func (r *Runner) ConfigSnapshot() ConfigSnapshot {
UploadToken: r.uploadToken,
UploadTokenSource: "local-ver233-upload",
UploadTokenSource: "local-fileauth-java",
CloudUploadToken: r.cloudUploadToken,

View File

@@ -34,7 +34,7 @@ type Runner struct {
store *db.Store
organ hy.OrganConfig
fileUploadURL string // 实际上传地址(经 forward 或直连政务云)
uploadToken string // 本地按 §2.3.3 生成(默认 prescription.pdf
uploadToken string // 本地按 FileAuth.java 生成
cloudUploadToken string // xk-api 预生成值,仅诊断对比
fileBucket string
fileUploadViaForward bool
@@ -478,7 +478,7 @@ func (r *Runner) CloudUploadToken() string {
}
// UploadTokenSource 说明 uploadToken 生成方式。
func (r *Runner) UploadTokenSource() string { return "local-ver233-upload" }
func (r *Runner) UploadTokenSource() string { return "local-fileauth-java" }
// RegenerateUploadToken 供诊断:用当前凭证重新生成 token与 refreshUploadToken 相同)。
func (r *Runner) RegenerateUploadToken() error {

View File

@@ -9,7 +9,7 @@ import (
"xk-hy-transit-go/internal/hyfile"
)
// UploadTestResult Web 测试上传结果(含本次 §2.3.3 token 分步信息)。
// UploadTestResult Web 测试上传结果(含本次 FileAuth.java token 分步信息)。
type UploadTestResult struct {
FileID string
Filename string
@@ -17,7 +17,7 @@ type UploadTestResult struct {
Parts fileauth.UploadTokenParts
}
// uploadTokenPartsForUpload 生成 §2.3.3 upload profile tokenscope=仅 bucket
// uploadTokenPartsForUpload 生成 FileAuth.java upload profile tokenscope=仅 bucket
func (r *Runner) uploadTokenPartsForUpload() (fileauth.UploadTokenParts, error) {
r.configMu.RLock()
key := strings.TrimSpace(r.hyAppKey)