Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
package activityclient
// Start 启动活动服客户端的所有定时同步任务(域名 + 红包场次等)
func Start() {
startDomainSync()
startHongbaoSync()
}
+120
View File
@@ -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))
})
}
+161
View File
@@ -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)))
}
+100
View File
@@ -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
}
+37
View File
@@ -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
}
+69
View File
@@ -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 hexnil/空 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))
}