feat:修改周边验证

This commit is contained in:
zerosaturation 2026-07-17 16:53:57 +08:00
parent 1f7faba9a8
commit d554d7be83
14 changed files with 454 additions and 108 deletions

View File

@ -131,3 +131,12 @@ OPENAI_MODEL=gpt-image-2
DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ
DIFY_API_BASE=http://localhost/v1
DIFY_TIMEOUT_SEC=60
# ==================== 周边防伪验真peripheral_verify====================
# 详见 docs/design/peripheral-verify.md §5.1
# ⚠️ 关键:必须与 TopFans-activity-admin/backend/.env 中的 PERIPHERAL_SECRET_KEY 完全一致
# 用于 Go gateway 验真接口的 HMAC 签名校验
# 决策 #9:永不轮换,仅在泄露时更换
# 生成命令:python3 -c "import secrets; print(secrets.token_hex(64))"
SECRET_KEY=

View File

@ -14,23 +14,37 @@ import (
"github.com/topfans/backend/services/assetService/service"
)
// GetVerificationByCode GET /api/v1/peripheral-info/by-code/:code
// GetVerificationByHash GET /api/v1/peripheral-info/by-hash/:hash?sign=xxx
//
// 扫码入口,周边尚未 mint 时用周边编号(code)查 peripheral_info
func (ctrl *PeripheralController) GetVerificationByCode(c *gin.Context) {
code := c.Param("code")
if code == "" {
response.Error(c, http.StatusBadRequest, "code 必填")
// stage 1 唯一验真接口(详见 §5.1.3/§5.1.5):按加密 code(code_hash)验真
// - path 参数:encrypted_code(32 hex)
// - query 参数:sign(32 hex,HMAC 签名,第二道防线)
// - 验签失败返 50013,前端引导用户重新扫码
func (ctrl *PeripheralController) GetVerificationByHash(c *gin.Context) {
codeHash := c.Param("hash")
sign := c.Query("sign")
if codeHash == "" {
response.Error(c, http.StatusBadRequest, "code_hash 必填")
return
}
if sign == "" {
response.Error(c, http.StatusBadRequest, "sign 必填")
return
}
result, err := ctrl.svc.GetVerificationByCode(c.Request.Context(), code)
result, err := ctrl.svc.GetVerificationByHash(c.Request.Context(), codeHash, sign)
if err != nil {
ctrl.handleServiceError(c, err)
return
}
response.Success(c, &dto.VerificationData{
response.Success(c, ctrl.toVerificationDTO(result))
}
// toVerificationDTO *VerificationResult → *dto.VerificationData 转换 helper
func (ctrl *PeripheralController) toVerificationDTO(result *service.VerificationResult) *dto.VerificationData {
return &dto.VerificationData{
AssetID: result.AssetID,
Code: result.Code,
Company: result.Company,
@ -41,7 +55,7 @@ func (ctrl *PeripheralController) GetVerificationByCode(c *gin.Context) {
Verifier: result.Verifier,
VerifiedAt: result.VerifiedAt,
MaterialType: result.MaterialType,
})
}
}
// PeripheralController 周边验真 + 加入藏品 controller
@ -85,14 +99,22 @@ func (ctrl *PeripheralController) GetVerification(c *gin.Context) {
})
}
// MintFromPeripheralByCode POST /api/v1/peripheral-info/mint-by-code/:code
// MintFromPeripheralByHash POST /api/v1/peripheral-info/mint-by-hash/:hash?sign=xxx
//
// 强制 JWT,由 router 层的 AuthMiddleware 拦截未登录;
// 用周边编号(code)走 mint(扫码入口)
func (ctrl *PeripheralController) MintFromPeripheralByCode(c *gin.Context) {
code := c.Param("code")
if code == "" {
response.Error(c, http.StatusBadRequest, "code 必填")
// stage 1 唯一加入藏品接口(详见 §5.1.5):按加密 code(code_hash)加入藏品
// - path 参数:encrypted_code(32 hex)
// - query 参数:sign(32 hex,HMAC 签名)
// - 强制 JWT
func (ctrl *PeripheralController) MintFromPeripheralByHash(c *gin.Context) {
codeHash := c.Param("hash")
sign := c.Query("sign")
if codeHash == "" {
response.Error(c, http.StatusBadRequest, "code_hash 必填")
return
}
if sign == "" {
response.Error(c, http.StatusBadRequest, "sign 必填")
return
}
@ -107,7 +129,7 @@ func (ctrl *PeripheralController) MintFromPeripheralByCode(c *gin.Context) {
return
}
result, err := ctrl.svc.MintFromPeripheral(c.Request.Context(), ownerUID, code)
result, err := ctrl.svc.MintFromPeripheralByHash(c.Request.Context(), ownerUID, codeHash, sign)
if err != nil {
ctrl.handleServiceError(c, err)
return

View File

@ -275,7 +275,7 @@ func TestPeripheralController_Mint_HappyPath(t *testing.T) {
c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}}
c.Set("user_id", ownerUID)
ctrl.MintFromPeripheralByCode(c)
ctrl.MintFromPeripheralByHash(c)
if w.Code != http.StatusOK {
t.Fatalf("expected HTTP 200, got %d, body=%s", w.Code, w.Body.String())
@ -329,7 +329,7 @@ func TestPeripheralController_Mint_AlreadyAdded(t *testing.T) {
c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}}
c.Set("user_id", ownerUID)
ctrl.MintFromPeripheralByCode(c)
ctrl.MintFromPeripheralByHash(c)
if w.Code != http.StatusOK {
t.Fatalf("expected HTTP 200 (response.ErrorWithCode), got %d, body=%s", w.Code, w.Body.String())
@ -381,7 +381,7 @@ func TestPeripheralController_Mint_RateLimited(t *testing.T) {
c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}}
c.Set("user_id", ownerUID)
ctrl.MintFromPeripheralByCode(c)
ctrl.MintFromPeripheralByHash(c)
if w.Code != http.StatusOK {
t.Fatalf("expected HTTP 200 (response.ErrorWithCode), got %d, body=%s", w.Code, w.Body.String())
@ -411,7 +411,7 @@ func TestPeripheralController_Mint_Unauthorized(t *testing.T) {
c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}}
// 故意不 Set("user_id")
ctrl.MintFromPeripheralByCode(c)
ctrl.MintFromPeripheralByHash(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected HTTP 401, got %d, body=%s", w.Code, w.Body.String())

View File

@ -208,16 +208,16 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
// 周边验真(公开,H5 可访问,无需 JWT)
// 同 prefix 不同 middleware:Gin 允许两个 group 共用前缀,
// 验证接口挂在无 auth 的 publicAssets,其他 /assets/* 走 AuthMiddleware。
// 按周边编号(code)查验真(公开)
// 按周边验真(公开) —— stage 1 后仅保留 by-hash(详见 design §5.1.4)
peripheralInfo := v1.Group("/peripheral-info")
{
peripheralInfo.GET("/by-code/:code", peripheralCtrl.GetVerificationByCode)
peripheralInfo.GET("/by-hash/:hash", peripheralCtrl.GetVerificationByHash)
}
// 按周边编号(code)加入藏品(需 JWT)
// 加入藏品(需 JWT)
peripheralMint := v1.Group("/peripheral-info")
peripheralMint.Use(middleware.AuthMiddleware())
{
peripheralMint.POST("/mint-by-code/:code", peripheralCtrl.MintFromPeripheralByCode)
peripheralMint.POST("/mint-by-hash/:hash", peripheralCtrl.MintFromPeripheralByHash)
}
// 当前用户相关路由(需要认证)
@ -361,7 +361,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
assets.GET("/:asset_id/materials", assetCtrl.GetAssetMaterials) // 获取资产素材列表(修复:原 handler 存在但未注册)
// 周边加入藏品(强制 JWT)
// 周边加入藏品 mint 改用 /peripheral-info/mint-by-code/:code(扫 QR 入口)
// 周边加入藏品 mint 用 /peripheral-info/mint-by-hash/:hash(详见 design §5.1)
}
// 分享相关路由(需要认证)— 用 assetCtrl(spec 规定 /api/v1/share/* 前缀)

View File

@ -6,16 +6,24 @@ type PeripheralInfo struct {
AssetID *int64 `gorm:"column:asset_id"` // mint 后回填,未铸时 NULL
StarID int64 `gorm:"not null;default:87;column:star_id"` // 归属明星
UserID *int64 `gorm:"column:user_id"` // mint 后回填,未铸时 NULL
Code string `gorm:"type:varchar(50);default:'';column:code"`
Image string `gorm:"type:varchar(500);default:'';column:image"` // 周边缩略图 URL
Brand string `gorm:"type:varchar(100);not null;default:'';column:brand"`
Company string `gorm:"type:varchar(200);not null;default:'';column:company"`
Hash string `gorm:"type:varchar(100);not null;default:'';column:hash"`
Verifier string `gorm:"type:varchar(100);not null;default:'';column:verifier"`
FirstVerifiedAt int64 `gorm:"not null;default:0;column:first_verified_at"`
VerifyCount int64 `gorm:"not null;default:0;column:verify_count"`
CreatedAt int64 `gorm:"not null;column:created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at"`
Code string `gorm:"type:varchar(50);default:'';column:code"`
Image string `gorm:"type:varchar(500);default:'';column:image"` // 周边缩略图 URL
Brand string `gorm:"type:varchar(100);not null;default:'';column:brand"`
Company string `gorm:"type:varchar(200);not null;default:'';column:company"`
Hash string `gorm:"type:varchar(100);not null;default:'';column:hash"`
Verifier string `gorm:"type:varchar(100);not null;default:'';column:verifier"`
FirstVerifiedAt int64 `gorm:"not null;default:0;column:first_verified_at"`
VerifyCount int64 `gorm:"not null;default:0;column:verify_count"`
CreatedAt int64 `gorm:"not null;column:created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at"`
// stage 1 新增字段(由 admin Python 端迁移加列,Go 端同步 model)
Status *int16 `gorm:"not null;default:0;column:status"` // 0=待激活 1=已激活 2=已作废 3=已冻结 4=已核销
RarityLevel *int16 `gorm:"not null;default:1;column:rarity_level"` // 1=普通 2=稀有 3=史诗 4=传说
BatchID *int64 `gorm:"column:batch_id"` // 所属批次(可空)
SerialNumber string `gorm:"type:varchar(32);not null;default:'';column:serial_number"`
ExpireAt int64 `gorm:"not null;default:0;column:expire_at"` // 0=永久
StatusReason string `gorm:"type:varchar(200);not null;default:'';column:status_reason"`
UpdatedBy string `gorm:"type:varchar(64);not null;default:'';column:updated_by"`
}
func (PeripheralInfo) TableName() string { return "peripheral_info" }

View File

@ -0,0 +1,33 @@
package models
// PeripheralVerifyCode 防伪码主表(stage 1 新增,详见 docs/design/peripheral-verify.md §3.2.3)
//
// 关系:
// - code(明文)与 peripheral_info.code 一对一(扫码时通过 code_hash 反查再 JOIN)
// - code_hash = HMAC-SHA256(SECRET_KEY, code)[:32],URL 中实际出现
// - sign = HMAC-SHA256(SECRET_KEY, code_hash)[:32],URL 中验签参数
//
// 一物一码:每条记录对应一个物理周边的防伪码。
type PeripheralVerifyCode struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
Code string `gorm:"type:varchar(50);not null;unique;column:code"` // 明文防伪码,内部使用
CodeHash string `gorm:"type:varchar(32);not null;unique;column:code_hash"` // HMAC 密文,URL 对外
BatchID *int64 `gorm:"column:batch_id"` // 所属批次
PeripheralInfoID *int64 `gorm:"column:peripheral_info_id"` // 关联 peripheral_info
SerialNumber string `gorm:"type:varchar(32);not null;default:'';column:serial_number"`
RarityLevel int16 `gorm:"not null;default:1;column:rarity_level"`
Status int16 `gorm:"not null;default:1;column:status"` // 0=待激活 1=已激活 2=已作废 3=已冻结 4=已核销
QRCodeURL string `gorm:"type:varchar(500);not null;default:'';column:qrcode_url"`
H5URL string `gorm:"type:varchar(500);not null;default:'';column:h5_url"`
Sign string `gorm:"type:varchar(64);not null;default:'';column:sign"` // URL HMAC 签名
StatusReason string `gorm:"type:varchar(200);not null;default:'';column:status_reason"`
ExpireAt int64 `gorm:"not null;default:0;column:expire_at"`
FirstScannedAt int64 `gorm:"not null;default:0;column:first_scanned_at"`
LastScannedAt int64 `gorm:"not null;default:0;column:last_scanned_at"`
ScanCount int64 `gorm:"not null;default:0;column:scan_count"`
CreatedBy string `gorm:"type:varchar(64);not null;column:created_by"`
CreatedAt int64 `gorm:"not null;column:created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at"`
}
func (PeripheralVerifyCode) TableName() string { return "peripheral_verify_code" }

View File

@ -0,0 +1,78 @@
// Package peripheral 提供周边防伪验真的 HMAC 加密/签名/验签工具
//
// 详见 docs/design/peripheral-verify.md §5.1
// - 第一道防线:HMAC-SHA256(code)[:32] = encrypted_code(防爬虫枚举)
// - 第二道防线:HMAC-SHA256(encrypted_code)[:32] = sign(防 URL 篡改)
//
// 决策 #9:SECRET_KEY 永不轮换(物理周边场景)
package peripheral
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
"sync"
)
const (
// encryptedCodeLen 截断长度(128 bit = 32 hex),防碰撞已足够(生日攻击需 2^64 次)
encryptedCodeLen = 32
)
// secret 共享 HMAC 密钥,从环境变量读取(必须与 admin 后端 PERIPHERAL_SECRET_KEY 一致)
// 延迟初始化,避免 import 阶段读环境变量
var (
secretOnce sync.Once
secretVal string
)
// getSecret 读取并缓存 SECRET_KEY 环境变量
// 优先级:SECRET_KEY > JWT_SECRET(共用环境变量)
func getSecret() string {
secretOnce.Do(func() {
secretVal = os.Getenv("SECRET_KEY")
if secretVal == "" {
// 兜底:JWT_SECRET 也用作周边验真密钥
secretVal = os.Getenv("JWT_SECRET")
}
if secretVal == "" {
// ⚠️ 生产环境必须配置 SECRET_KEY,这里只是开发兜底
secretVal = "default-dev-secret-change-me"
}
})
return secretVal
}
// EncryptCode 第一道防线:code → encrypted_code
//
// encrypted_code = HMAC-SHA256(SECRET, code)[:32]
//
// 输入:明文 code(如 "PERI-2026-88100001")
// 输出:32 hex chars 的密文
func EncryptCode(code string) string {
mac := hmac.New(sha256.New, []byte(getSecret()))
mac.Write([]byte(code))
return hex.EncodeToString(mac.Sum(nil))[:encryptedCodeLen]
}
// SignURL 第二道防线:encrypted_code → sign(URL 中验签参数)
//
// sign = HMAC-SHA256(SECRET, encrypted_code)[:32]
//
// 注:此处"URL"指 stage 1 新版 H5 URL,仅含 encrypted_code + sign 两字段
// (无 t/source,详见 §5.1.4)
func SignURL(encryptedCode string) string {
mac := hmac.New(sha256.New, []byte(getSecret()))
mac.Write([]byte(encryptedCode))
return hex.EncodeToString(mac.Sum(nil))[:encryptedCodeLen]
}
// VerifySign 验证 URL 中的 sign 是否合法
//
// 返回 true 表示 sign 匹配(URL 没被篡改),false 表示被篡改或损坏
// 使用 hmac.Equal 做常数时间比较,防计时攻击
func VerifySign(encryptedCode, sign string) bool {
expected := SignURL(encryptedCode)
return hmac.Equal([]byte(sign), []byte(expected))
}

View File

@ -52,7 +52,8 @@ func (r *PeripheralRepository) GetAssetForVerification(ctx context.Context, asse
return &asset, nil
}
// GetPeripheralInfo 查 peripheral_info,not found 返 (nil, nil)
// GetPeripheralInfo 按 peripheral_info.asset_id(mint 后回填的业务 ID)查
// 用途:GetVerification 接口入参是 URL asset_id,该 ID 就是 mint 后回填的 asset_id
// service 层把"peripheral_info 缺失"视为"非周边",返 50003
func (r *PeripheralRepository) GetPeripheralInfo(ctx context.Context, assetID int64) (*models.PeripheralInfo, error) {
if assetID <= 0 {
@ -72,22 +73,59 @@ func (r *PeripheralRepository) GetPeripheralInfo(ctx context.Context, assetID in
return &info, nil
}
// GetPeripheralInfoByCode 按周边编号查(扫码入口,asset_id=NULL 时也能查)
func (r *PeripheralRepository) GetPeripheralInfoByCode(ctx context.Context, code string) (*models.PeripheralInfo, error) {
if code == "" {
return nil, errors.New("code required")
// GetPeripheralInfoByID 按 peripheral_info 主键 id 查
// 用途:verify_code.peripheral_info_id 字段存的就是 peripheral_info.id(自增主键),
//
// 由 admin 后端生成码时写入,与 asset_id(mint 后回填)语义不同
//
// ⚠️ 不要与 GetPeripheralInfo(asset_id 查)混用,字段语义不同会查不到
func (r *PeripheralRepository) GetPeripheralInfoByID(ctx context.Context, id int64) (*models.PeripheralInfo, error) {
if id <= 0 {
return nil, errors.New("id must be greater than 0")
}
var info models.PeripheralInfo
err := r.db.WithContext(ctx).
Where("code = ?", code).
Where("id = ?", id).
First(&info).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &info, nil
}
// GetPeripheralInfoByHash 按加密 code(code_hash)查(stage 1 唯一扫码入口,详见 §5.1)
//
// 流程:peripheral_verify_code.code_hash → peripheral_info_id → peripheral_info
//
// code_hash 是 URL 中实际出现的"密文 code",由 admin 后端写入(决策 #8)
// 不可逆,只能查表;code_hash UNIQUE 索引保证查询性能
func (r *PeripheralRepository) GetPeripheralInfoByHash(ctx context.Context, codeHash string) (*models.PeripheralInfo, error) {
if codeHash == "" {
return nil, errors.New("code_hash required")
}
// step 1: 查 verify_code 表拿到 peripheral_info_id
var verifyCode models.PeripheralVerifyCode
err := r.db.WithContext(ctx).
Where("code_hash = ?", codeHash).
First(&verifyCode).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &info, nil
if verifyCode.PeripheralInfoID == nil {
// 码已生成但 peripheral_info 还没建(罕见,理论不会出现)
return nil, nil
}
// step 2: 查 peripheral_info(按主键 id,不要用 GetPeripheralInfo(asset_id))
return r.GetPeripheralInfoByID(ctx, *verifyCode.PeripheralInfoID)
}
// UpdatePeripheralInfoOnMint mint 时回填 asset_id 和 user_id

View File

@ -98,6 +98,65 @@ func TestPeripheralRepo_GetPeripheralInfo_NotFound(t *testing.T) {
}
}
// TestPeripheralRepo_GetPeripheralInfoByID_Exists 验证按 peripheral_info 主键 id 查到记录
// ★ 回归测试:2026-07-17 by-hash 接口 50003,根因是 GetPeripheralInfoByHash 错把 peripheral_info.id
//
// 传给 GetPeripheralInfo(asset_id 查),查不到。新增按 id 查的方法。
func TestPeripheralRepo_GetPeripheralInfoByID_Exists(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := NewPeripheralRepository(db)
setupPeripheralTestData(t, db, true) // 创建 peripheral_info
// 直接查 peripheral_info.id(setupPeripheralTestData 没返回这个值)
var peripheralID int64
if err := db.Raw(`SELECT id FROM peripheral_info WHERE code = 'PERI-2026-001' LIMIT 1`).Scan(&peripheralID).Error; err != nil {
t.Fatalf("query peripheral_info.id failed: %v", err)
}
if peripheralID == 0 {
t.Fatal("expected peripheral_info.id > 0, got 0")
}
got, err := repo.GetPeripheralInfoByID(context.Background(), peripheralID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected PeripheralInfo, got nil")
}
if got.ID != peripheralID {
t.Errorf("expected id=%d, got %d", peripheralID, got.ID)
}
if got.Brand != "TopFans" {
t.Errorf("expected brand=TopFans, got %s", got.Brand)
}
}
func TestPeripheralRepo_GetPeripheralInfoByID_NotFound(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := NewPeripheralRepository(db)
got, err := repo.GetPeripheralInfoByID(context.Background(), 999999999999)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
}
func TestPeripheralRepo_GetPeripheralInfoByID_InvalidID(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := NewPeripheralRepository(db)
_, err := repo.GetPeripheralInfoByID(context.Background(), 0)
if err == nil {
t.Fatal("expected error for id=0, got nil")
}
}
// TestPeripheralRepo_ExistsRegistry_True 验证 ExistsRegistry 命中已有记录
func TestPeripheralRepo_ExistsRegistry_True(t *testing.T) {
db := setupTestDB(t)

View File

@ -6,6 +6,7 @@ import (
"fmt"
"time"
"github.com/topfans/backend/pkg/peripheral"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/services/assetService/repository"
@ -26,10 +27,12 @@ func (e *BizError) Error() string { return fmt.Sprintf("[%d] %s", e.Code, e.Mess
// 周边业务码(spec §4.1/§4.2/§8.2 错误码表)
const (
BizCodeAssetNotFound = 50003 // 物品不存在或已下架
BizCodeAlreadyAdded = 50004 // 您已添加过此周边
BizCodeCannotAddSelf = 50011 // 防御性:周边不该出现
BizCodeRateLimited = 50012 // 今日提交过于频繁
BizCodeAssetNotFound = 50003 // 物品不存在或已下架
BizCodeAlreadyAdded = 50004 // 您已添加过此周边
BizCodeCannotAddSelf = 50011 // 防御性:周边不该出现
BizCodeRateLimited = 50012 // 今日提交过于频繁
BizCodeInvalidSignature = 50013 // URL sign 验证失败(§5.1.3 第二道防线)
BizCodeInactive = 50014 // 周边尚未激活(未上架)
)
// 藏品类型常量(2026-07-10 加)
@ -112,46 +115,70 @@ func (s *PeripheralService) GetVerification(ctx context.Context, assetID int64)
}, nil
}
// GetVerificationByCode 按周边编号(code)查验真(spec §4.1)
// GetVerificationByHash 按加密 code(code_hash)查验真(stage 1 唯一验真入口,详见 §5.1.3/§5.1.5)
//
// 流程:查 peripheral_info WHERE code = ? → 组装响应(asset_id 为 NULL 时也返回)
func (s *PeripheralService) GetVerificationByCode(ctx context.Context, code string) (*VerificationResult, error) {
info, err := s.repo.GetPeripheralInfoByCode(ctx, code)
// 流程:verify sign(防 URL 篡改) → peripheral_verify_code.code_hash → peripheral_info
//
// 入参:
// - codeHash:URL path 中的 encrypted_code(32 hex,HMAC-SHA256(code)[:32])
// - sign:URL query 中的 HMAC 签名(第二道防线)
func (s *PeripheralService) GetVerificationByHash(ctx context.Context, codeHash, sign string) (*VerificationResult, error) {
logger.Logger.Info("DEBUG GetVerificationByHash start", zap.String("codeHash", codeHash), zap.String("sign", sign))
// step 1: 验证 sign(第二道防线,必须在查 DB 之前,详见 §5.1.5)
if !verifySignOrFail(codeHash, sign) {
logger.Logger.Warn("DEBUG sign verify failed")
return nil, &BizError{Code: BizCodeInvalidSignature, Message: "签名错误,URL 可能被篡改"}
}
logger.Logger.Info("DEBUG sign verify passed")
// step 2: 查表(走新增的 repository.GetPeripheralInfoByHash)
info, err := s.repo.GetPeripheralInfoByHash(ctx, codeHash)
if err != nil {
return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_CODE_FAILED: %w", err)
logger.Logger.Error("DEBUG repo error", zap.Error(err))
return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_hash_FAILED: %w", err)
}
if info == nil {
logger.Logger.Warn("DEBUG info is nil (not found)")
return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
}
logger.Logger.Info("DEBUG info found", zap.Int64("id", info.ID), zap.String("code", info.Code), zap.Any("status", info.Status))
// step 2.5: 校验 status(未激活 / 已作废 / 已冻结 不可扫)
// status=0 待激活、=2 已作废、=3 已冻结 — 三种状态都不应验真成功
if info.Status != nil && *info.Status != 1 {
return nil, &BizError{
Code: BizCodeInactive,
Message: statusInactiveMessage(*info.Status),
}
}
assetID := int64(0)
if info.AssetID != nil {
assetID = *info.AssetID
}
// 扫码时原子自增 verify_count,同时处理首次验证时间
// step 3: 原子自增 verify_count
nowMs := time.Now().UnixMilli()
newCount, firstVerifiedAt, err := s.repo.IncrementVerifyCount(ctx, info.ID, nowMs)
if err != nil {
// 自增失败仅打 WARN,不阻塞主流程(验真数据仍可返回)
logger.Logger.Warn("IncrementVerifyCount failed",
zap.Error(err),
zap.Int64("peripheral_info_id", info.ID),
)
newCount = info.VerifyCount // fallback:自增前的值
firstVerifiedAt = info.FirstVerifiedAt // fallback:旧值
newCount = info.VerifyCount
firstVerifiedAt = info.FirstVerifiedAt
}
return &VerificationResult{
AssetID: assetID, // 可能为 0(mint 前)
AssetID: assetID,
Code: info.Code,
Company: info.Company,
Hash: info.Hash,
VerifyCount: newCount, // 实时计数(含本次扫码)
VerifyCount: newCount,
Brand: info.Brand,
Image: info.Image,
Verifier: info.Verifier,
VerifiedAt: firstVerifiedAt / 1000, // 毫秒 → 秒
VerifiedAt: firstVerifiedAt / 1000,
MaterialType: MintMaterialTypeNew,
}, nil
}
@ -164,24 +191,36 @@ type MintResult struct {
CoverImage string `json:"cover_image"`
}
// MintFromPeripheral 加入藏品(简化版 mint,跳过 AI 链路)
// MintFromPeripheralByHash 按加密 code(code_hash)加入藏品(stage 1 唯一入口,详见 §5.1.5)
//
// 流程:查 peripheral_info(code) → mint 后建新 asset → INSERT asset_registry → 异步刷 verify_count
func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID int64, code string) (*MintResult, error) {
// 1. 查 peripheral_info(按 code,asset_id 可能为 NULL)
info, err := s.repo.GetPeripheralInfoByCode(ctx, code)
// 流程:verify sign → peripheral_verify_code.code_hash → peripheral_info → mint
func (s *PeripheralService) MintFromPeripheralByHash(ctx context.Context, ownerUID int64, codeHash, sign string) (*MintResult, error) {
// step 1: 验签(第二道防线)
if !verifySignOrFail(codeHash, sign) {
return nil, &BizError{Code: BizCodeInvalidSignature, Message: "签名错误,URL 可能被篡改"}
}
// step 2: 按 hash 查 peripheral_info
info, err := s.repo.GetPeripheralInfoByHash(ctx, codeHash)
if err != nil {
return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_CODE_FAILED: %w", err)
return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_HASH_FAILED: %w", err)
}
if info == nil {
return nil, &BizError{Code: BizCodeAssetNotFound, Message: "周边不存在"}
}
return s.doMint(ctx, ownerUID, info)
}
// doMint mint 核心逻辑(由 MintFromPeripheralByHash 调用)
//
// 流程:查重 → 限频 → 建 asset → 回填 peripheral_info → INSERT asset_registry → 异步刷 verify_count
func (s *PeripheralService) doMint(ctx context.Context, ownerUID int64, info *models.PeripheralInfo) (*MintResult, error) {
assetID := int64(0)
if info.AssetID != nil {
assetID = *info.AssetID
}
// 2. 查重(依赖已有 uk_registry_owner_star_type_asset 约束)
// 1. 查重(依赖已有 uk_registry_owner_star_type_asset 约束)
exists, err := s.repo.ExistsRegistry(ctx, ownerUID, assetID, "peripheral")
if err != nil {
return nil, fmt.Errorf("DB_EXISTS_FAILED: %w", err)
@ -190,7 +229,7 @@ func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID int
return nil, &BizError{Code: BizCodeAlreadyAdded, Message: "您已添加过此周边"}
}
// 3. 限频:24h 最多 10 次
// 2. 限频:24h 最多 10 次
count, err := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour)
if err != nil {
return nil, fmt.Errorf("DB_COUNT_FAILED: %w", err)
@ -199,7 +238,7 @@ func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID int
return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
}
// 4. 建新 asset(从 peripheral_info 字段填充)
// 3. 建新 asset(从 peripheral_info 字段填充)
tmpAssetID := assetID
if tmpAssetID == 0 {
tmpAssetID = 0 // 让 GORM 分配
@ -221,14 +260,14 @@ func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID int
return nil, fmt.Errorf("DB_CREATE_ASSET_FAILED: %w", err)
}
// 4.1 回填 peripheral_info.asset_id 和 user_id
// 3.1 回填 peripheral_info.asset_id 和 user_id
if err := s.repo.UpdatePeripheralInfoOnMint(ctx, info.ID, newAssetID, ownerUID); err != nil {
logger.Logger.Warn("UpdatePeripheralInfoOnMint failed (non-blocking)",
zap.Error(err), zap.Int64("peripheral_info_id", info.ID),
)
}
// 5. INSERT asset_registry
// 4. INSERT asset_registry
newID, createdAtMs, err := s.repo.InsertPeripheralRegistry(ctx, &models.AssetRegistry{
OwnerUID: ownerUID,
AssetID: newAssetID,
@ -249,7 +288,7 @@ func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID int
return nil, fmt.Errorf("DB_INSERT_FAILED: %w", err)
}
// 6. 异步刷新 verify_count(失败仅日志,不阻塞 mint 主流程)
// 5. 异步刷新 verify_count(失败仅日志,不阻塞 mint 主流程)
//
// ★ P0 修复:加 panic recovery,防止内部 panic(数据库驱动、事务关闭、未来 JOIN 错误等)
// crash 整个 gateway 进程;同时把 `_ =` 吞错改为显式 WARN 日志,
@ -279,10 +318,35 @@ func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID int
}, nil
}
// verifySignOrFail 验签 helper(包装 peripheral.VerifySign,加日志)
func verifySignOrFail(encryptedCode, sign string) bool {
ok := peripheral.VerifySign(encryptedCode, sign)
if !ok {
logger.Logger.Warn("Peripheral sign verification failed",
zap.String("encrypted_code_prefix", encryptedCode[:8]+"..."),
)
}
return ok
}
// strPtr 字符串 → *string helper
func strPtr(s string) *string { return &s }
// statusInactiveMessage 周边未激活的友好提示
func statusInactiveMessage(status int16) string {
switch status {
case 0:
return "该周边尚未激活,请联系客服"
case 2:
return "该周边已作废"
case 3:
return "该周边已冻结"
case 4:
return "该周边已核销"
default:
return "该周边当前不可验真"
}
}
// derefStr *string → string(nil 返 "")
func derefStr(p *string) string {
if p == nil {

View File

@ -62,4 +62,6 @@ SMS_REGION=cn-hangzhou
# DIFY_API_BASE=http://101.132.250.62:8083/v1
# DIFY_API_KEY=app-iCsnp0R2jJppKdmrpoeOxEfL
# ==================== 周边防伪验真 ====================
SECRET_KEY=52ca8f411d925eef02dadd1957485821df9b7a54555301c1c32d58677f1815bce49cdbfb7e2780e8cbd962057c24078f8042903c842bec12d36644dad5a3f0f2

View File

@ -45,28 +45,28 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getPeripheralByCodeApi, mintToMyCollectionApi } from '@/utils/api.js'
import { getPeripheralByHashApi, mintToMyCollectionByHashApi } from '@/utils/api.js'
const code = ref('')
// stage 1 : URL /verify/{encrypted_code}?sign=xxx
// encrypted_code(32 hex)+ sign(32 hex) QR , code
const HEX32_RE = /^[0-9a-f]{32}$/i
const encryptedCode = ref('')
const sign = ref('')
const data = ref(null)
const loading = ref(true)
const error = ref('')
const submitting = ref(false)
onLoad((options) => {
// code (mint )
if (options.code) {
code.value = String(options.code)
// : encrypted_code + sign
if (HEX32_RE.test(options.encryptedCode || '') && options.sign) {
encryptedCode.value = String(options.encryptedCode)
sign.value = String(options.sign)
loadData()
return
}
// :asset_id
if (options.assetId) {
code.value = String(options.assetId)
loadData()
return
}
error.value = '二维码数据无效'
// ( code / asset_id)
error.value = '请使用最新版本扫描周边二维码'
loading.value = false
})
@ -95,8 +95,12 @@ async function loadData() {
loading.value = true
error.value = ''
try {
// code peripheral_info()
const res = await getPeripheralByCodeApi(code.value)
// stage 1: code ( design §5.1.3)
const res = await getPeripheralByHashApi(encryptedCode.value, sign.value)
// stage 1 50013(sign )
if (res?.code === 50013) {
throw new Error('验签失败,二维码可能已损坏或被篡改,请重新扫码')
}
if (!res || !res.data) throw new Error(res?.message || '此物品暂无验真信息')
data.value = res.data
} catch (e) {
@ -110,8 +114,7 @@ async function handleAddToCollection() {
if (submitting.value) return
submitting.value = true
try {
const res = await mintToMyCollectionApi(code.value)
// console.log('mintToMyCollectionApi res:', res)
const res = await mintToMyCollectionByHashApi(encryptedCode.value, sign.value)
uni.showToast({ title: '已加入我的藏品', icon: 'success' })
setTimeout(() => {
uni.navigateTo({
@ -159,6 +162,7 @@ function formatDate(ts) {
position: relative;
top: 96rpx;
z-index: 1;
margin-bottom: 24rpx;
}
.nav-back {
@ -199,7 +203,7 @@ function formatDate(ts) {
.info-card {
/* background: #1a1a1a; */
border-radius: 16rpx;
padding: 24rpx;
/* padding: 24rpx; */
margin-top: 24rpx;
}

View File

@ -1398,25 +1398,32 @@ export function trackShareApi(payload) {
/**
* 按周边编号查验真(GET /api/v1/peripheral-info/by-code/:code,扫码入口)
* @param {string} code 周边实体唯一编号
* 按加密 code 验真(stage 1 新版,详见 design §5.1.3/§5.1.5)
*
* URL 形如 /verify/{encrypted_code}?sign={sign},encrypted_code + sign
* 都从扫码 URL 中提取,传给本接口查询验真信息
*
* @param {string} encryptedCode 32 hex(160 bit),HMAC-SHA256(code)[:32]
* @param {string} sign 32 hex,HMAC-SHA256(encrypted_code)[:32](第二道防线)
* @returns {Promise<VerificationData>}
*/
export function getPeripheralByCodeApi(code) {
export function getPeripheralByHashApi(encryptedCode, sign) {
return request({
url: `/api/v1/peripheral-info/by-code/${encodeURIComponent(code)}`,
url: `/api/v1/peripheral-info/by-hash/${encodeURIComponent(encryptedCode)}?sign=${encodeURIComponent(sign)}`,
method: 'GET'
})
}
/**
* 加入我的藏品(简化版 mint,POST /api/v1/peripheral-info/mint-by-code/:code)
* @param {string} code 周边实体唯一编号
* 加入我的藏品(stage 1 新版,POST /api/v1/peripheral-info/mint-by-hash/:hash)
*
* @param {string} encryptedCode 32 hex
* @param {string} sign 32 hex
* @returns {Promise<MintData>}
*/
export function mintToMyCollectionApi(code) {
return request({
url: `/api/v1/peripheral-info/mint-by-code/${encodeURIComponent(code)}`,
method: 'POST'
})
}
export function mintToMyCollectionByHashApi(encryptedCode, sign) {
return request({
url: `/api/v1/peripheral-info/mint-by-hash/${encodeURIComponent(encryptedCode)}?sign=${encodeURIComponent(sign)}`,
method: 'POST'
})
}

View File

@ -1,7 +1,7 @@
/**
* 扫码结果处理(纯函数 + 副作用拆分)
* @param {string} rawUrl uni.scanCode 回调里的 result 字符串
* @returns {{ ok: true, assetId: number } | { ok: false, reason: string }}
* @returns {{ ok: true, encryptedCode: string, sign: string, rawUrl: string } | { ok: false, reason: string }}
*
* 兼容处理(2026-07-10 ):
* 1) 剥首尾空白(扫码 app 有时会带 \n / 空格)
@ -11,6 +11,9 @@
* 5) path 自动 strip 末尾 /(扫码有时会带 trailing slash)
* 6) JSON 包装 {url: "..."} 拆包
* 7) topfans:// 自定义 scheme(从 H5 唤起 app 时用)
*
* stage 1 强制(2026-07-17 ):必须同时取到 encrypted_code(32 hex) + sign(32 hex)
* 老版明文 code / asset_id 一律拒绝(verify.vue onLoad 会再校验一次)
*/
export function parseVerifyUrl(rawUrl) {
// ★ 诊断:在函数入口打印 raw 输入,方便定位扫码 app 返回什么
@ -96,8 +99,23 @@ export function parseVerifyUrl(rawUrl) {
return { ok: false, reason: '二维码格式不正确' }
}
console.log('[parseVerifyUrl] ✅ code=' + code)
return { ok: true, code, rawUrl }
// 8.5) 提取 sign(?sign=...)—— stage 1 强制要求,缺失或格式不对直接拒
const queryPart = urlMatch[4] || ''
const signMatch = queryPart.match(/[?&]sign=([^&#]+)/i)
const sign = signMatch ? decodeURIComponent(signMatch[1]).trim() : ''
if (!/^[0-9a-f]{32}$/i.test(sign)) {
console.warn('[parseVerifyUrl] sign missing/invalid:', sign || '(空)')
return { ok: false, reason: '二维码格式不正确' }
}
// 8.6) code 也要是 32 hex(stage 1 契约:encrypted_code = HMAC-SHA256(realCode)[:32])
if (!/^[0-9a-f]{32}$/i.test(code)) {
console.warn('[parseVerifyUrl] code not 32-hex:', code)
return { ok: false, reason: '二维码格式不正确' }
}
console.log('[parseVerifyUrl] ✅ encryptedCode=' + code + ', sign=' + sign.slice(0, 8) + '...')
return { ok: true, encryptedCode: code, sign, rawUrl }
}
/**
@ -109,7 +127,7 @@ export async function onScanResult(rawUrl) {
uni.showToast({ title: parsed.reason, icon: 'none' })
return
}
await navigateToVerify(parsed.code)
await navigateToVerify(parsed.encryptedCode, parsed.sign)
}
/**
@ -121,7 +139,7 @@ export async function onDeepLinkTo(rawUrl) {
// 静默:系统唤起常因剪贴板/分享被截获的旧 URL 出现,不应弹 toast
return
}
await navigateToVerify(parsed.code)
await navigateToVerify(parsed.encryptedCode, parsed.sign)
}
/**
@ -133,10 +151,14 @@ export async function onDeepLinkTo(rawUrl) {
* 1) 未登录时先查 getCurrentPages() 末页是否已在 portal,是则跳过 navigateTo
* 2) 始终把目标 URL 存到 storage(`pending_scan_url`),即使 preloader 先跳 portal
* 抢走了 redirect param,portal 仍能从 storage 读出来(后续 portal 侧需配合读取)
*
* stage 1 调整(2026-07-17):参数从 (code) 改为 (encryptedCode, sign),
* verify.vue onLoad 强校验 options.encryptedCode + options.sign,
* query 串必须用 encryptedCode / sign 两个名字(参见 verify.vue:62)
*/
async function navigateToVerify(code) {
async function navigateToVerify(encryptedCode, sign) {
const token = uni.getStorageSync('access_token') || ''
const targetUrl = `/pages/scan/verify?code=${encodeURIComponent(String(code))}`
const targetUrl = `/pages/scan/verify?encryptedCode=${encodeURIComponent(String(encryptedCode))}&sign=${encodeURIComponent(String(sign))}`
// 1) 始终落盘目标 URL,登录后 portal 可读
uni.setStorageSync('pending_scan_url', targetUrl)