@@ -0,0 +1,7 @@
|
||||
package activityclient
|
||||
|
||||
// Start 启动活动服客户端的所有定时同步任务(域名 + 红包场次等)
|
||||
func Start() {
|
||||
startDomainSync()
|
||||
startHongbaoSync()
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package activityclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/common"
|
||||
"91porn-server/common/httputil"
|
||||
"91porn-server/common/log"
|
||||
)
|
||||
|
||||
// 抵扣券状态回调(与 activity-public-server /api/app/deduct/coupon/notify 约定一致)。
|
||||
// 券状态流转由本服双回调驱动:建单成功->used、支付成功->verified。
|
||||
const (
|
||||
DeductCouponStatusUsed = "used" // 建单成功: 券 active -> used(下单占用/已使用),需携 deductAmount
|
||||
DeductCouponStatusVerified = "verified" // 支付成功: 券 used -> verified(已核销),usedCount++
|
||||
DeductCouponStatusFailed = "failed" // 支付失败: 券 used -> active(释放)
|
||||
DeductCouponStatusClosed = "closed" // 订单关闭/超时: 券 used -> active(释放)
|
||||
)
|
||||
|
||||
const deductCouponNotifyPath = "/api/app/deduct/coupon/notify"
|
||||
|
||||
// deductCouponNotifyReq 支付结果回调请求体
|
||||
type deductCouponNotifyReq struct {
|
||||
CouponID string `json:"couponId"` // 下单时收到的券号,原样回传
|
||||
OrderID string `json:"orderId,omitempty"` // 本服订单号,活动服存档对账用
|
||||
Status string `json:"status"` // paid/failed/closed
|
||||
DeductAmount int64 `json:"deductAmount,omitempty"` // 实际抵扣金额(分),对账用
|
||||
}
|
||||
|
||||
// NotifyDeductCoupon 向活动服回调抵扣券支付结果。
|
||||
// 鉴权走方向B(X-Svc-*),密钥用 secretKey 原始字节(BuildSvcHeaders 已按此实现)。
|
||||
// 单次调用;活动服对 couponId 幂等,重复回调安全。
|
||||
// 返回 nil 表示活动服已受理(HTTP 200 且 body.code==200)。
|
||||
func NotifyDeductCoupon(ctx context.Context, couponID, orderID, status string, deductAmount int64) error {
|
||||
conf := appg.Conf.ActivityServer
|
||||
if conf.ApiUrl == "" || conf.AppId == "" {
|
||||
return fmt.Errorf("活动服配置缺失(apiUrl/appId)")
|
||||
}
|
||||
if couponID == "" {
|
||||
return fmt.Errorf("couponId 为空")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(deductCouponNotifyReq{
|
||||
CouponID: couponID,
|
||||
OrderID: orderID,
|
||||
Status: status,
|
||||
DeductAmount: deductAmount,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal notify body failed: %w", err)
|
||||
}
|
||||
|
||||
// 签名针对下面实际发送的 body 字节;httputil 对 []byte 原样透传,两者一致
|
||||
headers := BuildSvcHeaders(conf.AppId, conf.SecretKey, "POST", deductCouponNotifyPath, "", body)
|
||||
if headers == nil {
|
||||
return fmt.Errorf("生成服务间签名头失败")
|
||||
}
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
resp, err := httputil.DefaultClientPostWithCtx(ctx, conf.ApiUrl+deductCouponNotifyPath, headers, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求活动服失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("活动服回调HTTP状态异常: %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||||
return fmt.Errorf("解析活动服回调响应失败: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
if out.Code != 200 {
|
||||
return fmt.Errorf("活动服回调返回非成功: code=%d, msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyDeductCouponAsync 异步回调抵扣券状态,失败按 1s/3s/5s/10s 退避重试。
|
||||
// 独立 goroutine 执行,不阻塞下单/支付主流程;最终仍失败仅记录错误——
|
||||
// 活动服会在 used 券超 30 分钟未核销时由定时任务兜底释放回 active,不会丢一致性。
|
||||
func NotifyDeductCouponAsync(couponID, orderID, status string, deductAmount int64) {
|
||||
if couponID == "" {
|
||||
return
|
||||
}
|
||||
common.Go(func() {
|
||||
backoffs := []time.Duration{0, time.Second, 3 * time.Second, 5 * time.Second, 10 * time.Second}
|
||||
var lastErr error
|
||||
for i, d := range backoffs {
|
||||
if d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
lastErr = NotifyDeductCoupon(ctx, couponID, orderID, status, deductAmount)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
if i > 0 {
|
||||
log.Info("活动服-抵扣券回调重试成功",
|
||||
log.Any("couponId", couponID), log.Any("status", status), log.Any("attempt", i+1))
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Warn("活动服-抵扣券回调失败,待重试",
|
||||
log.Any("couponId", couponID), log.Any("orderId", orderID),
|
||||
log.Any("status", status), log.Any("attempt", i+1), log.E(lastErr))
|
||||
}
|
||||
log.Error("活动服-抵扣券回调最终失败(依赖活动服到期兜底释放)",
|
||||
log.Any("couponId", couponID), log.Any("orderId", orderID),
|
||||
log.Any("status", status), log.Any("deductAmount", deductAmount), log.E(lastErr))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package activityclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/common"
|
||||
"91porn-server/common/httputil"
|
||||
"91porn-server/common/log"
|
||||
"91porn-server/models/v/usermod"
|
||||
"91porn-server/models/v/walletmod"
|
||||
)
|
||||
|
||||
const (
|
||||
ReplaceDomain = "https://activity.domain.com"
|
||||
)
|
||||
|
||||
// DomainItem 活动服域名项(与 activity-public-server 的 /api/app/domains 返回结构一致)
|
||||
type DomainItem struct {
|
||||
Domain string `json:"domain"` // 域名
|
||||
Remark string `json:"remark"` // 备注
|
||||
}
|
||||
|
||||
// domainsResp 活动服域名接口响应
|
||||
type domainsResp struct {
|
||||
Code int `json:"code"`
|
||||
Data []DomainItem `json:"data"`
|
||||
}
|
||||
|
||||
var (
|
||||
cachedDomains []DomainItem
|
||||
domainsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetActivityDomains 获取缓存的活动服域名列表
|
||||
func GetActivityDomains() []DomainItem {
|
||||
domainsMu.RLock()
|
||||
defer domainsMu.RUnlock()
|
||||
return cachedDomains
|
||||
}
|
||||
|
||||
// GetActivityDomain 随机获取一个可用的活动服域名
|
||||
func GetActivityDomain() string {
|
||||
domainsMu.RLock()
|
||||
defer domainsMu.RUnlock()
|
||||
n := len(cachedDomains)
|
||||
if n > 0 {
|
||||
return cachedDomains[rand.Intn(n)].Domain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReplaceActivityDomain 替换活动服占位域名,并追加授权参数 appId、sign
|
||||
func ReplaceActivityDomain(rawURL string, user *usermod.User, w *walletmod.Wallet) string {
|
||||
if !strings.Contains(rawURL, ReplaceDomain) {
|
||||
return rawURL
|
||||
}
|
||||
domain := GetActivityDomain()
|
||||
if domain == "" {
|
||||
return rawURL
|
||||
}
|
||||
rawURL = strings.ReplaceAll(rawURL, ReplaceDomain, domain)
|
||||
|
||||
if user == nil {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
conf := appg.Conf.ActivityServer
|
||||
now := time.Now()
|
||||
rechargeAmount := 0
|
||||
if w != nil {
|
||||
rechargeAmount = int(w.Consumption)
|
||||
}
|
||||
payload := &SignPayload{
|
||||
AppId: conf.AppId,
|
||||
UserId: strconv.FormatUint(user.UID, 10),
|
||||
Nickname: user.Name,
|
||||
Avatar: user.Portrait,
|
||||
Ts: now.Unix(),
|
||||
RegisteredAt: user.CreatedAt.Unix(),
|
||||
RechargeAmount: rechargeAmount,
|
||||
}
|
||||
sign, err := EncryptSign(conf.SecretKey, payload)
|
||||
if err != nil {
|
||||
log.Error("活动服-生成签名失败", log.E(err))
|
||||
return rawURL
|
||||
}
|
||||
|
||||
if strings.Contains(rawURL, "?") {
|
||||
rawURL += "&inner=1&appId=" + conf.AppId + "&sign=" + sign
|
||||
} else {
|
||||
rawURL += "?inner=1&appId=" + conf.AppId + "&sign=" + sign
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
var hrefRe = regexp.MustCompile(`href="([^"]*)"`)
|
||||
|
||||
// ReplaceActivityDomainInHTML 替换富文本 HTML 中所有 href 里的活动服占位域名
|
||||
func ReplaceActivityDomainInHTML(htmlStr string, user *usermod.User, w *walletmod.Wallet) string {
|
||||
if !strings.Contains(htmlStr, ReplaceDomain) {
|
||||
return htmlStr
|
||||
}
|
||||
return hrefRe.ReplaceAllStringFunc(htmlStr, func(match string) string {
|
||||
url := match[6 : len(match)-1] // 去掉 href=" 前缀和 " 后缀
|
||||
return `href="` + ReplaceActivityDomain(url, user, w) + `"`
|
||||
})
|
||||
}
|
||||
|
||||
// startDomainSync 定时拉取活动服域名(每5分钟)
|
||||
func startDomainSync() {
|
||||
fetchDomains()
|
||||
common.Go(func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
fetchDomains()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fetchDomains 从活动服拉取可用域名列表
|
||||
func fetchDomains() {
|
||||
conf := appg.Conf.ActivityServer
|
||||
apiUrl := conf.ApiUrl
|
||||
if apiUrl == "" {
|
||||
return
|
||||
}
|
||||
|
||||
const path = "/api/app/domains"
|
||||
url := apiUrl + path
|
||||
headers := BuildSvcHeaders(conf.AppId, conf.SecretKey, "GET", path, "", nil)
|
||||
_, body, err := httputil.DefaultClientGetBytes(url, headers)
|
||||
if err != nil {
|
||||
log.Error("活动服-拉取域名列表失败", log.Any("url", url), log.E(err))
|
||||
return
|
||||
}
|
||||
|
||||
var resp domainsResp
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
log.Error("活动服-解析域名列表失败", log.Any("body", string(body)), log.E(err))
|
||||
return
|
||||
}
|
||||
|
||||
if resp.Code != 200 {
|
||||
log.Warn("活动服-域名列表返回非成功状态", log.Any("code", resp.Code))
|
||||
return
|
||||
}
|
||||
|
||||
domainsMu.Lock()
|
||||
cachedDomains = resp.Data
|
||||
domainsMu.Unlock()
|
||||
|
||||
log.Info("活动服-域名列表更新成功", log.Any("count", len(resp.Data)))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package activityclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/common"
|
||||
"91porn-server/common/httputil"
|
||||
"91porn-server/common/log"
|
||||
)
|
||||
|
||||
// HongbaoActivity 与 activity-public-server activityMod.HongbaoActivity 保持一致
|
||||
type HongbaoActivity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
TriggerTimes []string `json:"triggerTimes"`
|
||||
SessionDuration int `json:"sessionDuration"`
|
||||
SessionClickLimit int `json:"sessionClickLimit"`
|
||||
AppIds []string `json:"appIds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpcomingSessionItem 进行中/即将开启的场次
|
||||
type UpcomingSessionItem struct {
|
||||
SessionId string `json:"sessionId,omitempty"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
Status string `json:"status"` // active=进行中, upcoming=未来即将开启
|
||||
}
|
||||
|
||||
// HongbaoUpcomingResp 拉取响应内容
|
||||
type HongbaoUpcomingResp struct {
|
||||
Activity *HongbaoActivity `json:"activity"`
|
||||
List []UpcomingSessionItem `json:"list"`
|
||||
}
|
||||
|
||||
type hongbaoApiResp struct {
|
||||
Code int `json:"code"`
|
||||
Data HongbaoUpcomingResp `json:"data"`
|
||||
}
|
||||
|
||||
var (
|
||||
cachedHongbao HongbaoUpcomingResp
|
||||
cachedHongbaoMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetHongbaoUpcoming 获取缓存的红包场次
|
||||
func GetHongbaoUpcoming() HongbaoUpcomingResp {
|
||||
cachedHongbaoMu.RLock()
|
||||
defer cachedHongbaoMu.RUnlock()
|
||||
return cachedHongbao
|
||||
}
|
||||
|
||||
// startHongbaoSync 启动定时拉取红包场次(每10秒)
|
||||
func startHongbaoSync() {
|
||||
fetchHongbaoUpcoming()
|
||||
common.Go(func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
fetchHongbaoUpcoming()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fetchHongbaoUpcoming 调活动服 /api/app/hongbao/upcoming
|
||||
func fetchHongbaoUpcoming() {
|
||||
conf := appg.Conf.ActivityServer
|
||||
if conf.ApiUrl == "" || conf.AppId == "" {
|
||||
return
|
||||
}
|
||||
|
||||
const path = "/api/app/hongbao/upcoming"
|
||||
rawQuery := "appId=" + conf.AppId
|
||||
url := conf.ApiUrl + path + "?" + rawQuery
|
||||
headers := BuildSvcHeaders(conf.AppId, conf.SecretKey, "GET", path, rawQuery, nil)
|
||||
_, body, err := httputil.DefaultClientGetBytes(url, headers)
|
||||
if err != nil {
|
||||
log.Error("活动服-拉取红包场次失败", log.Any("url", url), log.E(err))
|
||||
return
|
||||
}
|
||||
|
||||
var resp hongbaoApiResp
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
log.Error("活动服-解析红包场次失败", log.Any("body", string(body)), log.E(err))
|
||||
return
|
||||
}
|
||||
if resp.Code != 200 {
|
||||
log.Warn("活动服-红包场次返回非成功状态", log.Any("code", resp.Code))
|
||||
return
|
||||
}
|
||||
|
||||
cachedHongbaoMu.Lock()
|
||||
cachedHongbao = resp.Data
|
||||
cachedHongbaoMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package activityclient
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LinkTypeHongbaoRain 跳转链接中标识红包雨活动的 type 参数值
|
||||
const LinkTypeHongbaoRain = "hongbaoRain"
|
||||
|
||||
// ResolveCountdownByLink 根据跳转链接的 type 参数推导倒计时类型和场次时间。
|
||||
// 适用于 link 自身携带活动类型标识、但 DB 未单独建 countdownType 字段的业务(如任务列表)。
|
||||
//
|
||||
// 返回值约定:
|
||||
// - link 含 type=hongbaoRain 且有可用红包雨场次:返回 (场次开始, 场次结束, 1, true)
|
||||
// - link 含 type=hongbaoRain 但无可用场次:返回 (零, 零, 1, false),调用方据此过滤该条目
|
||||
// - 其他情况:返回 (零, 零, 0, true),无倒计时
|
||||
func ResolveCountdownByLink(link string) (start, end time.Time, countdownType int, ok bool) {
|
||||
if linkType(link) != LinkTypeHongbaoRain {
|
||||
return time.Time{}, time.Time{}, 0, true
|
||||
}
|
||||
s, e, hit := NextHongbaoSession(time.Now())
|
||||
if !hit {
|
||||
return time.Time{}, time.Time{}, 1, false
|
||||
}
|
||||
return s, e, 1, true
|
||||
}
|
||||
|
||||
// linkType 提取链接 query 中的 type 参数,解析失败返回空串
|
||||
func linkType(link string) string {
|
||||
if link == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Query().Get("type")
|
||||
}
|
||||
|
||||
// NextHongbaoSession 取一场用于倒计时展示的红包雨场次:
|
||||
// - 当前已处于某场次内(StartTime <= now < EndTime):返回该场次
|
||||
// - 否则返回最近一场未来未过期场次
|
||||
// - 无任何未过期场次:返回 (零值, 零值, false)
|
||||
func NextHongbaoSession(now time.Time) (start, end time.Time, ok bool) {
|
||||
data := GetHongbaoUpcoming()
|
||||
var (
|
||||
nearestStart time.Time
|
||||
nearestEnd time.Time
|
||||
)
|
||||
for _, s := range data.List {
|
||||
// 跳过已结束场次
|
||||
if !s.EndTime.IsZero() && !s.EndTime.After(now) {
|
||||
continue
|
||||
}
|
||||
// 当前已在场次内,直接返回
|
||||
if !s.StartTime.After(now) {
|
||||
return s.StartTime, s.EndTime, true
|
||||
}
|
||||
// 未来场次,取开始时间最早的
|
||||
if nearestStart.IsZero() || s.StartTime.Before(nearestStart) {
|
||||
nearestStart = s.StartTime
|
||||
nearestEnd = s.EndTime
|
||||
}
|
||||
}
|
||||
if nearestStart.IsZero() {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
return nearestStart, nearestEnd, true
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package activityclient
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"91porn-server/common/crypt"
|
||||
)
|
||||
|
||||
// SignPayload 签名载荷(与 activity-public-server 保持一致)
|
||||
type SignPayload struct {
|
||||
AppId string `json:"appId"` // 应用ID
|
||||
UserId string `json:"userId"` // 用户ID
|
||||
Nickname string `json:"nickname"` // 用户昵称
|
||||
Avatar string `json:"avatar"` // 用户头像
|
||||
Ts int64 `json:"ts"` // 时间戳(秒)
|
||||
RegisteredAt int64 `json:"registeredAt"` // 用户注册时间(Unix秒)
|
||||
RechargeAmount int `json:"rechargeAmount"` // 充值金额(分)
|
||||
}
|
||||
|
||||
// EncryptSign AES-CBC 加密签名载荷,返回 Base64 URL Safe 编码
|
||||
func EncryptSign(secretKeyBase64 string, payload *SignPayload) (string, error) {
|
||||
secretKey, err := base64.StdEncoding.DecodeString(secretKeyBase64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode secretKey failed: %w", err)
|
||||
}
|
||||
plaintext, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload failed: %w", err)
|
||||
}
|
||||
ciphertext, err := crypt.AESCBCPck5Encrypt(plaintext, secretKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt failed: %w", err)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package activityclient
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 与 activity-public-server middleware/svcauth 协议保持一致:
|
||||
//
|
||||
// X-Svc-AppId : 调用方 appId
|
||||
// X-Svc-Ts : 请求生成的 Unix 秒(服务端校验 ±300s)
|
||||
// X-Svc-Sign : HMAC_SHA256(canonical, secret) 的 hex 小写
|
||||
//
|
||||
// canonical 串结构("\n" 分隔):
|
||||
//
|
||||
// appId
|
||||
// ts
|
||||
// METHOD // 大写
|
||||
// path // URL.Path,不含 query
|
||||
// rawQuery // URL.RawQuery 原样,不重排序
|
||||
// sha256_hex(body) // 空 body 也参与运算
|
||||
const (
|
||||
headerSvcAppId = "X-Svc-AppId"
|
||||
headerSvcTs = "X-Svc-Ts"
|
||||
headerSvcSign = "X-Svc-Sign"
|
||||
)
|
||||
|
||||
// BuildSvcHeaders 生成服务间调用鉴权头。
|
||||
// method/path/rawQuery 必须与服务端 c.Request 上看到的一致;
|
||||
// body 传原始字节(GET 等无 body 接口传 nil)。
|
||||
// appId 或 secret 为空时返回 nil,调用方按未签名处理。
|
||||
func BuildSvcHeaders(appId, secret, method, path, rawQuery string, body []byte) map[string]string {
|
||||
if appId == "" || secret == "" {
|
||||
return nil
|
||||
}
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
return map[string]string{
|
||||
headerSvcAppId: appId,
|
||||
headerSvcTs: ts,
|
||||
headerSvcSign: computeSvcSign(secret, appId, ts, strings.ToUpper(method), path, rawQuery, hashBody(body)),
|
||||
}
|
||||
}
|
||||
|
||||
// hashBody 计算 body 的 sha256 hex;nil/空 body 也返回 sha256("") 的 hex
|
||||
func hashBody(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// computeSvcSign 按 canonical 串拼接后做 HMAC-SHA256
|
||||
func computeSvcSign(secret, appId, ts, method, path, rawQuery, bodyHash string) string {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write([]byte(appId))
|
||||
h.Write([]byte{'\n'})
|
||||
h.Write([]byte(ts))
|
||||
h.Write([]byte{'\n'})
|
||||
h.Write([]byte(method))
|
||||
h.Write([]byte{'\n'})
|
||||
h.Write([]byte(path))
|
||||
h.Write([]byte{'\n'})
|
||||
h.Write([]byte(rawQuery))
|
||||
h.Write([]byte{'\n'})
|
||||
h.Write([]byte(bodyHash))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
Reference in New Issue
Block a user