Files
huangguo_server/app/service/activityser/activityser.go
T
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

574 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package activityser
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"91porn-server/app/appg"
"91porn-server/app/proto"
"91porn-server/app/service/activityclient"
"91porn-server/app/service/customerser"
"91porn-server/app/service/productser"
"91porn-server/app/service/rechargeser"
"91porn-server/common"
"91porn-server/common/constant"
"91porn-server/common/crypt"
"91porn-server/common/log"
"91porn-server/common/rchgutil"
"91porn-server/common/stderr"
"91porn-server/middleware/ua"
"91porn-server/models/commod"
"91porn-server/models/v/advanceordermod"
"91porn-server/models/v/currencymod"
"91porn-server/models/v/productmod"
"91porn-server/models/v/rchgamtmod"
"91porn-server/models/v/sourcemod"
"91porn-server/models/v/usermod"
"91porn-server/models/v/walletmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// PayChannel 支付渠道(精简字段)
type PayChannel struct {
Type string `json:"type"`
TypeName string `json:"typeName"`
}
// ActivityCurrencyItem 活动服金币充值列表项
type ActivityCurrencyItem struct {
ID primitive.ObjectID `json:"id"`
Coin int64 `json:"coin"`
Money int64 `json:"money"`
Name string `json:"typeName"`
GiveCoin int64 `json:"giveCoin"`
RechargeType []PayChannel `json:"rechargeType"`
}
func toPayChannels(src []rchgamtmod.PayChannelRes) []PayChannel {
channels := make([]PayChannel, 0, len(src))
for _, ch := range src {
channels = append(channels, PayChannel{
Type: ch.Type,
TypeName: ch.TypeName,
})
}
return channels
}
// GetCurrencyList 获取金币充值列表
func GetCurrencyList(ctx context.Context) ([]*ActivityCurrencyItem, stderr.Code) {
currencys, err := currencymod.List(commod.Gold)
if err != nil {
log.ErrorX(ctx, "活动服-获取金币列表异常", log.E(err))
return nil, stderr.ErrNetWorkBusy
}
removeRepeat := make(map[int64]struct{})
var moneys []string
for _, cur := range currencys {
if _, ok := removeRepeat[cur.Price]; ok {
continue
}
removeRepeat[cur.Price] = struct{}{}
moneys = append(moneys, rechargeser.FenToYuan(cur.Price))
}
payReq := rchgutil.GainPayTypeReq{Money: moneys}
bc, err := payReq.GetPayType()
if err != nil {
log.ErrorX(ctx, "活动服-获取支付通道列表异常", log.E(err))
return nil, stderr.ErrNetWorkBusy
}
data := make([]*ActivityCurrencyItem, 0, len(currencys))
for _, v := range currencys {
if v.Price < 100 {
continue
}
rechargeType := rechargeser.GetPayChannelDetails_New(v.Price, bc, 0)
if len(rechargeType) <= 0 {
continue
}
data = append(data, &ActivityCurrencyItem{
ID: v.ID,
Coin: v.Coins,
Money: v.Price,
Name: v.Name,
GiveCoin: v.GiveGold,
RechargeType: toPayChannels(rechargeType),
})
}
return data, stderr.Success
}
// CardCategory 会员卡分类
type CardCategory int
const (
DefaultCard CardCategory = 0
PreSaleCard CardCategory = 1
NewbieCard CardCategory = 2
)
// VipCardInfo 活动服会员卡信息
type VipCardInfo struct {
Id string `json:"id"`
Category CardCategory `json:"category"`
VipGrade string `json:"vipGrade"`
Name string `json:"name"`
Desc string `json:"desc"`
PreMoney int64 `json:"preMoney"`
Money int64 `json:"money"`
CanUseCoin bool `json:"canUseCoin"`
DayCount int64 `json:"dayCount"`
GiveCoin int64 `json:"giveCoin"`
Sort int32 `json:"sort"`
Image string `json:"image"`
Rights []RightInfo `json:"rights"`
RechargeType []PayChannel `json:"rechargeType"`
ActivityStartAt time.Time `json:"activityStartAt"`
ActivityEndAt time.Time `json:"activityEndAt"`
CanUpgrade bool `json:"canUpgrade"`
PreSaleStep int `json:"preSaleStep"` // 预售卡阶段 0-预付阶段 1-尾款阶段 2-尾款付款完成(结束)
}
// RightInfo 权益信息
type RightInfo struct {
Name string `json:"name"`
Desc string `json:"desc"`
Image string `json:"image"`
}
// VipDeduct 会员卡当前可用抵扣(vip/list 按抵扣后金额匹配支付通道用)
type VipDeduct struct {
ProductID string // 会员卡ID
DeductAmount int64 // 券面额(分)
}
// GetProductList 获取会员卡列表。
// deducts 非空时,对携带抵扣的普通会员卡按"抵扣后有效金额"重新匹配支付通道(rechargeType)
// money/preMoney 仍返回原价(避免与下单预览重复扣减)。
func GetProductList(ctx context.Context, uid uint64, deducts []VipDeduct) ([]VipCardInfo, error) {
u, err := usermod.FindUserByUID(uid)
if err != nil {
return nil, fmt.Errorf("查询用户异常: %w", err)
}
if u == nil {
return nil, fmt.Errorf("用户不存在: %d", uid)
}
productList, err := rechargeser.New_ProductList(uid, u.SysType, true, 0)
if err != nil {
return nil, err
}
// 抵扣券:按抵扣后有效金额预匹配支付通道(命中的卡覆盖 rechargeType)
deductedChans := buildDeductedRechargeTypes(productList, deducts)
var cards []VipCardInfo
for _, group := range productList {
for _, item := range group.List {
p := item.Product
card := VipCardInfo{
Id: p.ID.Hex(),
VipGrade: fmt.Sprintf("%d", p.VipLevel),
Name: p.Name,
Desc: p.Desc,
PreMoney: p.OriginalPrice * 10,
Money: p.DiscountedPrice * 10,
CanUseCoin: p.IsAmountPay,
DayCount: int64(p.Duration),
GiveCoin: p.GiveCoin,
Sort: int32(p.Sort),
Image: p.BGImg,
CanUpgrade: p.IsUpgrade,
}
switch p.ProductType {
case commod.AdvanceCard:
card.Category = PreSaleCard
advOrder, _ := advanceordermod.IsExist(bson.M{"productID": p.ID, "uid": uid})
// advOrder.Status 取值(advanceordermod):
// 0 DEFAULT / 1 AdvanceProcessing(预付中) / 2 AdvanceSUCCESS(预付成功)
// 3 BalanceProcessing(尾款中) / 4 BalanceSUCCESS(尾款成功)
if advOrder == nil || advOrder.Status == advanceordermod.DEFAULT || advOrder.Status == advanceordermod.AdvanceProcessing {
// 预付阶段:尚未下单 / 预付中
card.PreSaleStep = 0
card.ActivityStartAt = p.ActivityTime
card.ActivityEndAt = p.EndTime
card.Money = p.AdvanceAmount * 10
} else if advOrder.Status == advanceordermod.AdvanceSUCCESS || advOrder.Status == advanceordermod.BalanceProcessing {
// 尾款阶段:预付成功 / 尾款支付中
card.PreSaleStep = 1
card.ActivityStartAt = p.StartTime
card.ActivityEndAt = p.EndTime
card.Money = p.BalanceAmount * 10
card.Image = p.NewBgImg
} else if advOrder.Status == advanceordermod.BalanceSUCCESS {
// 尾款付款完成(结束)
card.PreSaleStep = 2
}
case commod.NEWUSERCard:
card.Category = NewbieCard
card.ActivityStartAt = u.CreatedAt
card.ActivityEndAt = card.ActivityStartAt.Add(time.Duration(p.ShowCountdownTime) * time.Hour)
if time.Now().After(card.ActivityEndAt) {
continue
}
default:
card.Category = DefaultCard
}
if card.Image == "" {
card.Image = p.BGImg
}
rights := make([]RightInfo, 0, len(p.NewPrivilege))
for _, pr := range p.NewPrivilege {
rights = append(rights, RightInfo{
Name: pr.Name,
Desc: pr.Desc,
Image: pr.Image,
})
}
card.Rights = rights
card.RechargeType = toPayChannels(item.RechargeType)
// 抵扣券:按抵扣后金额匹配的支付通道覆盖原价通道
if chans, ok := deductedChans[p.ID.Hex()]; ok {
card.RechargeType = chans
}
cards = append(cards, card)
}
}
return cards, nil
}
// minDeductPayFen 抵扣后最低实付(分),与下单折价封顶保持一致
const minDeductPayFen = 1
// buildDeductedRechargeTypes 依据 deducts 计算各会员卡"抵扣后有效金额"对应的支付通道。
// 支付通道按支付金额匹配(如微信要求达标金额),券抵扣后实付变小可能改变可用通道,
// 故对携带抵扣的普通会员卡(非预售卡)按 min(券面额, 原价-最低实付) 折后金额重新匹配 rechargeType。
// 返回 productIdHex -> 支付通道;deducts 为空或无命中时返回 nil,调用方保留原价通道。
func buildDeductedRechargeTypes(productList []proto.ProductList, deducts []VipDeduct) map[string][]PayChannel {
deductMap := make(map[string]int64, len(deducts))
for _, d := range deducts {
if d.ProductID != "" && d.DeductAmount > 0 {
deductMap[d.ProductID] = d.DeductAmount
}
}
if len(deductMap) == 0 {
return nil
}
type effEntry struct {
hex string
effFen int64
}
var entries []effEntry
moneySet := make(map[string]struct{})
var moneys []string
for _, group := range productList {
for _, item := range group.List {
p := item.Product
if p.ProductType == commod.AdvanceCard {
continue // 预售卡分阶段付款,不参与抵扣
}
deduct, ok := deductMap[p.ID.Hex()]
if !ok {
continue
}
originFen := p.DiscountedPrice * 10
maxDeduct := originFen - minDeductPayFen
if maxDeduct <= 0 {
continue
}
if deduct > maxDeduct {
deduct = maxDeduct
}
effFen := originFen - deduct
entries = append(entries, effEntry{hex: p.ID.Hex(), effFen: effFen})
y := rechargeser.FenToYuan(effFen)
if _, exist := moneySet[y]; !exist {
moneySet[y] = struct{}{}
moneys = append(moneys, y)
}
}
}
if len(entries) == 0 {
return nil
}
bc, err := (&rchgutil.GainPayTypeReq{Money: moneys}).GetPayTypeFromCache()
if err != nil {
log.Warn("活动服-抵扣后支付通道查询失败", log.Any("moneys", moneys), log.E(err))
}
out := make(map[string][]PayChannel, len(entries))
for _, e := range entries {
out[e.hex] = toPayChannels(rechargeser.GetPayChannelDetails_New(e.effFen, bc, 0))
}
return out
}
// UserBalance 用户余额信息
type UserBalance struct {
Gold int64 `json:"gold"` // 金币余额
Integral int64 `json:"integral"` // 积分余额
LotteryFreeTimes int64 `json:"lotteryFreeTimes"` // 抽奖免费次数
VipLevel int `json:"vipLevel,omitempty"` // VIP 等级;用户未开通或已过期时不下发
VipExpireDate time.Time `json:"vipExpireDate,omitempty"` // VIP 截止时间;用户未开通或已过期时不下发
PromotionCode string `json:"promotionCode,omitempty"` // 邀请码
PromoteURL string `json:"promoteURL,omitempty"` // 邀请链接(已拼好 PromotionField + 邀请码)
}
// GetUserBalance 获取用户金币/积分余额及 VIP 信息(VIP 已过期时不下发 VIP 字段)
func GetUserBalance(uid uint64) (*UserBalance, error) {
w, err := walletmod.GetWallet(uid)
if err != nil {
return nil, fmt.Errorf("查询钱包异常: %w", err)
}
res := &UserBalance{}
if w != nil {
res.Gold = w.Amount
res.Integral = w.Integral
res.LotteryFreeTimes = w.LotteryTimes
}
// 用户信息独立取数,单点失败不影响余额下发
u, uerr := usermod.FindUserByUID(uid)
if uerr != nil {
log.Warn("活动服-查询用户信息失败", log.Any("uid", uid), log.E(uerr))
return res, nil
}
if u == nil {
return res, nil
}
if u.IsVIP(time.Now()) {
res.VipLevel = u.VipLevel
res.VipExpireDate = u.VipExpireDate
}
// 邀请码 + 邀请链接(与 /api/app/mine/info 的 promoteURL 拼接方式一致)
res.PromotionCode = u.PromCode
if base := sourcemod.GetRandomPromotionURL(); base != "" && u.PromCode != "" {
res.PromoteURL = common.BindUrl(base, constant.PromotionField+u.PromCode)
}
return res, nil
}
func convertBuyType(actBuyType int) commod.BuyType {
switch actBuyType {
case 1:
return commod.BuyGold
case 2:
return commod.BuyProduct
default:
return commod.BuyType(actBuyType)
}
}
type RechargeAttribution struct {
ActivityID string
ExperimentID string
ExperimentVariant string
SessionID string
}
// CreateRechargeOrder 创建充值订单。活动入口的 sourcePage 由服务端固定,
// 避免调用方伪造其他来源;其余实验字段仍走统一下单校验。
func CreateRechargeOrder(
ctx context.Context,
uid uint64,
rechargeType, productID string,
buyType int,
ip string,
attribution RechargeAttribution,
couponID string,
deductAmount int64,
) (payUrl, mode string, err error) {
pid, err := primitive.ObjectIDFromHex(productID)
if err != nil {
return "", "", fmt.Errorf("invalid productId: %w", err)
}
u, err := usermod.FindUserByUID(uid)
if err != nil {
return "", "", fmt.Errorf("查询用户异常: %w", err)
}
if u == nil {
return "", "", fmt.Errorf("用户不存在: %d", uid)
}
realBuyType := convertBuyType(buyType)
in := &rechargeser.RechargeRequest{
UID: uid,
IP: ip,
RechargeType: rechargeType,
ProductID: pid,
BuyType: realBuyType,
SourcePage: rechargeser.OrderSourcePageH5Activity,
SourceRef: attribution.ActivityID,
ActivityID: attribution.ActivityID,
ExperimentID: attribution.ExperimentID,
ExperimentVariant: attribution.ExperimentVariant,
SessionID: attribution.SessionID,
}
// 抵扣券作为活动服附加对象单独传入,不混入客户端下单请求体
var deduct *rechargeser.ActivityDeduct
if couponID != "" && deductAmount > 0 {
deduct = &rechargeser.ActivityDeduct{CouponID: couponID, DeductAmount: deductAmount}
}
return rechargeser.Recharge(ctx, in, ua.UA{SysType: u.SysType}, deduct)
}
// BuyCoinProduct 金币购买商品
func BuyCoinProduct(ctx context.Context, uid uint64, productID string, sysType string) (stderr.Code, error) {
pid, err := primitive.ObjectIDFromHex(productID)
if err != nil {
return stderr.ErrParamError, fmt.Errorf("invalid productId: %w", err)
}
p, err := productmod.FindProduct(pid, sysType)
if err != nil || p == nil {
return stderr.ErrParamError, fmt.Errorf("商品不存在: %s", productID)
}
if !p.IsAmountPay {
return stderr.ErrParamError, fmt.Errorf("该商品不支持金币购买")
}
code := productser.Buy(uid, p.ProductType, pid, primitive.NilObjectID, primitive.NilObjectID, 0, "", sysType, "", 0, false, ua.UA{SysType: sysType}, "", productser.VIPExperimentAttribution{})
if code != stderr.Success {
return code, fmt.Errorf("购买失败")
}
return stderr.Success, nil
}
// GenerateSecretKey 生成 AES-256 密钥
func GenerateSecretKey() (string, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key), nil
}
// DecryptSign AES-CBC 解密签名载荷
func DecryptSign(secretKeyBase64 string, signBase64 string) (*activityclient.SignPayload, error) {
secretKey, err := base64.StdEncoding.DecodeString(secretKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode secretKey failed: %w", err)
}
signBytes, err := base64.URLEncoding.DecodeString(signBase64)
if err != nil {
signBytes, err = base64.StdEncoding.DecodeString(signBase64)
if err != nil {
return nil, fmt.Errorf("decode sign failed: %w", err)
}
}
plaintext, err := crypt.CoreAesDecrypt(signBytes, string(secretKey))
if err != nil {
return nil, fmt.Errorf("decrypt sign failed: %w", err)
}
var payload activityclient.SignPayload
if err := json.Unmarshal([]byte(plaintext), &payload); err != nil {
return nil, fmt.Errorf("parse sign payload failed: %w", err)
}
return &payload, nil
}
// HmacSHA256 用于回调签名
func HmacSHA256(secretKeyBase64 string, data []byte) (string, error) {
keyBytes, err := base64.StdEncoding.DecodeString(secretKeyBase64)
if err != nil {
return "", fmt.Errorf("decode secretKey failed: %w", err)
}
h := hmac.New(sha256.New, keyBytes)
h.Write(data)
return hex.EncodeToString(h.Sum(nil)), nil
}
// BuildSignedURL 生成带 sign 的活动服请求 URL
func BuildSignedURL(userId, nickname, avatar string) (signedURL, sign string, err error) {
conf := appg.Conf.ActivityServer
baseURL := activityclient.GetActivityDomain()
if baseURL == "" {
return "", "", fmt.Errorf("活动服域名未获取,请检查配置")
}
now := time.Now()
payload := &activityclient.SignPayload{
AppId: conf.AppId,
UserId: userId,
Nickname: nickname,
Avatar: avatar,
Ts: now.Unix(),
}
sign, err = activityclient.EncryptSign(conf.SecretKey, payload)
if err != nil {
return "", "", fmt.Errorf("encrypt sign failed: %w", err)
}
signedURL = fmt.Sprintf("%s?appId=%s&sign=%s", baseURL, conf.AppId, sign)
return signedURL, sign, nil
}
// AppInfoResp 应用信息
type AppInfoResp struct {
CustomerServiceUrl string `json:"customerServiceUrl"`
}
// GetAppInfo 获取应用信息
func GetAppInfo(ctx context.Context, uid uint64) (*AppInfoResp, error) {
resp, err := customerser.GetUrl(uid, ua.UA{})
if err != nil {
return nil, fmt.Errorf("获取客服地址失败: %w", err)
}
return &AppInfoResp{
CustomerServiceUrl: resp.Data.Url,
}, nil
}
// BuildLoginCurl 生成活动服登录接口的 curl 命令(调试用)
func BuildLoginCurl(userId, nickname, avatar string) (string, error) {
conf := appg.Conf.ActivityServer
activityHost := activityclient.GetActivityDomain()
if activityHost == "" {
return "", fmt.Errorf("活动服域名未获取,请检查配置")
}
now := time.Now()
payload := &activityclient.SignPayload{
AppId: conf.AppId,
UserId: userId,
Nickname: nickname,
Avatar: avatar,
Ts: now.Unix(),
}
sign, err := activityclient.EncryptSign(conf.SecretKey, payload)
if err != nil {
return "", fmt.Errorf("encrypt sign failed: %w", err)
}
payloadJSON, _ := json.MarshalIndent(payload, "", " ")
result := fmt.Sprintf("AppId: %s\nSecretKey: %s\n\n", conf.AppId, conf.SecretKey)
result += fmt.Sprintf("--- Generated Sign ---\nsign: %s\n\n", sign)
result += fmt.Sprintf("payload: %s\n\n", string(payloadJSON))
result += fmt.Sprintf("--- CURL ---\ncurl -X POST '%s/api/app/index/login' \\\n -H 'Content-Type: application/json' \\\n -d '{\"appId\":\"%s\",\"sign\":\"%s\"}'",
activityHost, conf.AppId, sign)
return result, nil
}