至信SDK
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

421 lines
12 KiB

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