422 lines
12 KiB
Go
422 lines
12 KiB
Go
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)
|
||
}
|