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
+573
View File
@@ -0,0 +1,573 @@
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
}