fix(payment): 支付插件 - 修复跨平台构建与运行时缓存 (#402)
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
plugin-packages/*.sh text eol=lf
|
||||
19
.github/workflows/quality.yml
vendored
19
.github/workflows/quality.yml
vendored
@@ -27,6 +27,25 @@ jobs:
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
|
||||
payment-plugins:
|
||||
name: Payment plugin artifacts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify canonical payment packages
|
||||
env:
|
||||
PAYMENT_PLUGIN_SMOKE_TEST: "1"
|
||||
run: sh plugin-packages/verify-payment-packages.sh linux amd64
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build and execute payment packages for every image platform
|
||||
run: >-
|
||||
docker buildx build
|
||||
--platform linux/amd64,linux/arm64
|
||||
--target payment-package-smoke
|
||||
--file backend/Dockerfile
|
||||
.
|
||||
|
||||
web:
|
||||
name: Web checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 修复微信支付与支付宝插件被宿主平台二进制污染的问题,按 Docker 目标平台确定性生成并重打包 Linux amd64/arm64 provider,增加内容寻址的原子运行时缓存、可执行文件诊断和 CI 架构冒烟校验。
|
||||
|
||||
## v1.2.5
|
||||
|
||||
- 素材库支持用户自定义分类、分页筛选、网格密度切换、图片完整预览与单张缩放拖拽。
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS payment-package-build
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
ENV GOPROXY=$GOPROXY
|
||||
|
||||
RUN apk add --no-cache nodejs unzip zip
|
||||
|
||||
WORKDIR /src
|
||||
COPY backend/go.mod backend/go.sum ./backend/
|
||||
RUN --mount=type=cache,target=/go/pkg/mod cd backend && go mod download
|
||||
COPY backend ./backend
|
||||
COPY plugin-packages ./plugin-packages
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build set -eu; \
|
||||
test -n "$TARGETOS"; \
|
||||
test -n "$TARGETARCH"; \
|
||||
PAYMENT_PLUGIN_GOOS="$TARGETOS" \
|
||||
PAYMENT_PLUGIN_GOARCH="$TARGETARCH" \
|
||||
PAYMENT_PLUGIN_CGO_ENABLED=0 \
|
||||
./plugin-packages/build-packages.sh --payments-only; \
|
||||
sh ./plugin-packages/verify-payment-packages.sh "$TARGETOS" "$TARGETARCH"
|
||||
|
||||
FROM --platform=$TARGETPLATFORM alpine:3.22 AS payment-package-smoke
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
RUN apk add --no-cache unzip
|
||||
COPY --from=payment-package-build /src/plugin-packages /app/plugin-packages
|
||||
RUN PAYMENT_PLUGIN_SMOKE_TEST=1 \
|
||||
sh /app/plugin-packages/verify-payment-packages.sh "$TARGETOS" "$TARGETARCH"
|
||||
|
||||
FROM golang:1.25-alpine AS backend-build
|
||||
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
@@ -37,7 +74,7 @@ RUN mkdir -p /data && chown -R app:app /data
|
||||
COPY --from=backend-build /out/infinite-canvas-backend /usr/local/bin/infinite-canvas-backend
|
||||
COPY --from=backend-build /out/migrate-schema /usr/local/bin/migrate-schema
|
||||
COPY --from=backend-build /out/migrate-sqlite-postgres /usr/local/bin/migrate-sqlite-postgres
|
||||
COPY plugin-packages /app/plugin-packages
|
||||
COPY --from=payment-package-smoke /app/plugin-packages /app/plugin-packages
|
||||
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -7,16 +7,23 @@ package payment
|
||||
// transaction; the process only translates a payment provider protocol.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"debug/elf"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -36,7 +43,7 @@ func NewRPCProvider(descriptor Descriptor, packageDir, entry string) (*RPCProvid
|
||||
if strings.TrimSpace(descriptor.ID) == "" || strings.TrimSpace(descriptor.PluginID) == "" {
|
||||
return nil, errors.New("payment plugin descriptor requires id and plugin id")
|
||||
}
|
||||
if filepath.IsAbs(entry) || filepath.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
|
||||
if filepath.IsAbs(entry) || path.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
|
||||
return nil, errors.New("payment plugin entry must be relative to backend/")
|
||||
}
|
||||
if strings.TrimSpace(packageDir) == "" {
|
||||
@@ -69,11 +76,18 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
|
||||
if p == nil || strings.TrimSpace(p.command) == "" {
|
||||
return errors.New("payment plugin runtime is unavailable")
|
||||
}
|
||||
if _, err := exec.LookPath(p.command); err != nil {
|
||||
return fmt.Errorf("payment plugin executable unavailable: %w", err)
|
||||
info, err := os.Stat(p.command)
|
||||
if err != nil {
|
||||
return p.startError(err)
|
||||
}
|
||||
if _, err := filepath.Abs(p.command); err != nil {
|
||||
return err
|
||||
if info.IsDir() {
|
||||
return p.startError(fmt.Errorf("payment plugin executable is a directory: %w", syscall.EACCES))
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
|
||||
return p.startError(fmt.Errorf("payment plugin executable has no execute bit: %w", syscall.EACCES))
|
||||
}
|
||||
if err := validatePaymentExecutablePlatform(p.command); err != nil {
|
||||
return p.startError(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, pluginRPCTimeout)
|
||||
defer cancel()
|
||||
@@ -94,10 +108,10 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return &ProviderError{Code: "plugin_start_failed", Message: "支付插件启动失败", Temporary: true, Cause: err}
|
||||
return p.startError(err)
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(stdout, pluginRPCMaxOutput+1))
|
||||
errData, _ := io.ReadAll(io.LimitReader(stderr, 64<<10))
|
||||
_, _ = io.ReadAll(io.LimitReader(stderr, 64<<10))
|
||||
waitErr := cmd.Wait()
|
||||
if readErr != nil {
|
||||
return &ProviderError{Code: "plugin_read_failed", Message: "读取支付插件响应失败", Temporary: true, Cause: readErr}
|
||||
@@ -109,15 +123,11 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
|
||||
return &ProviderError{Code: "plugin_timeout", Message: "支付插件执行超时", Temporary: true, Cause: ctx.Err()}
|
||||
}
|
||||
if waitErr != nil {
|
||||
message := strings.TrimSpace(string(errData))
|
||||
if message == "" {
|
||||
message = "支付插件进程异常退出"
|
||||
}
|
||||
return &ProviderError{Code: "plugin_process_failed", Message: message, Temporary: true, Cause: waitErr}
|
||||
return &ProviderError{Code: "plugin_process_failed", Message: "支付插件进程异常退出", Temporary: true, Cause: waitErr}
|
||||
}
|
||||
var response rpcResponse
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return fmt.Errorf("支付插件响应不是有效 JSON:%w", err)
|
||||
return &ProviderError{Code: "plugin_invalid_response", Message: "支付插件返回了无效响应", Temporary: true, Cause: err}
|
||||
}
|
||||
if !response.OK {
|
||||
message := response.Message
|
||||
@@ -138,6 +148,90 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *RPCProvider) startError(cause error) error {
|
||||
providerErr := classifyPaymentPluginStartError(cause)
|
||||
log.Printf(
|
||||
"payment plugin executable failed: provider=%q plugin=%q host=%s/%s command=%q code=%s error=%v",
|
||||
p.descriptor.ID,
|
||||
p.descriptor.PluginID,
|
||||
runtime.GOOS,
|
||||
runtime.GOARCH,
|
||||
p.command,
|
||||
providerErr.Code,
|
||||
cause,
|
||||
)
|
||||
return providerErr
|
||||
}
|
||||
|
||||
func classifyPaymentPluginStartError(cause error) *ProviderError {
|
||||
switch {
|
||||
case errors.Is(cause, os.ErrNotExist), errors.Is(cause, syscall.ENOENT), errors.Is(cause, exec.ErrNotFound):
|
||||
return &ProviderError{Code: "plugin_executable_missing", Message: "支付插件可执行文件或其运行时加载器不可用", Cause: cause}
|
||||
case errors.Is(cause, os.ErrPermission), errors.Is(cause, syscall.EACCES):
|
||||
return &ProviderError{Code: "plugin_permission_denied", Message: "支付插件没有执行权限", Cause: cause}
|
||||
case errors.Is(cause, syscall.ENOEXEC):
|
||||
return &ProviderError{Code: "plugin_exec_format_error", Message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容", Cause: cause}
|
||||
default:
|
||||
return &ProviderError{Code: "plugin_start_failed", Message: "支付插件启动失败", Temporary: true, Cause: cause}
|
||||
}
|
||||
}
|
||||
|
||||
func validatePaymentExecutablePlatform(command string) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var magic [4]byte
|
||||
if _, err := io.ReadFull(file, magic[:]); err != nil {
|
||||
return fmt.Errorf("payment plugin executable header is incomplete: %w", syscall.ENOEXEC)
|
||||
}
|
||||
if bytes.Equal(magic[:2], []byte{'M', 'Z'}) {
|
||||
return fmt.Errorf("payment plugin executable is Windows PE on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC)
|
||||
}
|
||||
if isMachOMagic(magic) {
|
||||
return fmt.Errorf("payment plugin executable is Mach-O on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC)
|
||||
}
|
||||
if !bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) {
|
||||
// Scripts and other kernel-supported executable formats are left to
|
||||
// cmd.Start. Unknown binary data will be classified from ENOEXEC there.
|
||||
return nil
|
||||
}
|
||||
|
||||
binary, err := elf.NewFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payment plugin ELF header is invalid: %w", syscall.ENOEXEC)
|
||||
}
|
||||
defer binary.Close()
|
||||
expected, ok := expectedPaymentELFMachine(runtime.GOARCH)
|
||||
if binary.Class != elf.ELFCLASS64 || (ok && binary.Machine != expected) {
|
||||
return fmt.Errorf("payment plugin ELF architecture mismatch: host=linux/%s class=%s machine=%s: %w", runtime.GOARCH, binary.Class, binary.Machine, syscall.ENOEXEC)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isMachOMagic(magic [4]byte) bool {
|
||||
return magic == [4]byte{0xfe, 0xed, 0xfa, 0xce} ||
|
||||
magic == [4]byte{0xce, 0xfa, 0xed, 0xfe} ||
|
||||
magic == [4]byte{0xfe, 0xed, 0xfa, 0xcf} ||
|
||||
magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe}
|
||||
}
|
||||
|
||||
func expectedPaymentELFMachine(arch string) (elf.Machine, bool) {
|
||||
switch arch {
|
||||
case "amd64":
|
||||
return elf.EM_X86_64, true
|
||||
case "arm64":
|
||||
return elf.EM_AARCH64, true
|
||||
default:
|
||||
return elf.EM_NONE, false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RPCProvider) ValidateConfig(config Config) error {
|
||||
return p.call(context.Background(), rpcRequest{Version: pluginRPCVersion, Operation: "validate_config", Config: config}, nil)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,19 @@ package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRPCProviderUsesPaymentV1Contract(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test provider uses a POSIX shell script")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "provider")
|
||||
program := "#!/bin/sh\nread request\nprintf '%s\\n' '{\"ok\":true,\"data\":{\"mode\":\"qr_code\",\"value\":\"https://pay.example/qr\"}}'\n"
|
||||
@@ -27,3 +34,131 @@ func TestRPCProviderUsesPaymentV1Contract(t *testing.T) {
|
||||
t.Fatalf("checkout = %#v", checkout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCProviderClassifiesExecutableStartFailures(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("executable error classification is asserted against Linux errno behavior")
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
missing bool
|
||||
code string
|
||||
message string
|
||||
}{
|
||||
{name: "missing", missing: true, code: "plugin_executable_missing", message: "支付插件可执行文件或其运行时加载器不可用"},
|
||||
{name: "missing-loader", content: []byte("#!/definitely/missing/payment-loader\n"), mode: 0o700, code: "plugin_executable_missing", message: "支付插件可执行文件或其运行时加载器不可用"},
|
||||
{name: "permission", content: []byte("#!/bin/sh\nexit 0\n"), mode: 0o600, code: "plugin_permission_denied", message: "支付插件没有执行权限"},
|
||||
{name: "mach-o", content: []byte{0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01}, mode: 0o700, code: "plugin_exec_format_error", message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容"},
|
||||
{name: "windows-pe", content: []byte{'M', 'Z', 0x90, 0x00}, mode: 0o700, code: "plugin_exec_format_error", message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容"},
|
||||
{name: "unknown-binary", content: []byte("not an executable\n"), mode: 0o700, code: "plugin_exec_format_error", message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "backend", "provider")
|
||||
if !test.missing {
|
||||
if err := os.MkdirAll(filepath.Dir(entry), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(entry, test.content, test.mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
provider, err := NewRPCProvider(Descriptor{ID: "test-provider", PluginID: "test-plugin"}, dir, "backend/provider")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = provider.ValidateConfig(Config{})
|
||||
var providerErr *ProviderError
|
||||
if !errors.As(err, &providerErr) {
|
||||
t.Fatalf("ValidateConfig() error = %T %v, want ProviderError", err, err)
|
||||
}
|
||||
if providerErr.Code != test.code || providerErr.Message != test.message || providerErr.Cause == nil {
|
||||
t.Fatalf("ProviderError = %#v, want code=%q message=%q with cause", providerErr, test.code, test.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPaymentPluginStartError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cause error
|
||||
code string
|
||||
temporary bool
|
||||
}{
|
||||
{name: "missing", cause: os.ErrNotExist, code: "plugin_executable_missing"},
|
||||
{name: "permission", cause: os.ErrPermission, code: "plugin_permission_denied"},
|
||||
{name: "format", cause: syscall.ENOEXEC, code: "plugin_exec_format_error"},
|
||||
{name: "generic", cause: errors.New("start failed"), code: "plugin_start_failed", temporary: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
providerErr := classifyPaymentPluginStartError(test.cause)
|
||||
if providerErr.Code != test.code || providerErr.Temporary != test.temporary || !errors.Is(providerErr, test.cause) {
|
||||
t.Fatalf("classifyPaymentPluginStartError() = %#v", providerErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCProviderDoesNotExposeProcessStderr(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test provider uses a POSIX shell script")
|
||||
}
|
||||
provider := testRPCProvider(t, "#!/bin/sh\nprintf '%s\\n' 'credential=super-secret' >&2\nexit 1\n")
|
||||
err := provider.ValidateConfig(Config{})
|
||||
var providerErr *ProviderError
|
||||
if !errors.As(err, &providerErr) {
|
||||
t.Fatalf("ValidateConfig() error = %T %v, want ProviderError", err, err)
|
||||
}
|
||||
if providerErr.Code != "plugin_process_failed" || providerErr.Message != "支付插件进程异常退出" {
|
||||
t.Fatalf("ProviderError = %#v", providerErr)
|
||||
}
|
||||
if strings.Contains(providerErr.Error(), "super-secret") {
|
||||
t.Fatalf("process stderr leaked through public error: %q", providerErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCProviderClassifiesInvalidJSONResponse(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test provider uses a POSIX shell script")
|
||||
}
|
||||
provider := testRPCProvider(t, "#!/bin/sh\nprintf '%s\\n' 'not-json'\n")
|
||||
err := provider.ValidateConfig(Config{})
|
||||
var providerErr *ProviderError
|
||||
if !errors.As(err, &providerErr) || providerErr.Code != "plugin_invalid_response" || providerErr.Message != "支付插件返回了无效响应" {
|
||||
t.Fatalf("ValidateConfig() error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCProviderPreservesPluginValidationError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test provider uses a POSIX shell script")
|
||||
}
|
||||
provider := testRPCProvider(t, "#!/bin/sh\nprintf '%s\\n' '{\"ok\":false,\"code\":\"bad_config\",\"message\":\"配置无效\"}'\n")
|
||||
err := provider.ValidateConfig(Config{})
|
||||
var providerErr *ProviderError
|
||||
if !errors.As(err, &providerErr) || providerErr.Code != "bad_config" || providerErr.Message != "配置无效" {
|
||||
t.Fatalf("ValidateConfig() error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testRPCProvider(t *testing.T, program string) *RPCProvider {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "backend", "provider")
|
||||
if err := os.MkdirAll(filepath.Dir(entry), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(entry, []byte(program), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, err := NewRPCProvider(Descriptor{ID: "test-provider", PluginID: "test-plugin"}, dir, "backend/provider")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
82
backend/internal/protocol/payment_artifact_test.go
Normal file
82
backend/internal/protocol/payment_artifact_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/buildinfo"
|
||||
"debug/elf"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) {
|
||||
for _, packageID := range []string{"official-payment-wechat-native", "official-payment-alipay-page"} {
|
||||
t.Run(packageID, func(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "plugin-packages")
|
||||
providerPath := filepath.Join(root, packageID, "backend", "provider")
|
||||
directoryProvider, err := os.ReadFile(providerPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageData, err := os.ReadFile(filepath.Join(root, packageID+".yingce-plugin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkg, err := ParsePluginPackage(packageData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packagedProvider := pkg.Files["backend/provider"]
|
||||
if !bytes.Equal(directoryProvider, packagedProvider) {
|
||||
t.Fatal("directory provider differs from .yingce-plugin backend/provider")
|
||||
}
|
||||
binary, err := elf.NewFile(bytes.NewReader(directoryProvider))
|
||||
if err != nil {
|
||||
t.Fatalf("provider is not ELF: %v", err)
|
||||
}
|
||||
if binary.Class != elf.ELFCLASS64 || binary.Machine != elf.EM_X86_64 {
|
||||
t.Fatalf("provider ELF = class %s machine %s, want 64-bit x86-64", binary.Class, binary.Machine)
|
||||
}
|
||||
info, err := buildinfo.Read(bytes.NewReader(directoryProvider))
|
||||
if err != nil {
|
||||
t.Fatalf("read provider build info: %v", err)
|
||||
}
|
||||
settings := make(map[string]string, len(info.Settings))
|
||||
for _, setting := range info.Settings {
|
||||
settings[setting.Key] = setting.Value
|
||||
}
|
||||
if settings["GOOS"] != "linux" || settings["GOARCH"] != "amd64" || settings["CGO_ENABLED"] != "0" {
|
||||
t.Fatalf("provider build settings = GOOS=%s GOARCH=%s CGO_ENABLED=%s", settings["GOOS"], settings["GOARCH"], settings["CGO_ENABLED"])
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
|
||||
fileInfo, err := os.Stat(providerPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fileInfo.Mode().Perm()&0o111 == 0 {
|
||||
t.Fatal("provider is not executable")
|
||||
}
|
||||
command := exec.Command(providerPath)
|
||||
command.Stdin = bytes.NewBufferString("invalid-json\n")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("provider smoke test failed: %v", err)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &response); err != nil {
|
||||
t.Fatalf("provider smoke response is invalid JSON: %v", err)
|
||||
}
|
||||
if response.OK || response.Code != "invalid_request" {
|
||||
t.Fatalf("provider smoke response = %s", output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
171
backend/internal/service/payment_runtime_cache_test.go
Normal file
171
backend/internal/service/payment_runtime_cache_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
)
|
||||
|
||||
func TestPaymentRuntimeCacheUsesCompletePackageDigest(t *testing.T) {
|
||||
packageA := paymentRuntimePackage(t, []byte("provider-a"))
|
||||
packageB := paymentRuntimePackage(t, []byte("provider-b"))
|
||||
if pluginHash(packageA) == pluginHash(packageB) {
|
||||
t.Fatal("payment packages with different providers have the same digest")
|
||||
}
|
||||
|
||||
pkgA, err := protocol.ParsePluginPackage(packageA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkgB, err := protocol.ParsePluginPackage(packageB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageDir := t.TempDir()
|
||||
runtimeA, err := materializePaymentBackend(packageDir, pluginHash(packageA), pkgA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeB, err := materializePaymentBackend(packageDir, pluginHash(packageB), pkgB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtimeA == runtimeB {
|
||||
t.Fatalf("different package contents reused runtime path %q", runtimeA)
|
||||
}
|
||||
assertPaymentRuntimeProvider(t, runtimeA, []byte("provider-a"))
|
||||
assertPaymentRuntimeProvider(t, runtimeB, []byte("provider-b"))
|
||||
}
|
||||
|
||||
func TestPaymentRuntimeReplacesInterruptedExtraction(t *testing.T) {
|
||||
packageData := paymentRuntimePackage(t, []byte("complete-provider"))
|
||||
pkg, err := protocol.ParsePluginPackage(packageData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := pluginHash(packageData)
|
||||
packageDir := t.TempDir()
|
||||
interruptedRoot := filepath.Join(packageDir, "runtime", digest)
|
||||
if err := os.MkdirAll(filepath.Join(interruptedRoot, "backend"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(interruptedRoot, "backend", "provider"), []byte("partial-provider"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runtimeDir, err := materializePaymentBackend(packageDir, digest, pkg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtimeDir != interruptedRoot {
|
||||
t.Fatalf("runtime directory = %q, want %q", runtimeDir, interruptedRoot)
|
||||
}
|
||||
assertPaymentRuntimeProvider(t, runtimeDir, []byte("complete-provider"))
|
||||
ready, err := os.ReadFile(filepath.Join(runtimeDir, paymentRuntimeReadyFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(ready) != digest+"\n" {
|
||||
t.Fatalf("runtime ready marker = %q", ready)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(runtimeDir, "backend", "provider")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := materializePaymentBackend(packageDir, digest, pkg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPaymentRuntimeProvider(t, runtimeDir, []byte("complete-provider"))
|
||||
}
|
||||
|
||||
func TestBuiltInPaymentPackageUpgradeSelectsNewRuntimeDigest(t *testing.T) {
|
||||
oldPackage := paymentRuntimePackage(t, []byte("old-provider"))
|
||||
newPackage := paymentRuntimePackage(t, []byte("new-provider"))
|
||||
oldDigest := pluginHash(oldPackage)
|
||||
newDigest := pluginHash(newPackage)
|
||||
oldPkg, err := protocol.ParsePluginPackage(oldPackage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
packageDir := filepath.Join(dataDir, "plugin-packages")
|
||||
if err := os.MkdirAll(packageDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(packageDir, oldDigest+".yingce-plugin"), oldPackage, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := materializePaymentBackend(packageDir, oldDigest, oldPkg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := []pluginRegistryRecord{{
|
||||
ID: "test-payment-runtime", Raw: oldPkg.ManifestRaw, Source: PluginOriginOfficial,
|
||||
FileName: "test-payment-runtime.yingce-plugin", PackagePath: oldDigest + ".yingce-plugin", PackageSHA256: oldDigest,
|
||||
}}
|
||||
if err := (&pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json")}).writeRegistry(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
officialDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(officialDir, "test-payment-runtime.yingce-plugin"), newPackage, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CANVAS_OFFICIAL_PLUGIN_DIR", officialDir)
|
||||
center, err := newPluginRuntime(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertPaymentRuntimeProvider(t, filepath.Join(packageDir, "runtime", newDigest), []byte("new-provider"))
|
||||
for _, plugin := range center.list() {
|
||||
if plugin.Manifest.ID == "test-payment-runtime" {
|
||||
if plugin.SHA256 != newDigest {
|
||||
t.Fatalf("upgraded plugin digest = %q, want %q", plugin.SHA256, newDigest)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("upgraded payment plugin is missing")
|
||||
}
|
||||
|
||||
func TestPaymentRuntimeRejectsInvalidDigest(t *testing.T) {
|
||||
pkg := protocol.PluginPackage{Files: map[string][]byte{"backend/provider": []byte("provider")}}
|
||||
if _, err := materializePaymentBackend(t.TempDir(), "../outside", pkg); err == nil {
|
||||
t.Fatal("invalid package digest was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func paymentRuntimePackage(t *testing.T, provider []byte) []byte {
|
||||
t.Helper()
|
||||
manifest := []byte(`{"apiVersion":"yingce.plugin/v1","id":"test-payment-runtime","version":"1.0.0","name":"Test Payment Runtime","author":"Test","enabled":true,"runtime":{"backend":"rpc","backendEntry":"backend/provider"},"contributes":{"paymentProviders":[{"id":"test-payment","label":"Test Payment","icon":"brand:test","checkoutMode":"redirect","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}}`)
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, content := range map[string][]byte{"manifest.json": manifest, "backend/provider": provider} {
|
||||
file, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func assertPaymentRuntimeProvider(t *testing.T, runtimeDir string, expected []byte) {
|
||||
t.Helper()
|
||||
actual, err := os.ReadFile(filepath.Join(runtimeDir, "backend", "provider"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(actual, expected) {
|
||||
t.Fatalf("runtime provider = %q, want %q", actual, expected)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ import (
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
)
|
||||
|
||||
const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
|
||||
const (
|
||||
protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
|
||||
paymentRuntimeReadyFile = ".ready"
|
||||
)
|
||||
|
||||
// PluginView is the backend representation consumed by the single frontend
|
||||
// plugin center. Protocol-specific runtime data is nested under Protocol so
|
||||
@@ -297,7 +300,7 @@ func (c *pluginRuntime) reload() error {
|
||||
return fmt.Errorf("duplicate installed protocol %q", metadata.ID)
|
||||
}
|
||||
packageSHA256 := storedRecord.PackageSHA256
|
||||
if packageSHA256 == "" {
|
||||
if packageSHA256 == "" && strings.TrimSpace(storedRecord.PackagePath) == "" {
|
||||
packageSHA256 = pluginHash(data)
|
||||
}
|
||||
plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: storedRecord.Source, FileName: storedRecord.FileName, PackagePath: storedRecord.PackagePath, PackageSHA256: packageSHA256, SHA256: packageSHA256, InstalledAt: storedRecord.InstalledAt, UpdatedAt: storedRecord.UpdatedAt, Status: "invalid"}
|
||||
@@ -403,6 +406,16 @@ func (c *pluginRuntime) reload() error {
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
packageSHA256 := pluginHash(packageData)
|
||||
if expected := strings.TrimSpace(record.PackageSHA256); expected != "" && !strings.EqualFold(expected, packageSHA256) {
|
||||
record.Status = "invalid"
|
||||
record.Error = "支付插件包完整性校验失败"
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
record.PackageSHA256 = packageSHA256
|
||||
record.SHA256 = packageSHA256
|
||||
plugins[id] = record
|
||||
pkg, parseErr := protocol.ParsePluginPackage(packageData)
|
||||
if parseErr != nil {
|
||||
record.Status = "invalid"
|
||||
@@ -410,7 +423,7 @@ func (c *pluginRuntime) reload() error {
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, record.PackageSHA256, pkg)
|
||||
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, packageSHA256, pkg)
|
||||
if materializeErr != nil {
|
||||
record.Status = "invalid"
|
||||
record.Error = materializeErr.Error()
|
||||
@@ -447,15 +460,34 @@ func (c *pluginRuntime) reload() error {
|
||||
}
|
||||
|
||||
func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) {
|
||||
root := filepath.Join(packageDir, "runtime", strings.TrimSpace(hash))
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
digest := strings.ToLower(strings.TrimSpace(hash))
|
||||
if len(digest) != sha256.Size*2 {
|
||||
return "", errors.New("支付插件包摘要无效")
|
||||
}
|
||||
if _, err := hex.DecodeString(digest); err != nil {
|
||||
return "", errors.New("支付插件包摘要无效")
|
||||
}
|
||||
|
||||
runtimeRoot := filepath.Join(packageDir, "runtime")
|
||||
root := filepath.Join(runtimeRoot, digest)
|
||||
backendEntry := strings.TrimSpace(pkg.Manifest.Runtime.BackendEntry)
|
||||
if paymentRuntimeReady(root, digest, backendEntry) {
|
||||
return root, nil
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
return "", fmt.Errorf("创建支付插件运行目录失败:%w", err)
|
||||
}
|
||||
temporaryRoot, err := os.MkdirTemp(runtimeRoot, ".payment-runtime-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建支付插件临时运行目录失败:%w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryRoot)
|
||||
|
||||
for name, content := range pkg.Files {
|
||||
if !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
target := filepath.Join(temporaryRoot, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -463,9 +495,34 @@ func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPacka
|
||||
return "", fmt.Errorf("写入支付插件运行文件失败:%w", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(temporaryRoot, paymentRuntimeReadyFile), []byte(digest+"\n"), 0o600); err != nil {
|
||||
return "", fmt.Errorf("写入支付插件运行状态失败:%w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryRoot, root); err != nil {
|
||||
if paymentRuntimeReady(root, digest, backendEntry) {
|
||||
return root, nil
|
||||
}
|
||||
// Digest directories are immutable once ready. An unmarked directory can
|
||||
// only be residue from an interrupted extraction and is safe to replace.
|
||||
if removeErr := os.RemoveAll(root); removeErr != nil {
|
||||
return "", fmt.Errorf("清理未完成的支付插件运行目录失败:%w", removeErr)
|
||||
}
|
||||
if renameErr := os.Rename(temporaryRoot, root); renameErr != nil {
|
||||
return "", fmt.Errorf("发布支付插件运行目录失败:%w", renameErr)
|
||||
}
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func paymentRuntimeReady(root, digest, backendEntry string) bool {
|
||||
ready, err := os.ReadFile(filepath.Join(root, paymentRuntimeReadyFile))
|
||||
if err != nil || strings.TrimSpace(string(ready)) != digest {
|
||||
return false
|
||||
}
|
||||
entry, err := os.Stat(filepath.Join(root, filepath.FromSlash(backendEntry)))
|
||||
return err == nil && !entry.IsDir()
|
||||
}
|
||||
|
||||
func (c *pluginRuntime) list() []PluginView {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -29,6 +29,27 @@ Manifest 的支付贡献声明 `runtime.backend` 为 `rpc` 或 `wasm`,并通
|
||||
|
||||
请求固定带有 `version: "yingce.payment/v1"`。插件不得访问宿主数据库,不得自行入账积分。宿主目前控制进程工作目录、环境变量、标准输出大小和 30 秒超时;支付宝、微信实现还会在插件内部校验网关和账单下载域名。通用插件的操作系统级沙箱、签名校验和网络白名单尚未启用,不应直接运行不受信任的支付插件。
|
||||
|
||||
## 原生包的平台边界
|
||||
|
||||
`runtime.backend: "rpc"` 的 `backend/provider` 是原生可执行文件,不是平台无关静态资源。官方 backend 镜像在 Docker 构建中读取 `TARGETOS` 和 `TARGETARCH`,以 `CGO_ENABLED=0` 分别生成目标平台 provider,并在同一阶段重新生成 `.yingce-plugin`;目录文件与包内文件会比较内容,并在目标平台容器中真实执行冒烟请求。因此 Apple Silicon、Intel Mac 或 Windows 上发起的 Docker 构建不会把宿主二进制复制进 Linux 镜像。
|
||||
|
||||
仓库中保留的官方支付包用于 Linux amd64 源码运行和回归测试,其规范平台固定为 `linux/amd64`。在其他平台直接运行源码时,应先在具有 Node.js 和 `zip` 的 POSIX shell 中执行:
|
||||
|
||||
```bash
|
||||
PAYMENT_PLUGIN_GOOS="$(go env GOOS)" \
|
||||
PAYMENT_PLUGIN_GOARCH="$(go env GOARCH)" \
|
||||
PAYMENT_PLUGIN_CGO_ENABLED=0 \
|
||||
./plugin-packages/build-packages.sh --payments-only
|
||||
```
|
||||
|
||||
当前 manifest 尚未声明 native artifact 的 OS/CPU;第三方市场若要分发原生 RPC 插件,应后续在 package index 或 manifest ABI 中增加平台选择信息,不能把单一 native package 视为跨平台包。
|
||||
|
||||
## 运行时缓存与诊断
|
||||
|
||||
宿主以完整 `.yingce-plugin` 字节的 SHA-256 作为 package 文件名和 `runtime/<digest>` 缓存键。provider 内容变化会产生新 digest 和新运行目录;解包先写临时目录,完成后写入 ready marker 并原子发布,旧目录不会覆盖新包,也不要求升级时手工清空整个数据卷。
|
||||
|
||||
执行前,Linux 宿主会拒绝 Mach-O、Windows PE、损坏的 ELF 和与当前 CPU 不匹配的 ELF。管理员可区分可执行文件缺失或加载器不可用、权限不足、格式或架构不兼容、进程异常退出、超时和无效 JSON。服务端启动日志只记录插件 ID、provider ID、宿主平台、可执行路径和操作系统错误;不会记录 RPC 请求、支付配置、密钥或插件 stderr。
|
||||
|
||||
## Manifest 示例
|
||||
|
||||
```json
|
||||
|
||||
@@ -5,6 +5,13 @@ description: 影策已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 支付插件跨平台构建
|
||||
|
||||
- [x] 微信与支付宝 provider 已使用同一 target-aware 构建脚本完成 `CGO_ENABLED=0` 的 linux/amd64、linux/arm64 交叉编译;目录 provider 与 `.yingce-plugin` 内 provider 的 ELF 架构和内容一致性校验通过。
|
||||
- [x] 包内外产物一致性、完整包摘要缓存、同版本 provider 更新失效、未完成解包恢复等专项测试通过;RPC 错误映射的跨平台单元测试通过。
|
||||
- [ ] 在 Linux CI 完成无文件、无权限、错误格式、异常退出与无效 JSON 的 RPC 进程测试,并使用 Docker Buildx/QEMU 执行 `payment-package-smoke` 的 amd64/arm64 双平台构建,确认目标容器内两个 provider 均能真实执行无效 JSON 冒烟请求。
|
||||
- [ ] 保留含旧 Mach-O runtime 的生产 `/data` 卷升级到修复镜像,确认启动后自动选择新 package digest,管理后台保存微信和支付宝配置进入真实凭证校验。
|
||||
|
||||
## 平台外观管理
|
||||
|
||||
管理后台已新增“系统配置 → 外观管理”,并让公开品牌配置在应用渲染前完成解析;以下真实浏览器与媒体场景需要持续确认:
|
||||
|
||||
@@ -2,21 +2,69 @@
|
||||
set -eu
|
||||
|
||||
root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd)
|
||||
payment_plugins="official-payment-wechat-native official-payment-alipay-page"
|
||||
payments_only=false
|
||||
|
||||
node "$root_dir/embed-documentation.mjs"
|
||||
if [ "${1:-}" = "--payments-only" ]; then
|
||||
payments_only=true
|
||||
shift
|
||||
fi
|
||||
if [ "$#" -ne 0 ]; then
|
||||
echo "usage: $0 [--payments-only]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$payments_only" = true ]; then
|
||||
node "$root_dir/embed-documentation.mjs" $payment_plugins
|
||||
else
|
||||
node "$root_dir/embed-documentation.mjs"
|
||||
fi
|
||||
|
||||
# Payment backends are executable plugin payloads. They are compiled outside
|
||||
# the host binary and then included under backend/provider in the package.
|
||||
repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd)
|
||||
for payment_plugin in official-payment-wechat-native official-payment-alipay-page; do
|
||||
mkdir -p "$root_dir/$payment_plugin/backend"
|
||||
done
|
||||
(cd "$repo_dir/backend" && go build -trimpath -ldflags='-s -w' -o "$root_dir/official-payment-wechat-native/backend/provider" ./cmd/payment-wechat)
|
||||
(cd "$repo_dir/backend" && go build -trimpath -ldflags='-s -w' -o "$root_dir/official-payment-alipay-page/backend/provider" ./cmd/payment-alipay)
|
||||
payment_goos=${PAYMENT_PLUGIN_GOOS:-$(go env GOOS)}
|
||||
payment_goarch=${PAYMENT_PLUGIN_GOARCH:-$(go env GOARCH)}
|
||||
payment_cgo=${PAYMENT_PLUGIN_CGO_ENABLED:-0}
|
||||
|
||||
for manifest in "$root_dir"/*/manifest.json; do
|
||||
package_dir=${manifest%/manifest.json}
|
||||
package_id=${package_dir##*/}
|
||||
case "$payment_cgo" in
|
||||
0|1) ;;
|
||||
*)
|
||||
echo "PAYMENT_PLUGIN_CGO_ENABLED must be 0 or 1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
build_payment_provider() {
|
||||
package_id=$1
|
||||
command_path=$2
|
||||
output_file="$root_dir/$package_id/backend/provider"
|
||||
temporary_file="$output_file.tmp"
|
||||
|
||||
mkdir -p "$(dirname -- "$output_file")"
|
||||
rm -f "$temporary_file"
|
||||
(
|
||||
cd "$repo_dir/backend"
|
||||
CGO_ENABLED="$payment_cgo" \
|
||||
GOOS="$payment_goos" \
|
||||
GOARCH="$payment_goarch" \
|
||||
go build -trimpath -ldflags='-s -w' -o "$temporary_file" "$command_path"
|
||||
)
|
||||
mv "$temporary_file" "$output_file"
|
||||
chmod 0755 "$output_file"
|
||||
|
||||
build_info=$(go version -m "$output_file")
|
||||
printf '%s\n' "$build_info" | grep -F "GOOS=$payment_goos" >/dev/null
|
||||
printf '%s\n' "$build_info" | grep -F "GOARCH=$payment_goarch" >/dev/null
|
||||
printf '%s\n' "$build_info" | grep -F "CGO_ENABLED=$payment_cgo" >/dev/null
|
||||
}
|
||||
|
||||
build_payment_provider official-payment-wechat-native ./cmd/payment-wechat
|
||||
build_payment_provider official-payment-alipay-page ./cmd/payment-alipay
|
||||
|
||||
package_plugin() {
|
||||
package_id=$1
|
||||
package_dir="$root_dir/$package_id"
|
||||
output_file="$root_dir/$package_id.yingce-plugin"
|
||||
temporary_file="$root_dir/.$package_id.yingce-plugin.tmp"
|
||||
rm -f "$temporary_file"
|
||||
@@ -25,4 +73,21 @@ for manifest in "$root_dir"/*/manifest.json; do
|
||||
find manifest.json README.md docs assets web backend LICENSE -type f 2>/dev/null | LC_ALL=C sort | zip -X -q "$temporary_file" -@
|
||||
)
|
||||
mv "$temporary_file" "$output_file"
|
||||
}
|
||||
|
||||
if [ "$payments_only" = true ]; then
|
||||
for payment_plugin in $payment_plugins; do
|
||||
package_plugin "$payment_plugin"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for payment_plugin in $payment_plugins; do
|
||||
mkdir -p "$root_dir/$payment_plugin/backend"
|
||||
done
|
||||
|
||||
for manifest in "$root_dir"/*/manifest.json; do
|
||||
package_dir=${manifest%/manifest.json}
|
||||
package_id=${package_dir##*/}
|
||||
package_plugin "$package_id"
|
||||
done
|
||||
|
||||
@@ -8,6 +8,10 @@ const contractEnd = "<!-- YINGCE_MANIFEST_CONTRACT_END -->";
|
||||
const recursiveDocumentationPlaceholder =
|
||||
"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>";
|
||||
|
||||
function normalizeNewlines(document) {
|
||||
return document.replace(/\r\n?/g, "\n");
|
||||
}
|
||||
|
||||
function withoutGeneratedContract(document) {
|
||||
const start = document.indexOf(contractStart);
|
||||
if (start < 0) return document.trimEnd();
|
||||
@@ -45,19 +49,21 @@ for (const packageID of packageDirectories) {
|
||||
const packageRoot = join(root, packageID);
|
||||
let manifestSource;
|
||||
try {
|
||||
manifestSource = await readFile(join(packageRoot, "manifest.json"), "utf8");
|
||||
manifestSource = normalizeNewlines(await readFile(join(packageRoot, "manifest.json"), "utf8"));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestSource);
|
||||
const readme = (await readFile(join(packageRoot, "README.md"), "utf8")).trim();
|
||||
const readmePath = join(packageRoot, "README.md");
|
||||
const readme = normalizeNewlines(await readFile(readmePath, "utf8")).trim();
|
||||
const interfacePath = join(packageRoot, "docs", "interface.md");
|
||||
const interfaceSource = await readFile(interfacePath, "utf8");
|
||||
const interfaceSource = normalizeNewlines(await readFile(interfacePath, "utf8"));
|
||||
const interfaceDocument = `${withoutGeneratedContract(interfaceSource)}\n\n${renderContract(manifest)}\n`;
|
||||
manifest.documentation = `${readme}\n\n---\n\n${interfaceDocument.trim()}\n`;
|
||||
|
||||
await writeFile(readmePath, `${readme}\n`);
|
||||
await writeFile(interfacePath, interfaceDocument);
|
||||
await writeFile(join(packageRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
updated += 1;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
65
plugin-packages/verify-payment-packages.sh
Normal file
65
plugin-packages/verify-payment-packages.sh
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
expected_goos=${1:-}
|
||||
expected_goarch=${2:-}
|
||||
|
||||
if [ "$expected_goos" != "linux" ]; then
|
||||
echo "payment package verification currently requires expected GOOS linux" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$expected_goarch" in
|
||||
amd64) expected_machine=3e00 ;;
|
||||
arm64) expected_machine=b700 ;;
|
||||
*)
|
||||
echo "unsupported payment package architecture: $expected_goarch" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
temporary_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM
|
||||
|
||||
for package_id in official-payment-wechat-native official-payment-alipay-page; do
|
||||
provider="$root_dir/$package_id/backend/provider"
|
||||
package_file="$root_dir/$package_id.yingce-plugin"
|
||||
archived_provider="$temporary_dir/$package_id-provider"
|
||||
|
||||
test -f "$provider"
|
||||
if [ "$(uname -s)" = "Linux" ]; then
|
||||
test -x "$provider"
|
||||
fi
|
||||
test -f "$package_file"
|
||||
|
||||
magic=$(od -An -tx1 -N4 "$provider" | tr -d '[:space:]')
|
||||
if [ "$magic" != "7f454c46" ]; then
|
||||
echo "$package_id provider is not an ELF executable (magic=$magic)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
class=$(od -An -tx1 -j4 -N1 "$provider" | tr -d '[:space:]')
|
||||
byte_order=$(od -An -tx1 -j5 -N1 "$provider" | tr -d '[:space:]')
|
||||
if [ "$class" != "02" ] || [ "$byte_order" != "01" ]; then
|
||||
echo "$package_id provider is not a 64-bit little-endian ELF executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
machine=$(od -An -tx1 -j18 -N2 "$provider" | tr -d '[:space:]')
|
||||
if [ "$machine" != "$expected_machine" ]; then
|
||||
echo "$package_id provider architecture does not match linux/$expected_goarch (ELF machine=$machine)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unzip -p "$package_file" backend/provider >"$archived_provider"
|
||||
if ! cmp -s "$provider" "$archived_provider"; then
|
||||
echo "$package_id directory provider and packaged provider differ" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${PAYMENT_PLUGIN_SMOKE_TEST:-0}" = "1" ]; then
|
||||
response=$(printf 'invalid-json\n' | "$provider")
|
||||
printf '%s\n' "$response" | grep -F '"code":"invalid_request"' >/dev/null
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user