From d554d7be834c315bd650c92d99129df0a1b92a2a Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Fri, 17 Jul 2026 16:53:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E5=91=A8=E8=BE=B9?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 9 ++ .../controller/peripheral_controller.go | 56 +++++--- .../controller/peripheral_controller_test.go | 8 +- backend/gateway/router/router.go | 10 +- backend/pkg/models/peripheral_info.go | 28 ++-- backend/pkg/models/peripheral_verify_code.go | 33 +++++ backend/pkg/peripheral/sign.go | 78 +++++++++++ .../repository/peripheral_repo.go | 52 +++++++- .../repository/peripheral_repo_test.go | 59 +++++++++ .../service/peripheral_service.go | 122 +++++++++++++----- docker/.env.prod | 2 + frontend/pages/scan/verify.vue | 38 +++--- frontend/utils/api.js | 31 +++-- frontend/utils/scanLaunch.js | 36 +++++- 14 files changed, 454 insertions(+), 108 deletions(-) create mode 100644 backend/pkg/models/peripheral_verify_code.go create mode 100644 backend/pkg/peripheral/sign.go diff --git a/backend/.env.example b/backend/.env.example index 86d0cbc..a99d7fd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/gateway/controller/peripheral_controller.go b/backend/gateway/controller/peripheral_controller.go index 621a5fd..58abf33 100644 --- a/backend/gateway/controller/peripheral_controller.go +++ b/backend/gateway/controller/peripheral_controller.go @@ -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 diff --git a/backend/gateway/controller/peripheral_controller_test.go b/backend/gateway/controller/peripheral_controller_test.go index 41d7859..13fff67 100644 --- a/backend/gateway/controller/peripheral_controller_test.go +++ b/backend/gateway/controller/peripheral_controller_test.go @@ -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()) diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 664a964..097ec89 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -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/* 前缀) diff --git a/backend/pkg/models/peripheral_info.go b/backend/pkg/models/peripheral_info.go index 6beaea5..25f4443 100644 --- a/backend/pkg/models/peripheral_info.go +++ b/backend/pkg/models/peripheral_info.go @@ -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" } diff --git a/backend/pkg/models/peripheral_verify_code.go b/backend/pkg/models/peripheral_verify_code.go new file mode 100644 index 0000000..623a131 --- /dev/null +++ b/backend/pkg/models/peripheral_verify_code.go @@ -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" } \ No newline at end of file diff --git a/backend/pkg/peripheral/sign.go b/backend/pkg/peripheral/sign.go new file mode 100644 index 0000000..b053099 --- /dev/null +++ b/backend/pkg/peripheral/sign.go @@ -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)) +} \ No newline at end of file diff --git a/backend/services/assetService/repository/peripheral_repo.go b/backend/services/assetService/repository/peripheral_repo.go index 557e41e..5775f1b 100644 --- a/backend/services/assetService/repository/peripheral_repo.go +++ b/backend/services/assetService/repository/peripheral_repo.go @@ -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 diff --git a/backend/services/assetService/repository/peripheral_repo_test.go b/backend/services/assetService/repository/peripheral_repo_test.go index cb82390..a2f2d84 100644 --- a/backend/services/assetService/repository/peripheral_repo_test.go +++ b/backend/services/assetService/repository/peripheral_repo_test.go @@ -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) diff --git a/backend/services/assetService/service/peripheral_service.go b/backend/services/assetService/service/peripheral_service.go index 4d702cf..3968eba 100644 --- a/backend/services/assetService/service/peripheral_service.go +++ b/backend/services/assetService/service/peripheral_service.go @@ -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 { diff --git a/docker/.env.prod b/docker/.env.prod index e3ebece..eb68520 100644 --- a/docker/.env.prod +++ b/docker/.env.prod @@ -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 diff --git a/frontend/pages/scan/verify.vue b/frontend/pages/scan/verify.vue index dfe5286..5a74e2c 100644 --- a/frontend/pages/scan/verify.vue +++ b/frontend/pages/scan/verify.vue @@ -45,28 +45,28 @@