Initial commit

This commit is contained in:
ywlmac
2026-05-13 10:30:19 +08:00
commit a2a693885b
38 changed files with 5757 additions and 0 deletions
+421
View File
@@ -0,0 +1,421 @@
package zxchainsdk
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/fogleman/gg"
"github.com/skip2/go-qrcode"
xdraw "golang.org/x/image/draw"
)
const (
defaultCertificateBackgroundURL = "https://popiart-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/2026/0511/83034.png"
defaultCertificateLogoURL = "https://popiart-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/2026/0511/83035.png"
defaultCertificateBucketBaseURL = "https://popi-certificate-1386008232.cos.ap-guangzhou.myqcloud.com"
)
var (
ErrMissingCertificateNo = errors.New("missing certificate no")
ErrMissingVerifyURL = errors.New("missing verify url")
)
// HashCertificateRequest 定义 Popi Hash 存证证书生成请求。
type HashCertificateRequest struct {
Title string
CertNo string
UserName string
UserID string
WorkName string
TaskID string
CompletedAt string
EvidenceID string
TxID string
BlockHeight string
FinalHash string
EvidenceHash string
TrustedAt string
VerifyURL string
WorkPreviewURL string
WorkPreviewBytes []byte
BackgroundURL string
BackgroundBytes []byte
LogoURL string
LogoBytes []byte
FontPath string
HTTPClient *http.Client
BucketBaseURL string
}
// HashCertificateResult 保存生成后的证书 PNG 和建议上传路径。
type HashCertificateResult struct {
CertNo string
VerifyURL string
PNG []byte
CertificateImageKey string
CertificateImageURL string
}
// GenerateHashCertificate 按 Figma 模板合成 Popi Hash 存证证书 PNG。
func GenerateHashCertificate(ctx context.Context, req HashCertificateRequest) (*HashCertificateResult, error) {
if req.CertNo == "" {
return nil, ErrMissingCertificateNo
}
if req.VerifyURL == "" {
return nil, ErrMissingVerifyURL
}
if req.Title == "" {
req.Title = "数字作品区块链存证证明"
}
if req.BackgroundURL == "" && len(req.BackgroundBytes) == 0 {
req.BackgroundURL = defaultCertificateBackgroundURL
}
if req.LogoURL == "" && len(req.LogoBytes) == 0 {
req.LogoURL = defaultCertificateLogoURL
}
if req.BucketBaseURL == "" {
req.BucketBaseURL = defaultCertificateBucketBaseURL
}
background, err := loadCertificateImage(ctx, req.HTTPClient, req.BackgroundURL, req.BackgroundBytes)
if err != nil {
return nil, fmt.Errorf("load certificate background: %w", err)
}
logo, err := loadCertificateImage(ctx, req.HTTPClient, req.LogoURL, req.LogoBytes)
if err != nil {
return nil, fmt.Errorf("load certificate logo: %w", err)
}
var workPreview image.Image
if req.WorkPreviewURL != "" || len(req.WorkPreviewBytes) > 0 {
workPreview, err = loadCertificateImage(ctx, req.HTTPClient, req.WorkPreviewURL, req.WorkPreviewBytes)
if err != nil {
return nil, fmt.Errorf("load work preview: %w", err)
}
}
qr, err := newQRCodeImage(req.VerifyURL, 184)
if err != nil {
return nil, fmt.Errorf("generate qr code: %w", err)
}
fontPath, err := resolveCertificateFont(req.FontPath)
if err != nil {
return nil, err
}
dc := gg.NewContext(1369, 1920)
dc.DrawImage(resizeImage(background, 1369, 1920), 0, 0)
dc.DrawImage(resizeImage(logo, 304, 60), 533, 470)
drawTitle(dc, fontPath, req.Title)
drawCertNo(dc, fontPath, req.CertNo)
drawCertificateText(dc, fontPath, req)
drawWorkPreview(dc, workPreview)
dc.DrawImage(resizeImage(qr, 184, 184), 593, 1493)
drawQRCodeText(dc, fontPath)
drawDisclaimer(dc, fontPath)
var buf bytes.Buffer
if err := png.Encode(&buf, dc.Image()); err != nil {
return nil, err
}
imageKey := certificateImageObjectKey(req.EvidenceID, req.CertNo)
return &HashCertificateResult{
CertNo: req.CertNo,
VerifyURL: req.VerifyURL,
PNG: buf.Bytes(),
CertificateImageKey: imageKey,
CertificateImageURL: joinBucketURL(req.BucketBaseURL, imageKey),
}, nil
}
func drawTitle(dc *gg.Context, fontPath, title string) {
_ = dc.LoadFontFace(fontPath, 85)
dc.SetHexColor("#000000")
dc.DrawStringAnchored(title, 684.5, 550, 0.5, 1)
}
func drawCertNo(dc *gg.Context, fontPath, certNo string) {
gradient := gg.NewLinearGradient(331.5, 670, 1037.5, 670)
gradient.AddColorStop(0, color.White)
gradient.AddColorStop(0.15, mustHexColor("#eed595"))
gradient.AddColorStop(0.50142, mustHexColor("#9c6b3c"))
gradient.AddColorStop(0.85, mustHexColor("#eed595"))
gradient.AddColorStop(1, color.White)
dc.SetFillStyle(gradient)
dc.DrawRectangle(331.5, 670, 706, 60)
dc.Fill()
_ = dc.LoadFontFace(fontPath, 25)
dc.SetHexColor("#ffffff")
dc.DrawStringAnchored("编号:"+certNo, 684.5, 700, 0.5, 0.5)
}
func drawCertificateText(dc *gg.Context, fontPath string, req HashCertificateRequest) {
subjectLines := []certificateLine{
{Text: "一、存证主体", Medium: true},
{Text: "申请用户名称:" + req.UserName},
{Text: "用户ID" + req.UserID},
}
drawLines(dc, fontPath, subjectLines, 143, 796, 1083, 49)
summaryLines := []certificateLine{
{Text: "二、存证信息摘要", Medium: true},
{Text: "作品名称:" + req.WorkName},
{Text: "生成任务ID" + req.TaskID},
{Text: "作品完成时间:" + req.CompletedAt},
{Text: "存证ID" + req.EvidenceID},
{Text: `区块链交易ID"` + req.TxID + `"`},
{Text: "区块高度:" + req.BlockHeight},
{Text: `最终产物 Hash"` + req.FinalHash + `"`},
{Text: `证据包 Hash"` + req.EvidenceHash + `"`},
{Text: "可信存证时间:" + req.TrustedAt},
}
drawLines(dc, fontPath, summaryLines, 143, 963, 1083, 49)
}
type certificateLine struct {
Text string
Medium bool
}
func drawLines(dc *gg.Context, fontPath string, lines []certificateLine, x, y, maxWidth, lineStep float64) {
for i, line := range lines {
size := fitFontSize(dc, fontPath, line.Text, 25, 18, maxWidth)
_ = dc.LoadFontFace(fontPath, size)
dc.SetHexColor("#000000")
dc.DrawStringAnchored(line.Text, x, y+float64(i)*lineStep, 0, 1)
}
}
func drawWorkPreview(dc *gg.Context, preview image.Image) {
dc.Push()
dc.RotateAbout(gg.Radians(4.33), 959, 950)
gradient := gg.NewLinearGradient(727, 823, 1190, 1078)
gradient.AddColorStop(0.10985, mustHexColor("#eed594"))
gradient.AddColorStop(0.89015, mustHexColor("#b78e5a"))
dc.SetFillStyle(gradient)
dc.DrawRectangle(735, 825, 463, 255)
dc.Fill()
dc.Pop()
dc.SetHexColor("#ffffff")
dc.DrawRectangle(717, 813, 488, 273)
dc.Fill()
if preview != nil {
fitted := containImage(preview, 488, 273)
dc.DrawImageAnchored(fitted, 961, 950, 0.5, 0.5)
}
dc.SetHexColor("#eed594")
dc.SetLineWidth(2)
dc.DrawRectangle(717, 813, 488, 273)
dc.Stroke()
}
func drawQRCodeText(dc *gg.Context, fontPath string) {
_ = dc.LoadFontFace(fontPath, 25)
dc.SetHexColor("#000000")
dc.DrawStringAnchored("扫码查看在线证书", 684.5, 1715, 0.5, 1)
}
func drawDisclaimer(dc *gg.Context, fontPath string) {
_ = dc.LoadFontFace(fontPath, 18)
dc.SetHexColor("#999999")
text := "*本凭证为电子数据存证凭证,不等同于官方著作权登记证书。作品原创性、素材合法性及第三方授权责任由申请用户承担。"
dc.DrawStringAnchored(text, 194, 1853.5, 0, 0.5)
}
func fitFontSize(dc *gg.Context, fontPath, text string, preferred, minimum, maxWidth float64) float64 {
for size := preferred; size >= minimum; size-- {
if err := dc.LoadFontFace(fontPath, size); err != nil {
return preferred
}
width, _ := dc.MeasureString(text)
if width <= maxWidth {
return size
}
}
return minimum
}
func newQRCodeImage(content string, size int) (image.Image, error) {
qr, err := qrcode.New(content, qrcode.Medium)
if err != nil {
return nil, err
}
qr.DisableBorder = true
return qr.Image(size), nil
}
func loadCertificateImage(ctx context.Context, client *http.Client, imageURL string, inline []byte) (image.Image, error) {
var data []byte
var err error
if len(inline) > 0 {
data = inline
} else {
if imageURL == "" {
return nil, errors.New("missing image url")
}
data, err = fetchBytes(ctx, client, imageURL)
if err != nil {
return nil, err
}
}
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
if format == "jpeg" {
return img, nil
}
return img, nil
}
func fetchBytes(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func resizeImage(src image.Image, width, height int) image.Image {
dst := image.NewRGBA(image.Rect(0, 0, width, height))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
return dst
}
func containImage(src image.Image, boxWidth, boxHeight int) image.Image {
bounds := src.Bounds()
sourceWidth := bounds.Dx()
sourceHeight := bounds.Dy()
if sourceWidth == 0 || sourceHeight == 0 {
return image.NewRGBA(image.Rect(0, 0, boxWidth, boxHeight))
}
scale := min(float64(boxWidth)/float64(sourceWidth), float64(boxHeight)/float64(sourceHeight))
width := max(1, int(float64(sourceWidth)*scale))
height := max(1, int(float64(sourceHeight)*scale))
dst := image.NewRGBA(image.Rect(0, 0, boxWidth, boxHeight))
x := (boxWidth - width) / 2
y := (boxHeight - height) / 2
target := image.Rect(x, y, x+width, y+height)
xdraw.CatmullRom.Scale(dst, target, src, bounds, xdraw.Over, nil)
return dst
}
func resolveCertificateFont(explicitPath string) (string, error) {
if explicitPath != "" {
if _, err := os.Stat(explicitPath); err != nil {
return "", err
}
if _, err := gg.LoadFontFace(explicitPath, 25); err != nil {
return "", fmt.Errorf("load certificate font %s: %w", explicitPath, err)
}
return explicitPath, nil
}
candidates := []string{
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Microsoft Sans Serif.ttf",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/Library/Fonts/Arial Unicode.ttf",
"/usr/share/fonts/opentype/source-han-sans/SourceHanSansCN-Regular.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err != nil {
continue
}
if _, err := gg.LoadFontFace(candidate, 25); err == nil {
return candidate, nil
}
}
return "", errors.New("missing certificate font, set HashCertificateRequest.FontPath to a Chinese TTF/TTC font")
}
func certificateImageObjectKey(evidenceID, certNo string) string {
dirName := evidenceID
if dirName == "" {
dirName = certNo
}
dirName = strings.Trim(dirName, "/")
if dirName == "" {
dirName = "unknown"
}
return path.Join("certificates", dirName, "certificate.png")
}
func joinBucketURL(bucketBaseURL, objectKey string) string {
if bucketBaseURL == "" || objectKey == "" {
return ""
}
escaped := strings.TrimLeft(objectKey, "/")
u, err := url.Parse(strings.TrimRight(bucketBaseURL, "/"))
if err != nil {
return strings.TrimRight(bucketBaseURL, "/") + "/" + escaped
}
u.Path = path.Join(u.Path, escaped)
return u.String()
}
func mustHexColor(hex string) color.Color {
c, err := parseHexColor(hex)
if err != nil {
panic(err)
}
return c
}
func parseHexColor(hex string) (color.Color, error) {
hex = strings.TrimPrefix(hex, "#")
if len(hex) != 6 {
return nil, fmt.Errorf("invalid hex color %q", hex)
}
var r, g, b uint8
if _, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b); err != nil {
return nil, err
}
return color.RGBA{R: r, G: g, B: b, A: 255}, nil
}
func init() {
image.RegisterFormat("jpeg", "\xff\xd8", jpeg.Decode, jpeg.DecodeConfig)
image.RegisterFormat("png", "\x89PNG\r\n\x1a\n", png.Decode, png.DecodeConfig)
}
+412
View File
@@ -0,0 +1,412 @@
package zxchainsdk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/tencentyun/cos-go-sdk-v5"
"wallet_sdk"
)
var (
ErrMissingCertificateStorage = errors.New("missing certificate storage")
ErrMissingCOSSecretID = errors.New("missing cos secret id")
ErrMissingCOSSecretKey = errors.New("missing cos secret key")
)
// downloadAndHashFromURL 从指定的 URL 下载内容并计算 SM3 哈希。
func downloadAndHashFromURL(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download from URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download from URL, status: %d", resp.StatusCode)
}
var buf bytes.Buffer
_, err = buf.ReadFrom(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
hash, err := wallet_sdk.SM3Hash(buf.Bytes())
if err != nil {
return "", fmt.Errorf("failed to calculate SM3 hash: %w", err)
}
return hash, nil
}
// SimpleHashAttestationCertificateRequest 是简化的一站式证书请求,只需要最少的业务字段。
type SimpleHashAttestationCertificateRequest struct {
UserName string `json:"user_name"` // UserName 表示申请用户名称
UserID string `json:"user_id"` // UserID 表示申请用户 ID
WorkName string `json:"work_name"` // WorkName 表示作品名称
TaskID string `json:"task_id"` // TaskID 表示生成任务 ID
CompletedAt string `json:"completed_at"` // CompletedAt 表示作品完成时间
WorkPreviewURL string `json:"work_preview_url"` // WorkPreviewURL 表示作品预览图 URL
EvidenceHash string `json:"evidence_hash,omitempty"` // EvidenceHash 表示作品文件哈希,可选。如果不传,SDK 将从 WorkPreviewURL 下载并计算哈希
// 可选:如果不传,将使用内置默认值
CertificateStorage *COSCertificateStorage `json:"certificate_storage,omitempty"` // CertificateStorage 表示 COS 上传配置
}
// HashAttestationCertificateRequest 是业务层发起 Hash 存证并生成证书的一站式请求。
type HashAttestationCertificateRequest struct {
HashAttestationRequest
UserName string `json:"user_name"` // UserName 表示申请用户名称
UserID string `json:"user_id"` // UserID 表示申请用户 ID
WorkName string `json:"work_name"` // WorkName 表示作品名称
TaskID string `json:"task_id"` // TaskID 表示生成任务 ID
CompletedAt string `json:"completed_at"` // CompletedAt 表示作品完成时间
WorkPreviewURL string `json:"work_preview_url"` // WorkPreviewURL 表示作品预览图 URL
WorkPreviewBytes []byte `json:"-"` // WorkPreviewBytes 表示作品预览图二进制数据
EvidenceHash string `json:"evidence_hash"` // EvidenceHash 表示作品文件哈希
BackgroundURL string `json:"background_url"` // BackgroundURL 表示证书背景图 URL
LogoURL string `json:"logo_url"` // LogoURL 表示证书 logo URL
FontPath string `json:"font_path"` // FontPath 表示用于绘制证书的字体路径
HTTPClient *http.Client `json:"-"` // HTTPClient 表示证书生成时使用的 HTTP 客户端
CertBucketBase string `json:"cert_bucket_base"`// CertBucketBase 表示证书对象 URL 的基础 Bucket URL
CertificateStorage *COSCertificateStorage `json:"certificate_storage"` // CertificateStorage 表示 COS 上传配置
}
// COSCertificateStorage 定义 SDK 上传 Popi 证书到 COS 所需的配置。
type COSCertificateStorage struct {
BucketURL string `json:"bucket_url"` // BucketURL 表示 COS 存储桶地址
PublicBaseURL string `json:"public_base_url"` // PublicBaseURL 表示公开访问的 Bucket 基础 URL
SecretID string `json:"secret_id"` // SecretID 表示 COS 访问密钥 ID
SecretKey string `json:"secret_key"` // SecretKey 表示 COS 访问密钥
SessionToken string `json:"session_token"` // SessionToken 表示 COS 临时会话令牌
ObjectKey string `json:"object_key"` // ObjectKey 表示证书对象完整 Key
ObjectKeyPrefix string `json:"object_key_prefix"` // ObjectKeyPrefix 表示证书对象 Key 前缀
}
// HashAttestationCertificateResult 是业务接口可直接返回或落库的证书结果快照。
type HashAttestationCertificateResult struct {
UserName string `json:"user_name"` // UserName 表示申请用户名称
UserID string `json:"user_id"` // UserID 表示申请用户 ID
WorkName string `json:"work_name"` // WorkName 表示作品名称
TaskID string `json:"task_id"` // TaskID 表示生成任务 ID
CompletedAt string `json:"completed_at"` // CompletedAt 表示作品完成时间
WorkPreviewURL string `json:"work_preview_url,omitempty"` // WorkPreviewURL 表示作品预览图 URL
EvidenceHash string `json:"evidence_hash,omitempty"` // EvidenceHash 表示作品哈希
ReceiptID string `json:"receipt_id,omitempty"` // ReceiptID 表示官方 EvSave 返回的 evId
RequestID string `json:"request_id,omitempty"` // RequestID 表示官方 EvSave 返回的 txId
BlockHeight int64 `json:"block_height,omitempty"` // BlockHeight 表示上链区块高度
TxTime string `json:"tx_time,omitempty"` // TxTime 表示上链时间
SM3Hash string `json:"sm3_hash,omitempty"` // SM3Hash 表示本次上链内容的 SM3 哈希
PopiCertificateCertNo string `json:"popi_certificate_cert_no,omitempty"` // PopiCertificateCertNo 表示 popiart 证书编号
PopiCertificateVerifyURL string `json:"popi_certificate_verify_url,omitempty"` // PopiCertificateVerifyURL 表示 popiart 证书二维码映射地址
PopiCertificateCertificateImageURL string `json:"popi_certificate_certificate_image_url,omitempty"` // PopiCertificateCertificateImageURL 表示 popiart 证书地址
ZXChainCertURL string `json:"zxchain_cert_url"` // ZXChainCertURL 表示至信链官方证书 URL
Attestation *AttestationResult `json:"attestation,omitempty"` // Attestation 表示链上存证返回结果
PopiCertificate PopiCertificateFields `json:"popi_certificate,omitempty"` // PopiCertificate 表示 Popi 证书字段
}
// PopiCertificateFields 定义 Popi 自定义证书字段。
type PopiCertificateFields struct {
CertNo string `json:"cert_no"` // CertNo 表示证书编号
VerifyURL string `json:"verify_url"` // VerifyURL 表示证书核验 URL
CertificateImageKey string `json:"certificate_image_key"` // CertificateImageKey 表示证书图片对象 Key
CertificateImageURL string `json:"certificate_image_url"` // CertificateImageURL 表示证书图片公开 URL
}
type evidencePackage struct {
UserName string `json:"user_name"` // UserName 表示申请用户名称
UserID string `json:"user_id"` // UserID 表示申请用户 ID
WorkName string `json:"work_name"` // WorkName 表示作品名称
TaskID string `json:"task_id"` // TaskID 表示生成任务 ID
CompletedAt string `json:"completed_at"` // CompletedAt 表示作品完成时间
WorkPreviewURL string `json:"work_preview_url"` // WorkPreviewURL 表示作品预览图 URL
WorkFileHash string `json:"work_file_hash"` // WorkFileHash 表示作品文件哈希
}
func buildEvidencePackage(req HashAttestationCertificateRequest) ([]byte, error) {
workFileHash := firstNonEmpty(req.EvidenceHash, req.HashAttestationRequest.Hash)
if workFileHash == "" {
data := req.HashAttestationRequest.ContentBytes
if len(data) == 0 && req.HashAttestationRequest.Content != "" {
data = []byte(req.HashAttestationRequest.Content)
}
if len(data) > 0 {
hash, err := wallet_sdk.SM3Hash(data)
if err != nil {
return nil, err
}
workFileHash = hash
}
}
if workFileHash == "" {
return nil, errors.New("missing work file hash for evidence package")
}
pkg := evidencePackage{
UserName: req.UserName,
UserID: req.UserID,
WorkName: req.WorkName,
TaskID: req.TaskID,
CompletedAt: req.CompletedAt,
WorkPreviewURL: req.WorkPreviewURL,
WorkFileHash: workFileHash,
}
return json.Marshal(pkg)
}
// CreateSimpleHashAttestationCertificate 简化的一站式证书生成方法,只需要最少的业务字段。
func (c *SDKClient) CreateSimpleHashAttestationCertificate(ctx context.Context, req SimpleHashAttestationCertificateRequest) (*HashAttestationCertificateResult, error) {
// 如果没有提供 EvidenceHash,从 WorkPreviewURL 下载并计算哈希
evidenceHash := req.EvidenceHash
if evidenceHash == "" {
if req.WorkPreviewURL == "" {
return nil, errors.New("either EvidenceHash or WorkPreviewURL must be provided")
}
hash, err := downloadAndHashFromURL(ctx, req.WorkPreviewURL)
if err != nil {
return nil, fmt.Errorf("failed to download and hash from WorkPreviewURL: %w", err)
}
evidenceHash = hash
}
// 转换为完整的请求结构体,使用默认值
fullReq := HashAttestationCertificateRequest{
HashAttestationRequest: HashAttestationRequest{
BizID: req.TaskID, // 使用 TaskID 作为 BizID
Scene: "simple-popi-work-hash",
},
UserName: req.UserName,
UserID: req.UserID,
WorkName: req.WorkName,
TaskID: req.TaskID,
CompletedAt: req.CompletedAt,
WorkPreviewURL: req.WorkPreviewURL,
EvidenceHash: evidenceHash,
CertificateStorage: req.CertificateStorage,
}
// 如果没有传 CertificateStorage,使用默认的
if fullReq.CertificateStorage == nil {
fullReq.CertificateStorage = &COSCertificateStorage{
BucketURL: defaultCertificateBucketBaseURL,
PublicBaseURL: defaultCertificateBucketBaseURL,
SecretID: "", // 需要业务侧传入
SecretKey: "", // 需要业务侧传入
}
}
return c.CreateHashAttestationCertificate(ctx, fullReq)
}
// CreateHashAttestationCertificate 完成 Hash 存证、至信链证书查询、Popi 证书生成和上传。
func (c *SDKClient) CreateHashAttestationCertificate(ctx context.Context, req HashAttestationCertificateRequest) (*HashAttestationCertificateResult, error) {
evidenceBytes, err := buildEvidencePackage(req)
if err != nil {
return nil, err
}
req.HashAttestationRequest.ContentBytes = evidenceBytes
req.HashAttestationRequest.Content = ""
attestation, err := c.CreateHashAttestation(ctx, req.HashAttestationRequest)
if err != nil {
return nil, err
}
zxCert, err := c.GetHashCert(ctx, attestation.ReceiptID)
if err != nil {
return nil, err
}
certNo := NewHashCertificateNo(time.Now(), req.UserID)
storage := req.CertificateStorage
objectKey := certificateImageObjectKey(attestation.ReceiptID, certNo)
if storage != nil && storage.ObjectKeyPrefix != "" {
objectKey = certificateObjectKeyWithPrefix(storage.ObjectKeyPrefix, attestation.ReceiptID, certNo)
}
if storage != nil && storage.ObjectKey != "" {
objectKey = strings.TrimLeft(storage.ObjectKey, "/")
}
certificateImageURL := joinBucketURL(firstNonEmpty(req.CertBucketBase, storagePublicBaseURL(storage), defaultCertificateBucketBaseURL), objectKey)
verifyURL := certificateImageURL
cert, err := GenerateHashCertificate(ctx, HashCertificateRequest{
Title: "数字作品区块链存证证明",
CertNo: certNo,
UserName: req.UserName,
UserID: req.UserID,
WorkName: req.WorkName,
TaskID: req.TaskID,
CompletedAt: req.CompletedAt,
EvidenceID: attestation.ReceiptID,
TxID: attestation.RequestID,
BlockHeight: fmt.Sprint(attestation.BlockHeight),
FinalHash: attestation.SM3Hash,
EvidenceHash: req.EvidenceHash,
TrustedAt: attestation.TxTime,
VerifyURL: verifyURL,
WorkPreviewURL: req.WorkPreviewURL,
WorkPreviewBytes: req.WorkPreviewBytes,
BackgroundURL: req.BackgroundURL,
LogoURL: req.LogoURL,
FontPath: req.FontPath,
HTTPClient: req.HTTPClient,
BucketBaseURL: firstNonEmpty(req.CertBucketBase, storagePublicBaseURL(storage)),
})
if err != nil {
return nil, err
}
if storage != nil && storage.ObjectKey == "" {
storageCopy := *storage
storageCopy.ObjectKey = objectKey
storage = &storageCopy
}
certURL, certKey, err := UploadCertificatePNGToCOS(ctx, cert.PNG, cert.CertificateImageKey, storage)
if err != nil {
return nil, err
}
if certURL != "" {
cert.CertificateImageURL = certURL
}
if certKey != "" {
cert.CertificateImageKey = certKey
}
return &HashAttestationCertificateResult{
UserName: req.UserName,
UserID: req.UserID,
WorkName: req.WorkName,
TaskID: req.TaskID,
CompletedAt: req.CompletedAt,
WorkPreviewURL: req.WorkPreviewURL,
EvidenceHash: req.EvidenceHash,
ReceiptID: attestation.ReceiptID,
RequestID: attestation.RequestID,
BlockHeight: attestation.BlockHeight,
TxTime: attestation.TxTime,
SM3Hash: attestation.SM3Hash,
PopiCertificateCertNo: cert.CertNo,
PopiCertificateVerifyURL: cert.VerifyURL,
PopiCertificateCertificateImageURL: cert.CertificateImageURL,
ZXChainCertURL: zxCert.CertURL,
Attestation: attestation,
PopiCertificate: PopiCertificateFields{
CertNo: cert.CertNo,
VerifyURL: cert.VerifyURL,
CertificateImageKey: cert.CertificateImageKey,
CertificateImageURL: cert.CertificateImageURL,
},
}, nil
}
// BuildCertificateVerifyURL 生成二维码里使用的 Popi 在线核验 URL。
func BuildCertificateVerifyURL(baseURL, evidenceID string) string {
if baseURL == "" {
baseURL = "https://popi.art/cert/verify"
}
return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(evidenceID)
}
// NewHashCertificateNo 生成证书编号,格式为 yyyyMMddHHmmss + 用户ID。
func NewHashCertificateNo(t time.Time, userID string) string {
return t.Format("20060102150405") + userID
}
// UploadCertificatePNGToCOS 上传 Popi 证书 PNG 到 COS,并返回公开 URL 和对象 Key。
func UploadCertificatePNGToCOS(ctx context.Context, pngBytes []byte, defaultObjectKey string, storage *COSCertificateStorage) (string, string, error) {
if storage == nil {
return "", "", ErrMissingCertificateStorage
}
if storage.SecretID == "" {
return "", "", ErrMissingCOSSecretID
}
if storage.SecretKey == "" {
return "", "", ErrMissingCOSSecretKey
}
bucketURL := firstNonEmpty(storage.BucketURL, defaultCertificateBucketBaseURL)
parsed, err := url.Parse(bucketURL)
if err != nil {
return "", "", err
}
objectKey := strings.TrimLeft(firstNonEmpty(storage.ObjectKey, defaultObjectKey), "/")
if objectKey == "" {
return "", "", errors.New("missing certificate object key")
}
client := cos.NewClient(&cos.BaseURL{BucketURL: parsed}, &http.Client{
Transport: &cos.AuthorizationTransport{
SecretID: storage.SecretID,
SecretKey: storage.SecretKey,
SessionToken: storage.SessionToken,
},
})
_, err = client.Object.Put(ctx, objectKey, bytes.NewReader(pngBytes), &cos.ObjectPutOptions{
ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
ContentType: "image/png",
ContentLength: len(pngBytes),
CacheControl: "public, max-age=31536000, immutable",
},
})
if err != nil {
return "", "", err
}
publicBaseURL := firstNonEmpty(storage.PublicBaseURL, bucketURL)
return joinBucketURL(publicBaseURL, objectKey), objectKey, nil
}
func storagePublicBaseURL(storage *COSCertificateStorage) string {
if storage == nil {
return ""
}
return firstNonEmpty(storage.PublicBaseURL, storage.BucketURL)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func certificateObjectKeyWithPrefix(prefix, evidenceID, certNo string) string {
dirName := evidenceID
if dirName == "" {
dirName = certNo
}
if prefix == "" {
prefix = "certificates"
}
return path.Join(prefix, strings.Trim(dirName, "/"), "certificate.png")
}
+24
View File
@@ -0,0 +1,24 @@
package zxchainsdk
import "context"
// Client 定义可复用的至信链存证 SDK 能力。
type Client interface {
// CreateHashAttestation 发起 Hash 存证。
CreateHashAttestation(ctx context.Context, req HashAttestationRequest) (*AttestationResult, error)
// QueryHashAttestation 查询 Hash 存证链上记录。
QueryHashAttestation(ctx context.Context, req HashAttestationQueryRequest) (*HashAttestationQueryResult, error)
// CreateKVAttestation 发起 KV 存证。
CreateKVAttestation(ctx context.Context, req KVAttestationRequest) (*AttestationResult, error)
// GetHashCert 根据 Hash 存证 evId 获取官方证书地址。
GetHashCert(ctx context.Context, evID string) (*HashCertResult, error)
// CreateHashAttestationCertificate 发起 Hash 存证,并生成 Popi 自定义证书。
CreateHashAttestationCertificate(ctx context.Context, req HashAttestationCertificateRequest) (*HashAttestationCertificateResult, error)
// CreateSimpleHashAttestationCertificate 简化的一站式证书生成方法,只需要最少的业务字段。
CreateSimpleHashAttestationCertificate(ctx context.Context, req SimpleHashAttestationCertificateRequest) (*HashAttestationCertificateResult, error)
}
+15
View File
@@ -0,0 +1,15 @@
package zxchainsdk
import "errors"
var (
ErrMissingBizID = errors.New("missing biz_id")
ErrMissingContent = errors.New("missing content")
ErrMissingEvID = errors.New("missing ev_id")
ErrMissingHash = errors.New("missing hash")
ErrMissingScene = errors.New("missing scene")
ErrMissingKVFields = errors.New("missing kv fields")
ErrMissingFileName = errors.New("missing file_name")
ErrMissingQueryKey = errors.New("missing ev_id, hash or tx_id")
ErrEmptyAttestation = errors.New("empty attestation request")
)
+64
View File
@@ -0,0 +1,64 @@
package zxchainsdk
// HashAttestationRequest 定义 Hash 存证请求模型。
type HashAttestationRequest struct {
BizID string // BizID 表示业务主键。
Scene string // Scene 表示业务场景,会作为官方 EvSave 的 extendInfo。
Content string // Content 表示待存证的文本内容。
ContentBytes []byte // ContentBytes 表示待存证的二进制内容,优先级高于 Content。
Hash string // Hash 表示业务侧传入的哈希值,SDK 实际上链仍按内容计算 SM3。
FileName string // FileName 表示原始文件名。
FileSize int64 // FileSize 表示原始文件大小。
ContentType string // ContentType 表示原始内容类型。
StorageURI string // StorageURI 表示业务侧保存原始文件的位置。
CreatedAt string // CreatedAt 表示业务侧创建存证请求的时间。
Metadata map[string]string // Metadata 表示附加元数据。
}
// HashAttestationQueryRequest 定义 Hash 存证取证查询请求模型。
type HashAttestationQueryRequest struct {
EvID string // EvID 表示 Hash 存证回执中的 evId。
Hash string // Hash 表示本次存证内容的 SM3 哈希。
TxID string // TxID 表示 Hash 存证回执中的 txId。
}
// KVAttestationRequest 定义 KV 存证请求模型。
type KVAttestationRequest struct {
BizID string // BizID 表示业务主键,同时作为链上 KV key。
Scene string // Scene 表示业务场景。
Fields map[string]string // Fields 表示待上链的键值字段集合。
Metadata map[string]string // Metadata 表示附加元数据。
}
// AttestationResult 定义存证结果模型。
type AttestationResult struct {
ReceiptID string // ReceiptID 表示链上回执编号,Hash 存证时对应 evId。
RequestID string // RequestID 表示请求追踪编号,通常对应 txId。
Status string // Status 表示存证状态。
RawResponse []byte // RawResponse 保存官方 SDK 原始响应内容。
ExternalRefID string // ExternalRefID 表示外部引用标识。
BlockHeight int64 `json:"blockHeight,omitempty"` // BlockHeight 表示上链区块高度。
TxTime string `json:"txTime,omitempty"` // TxTime 表示官方返回的上链时间。
FileName string `json:"fileName,omitempty"` // FileName 表示原始文件名。
FileSize int64 `json:"fileSize,omitempty"` // FileSize 表示原始文件大小。
ContentType string `json:"contentType,omitempty"` // ContentType 表示原始内容类型。
StorageURI string `json:"storageUri,omitempty"` // StorageURI 表示业务侧保存原始文件的位置。
SM3Hash string `json:"sm3Hash,omitempty"` // SM3Hash 表示本次上链内容的 SM3 哈希。
CreatedAt string `json:"createdAt,omitempty"` // CreatedAt 表示业务侧创建存证请求的时间。
}
// HashAttestationQueryResult 定义 Hash 存证取证查询结果。
type HashAttestationQueryResult struct {
EvID string `json:"evId,omitempty"` // EvID 表示 Hash 存证回执中的 evId。
TxID string `json:"txId,omitempty"` // TxID 表示 Hash 存证回执中的 txId。
BlockHeight int64 `json:"blockHeight,omitempty"` // BlockHeight 表示上链区块高度。
TxTime string `json:"txTime,omitempty"` // TxTime 表示官方返回的上链时间。
ExtendInfo string `json:"extendInfo,omitempty"` // ExtendInfo 表示存证时传入的业务场景信息。
RawResponse []byte `json:"-"` // RawResponse 保存官方 SDK 原始响应内容。
}
// HashCertResult 定义 Hash 存证证书查询结果。
type HashCertResult struct {
EvID string // EvID 表示 Hash 存证回执中的 evId。
CertURL string // CertURL 表示官方证书访问地址。
}
+129
View File
@@ -0,0 +1,129 @@
package zxchainsdk
import (
"context"
"encoding/json"
"time"
"wallet_sdk"
"zxsdk"
)
// SDKClient 基于官方至信链 Go SDK 实现 Client。
type SDKClient struct {
client *zxsdk.ZxChainClient
}
// NewSDKClient 使用官方凭据创建至信链 SDK 客户端。
func NewSDKClient(secretID, secretKey, privateKey string) (*SDKClient, error) {
client, err := zxsdk.NewZxChainClient(secretID, secretKey, privateKey)
if err != nil {
return nil, err
}
return &SDKClient{client: client}, nil
}
// CreateHashAttestation 使用官方 EvSave 发起 Hash 存证。
func (c *SDKClient) CreateHashAttestation(_ context.Context, req HashAttestationRequest) (*AttestationResult, error) {
data := req.ContentBytes
if len(data) == 0 && req.Content != "" {
data = []byte(req.Content)
}
if len(data) == 0 {
return nil, ErrMissingContent
}
sm3Hash, err := wallet_sdk.SM3Hash(data)
if err != nil {
return nil, err
}
createdAt := req.CreatedAt
if createdAt == "" {
createdAt = time.Now().Format(time.RFC3339)
}
resp, err := c.client.EvSave(data, req.Scene)
if err != nil {
return nil, err
}
raw, _ := json.Marshal(resp)
return &AttestationResult{
ReceiptID: resp.EvId, // ReceiptID 保存官方 EvSave 返回的 evId。
RequestID: resp.TxId, // RequestID 保存官方 EvSave 返回的 txId。
Status: "success", // Status 表示本次 SDK 调用成功。
RawResponse: raw, // RawResponse 保存官方 SDK 原始响应 JSON。
ExternalRefID: req.BizID, // ExternalRefID 关联业务侧传入的 BizID。
BlockHeight: resp.BlockHeight, // BlockHeight 表示上链区块高度。
TxTime: resp.TxTime, // TxTime 表示官方返回的上链时间。
FileName: req.FileName, // FileName 回传业务侧传入的文件名。
FileSize: int64(len(data)), // FileSize 表示本次参与 Hash 存证的内容字节数。
ContentType: req.ContentType, // ContentType 回传业务侧传入的内容类型。
StorageURI: req.StorageURI, // StorageURI 回传业务侧保存原始文件的位置。
SM3Hash: sm3Hash, // SM3Hash 表示本次上链内容的 SM3 哈希。
CreatedAt: createdAt, // CreatedAt 表示业务侧传入或 SDK 自动生成的创建时间。
}, nil
}
// QueryHashAttestation 使用官方 EvQuery 查询 Hash 存证链上记录。
func (c *SDKClient) QueryHashAttestation(_ context.Context, req HashAttestationQueryRequest) (*HashAttestationQueryResult, error) {
if req.EvID == "" && req.Hash == "" && req.TxID == "" {
return nil, ErrMissingQueryKey
}
resp, err := c.client.EvQuery(req.EvID, req.Hash, req.TxID)
if err != nil {
return nil, err
}
raw, _ := json.Marshal(resp)
return &HashAttestationQueryResult{
EvID: resp.EvId, // EvID 表示查询到的 Hash 存证 evId。
TxID: resp.TxId, // TxID 表示查询到的链上交易 txId。
BlockHeight: resp.BlockHeight, // BlockHeight 表示查询到的上链区块高度。
TxTime: resp.TxTime, // TxTime 表示查询到的上链时间。
ExtendInfo: resp.ExtendInfo, // ExtendInfo 表示存证时传入的业务场景信息。
RawResponse: raw, // RawResponse 保存官方 SDK 原始响应 JSON。
}, nil
}
// CreateKVAttestation 使用官方 KvSave 发起 KV 存证。
func (c *SDKClient) CreateKVAttestation(_ context.Context, req KVAttestationRequest) (*AttestationResult, error) {
kvValueBytes, _ := json.Marshal(req.Fields)
err := c.client.KvSave(req.BizID, string(kvValueBytes), req.Scene)
if err != nil {
return nil, err
}
resp, err := c.client.KvQuery(req.BizID, true)
if err != nil {
return nil, err
}
raw, _ := json.Marshal(resp)
return &AttestationResult{
ReceiptID: resp.TxId, // ReceiptID 保存 KV 查询结果中的 txId。
RequestID: resp.TxId, // RequestID 保存 KV 查询结果中的 txId。
Status: "success", // Status 表示本次 SDK 调用成功。
RawResponse: raw, // RawResponse 保存官方 SDK 原始响应 JSON。
ExternalRefID: resp.KvKey, // ExternalRefID 保存链上的 kvKey。
}, nil
}
// GetHashCert 根据 evId 获取官方证书地址。
func (c *SDKClient) GetHashCert(_ context.Context, evID string) (*HashCertResult, error) {
if evID == "" {
return nil, ErrMissingEvID
}
certURL, err := c.client.GetEvCert(evID)
if err != nil {
return nil, err
}
return &HashCertResult{
EvID: evID, // EvID 表示 Hash 存证回执中的 evId。
CertURL: certURL, // CertURL 表示官方证书访问地址。
}, nil
}