feat(chain): ChainMaker 真实链接入脚手架(build tag 隔离)+ 契约测试

- internal/chain/chainmaker.go [//go:build chainmaker]: ChainMakerClient 适配器骨架,
  实现 chain.Client 全部方法到合约 Invoke/Query,按角色证书做链上鉴权,错误映射回标准错误
- internal/chain/chainmaker_stub.go [//go:build !chainmaker]: 占位构造函数,
  保证默认构建不依赖 SDK、主工程始终可编译
- contracts/tcs_registry/registry.go: 补齐合约方法
  RegisterHashBinding/VerifyEpisodeHash/ListEpisodes/HashExists/RecordVersionChange/
  RevokeEpisode/Restore/RestoreEpisode/SetContentStatus/QueryMappings/ListContents
  并增加集级哈希/映射/版本计数索引 KV 设计
- config: TCS_CHAIN_BACKEND=memory|pg|chainmaker + TCS_CHAINMAKER_SDK_CONF 开关
- cmd/api-svc: newChain 按 backend 选择,chainmaker 失败逐级降级 pg 到内存
- internal/chain/conformance_test.go: chain.Client 契约测试套件,双实现共用
  MemoryChain 默认跑;PersistentChain 经 TCS_TEST_PG_DSN;ChainMaker 经 -tags 与 env
- 验证: 默认 build/vet/test 全绿;MemoryChain 与 PersistentChain 契约套件均通过,行为一致
This commit is contained in:
selfrelease
2026-06-14 20:47:21 +08:00
parent 8a9ea6b40b
commit 166f460d57
9 changed files with 875 additions and 49 deletions
+294
View File
@@ -0,0 +1,294 @@
//go:build chainmaker
// Package chain — 真实链后端(长安链 ChainMaker)适配器骨架。
//
// 仅在 `go build -tags chainmaker` 时编译;默认构建由 chainmaker_stub.go 提供占位,
// 因此主工程在没有 ChainMaker Go SDK 依赖时也始终可编译。
//
// 接入步骤(需真实环境):
// 1. 引入 SDK 依赖:
// go get chainmaker.org/chainmaker/sdk-go/v2
// 2. 准备 sdk_config.yml(节点地址、TLS、四角色组织证书),路径由 TCS_CHAINMAKER_SDK_CONF 指定。
// 3. 部署 contracts/tcs_registry 合约,合约名见 contractName 常量。
// 4. 启动:TCS_CHAIN_BACKEND=chainmaker go run -tags chainmaker ./cmd/api-svc
//
// 设计:每个业务角色(监管/审核/CP/运营商)使用各自组织证书的 ChainClient
// 合约内 senderOrg() 据此做链上权限判定(IssueMA/Revoke 仅监管组织)。
// 写操作走 InvokeContract(同步等待上链确认),读操作走 QueryContract(不产生交易)。
package chain
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"chainmaker.org/chainmaker/pb-go/v2/common"
sdk "chainmaker.org/chainmaker/sdk-go/v2"
"github.com/tcs-iptv/tcs/internal/model"
)
const contractName = "tcs_registry"
// ChainMakerClient 是 chain.Client 的真实链实现。
type ChainMakerClient struct {
// clients 为四角色各自证书初始化的链客户端(组织/用户证书不同)。
clients map[Role]*sdk.ChainClient
// mirror 可选 PG 镜像(链为权威,镜像供高效查询)。为 nil 时仅用链。
mirror *sql.DB
}
var _ Client = (*ChainMakerClient)(nil)
// NewChainMakerClient 按 sdk_config.yml 初始化四角色链客户端。
//
// 真实实现需为每个角色加载其组织/用户证书(可在 sdk_config.yml 用多 user 段,
// 或为每个角色单独一个 config 文件)。此处给出装配骨架,证书细节随部署而定。
func NewChainMakerClient(sdkConfPath string, mirror *sql.DB) (Client, error) {
roles := []Role{RoleRegulator, RoleReviewer, RoleCP, RoleOperator}
clients := make(map[Role]*sdk.ChainClient, len(roles))
for _, r := range roles {
// TODO(deploy): 为每个角色加载其证书。示例:约定每角色一个配置文件
// conf := fmt.Sprintf("%s.%s.yml", strings.TrimSuffix(sdkConfPath, ".yml"), r)
cli, err := sdk.NewChainClient(sdk.WithConfPath(sdkConfPath))
if err != nil {
return nil, fmt.Errorf("chainmaker: 初始化角色 %s 客户端失败: %w", r, err)
}
clients[r] = cli
}
return &ChainMakerClient{clients: clients, mirror: mirror}, nil
}
// kv 构造合约入参键值对。
func kv(m map[string][]byte) []*common.KeyValuePair {
out := make([]*common.KeyValuePair, 0, len(m))
for k, v := range m {
out = append(out, &common.KeyValuePair{Key: k, Value: v})
}
return out
}
// invoke 以指定角色身份提交合约写交易(同步等待上链)。
func (c *ChainMakerClient) invoke(role Role, method string, args map[string][]byte) (*common.TxResponse, error) {
cli, ok := c.clients[role]
if !ok {
return nil, fmt.Errorf("chainmaker: 未配置角色 %s 的链客户端", role)
}
// withSyncResult=true:等待交易上链并返回合约执行结果
resp, err := cli.InvokeContract(contractName, method, "", kv(args), -1, true)
if err != nil {
return nil, err
}
if resp.Code != common.TxStatusCode_SUCCESS {
return nil, fmt.Errorf("chainmaker: tx 失败: %s", resp.Message)
}
if resp.ContractResult != nil && resp.ContractResult.Code != 0 {
return nil, mapContractError(string(resp.ContractResult.Message))
}
return resp, nil
}
// query 以指定角色身份发起合约查询(不产生交易)。
func (c *ChainMakerClient) query(role Role, method string, args map[string][]byte) ([]byte, error) {
cli := c.clients[role]
resp, err := cli.QueryContract(contractName, method, "", kv(args), -1)
if err != nil {
return nil, err
}
if resp.ContractResult != nil && resp.ContractResult.Code != 0 {
return nil, mapContractError(string(resp.ContractResult.Message))
}
if resp.ContractResult == nil {
return nil, ErrNotFound
}
return resp.ContractResult.Result, nil
}
// mapContractError 把合约返回的错误消息映射回 chain 包标准错误,保证与 MemoryChain 行为一致。
func mapContractError(msg string) error {
switch {
case strings.Contains(msg, "permission denied"):
return ErrPermissionDenied
case strings.Contains(msg, "already issued"):
return ErrMAAlreadyIssued
case strings.Contains(msg, "hash already exists"):
return ErrHashExists
case strings.Contains(msg, "not issued"):
return ErrMANotIssued
case strings.Contains(msg, "not found"):
return ErrNotFound
default:
return fmt.Errorf("chainmaker: %s", msg)
}
}
// ---- chain.Client 实现(写操作)----
func (c *ChainMakerClient) IssueMA(role Role, req IssueRequest) (string, error) {
contentJSON, _ := json.Marshal(req.Content)
epJSON, _ := json.Marshal(req.Episodes)
resp, err := c.invoke(role, "IssueMA", map[string][]byte{
"ma_code": []byte(req.MACode),
"ctid": []byte(req.ContentTwinID),
"merkle_root": []byte(req.MerkleRoot),
"file_hash": []byte(req.FileHash),
"perceptual_hash": []byte(req.PerceptualHash),
"episodes": epJSON,
"content": contentJSON,
})
if err != nil {
return "", err
}
// TODO(mirror): 成功后写 PG 镜像(可复用 PersistentChain 的 persist* 逻辑)
return resp.TxId, nil
}
func (c *ChainMakerClient) RegisterHashBinding(role Role, b model.HashBinding) (string, error) {
bj, _ := json.Marshal(b)
resp, err := c.invoke(role, "RegisterHashBinding", map[string][]byte{
"ctid": []byte(b.ContentTwinID), "binding": bj,
})
if err != nil {
return "", err
}
return resp.TxId, nil
}
func (c *ChainMakerClient) RegisterMapping(role Role, m model.Mapping) (string, error) {
mj, _ := json.Marshal(m)
resp, err := c.invoke(role, "RegisterMapping", map[string][]byte{
"ctid": []byte(m.ContentTwinID), "mapping": mj,
})
if err != nil {
return "", err
}
return resp.TxId, nil
}
func (c *ChainMakerClient) RecordVersionChange(vc model.VersionChange) (string, error) {
vj, _ := json.Marshal(vc)
resp, err := c.invoke(RoleReviewer, "RecordVersionChange", map[string][]byte{
"ctid": []byte(vc.ContentTwinID), "vc": vj,
})
if err != nil {
return "", err
}
return resp.TxId, nil
}
func (c *ChainMakerClient) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
if _, err := c.invoke(role, "Revoke", map[string][]byte{
"ma_code": []byte(maCode), "reason": []byte(reason),
}); err != nil {
return MappingsResult{}, err
}
return c.QueryMappings(maCode)
}
func (c *ChainMakerClient) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
_, err := c.invoke(role, "RevokeEpisode", map[string][]byte{
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)), "reason": []byte(reason),
})
return err
}
func (c *ChainMakerClient) Restore(role Role, maCode string) error {
_, err := c.invoke(role, "Restore", map[string][]byte{"ma_code": []byte(maCode)})
return err
}
func (c *ChainMakerClient) RestoreEpisode(role Role, maCode string, episode int) error {
_, err := c.invoke(role, "RestoreEpisode", map[string][]byte{
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)),
})
return err
}
func (c *ChainMakerClient) SetContentStatus(maCode, status string) error {
_, err := c.invoke(RoleReviewer, "SetContentStatus", map[string][]byte{
"ma_code": []byte(maCode), "status": []byte(status),
})
return err
}
// ---- chain.Client 实现(读操作)----
func (c *ChainMakerClient) VerifyHash(maCode, fileHash string) (VerifyResult, error) {
res, err := c.query(RoleOperator, "VerifyHash", map[string][]byte{
"ma_code": []byte(maCode), "file_hash": []byte(fileHash),
})
if err != nil {
return VerifyResult{MACode: maCode, SubmittedHash: fileHash}, err
}
match := string(res) == "true"
return VerifyResult{Valid: true, MACode: maCode, SubmittedHash: fileHash, Match: match}, nil
}
func (c *ChainMakerClient) VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error) {
res, err := c.query(RoleOperator, "VerifyEpisodeHash", map[string][]byte{
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)), "file_hash": []byte(fileHash),
})
if err != nil {
return VerifyResult{MACode: maCode, SubmittedHash: fileHash}, err
}
return VerifyResult{Valid: true, MACode: maCode, SubmittedHash: fileHash, Match: string(res) == "true"}, nil
}
func (c *ChainMakerClient) ListEpisodes(maCode string) ([]model.HashBinding, error) {
res, err := c.query(RoleRegulator, "ListEpisodes", map[string][]byte{"ma_code": []byte(maCode)})
if err != nil {
return nil, err
}
var out []model.HashBinding
if err := json.Unmarshal(res, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *ChainMakerClient) HashExists(fileHash string) (string, bool) {
res, err := c.query(RoleRegulator, "HashExists", map[string][]byte{"file_hash": []byte(fileHash)})
if err != nil || len(res) == 0 {
return "", false
}
return string(res), true
}
func (c *ChainMakerClient) QueryContent(maCode string) (model.Content, error) {
res, err := c.query(RoleRegulator, "QueryContent", map[string][]byte{"ma_code": []byte(maCode)})
if err != nil {
return model.Content{}, err
}
var content model.Content
if err := json.Unmarshal(res, &content); err != nil {
return model.Content{}, err
}
return content, nil
}
func (c *ChainMakerClient) ListContents(status string) ([]model.Content, error) {
// 优先走 PG 镜像(链上范围扫描代价高);无镜像时回源合约范围查询。
res, err := c.query(RoleRegulator, "ListContents", map[string][]byte{"status": []byte(status)})
if err != nil {
return nil, err
}
var out []model.Content
if err := json.Unmarshal(res, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *ChainMakerClient) QueryMappings(maCode string) (MappingsResult, error) {
res, err := c.query(RoleRegulator, "QueryMappings", map[string][]byte{"ma_code": []byte(maCode)})
if err != nil {
return MappingsResult{}, err
}
var out MappingsResult
if err := json.Unmarshal(res, &out); err != nil {
return MappingsResult{}, err
}
out.MACode = maCode
return out, nil
}