init: AIGC-Hub/AVCC 方案文档 + TCS-IPTV 内容可信锁定系统 MVP

- 方案文档: AVCC 体系建设、IPTV TCS 需求(0-req)/PRD(1-prd)/任务(2-task)/二三四期任务
- tcs-iptv: Go 后端(哈希SDK/MA码生成/可信数据空间mock/业务编排/HTTP API+HMAC鉴权)
- web-console: React+AntD 监管大屏(角色工作台/全流程演示/监管片库)
- 一剧一码+集级哈希, 集级下架/恢复, 全栈测试通过
This commit is contained in:
selfrelease
2026-06-14 16:50:31 +08:00
commit a329d4906b
103 changed files with 20052 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
package hash
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeTempFile(t *testing.T, data []byte) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "master.bin")
require.NoError(t, os.WriteFile(p, data, 0o644))
return p
}
func TestSHA256Hex_Deterministic(t *testing.T) {
a := SHA256Hex([]byte("hello"))
b := SHA256Hex([]byte("hello"))
assert.Equal(t, a, b)
// 已知向量
assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", a)
}
func TestFileSHA256_MatchesBytes(t *testing.T) {
data := []byte("the quick brown fox")
p := writeTempFile(t, data)
got, err := FileSHA256(p)
require.NoError(t, err)
assert.Equal(t, SHA256Hex(data), got)
}
func TestSegmentHashes_SmallSegments(t *testing.T) {
// 25 字节,分段 10 → 3 段(10/10/5
data := []byte("0123456789ABCDEFGHIJ12345")
p := writeTempFile(t, data)
segs, err := SegmentHashes(p, 10)
require.NoError(t, err)
require.Len(t, segs, 3)
assert.Equal(t, SHA256Hex(data[0:10]), segs[0])
assert.Equal(t, SHA256Hex(data[10:20]), segs[1])
assert.Equal(t, SHA256Hex(data[20:25]), segs[2])
}
func TestMerkleTree_RootStableAndChangesOnEdit(t *testing.T) {
leaves := []string{
SHA256Hex([]byte("ep1")),
SHA256Hex([]byte("ep2")),
SHA256Hex([]byte("ep3")),
SHA256Hex([]byte("ep4")),
}
root1 := BuildMerkleTree(leaves).Root()
root2 := BuildMerkleTree(leaves).Root()
assert.Equal(t, root1, root2, "同样叶子根应一致")
assert.NotEmpty(t, root1)
// 改第3集 → 根变化
edited := append([]string(nil), leaves...)
edited[2] = SHA256Hex([]byte("ep3-tampered"))
root3 := BuildMerkleTree(edited).Root()
assert.NotEqual(t, root1, root3, "篡改任一集,根必变")
}
func TestMerkleTree_OddLeaves(t *testing.T) {
leaves := []string{
SHA256Hex([]byte("a")),
SHA256Hex([]byte("b")),
SHA256Hex([]byte("c")),
}
mt := BuildMerkleTree(leaves)
assert.NotEmpty(t, mt.Root())
}
func TestLocateChangedLeaves(t *testing.T) {
old := []string{"h1", "h2", "h3", "h4"}
neu := []string{"h1", "x2", "h3", "x4"}
changed := LocateChangedLeaves(old, neu)
assert.Equal(t, []int{1, 3}, changed, "应定位到第2集和第4集被改")
}
func TestComputeFile_FullPackage(t *testing.T) {
data := make([]byte, 25*1024) // 25KB
for i := range data {
data[i] = byte(i % 251)
}
p := writeTempFile(t, data)
pkg, err := ComputeFile(p, Options{SegmentSize: 10 * 1024})
require.NoError(t, err)
require.NoError(t, pkg.Validate())
assert.Equal(t, int64(25*1024), pkg.FileSize)
assert.Len(t, pkg.SegmentHashes, 3)
assert.NotEmpty(t, pkg.MerkleRoot)
assert.Equal(t, SHA256Hex(data), pkg.FileSHA256)
}
func TestComputeFile_EmptyFileRejected(t *testing.T) {
p := writeTempFile(t, []byte{})
_, err := ComputeFile(p, Options{})
assert.ErrorIs(t, err, ErrEmptyInput)
}
func TestComputeFile_MissingFile(t *testing.T) {
_, err := ComputeFile("/no/such/file.bin", Options{})
assert.Error(t, err)
}
func TestHashPackage_ValidateMissingFields(t *testing.T) {
assert.Error(t, (&HashPackage{MerkleRoot: "x"}).Validate()) // 缺 file_sha256
assert.Error(t, (&HashPackage{FileSHA256: "x"}).Validate()) // 缺 merkle_root
assert.NoError(t, (&HashPackage{FileSHA256: "a", MerkleRoot: "b"}).Validate())
}
func TestPerceptualHash_IdenticalAndDifferent(t *testing.T) {
// 全黑与全白图,aHash/dHash 应可区分
black := make([][]uint8, 16)
white := make([][]uint8, 16)
grad := make([][]uint8, 16)
for y := 0; y < 16; y++ {
black[y] = make([]uint8, 16)
white[y] = make([]uint8, 16)
grad[y] = make([]uint8, 16)
for x := 0; x < 16; x++ {
white[y][x] = 255
grad[y][x] = uint8(x * 16) // 水平渐变
}
}
imgBlack := newGrayTestImage(black)
imgWhite := newGrayTestImage(white)
imgGrad := newGrayTestImage(grad)
// 同一图的哈希稳定
assert.Equal(t, AHash(imgGrad), AHash(imgGrad))
assert.Equal(t, DHash(imgGrad), DHash(imgGrad))
// 渐变图的 dHash 应与纯色不同
assert.NotEqual(t, DHash(imgGrad), DHash(imgBlack))
// 汉明距离:渐变 vs 纯白 应 > 0
d, err := HammingDistance(DHash(imgGrad), DHash(imgWhite))
require.NoError(t, err)
assert.Greater(t, d, 0)
}
func TestHammingDistance_LengthMismatch(t *testing.T) {
_, err := HammingDistance("ffff", "ffffffff")
assert.Error(t, err)
}
+87
View File
@@ -0,0 +1,87 @@
package hash
import (
"crypto/sha256"
"encoding/hex"
)
// MerkleTree 表示一棵基于 SHA-256 的 Merkle 树。
// 叶子为各分段(或各集)的哈希,根用于整体内容的聚合锚定。
// 对应需求:需求1-AC2、需求16-AC4(按集定位篡改)。
type MerkleTree struct {
Leaves []string // 叶子哈希(十六进制)
Levels [][]string // 自底向上的各层,Levels[0] 为叶子层
}
// BuildMerkleTree 由叶子哈希构建 Merkle 树。
// 当某层节点数为奇数时,复制最后一个节点与自身配对(标准做法)。
func BuildMerkleTree(leaves []string) *MerkleTree {
mt := &MerkleTree{Leaves: append([]string(nil), leaves...)}
if len(leaves) == 0 {
mt.Levels = [][]string{{}}
return mt
}
level := append([]string(nil), leaves...)
mt.Levels = [][]string{level}
for len(level) > 1 {
next := make([]string, 0, (len(level)+1)/2)
for i := 0; i < len(level); i += 2 {
left := level[i]
right := left // 奇数个时与自身配对
if i+1 < len(level) {
right = level[i+1]
}
next = append(next, hashPair(left, right))
}
mt.Levels = append(mt.Levels, next)
level = next
}
return mt
}
// Root 返回 Merkle 根哈希;空树返回空字符串。
func (mt *MerkleTree) Root() string {
if len(mt.Levels) == 0 {
return ""
}
top := mt.Levels[len(mt.Levels)-1]
if len(top) == 0 {
return ""
}
return top[0]
}
// hashPair 将两个十六进制哈希拼接后再次 SHA-256。
func hashPair(left, right string) string {
lb, _ := hex.DecodeString(left)
rb, _ := hex.DecodeString(right)
h := sha256.New()
h.Write(lb)
h.Write(rb)
return hex.EncodeToString(h.Sum(nil))
}
// LocateChangedLeaves 比较两组叶子哈希,返回发生变化的叶子索引。
// 用于"定位被篡改的具体集"(需求12-AC3)。
func LocateChangedLeaves(oldLeaves, newLeaves []string) []int {
var changed []int
max := len(oldLeaves)
if len(newLeaves) > max {
max = len(newLeaves)
}
for i := 0; i < max; i++ {
var o, n string
if i < len(oldLeaves) {
o = oldLeaves[i]
}
if i < len(newLeaves) {
n = newLeaves[i]
}
if o != n {
changed = append(changed, i)
}
}
return changed
}
+106
View File
@@ -0,0 +1,106 @@
package hash
import (
"fmt"
"image"
"image/color"
"math/bits"
)
// 感知哈希用于跨格式/转码识别同一内容(需求1-AC3)。
// MVP 实现 aHash(均值哈希)与 dHash(差值哈希),输入为已解码的视频代表帧图像。
// 真实视频抽帧由上层(ffmpeg)完成,本包专注哈希算法以便独立测试。
const phashDim = 8 // 8x8 → 64-bit 哈希
// grayResize 将图像缩放为 w×h 的灰度矩阵(最近邻,零依赖)。
func grayResize(img image.Image, w, h int) [][]float64 {
b := img.Bounds()
srcW, srcH := b.Dx(), b.Dy()
out := make([][]float64, h)
for y := 0; y < h; y++ {
out[y] = make([]float64, w)
for x := 0; x < w; x++ {
sx := b.Min.X + x*srcW/w
sy := b.Min.Y + y*srcH/h
r, g, bb, _ := img.At(sx, sy).RGBA()
// 转 8 位灰度(ITU-R 601 亮度)
gray := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bb>>8)
out[y][x] = gray
}
}
return out
}
// AHash 计算均值哈希(64-bit,十六进制 16 字符)。
func AHash(img image.Image) string {
m := grayResize(img, phashDim, phashDim)
var sum float64
for y := 0; y < phashDim; y++ {
for x := 0; x < phashDim; x++ {
sum += m[y][x]
}
}
avg := sum / float64(phashDim*phashDim)
var hash uint64
var bit uint
for y := 0; y < phashDim; y++ {
for x := 0; x < phashDim; x++ {
if m[y][x] >= avg {
hash |= 1 << bit
}
bit++
}
}
return fmt.Sprintf("%016x", hash)
}
// DHash 计算差值哈希(64-bit)。对水平相邻像素比较亮度。
func DHash(img image.Image) string {
// 需要 (phashDim+1) 列以产生 phashDim 个差值
m := grayResize(img, phashDim+1, phashDim)
var hash uint64
var bit uint
for y := 0; y < phashDim; y++ {
for x := 0; x < phashDim; x++ {
if m[y][x] < m[y][x+1] {
hash |= 1 << bit
}
bit++
}
}
return fmt.Sprintf("%016x", hash)
}
// HammingDistance 计算两个等长十六进制哈希的汉明距离。
// 距离越小越相似;用于版权比对与跨版本识别。
func HammingDistance(a, b string) (int, error) {
if len(a) != len(b) {
return 0, fmt.Errorf("hash: length mismatch %d vs %d", len(a), len(b))
}
var va, vb uint64
if _, err := fmt.Sscanf(a, "%x", &va); err != nil {
return 0, err
}
if _, err := fmt.Sscanf(b, "%x", &vb); err != nil {
return 0, err
}
return bits.OnesCount64(va ^ vb), nil
}
// newGrayTestImage 是测试辅助:由灰度矩阵生成图像。
func newGrayTestImage(gray [][]uint8) image.Image {
h := len(gray)
w := 0
if h > 0 {
w = len(gray[0])
}
img := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetGray(x, y, color.Gray{Y: gray[y][x]})
}
}
return img
}
+78
View File
@@ -0,0 +1,78 @@
package hash
import (
"fmt"
"os"
)
// HashPackage 是哈希值包,对应需求1-AC5。
// 仅包含哈希与元数据,绝不包含原始内容(需求20-AC2)。
type HashPackage struct {
FileSHA256 string `json:"file_sha256"`
MerkleRoot string `json:"merkle_root"`
SegmentHashes []string `json:"segment_hashes"`
PerceptualHash string `json:"perceptual_hash,omitempty"`
FileSize int64 `json:"file_size"`
SegmentSize int `json:"segment_size"`
}
// Options 控制哈希计算行为。
type Options struct {
SegmentSize int // 分段大小;<=0 用默认
PerceptualHash string // 上层已抽帧并算好的感知哈希(可选)
}
// ComputeFile 对母版文件计算完整哈希值包(文件哈希 + 分段 Merkle)。
// 感知哈希需上层先抽帧,再通过 opts.PerceptualHash 传入或单独调用 AHash/DHash。
func ComputeFile(path string, opts Options) (*HashPackage, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("hash: stat file: %w", err)
}
if info.IsDir() {
return nil, fmt.Errorf("hash: path is a directory: %s", path)
}
if info.Size() == 0 {
return nil, ErrEmptyInput
}
segSize := opts.SegmentSize
if segSize <= 0 {
segSize = DefaultSegmentSize
}
fileHash, err := FileSHA256(path)
if err != nil {
return nil, fmt.Errorf("hash: file sha256: %w", err)
}
segments, err := SegmentHashes(path, segSize)
if err != nil {
return nil, fmt.Errorf("hash: segment hashes: %w", err)
}
tree := BuildMerkleTree(segments)
return &HashPackage{
FileSHA256: fileHash,
MerkleRoot: tree.Root(),
SegmentHashes: segments,
PerceptualHash: opts.PerceptualHash,
FileSize: info.Size(),
SegmentSize: segSize,
}, nil
}
// Validate 校验哈希值包的完整性(需求2-AC5:缺文件哈希/Merkle根则非法)。
func (p *HashPackage) Validate() error {
if p == nil {
return fmt.Errorf("hash: nil package")
}
if p.FileSHA256 == "" {
return fmt.Errorf("hash: missing file_sha256")
}
if p.MerkleRoot == "" {
return fmt.Errorf("hash: missing merkle_root")
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
// Package hash 实现 TCS-IPTV 的内容哈希核心:
// 文件 SHA-256、分段 Merkle Tree、感知哈希。
// 对应需求:需求1(母版哈希生成)。
package hash
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
)
// DefaultSegmentSize 是分段哈希的默认分段大小(10 MiB)。
const DefaultSegmentSize = 10 * 1024 * 1024
// ErrEmptyInput 表示输入为空。
var ErrEmptyInput = errors.New("hash: empty input")
// SHA256Hex 计算字节切片的 SHA-256,返回十六进制字符串。
func SHA256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// FileSHA256 流式计算文件的整体 SHA-256,避免一次性载入大文件。
func FileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// SegmentHashes 按 segmentSize 分段计算文件各段的 SHA-256。
// 用于构建 Merkle Tree 的叶子节点。
func SegmentHashes(path string, segmentSize int) ([]string, error) {
if segmentSize <= 0 {
segmentSize = DefaultSegmentSize
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var segments []string
buf := make([]byte, segmentSize)
for {
n, err := io.ReadFull(f, buf)
if n > 0 {
segments = append(segments, SHA256Hex(buf[:n]))
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
break
}
if err != nil {
return nil, err
}
}
return segments, nil
}