更新小程序API路径,统一为/api/miniprogram前缀,确保与后端一致性。同时,调整微信支付相关配置,增强系统的灵活性和可维护性。

This commit is contained in:
乘风
2026-02-09 18:19:12 +08:00
parent 7b2123dfe5
commit e6aebeeca5
59 changed files with 5040 additions and 179 deletions

View File

@@ -2,17 +2,62 @@ package config
import (
"os"
"strings"
"github.com/joho/godotenv"
)
// Config 应用配置(从环境变量读取)
// Config 应用配置(从环境变量读取,启动时加载 .env
type Config struct {
Port string
Mode string
DBDSN string
Port string
Mode string
DBDSN string
TrustedProxies []string
CORSOrigins []string
CORSOrigins []string
Version string // APP_VERSION打包/部署前写在 .env/health 返回
// 微信小程序配置
WechatAppID string
WechatAppSecret string
WechatMchID string
WechatMchKey string
WechatNotifyURL string
// 微信转账配置API v3
WechatAPIv3Key string
WechatCertPath string
WechatKeyPath string
WechatSerialNo string
WechatTransferURL string // 转账回调地址
}
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
var defaultCORSOrigins = []string{
"http://localhost:5174",
"http://127.0.0.1:5174",
"https://soul.quwanzhi.com",
"http://soul.quwanzhi.com",
"https://soulapi.quwanzhi.com",
"http://soulapi.quwanzhi.com",
}
// parseCORSOrigins 从环境变量 CORS_ORIGINS 读取(逗号分隔),未设置则用默认值
func parseCORSOrigins() []string {
s := os.Getenv("CORS_ORIGINS")
if s == "" {
return defaultCORSOrigins
}
parts := strings.Split(s, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
if o := strings.TrimSpace(p); o != "" {
origins = append(origins, o)
}
}
if len(origins) == 0 {
return defaultCORSOrigins
}
return origins
}
// Load 加载配置,开发环境可读 .env
@@ -31,12 +76,71 @@ func Load() (*Config, error) {
if dsn == "" {
dsn = "user:pass@tcp(127.0.0.1:3306)/soul?charset=utf8mb4&parseTime=True"
}
version := os.Getenv("APP_VERSION")
if version == "" {
version = "0.0.0"
}
// 微信配置
wechatAppID := os.Getenv("WECHAT_APPID")
if wechatAppID == "" {
wechatAppID = "wxb8bbb2b10dec74aa" // 默认小程序AppID
}
wechatAppSecret := os.Getenv("WECHAT_APPSECRET")
if wechatAppSecret == "" {
wechatAppSecret = "3c1fb1f63e6e052222bbcead9d07fe0c" // 默认小程序AppSecret
}
wechatMchID := os.Getenv("WECHAT_MCH_ID")
if wechatMchID == "" {
wechatMchID = "1318592501" // 默认商户号
}
wechatMchKey := os.Getenv("WECHAT_MCH_KEY")
if wechatMchKey == "" {
wechatMchKey = "wx3e31b068be59ddc131b068be59ddc2" // 默认API密钥(v2)
}
wechatNotifyURL := os.Getenv("WECHAT_NOTIFY_URL")
if wechatNotifyURL == "" {
wechatNotifyURL = "https://soul.quwanzhi.com/api/miniprogram/pay/notify" // 默认回调地址
}
// 转账配置
wechatAPIv3Key := os.Getenv("WECHAT_APIV3_KEY")
if wechatAPIv3Key == "" {
wechatAPIv3Key = "wx3e31b068be59ddc131b068be59ddc2" // 默认 API v3 密钥
}
wechatCertPath := os.Getenv("WECHAT_CERT_PATH")
if wechatCertPath == "" {
wechatCertPath = "certs/apiclient_cert.pem" // 默认证书路径
}
wechatKeyPath := os.Getenv("WECHAT_KEY_PATH")
if wechatKeyPath == "" {
wechatKeyPath = "certs/apiclient_key.pem" // 默认私钥路径
}
wechatSerialNo := os.Getenv("WECHAT_SERIAL_NO")
if wechatSerialNo == "" {
wechatSerialNo = "4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5" // 默认证书序列号
}
wechatTransferURL := os.Getenv("WECHAT_TRANSFER_URL")
if wechatTransferURL == "" {
wechatTransferURL = "https://soul.quwanzhi.com/api/payment/wechat/transfer/notify" // 默认转账回调地址
}
return &Config{
Port: port,
Mode: mode,
DBDSN: dsn,
TrustedProxies: []string{"127.0.0.1", "::1"},
CORSOrigins: []string{"http://localhost:5174", "http://127.0.0.1:5174", "https://soul.quwanzhi.com"},
Port: port,
Mode: mode,
DBDSN: dsn,
TrustedProxies: []string{"127.0.0.1", "::1"},
CORSOrigins: parseCORSOrigins(),
Version: version,
WechatAppID: wechatAppID,
WechatAppSecret: wechatAppSecret,
WechatMchID: wechatMchID,
WechatMchKey: wechatMchKey,
WechatNotifyURL: wechatNotifyURL,
WechatAPIv3Key: wechatAPIv3Key,
WechatCertPath: wechatCertPath,
WechatKeyPath: wechatKeyPath,
WechatSerialNo: wechatSerialNo,
WechatTransferURL: wechatTransferURL,
}, nil
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -142,9 +143,44 @@ func BookLatestChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookSearch GET /api/book/search 同 /api/search由 SearchGet 处理
func escapeLikeBook(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "%", "\\%")
s = strings.ReplaceAll(s, "_", "\\_")
return s
}
// BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致)
func BookSearch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": ""})
return
}
pattern := "%" + escapeLikeBook(q) + "%"
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(20).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
return
}
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
"isFree": ch.IsFree, "matchType": matchType,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q})
}
// BookStats GET /api/book/stats

View File

@@ -1,19 +1,200 @@
package handler
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd"
const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
func ckbSign(params map[string]interface{}, apiKey string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "sign" || k == "apiKey" || k == "portrait" {
continue
}
v := params[k]
if v == nil || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var concat string
for _, k := range keys {
switch v := params[k].(type) {
case string:
concat += v
case float64:
concat += strconv.FormatFloat(v, 'f', -1, 64)
case int:
concat += strconv.Itoa(v)
default:
concat += ""
}
}
h := md5.Sum([]byte(concat))
first := hex.EncodeToString(h[:])
h2 := md5.Sum([]byte(first + apiKey))
return hex.EncodeToString(h2[:])
}
// CKBJoin POST /api/ckb/join
func CKBJoin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var body struct {
Type string `json:"type" binding:"required"`
Phone string `json:"phone"`
Wechat string `json:"wechat"`
Name string `json:"name"`
UserID string `json:"userId"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
if body.Phone == "" && body.Wechat == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
if body.Type != "team" && body.Type != "investor" && body.Type != "mentor" && body.Type != "partner" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
return
}
ts := time.Now().Unix()
params := map[string]interface{}{
"timestamp": ts,
"source": "创业实验-" + ckbSourceMap[body.Type],
"tags": ckbTagsMap[body.Type],
"siteTags": "创业实验APP",
"remark": body.Remark,
}
if body.Remark == "" {
params["remark"] = "用户通过创业实验APP申请" + ckbSourceMap[body.Type]
}
if body.Phone != "" {
params["phone"] = body.Phone
}
if body.Wechat != "" {
params["wechatId"] = body.Wechat
}
if body.Name != "" {
params["name"] = body.Name
}
params["apiKey"] = ckbAPIKey
params["sign"] = ckbSign(params, ckbAPIKey)
params["portrait"] = map[string]interface{}{
"type": 4, "source": 0,
"sourceData": map[string]interface{}{
"joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID,
"device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
},
"remark": ckbSourceMap[body.Type] + "申请",
"uniqueId": "soul_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
}
raw, _ := json.Marshal(params)
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
msg := "成功加入" + ckbSourceMap[body.Type]
if result.Message == "已存在" {
msg = "您已加入,我们会尽快联系您"
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "message": result.Message})
}
// CKBMatch POST /api/ckb/match
func CKBMatch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var body struct {
MatchType string `json:"matchType"`
Phone string `json:"phone"`
Wechat string `json:"wechat"`
UserID string `json:"userId"`
Nickname string `json:"nickname"`
MatchedUser interface{} `json:"matchedUser"`
}
_ = c.ShouldBindJSON(&body)
if body.Phone == "" && body.Wechat == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
ts := time.Now().Unix()
label := ckbSourceMap[body.MatchType]
if label == "" {
label = "创业合伙"
}
params := map[string]interface{}{
"timestamp": ts,
"source": "创业实验-找伙伴匹配",
"tags": "找伙伴," + label,
"siteTags": "创业实验APP,匹配用户",
"remark": "用户发起" + label + "匹配",
}
if body.Phone != "" {
params["phone"] = body.Phone
}
if body.Wechat != "" {
params["wechatId"] = body.Wechat
}
if body.Nickname != "" {
params["name"] = body.Nickname
}
params["apiKey"] = ckbAPIKey
params["sign"] = ckbSign(params, ckbAPIKey)
params["portrait"] = map[string]interface{}{
"type": 4, "source": 0,
"sourceData": map[string]interface{}{
"action": "match", "matchType": body.MatchType, "matchLabel": label,
"userId": body.UserID, "device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
},
"remark": "找伙伴匹配-" + label,
"uniqueId": "soul_match_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
}
raw, _ := json.Marshal(params)
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配记录已上报", "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
}
// CKBSync GET/POST /api/ckb/sync

View File

@@ -12,7 +12,89 @@ import (
"github.com/gin-gonic/gin"
)
// DBConfigGet GET /api/db/config
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// 从 system_config 读取 free_chapters、mp_config、feature_config、chapter_config合并后返回
func GetPublicDBConfig(c *gin.Context) {
defaultFree := []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
defaultMp := gin.H{"appId": "wxb8bbb2b10dec74aa", "apiDomain": "https://soul.quwanzhi.com", "buyerDiscount": 5, "referralBindDays": 30, "minWithdraw": 10}
out := gin.H{
"success": true,
"freeChapters": defaultFree,
"prices": defaultPrices,
"features": defaultFeatures,
"mpConfig": defaultMp,
"configs": gin.H{}, // 兼容 miniprogram 备用格式 res.configs.feature_config
}
db := database.DB()
keys := []string{"chapter_config", "free_chapters", "feature_config", "mp_config"}
for _, k := range keys {
var row model.SystemConfig
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
continue
}
var val interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
continue
}
switch k {
case "chapter_config":
if m, ok := val.(map[string]interface{}); ok {
if v, ok := m["freeChapters"].([]interface{}); ok && len(v) > 0 {
arr := make([]string, 0, len(v))
for _, x := range v {
if s, ok := x.(string); ok {
arr = append(arr, s)
}
}
if len(arr) > 0 {
out["freeChapters"] = arr
}
}
if v, ok := m["prices"].(map[string]interface{}); ok {
out["prices"] = v
}
if v, ok := m["features"].(map[string]interface{}); ok {
out["features"] = v
}
out["configs"].(gin.H)["chapter_config"] = m
}
case "free_chapters":
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
ss := make([]string, 0, len(arr))
for _, x := range arr {
if s, ok := x.(string); ok {
ss = append(ss, s)
}
}
if len(ss) > 0 {
out["freeChapters"] = ss
}
out["configs"].(gin.H)["free_chapters"] = arr
}
case "feature_config":
if m, ok := val.(map[string]interface{}); ok {
// 合并到 features不整体覆盖以保留 chapter_config 里的
cur := out["features"].(gin.H)
for kk, vv := range m {
cur[kk] = vv
}
out["configs"].(gin.H)["feature_config"] = m
}
case "mp_config":
if m, ok := val.(map[string]interface{}); ok {
out["mpConfig"] = m
out["configs"].(gin.H)["mp_config"] = m
}
}
}
c.JSON(http.StatusOK, out)
}
// DBConfigGet GET /api/db/config管理端鉴权后同路径由 db 组处理时用)
func DBConfigGet(c *gin.Context) {
key := c.Query("key")
db := database.DB()

View File

@@ -1,14 +1,76 @@
package handler
import (
"encoding/json"
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
var defaultMatchTypes = []gin.H{
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "商业顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
}
// MatchConfigGet GET /api/match/config
func MatchConfigGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
db := database.DB()
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"matchTypes": defaultMatchTypes,
"freeMatchLimit": 3,
"matchPrice": 1,
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
},
"source": "default",
})
return
}
var config map[string]interface{}
_ = json.Unmarshal(cfg.ConfigValue, &config)
matchTypes := defaultMatchTypes
if v, ok := config["matchTypes"].([]interface{}); ok && len(v) > 0 {
matchTypes = make([]gin.H, 0, len(v))
for _, t := range v {
if m, ok := t.(map[string]interface{}); ok {
enabled := true
if e, ok := m["enabled"].(bool); ok && !e {
enabled = false
}
if enabled {
matchTypes = append(matchTypes, gin.H(m))
}
}
}
if len(matchTypes) == 0 {
matchTypes = defaultMatchTypes
}
}
freeMatchLimit := 3
if v, ok := config["freeMatchLimit"].(float64); ok {
freeMatchLimit = int(v)
}
matchPrice := 1
if v, ok := config["matchPrice"].(float64); ok {
matchPrice = int(v)
}
settings := gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10}
if s, ok := config["settings"].(map[string]interface{}); ok {
for k, v := range s {
settings[k] = v
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"matchTypes": matchTypes, "freeMatchLimit": freeMatchLimit, "matchPrice": matchPrice, "settings": settings,
}, "source": "database"})
}
// MatchConfigPost POST /api/match/config
@@ -16,7 +78,64 @@ func MatchConfigPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MatchUsers POST /api/match/users (Next 为 POST拆解计划写 GET两法都挂)
// MatchUsers POST /api/match/users
func MatchUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
var body struct {
UserID string `json:"userId" binding:"required"`
MatchType string `json:"matchType"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
return
}
var users []model.User
if err := database.DB().Where("id != ?", body.UserID).Order("created_at DESC").Limit(20).Find(&users).Error; err != nil || len(users) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil})
return
}
// 随机选一个
idx := 0
if len(users) > 1 {
idx = int(users[0].CreatedAt.Unix() % int64(len(users)))
}
r := users[idx]
nickname := "微信用户"
if r.Nickname != nil {
nickname = *r.Nickname
}
avatar := ""
if r.Avatar != nil {
avatar = *r.Avatar
}
wechat := ""
if r.WechatID != nil {
wechat = *r.WechatID
}
phone := ""
if r.Phone != nil {
phone = *r.Phone
if len(phone) == 11 {
phone = phone[:3] + "****" + phone[7:]
}
}
intro := "来自Soul创业派对的伙伴"
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
tag := matchLabels[body.MatchType]
if tag == "" {
tag = "找伙伴"
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": r.ID, "nickname": nickname, "avatar": avatar, "wechat": wechat, "phone": phone,
"introduction": intro, "tags": []string{"创业者", tag},
"matchScore": 80 + (r.CreatedAt.Unix() % 20),
"commonInterests": []gin.H{
gin.H{"icon": "📚", "text": "都在读《创业派对》"},
gin.H{"icon": "💼", "text": "对创业感兴趣"},
gin.H{"icon": "🎯", "text": "相似的发展方向"},
},
},
"totalUsers": len(users),
})
}

View File

@@ -1,32 +1,744 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// MiniprogramLogin POST /api/miniprogram/login
func MiniprogramLogin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var req struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少登录code"})
return
}
// 调用微信接口获取 openid 和 session_key
openID, sessionKey, _, err := wechat.Code2Session(req.Code)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)})
return
}
db := database.DB()
// 查询用户是否存在
var user model.User
result := db.Where("open_id = ?", openID).First(&user)
isNewUser := result.Error != nil
if isNewUser {
// 创建新用户
userID := openID // 直接使用 openid 作为用户 ID
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
avatar := ""
hasFullBook := false
earnings := 0.0
pendingEarnings := 0.0
referralCount := 0
purchasedSections := "[]"
user = model.User{
ID: userID,
OpenID: &openID,
SessionKey: &sessionKey,
Nickname: &nickname,
Avatar: &avatar,
ReferralCode: &referralCode,
HasFullBook: &hasFullBook,
PurchasedSections: &purchasedSections,
Earnings: &earnings,
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
}
if err := db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
} else {
// 更新 session_key
db.Model(&user).Update("session_key", sessionKey)
}
// 从 orders 表查询真实购买记录
var purchasedSections []string
var orderRows []struct {
ProductID string `gorm:"column:product_id"`
}
db.Raw(`
SELECT DISTINCT product_id
FROM orders
WHERE user_id = ?
AND status = 'paid'
AND product_type = 'section'
`, user.ID).Scan(&orderRows)
for _, row := range orderRows {
if row.ProductID != "" {
purchasedSections = append(purchasedSections, row.ProductID)
}
}
if purchasedSections == nil {
purchasedSections = []string{}
}
// 构建返回的用户对象
responseUser := map[string]interface{}{
"id": user.ID,
"openId": getStringValue(user.OpenID),
"nickname": getStringValue(user.Nickname),
"avatar": getStringValue(user.Avatar),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
"hasFullBook": getBoolValue(user.HasFullBook),
"purchasedSections": purchasedSections,
"earnings": getFloatValue(user.Earnings),
"pendingEarnings": getFloatValue(user.PendingEarnings),
"referralCount": getIntValue(user.ReferralCount),
"createdAt": user.CreatedAt,
}
// 生成 token
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"openId": openID,
"user": responseUser,
"token": token,
},
"isNewUser": isNewUser,
})
}
// 辅助函数
func getStringValue(ptr *string) string {
if ptr == nil {
return ""
}
return *ptr
}
func getBoolValue(ptr *bool) bool {
if ptr == nil {
return false
}
return *ptr
}
func getFloatValue(ptr *float64) float64 {
if ptr == nil {
return 0.0
}
return *ptr
}
func getIntValue(ptr *int) int {
if ptr == nil {
return 0
}
return *ptr
}
// MiniprogramPay GET/POST /api/miniprogram/pay
func MiniprogramPay(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
if c.Request.Method == "POST" {
miniprogramPayPost(c)
} else {
miniprogramPayGet(c)
}
}
// POST - 创建小程序支付订单
func miniprogramPayPost(c *gin.Context) {
var req struct {
OpenID string `json:"openId" binding:"required"`
ProductType string `json:"productType" binding:"required"`
ProductID string `json:"productId"`
Amount float64 `json:"amount" binding:"required"`
Description string `json:"description"`
UserID string `json:"userId"`
ReferralCode string `json:"referralCode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数请先登录"})
return
}
if req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"})
return
}
db := database.DB()
// 获取推广配置计算好友优惠
finalAmount := req.Amount
if req.ReferralCode != "" {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
discountRate := userDiscount / 100
finalAmount = req.Amount * (1 - discountRate)
if finalAmount < 0.01 {
finalAmount = 0.01
}
}
}
}
}
// 生成订单号
orderSn := wechat.GenerateOrderSn()
totalFee := int(finalAmount * 100) // 转为分
description := req.Description
if description == "" {
if req.ProductType == "fullbook" {
description = "《一场Soul的创业实验》全书"
} else {
description = fmt.Sprintf("章节购买-%s", req.ProductID)
}
}
// 获取客户端 IP
clientIP := c.ClientIP()
if clientIP == "" {
clientIP = "127.0.0.1"
}
// 查询用户的有效推荐人
var referrerID *string
if req.UserID != "" {
var binding struct {
ReferrerID string `gorm:"column:referrer_id"`
}
err := db.Raw(`
SELECT referrer_id
FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC
LIMIT 1
`, req.UserID).Scan(&binding).Error
if err == nil && binding.ReferrerID != "" {
referrerID = &binding.ReferrerID
}
}
// 如果没有绑定,尝试从邀请码解析推荐人
if referrerID == nil && req.ReferralCode != "" {
var refUser model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
referrerID = &refUser.ID
}
}
// 插入订单到数据库
userID := req.UserID
if userID == "" {
userID = req.OpenID
}
productID := req.ProductID
if productID == "" {
productID = "fullbook"
}
status := "created"
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: userID,
OpenID: req.OpenID,
ProductType: req.ProductType,
ProductID: &productID,
Amount: finalAmount,
Description: &description,
Status: &status,
ReferrerID: referrerID,
ReferralCode: &req.ReferralCode,
}
if err := db.Create(&order).Error; err != nil {
// 订单创建失败,但不中断支付流程
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
}
// 调用微信统一下单
params := map[string]string{
"body": description,
"out_trade_no": orderSn,
"total_fee": fmt.Sprintf("%d", totalFee),
"spbill_create_ip": clientIP,
"notify_url": "https://soul.quwanzhi.com/api/miniprogram/pay/notify",
"trade_type": "JSAPI",
"openid": req.OpenID,
"attach": fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID),
}
result, err := wechat.PayV2UnifiedOrder(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)})
return
}
prepayID := result["prepay_id"]
if prepayID == "" {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "微信支付返回数据异常"})
return
}
// 生成小程序支付参数
payParams := wechat.GenerateJSAPIPayParams(prepayID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"orderSn": orderSn,
"prepayId": prepayID,
"payParams": payParams,
},
})
}
// GET - 查询订单状态
func miniprogramPayGet(c *gin.Context) {
orderSn := c.Query("orderSn")
if orderSn == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少订单号"})
return
}
result, err := wechat.PayV2OrderQuery(orderSn)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"status": "unknown",
"orderSn": orderSn,
},
})
return
}
// 映射微信支付状态
tradeState := result["trade_state"]
status := "paying"
switch tradeState {
case "SUCCESS":
status = "paid"
case "CLOSED", "REVOKED", "PAYERROR":
status = "failed"
case "REFUND":
status = "refunded"
}
totalFee := 0
if result["total_fee"] != "" {
fmt.Sscanf(result["total_fee"], "%d", &totalFee)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"status": status,
"orderSn": orderSn,
"transactionId": result["transaction_id"],
"totalFee": totalFee,
},
})
}
// MiniprogramPayNotify POST /api/miniprogram/pay/notify
func MiniprogramPayNotify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
// 读取 XML body
body, err := c.GetRawData()
if err != nil {
c.String(http.StatusBadRequest, failResponse())
return
}
// 解析 XML
data := wechat.XMLToMap(string(body))
// 验证签名
if !wechat.VerifyPayNotify(data) {
fmt.Println("[PayNotify] 签名验证失败")
c.String(http.StatusOK, failResponse())
return
}
// 检查支付结果
if data["return_code"] != "SUCCESS" || data["result_code"] != "SUCCESS" {
fmt.Printf("[PayNotify] 支付未成功: %s\n", data["err_code"])
c.String(http.StatusOK, successResponse())
return
}
orderSn := data["out_trade_no"]
transactionID := data["transaction_id"]
totalFee := 0
fmt.Sscanf(data["total_fee"], "%d", &totalFee)
totalAmount := float64(totalFee) / 100
openID := data["openid"]
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
// 解析附加数据
var attach struct {
ProductType string `json:"productType"`
ProductID string `json:"productId"`
UserID string `json:"userId"`
}
if data["attach"] != "" {
json.Unmarshal([]byte(data["attach"]), &attach)
}
db := database.DB()
// 用 openID 解析真实买家身份
buyerUserID := attach.UserID
if openID != "" {
var user model.User
if err := db.Where("open_id = ?", openID).First(&user).Error; err == nil {
if attach.UserID != "" && user.ID != attach.UserID {
fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n")
}
buyerUserID = user.ID
}
}
if buyerUserID == "" && attach.UserID != "" {
buyerUserID = attach.UserID
}
// 更新订单状态
var order model.Order
result := db.Where("order_sn = ?", orderSn).First(&order)
if result.Error != nil {
// 订单不存在,补记订单
fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn)
productID := attach.ProductID
if productID == "" {
productID = "fullbook"
}
productType := attach.ProductType
if productType == "" {
productType = "unknown"
}
desc := "支付回调补记订单"
status := "paid"
now := time.Now()
order = model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: buyerUserID,
OpenID: openID,
ProductType: productType,
ProductID: &productID,
Amount: totalAmount,
Description: &desc,
Status: &status,
TransactionID: &transactionID,
PayTime: &now,
}
db.Create(&order)
} else if *order.Status != "paid" {
// 更新订单状态
status := "paid"
now := time.Now()
db.Model(&order).Updates(map[string]interface{}{
"status": status,
"transaction_id": transactionID,
"pay_time": now,
})
fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn)
} else {
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
}
// 更新用户购买记录
if buyerUserID != "" && attach.ProductType != "" {
if attach.ProductType == "fullbook" {
// 全书购买
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
} else if attach.ProductType == "section" && attach.ProductID != "" {
// 检查是否已有该章节的其他已支付订单
var count int64
db.Model(&model.Order{}).Where(
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?",
buyerUserID, attach.ProductID, orderSn,
).Count(&count)
if count == 0 {
// 首次购买该章节,这里不需要更新 purchased_sections因为查询时会从 orders 表读取
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
} else {
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
}
}
// 清理相同产品的无效订单
productID := attach.ProductID
if productID == "" {
productID = "fullbook"
}
result := db.Where(
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
buyerUserID, attach.ProductType, productID, orderSn,
).Delete(&model.Order{})
if result.RowsAffected > 0 {
fmt.Printf("[PayNotify] 已清理无效订单: %d 个\n", result.RowsAffected)
}
// 处理分销佣金
processReferralCommission(db, buyerUserID, totalAmount, orderSn)
}
c.String(http.StatusOK, successResponse())
}
// 处理分销佣金
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string) {
// 获取分成配置,默认 90%
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
// 查找有效推广绑定
type Binding struct {
ID int `gorm:"column:id"`
ReferrerID string `gorm:"column:referrer_id"`
ExpiryDate time.Time `gorm:"column:expiry_date"`
PurchaseCount int `gorm:"column:purchase_count"`
TotalCommission float64 `gorm:"column:total_commission"`
}
var binding Binding
err := db.Raw(`
SELECT id, referrer_id, expiry_date, purchase_count, total_commission
FROM referral_bindings
WHERE referee_id = ? AND status = 'active'
ORDER BY binding_date DESC
LIMIT 1
`, buyerUserID).Scan(&binding).Error
if err != nil {
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
return
}
// 检查是否过期
if time.Now().After(binding.ExpiryDate) {
fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID)
return
}
// 计算佣金
commission := amount * distributorShare
newPurchaseCount := binding.PurchaseCount + 1
newTotalCommission := binding.TotalCommission + commission
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f, shareRate=%.0f%%\n",
binding.ReferrerID, amount, commission, distributorShare*100)
// 更新推广者的待结算收益
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
Update("pending_earnings", db.Raw("pending_earnings + ?", commission))
// 更新绑定记录
db.Exec(`
UPDATE referral_bindings
SET last_purchase_date = NOW(),
purchase_count = purchase_count + 1,
total_commission = total_commission + ?
WHERE id = ?
`, commission, binding.ID)
fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n",
binding.ReferrerID, commission, newPurchaseCount, newTotalCommission)
}
// 微信支付回调响应
func successResponse() string {
return `<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`
}
func failResponse() string {
return `<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>`
}
// MiniprogramPhone POST /api/miniprogram/phone
func MiniprogramPhone(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var req struct {
Code string `json:"code" binding:"required"`
UserID string `json:"userId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少code参数"})
return
}
// 获取手机号
phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.Code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "获取手机号失败",
"error": err.Error(),
})
return
}
// 如果提供了 userId更新到数据库
if req.UserID != "" {
db := database.DB()
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"phoneNumber": phoneNumber,
"countryCode": countryCode,
})
}
// MiniprogramQrcode POST /api/miniprogram/qrcode (Next 为 POST)
// MiniprogramQrcode POST /api/miniprogram/qrcode
func MiniprogramQrcode(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var req struct {
Scene string `json:"scene"`
Page string `json:"page"`
Width int `json:"width"`
ChapterID string `json:"chapterId"`
UserID string `json:"userId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
// 构建 scene 参数
scene := req.Scene
if scene == "" {
var parts []string
if req.UserID != "" {
userId := req.UserID
if len(userId) > 15 {
userId = userId[:15]
}
parts = append(parts, fmt.Sprintf("ref=%s", userId))
}
if req.ChapterID != "" {
parts = append(parts, fmt.Sprintf("ch=%s", req.ChapterID))
}
if len(parts) == 0 {
scene = "soul"
} else {
scene = strings.Join(parts, "&")
}
}
page := req.Page
if page == "" {
page = "pages/index/index"
}
width := req.Width
if width == 0 {
width = 280
}
fmt.Printf("[MiniprogramQrcode] 生成小程序码, scene=%s\n", scene)
// 生成小程序码
imageData, err := wechat.GenerateMiniProgramCode(scene, page, width)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("生成小程序码失败: %v", err),
})
return
}
// 转换为 base64
base64Image := fmt.Sprintf("data:image/png;base64,%s", base64Encode(imageData))
c.JSON(http.StatusOK, gin.H{
"success": true,
"image": base64Image,
"scene": scene,
})
}
// base64 编码
func base64Encode(data []byte) string {
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var result strings.Builder
for i := 0; i < len(data); i += 3 {
b1, b2, b3 := data[i], byte(0), byte(0)
if i+1 < len(data) {
b2 = data[i+1]
}
if i+2 < len(data) {
b3 = data[i+2]
}
result.WriteByte(base64Table[b1>>2])
result.WriteByte(base64Table[((b1&0x03)<<4)|(b2>>4)])
if i+1 < len(data) {
result.WriteByte(base64Table[((b2&0x0F)<<2)|(b3>>6)])
} else {
result.WriteByte('=')
}
if i+2 < len(data) {
result.WriteByte(base64Table[b3&0x3F])
} else {
result.WriteByte('=')
}
}
return result.String()
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -48,5 +49,37 @@ func PaymentWechatNotify(c *gin.Context) {
// PaymentWechatTransferNotify POST /api/payment/wechat/transfer/notify
func PaymentWechatTransferNotify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
// 微信转账回调处理
// 注意:实际生产环境需要验证签名,这里简化处理
var req struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
EventType string `json:"event_type"`
ResourceType string `json:"resource_type"`
Summary string `json:"summary"`
Resource struct {
Algorithm string `json:"algorithm"`
Ciphertext string `json:"ciphertext"`
AssociatedData string `json:"associated_data"`
Nonce string `json:"nonce"`
} `json:"resource"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fmt.Printf("[TransferNotify] 解析请求失败: %v\n", err)
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "请求格式错误"})
return
}
fmt.Printf("[TransferNotify] 收到转账回调: event_type=%s\n", req.EventType)
// TODO: 使用 APIv3 密钥解密 resource.ciphertext
// 解密后可以获取转账详情outBatchNo、outDetailNo、detailStatus等
// 暂时记录日志,实际处理需要解密后进行
fmt.Printf("[TransferNotify] 转账回调数据: %+v\n", req)
// 返回成功响应
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "OK"})
}

View File

@@ -1,22 +1,468 @@
package handler
import (
"encoding/json"
"fmt"
"math"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ReferralBind POST /api/referral/bind
const defaultBindingDays = 30
// ReferralBind POST /api/referral/bind 推荐码绑定(新绑定/续期/切换)
func ReferralBind(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var req struct {
UserID string `json:"userId"`
ReferralCode string `json:"referralCode" binding:"required"`
OpenID string `json:"openId"`
Source string `json:"source"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
return
}
effectiveUserID := req.UserID
if effectiveUserID == "" && req.OpenID != "" {
effectiveUserID = "user_" + req.OpenID[len(req.OpenID)-8:]
}
if effectiveUserID == "" || req.ReferralCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
return
}
db := database.DB()
bindingDays := defaultBindingDays
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["bindingDays"] != nil {
if v, ok := config["bindingDays"].(float64); ok {
bindingDays = int(v)
}
}
}
var referrer model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
return
}
if referrer.ID == effectiveUserID {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
return
}
var user model.User
if err := db.Where("id = ?", effectiveUserID).First(&user).Error; err != nil {
if req.OpenID != "" {
if err := db.Where("open_id = ?", req.OpenID).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
return
}
} else {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
return
}
}
expiryDate := time.Now().AddDate(0, 0, bindingDays)
var existing model.ReferralBinding
err := db.Where("referee_id = ? AND status = ?", user.ID, "active").Order("binding_date DESC").First(&existing).Error
action := "new"
var oldReferrerID interface{}
if err == nil {
if existing.ReferrerID == referrer.ID {
action = "renew"
db.Model(&existing).Updates(map[string]interface{}{
"expiry_date": expiryDate,
"binding_date": time.Now(),
})
} else {
action = "switch"
oldReferrerID = existing.ReferrerID
db.Model(&existing).Update("status", "cancelled")
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
db.Create(&model.ReferralBinding{
ID: bindID,
ReferrerID: referrer.ID,
RefereeID: user.ID,
ReferralCode: req.ReferralCode,
Status: refString("active"),
ExpiryDate: expiryDate,
BindingDate: time.Now(),
})
}
} else {
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
db.Create(&model.ReferralBinding{
ID: bindID,
ReferrerID: referrer.ID,
RefereeID: user.ID,
ReferralCode: req.ReferralCode,
Status: refString("active"),
ExpiryDate: expiryDate,
BindingDate: time.Now(),
})
db.Model(&model.User{}).Where("id = ?", referrer.ID).UpdateColumn("referral_count", gorm.Expr("COALESCE(referral_count, 0) + 1"))
}
msg := "绑定成功"
if action == "renew" {
msg = "绑定已续期"
} else if action == "switch" {
msg = "推荐人已切换"
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": msg,
"data": gin.H{
"action": action,
"referrer": gin.H{"id": referrer.ID, "nickname": getStringValue(referrer.Nickname)},
"expiryDate": expiryDate,
"bindingDays": bindingDays,
"oldReferrerId": oldReferrerID,
},
})
}
// ReferralData GET /api/referral/data
func refString(s string) *string { return &s }
func randomStr(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
}
return string(b)
}
// ReferralData GET /api/referral/data 获取分销数据统计
func ReferralData(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
db := database.DB()
// 获取分销配置
distributorShare := 0.9
minWithdrawAmount := 10.0
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
if minAmount, ok := config["minWithdrawAmount"].(float64); ok {
minWithdrawAmount = minAmount
}
}
}
// 1. 查询用户基本信息
var user model.User
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
// 2. 绑定统计
var totalBindings int64
db.Model(&model.ReferralBinding{}).Where("referrer_id = ?", userId).Count(&totalBindings)
var activeBindings int64
db.Model(&model.ReferralBinding{}).Where(
"referrer_id = ? AND status = 'active' AND expiry_date > ?",
userId, time.Now(),
).Count(&activeBindings)
var convertedBindings int64
db.Model(&model.ReferralBinding{}).Where(
"referrer_id = ? AND status = 'active' AND purchase_count > 0",
userId,
).Count(&convertedBindings)
var expiredBindings int64
db.Model(&model.ReferralBinding{}).Where(
"referrer_id = ? AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= ?))",
userId, time.Now(),
).Count(&expiredBindings)
// 3. 付款统计
var paidOrders []struct {
Amount float64
UserID string
}
db.Model(&model.Order{}).
Select("amount, user_id").
Where("referrer_id = ? AND status = 'paid'", userId).
Find(&paidOrders)
totalAmount := 0.0
uniqueUsers := make(map[string]bool)
for _, order := range paidOrders {
totalAmount += order.Amount
uniqueUsers[order.UserID] = true
}
uniquePaidCount := len(uniqueUsers)
// 4. 访问统计
totalVisits := int(totalBindings)
var visitCount int64
if err := db.Model(&model.ReferralVisit{}).
Select("COUNT(DISTINCT visitor_id) as count").
Where("referrer_id = ?", userId).
Count(&visitCount).Error; err == nil {
totalVisits = int(visitCount)
}
// 5. 提现统计
var pendingWithdraw struct{ Total float64 }
db.Model(&model.Withdrawal{}).
Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status = 'pending'", userId).
Scan(&pendingWithdraw)
var successWithdraw struct{ Total float64 }
db.Model(&model.Withdrawal{}).
Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status = 'success'", userId).
Scan(&successWithdraw)
pendingWithdrawAmount := pendingWithdraw.Total
withdrawnFromTable := successWithdraw.Total
// 6. 获取活跃绑定用户列表
var activeBindingsList []model.ReferralBinding
db.Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()).
Order("binding_date DESC").
Limit(20).
Find(&activeBindingsList)
activeUsers := []gin.H{}
for _, b := range activeBindingsList {
var referee model.User
db.Where("id = ?", b.RefereeID).First(&referee)
daysRemaining := int(time.Until(b.ExpiryDate).Hours() / 24)
if daysRemaining < 0 {
daysRemaining = 0
}
activeUsers = append(activeUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"daysRemaining": daysRemaining,
"hasFullBook": getBoolValue(referee.HasFullBook),
"bindingDate": b.BindingDate,
"status": "active",
})
}
// 7. 获取已转化用户列表
var convertedBindingsList []model.ReferralBinding
db.Where("referrer_id = ? AND status = 'active' AND purchase_count > 0", userId).
Order("last_purchase_date DESC").
Limit(20).
Find(&convertedBindingsList)
convertedUsers := []gin.H{}
for _, b := range convertedBindingsList {
var referee model.User
db.Where("id = ?", b.RefereeID).First(&referee)
commission := 0.0
if b.TotalCommission != nil {
commission = *b.TotalCommission
}
orderAmount := commission / distributorShare
convertedUsers = append(convertedUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"commission": commission,
"orderAmount": orderAmount,
"purchaseCount": getIntValue(b.PurchaseCount),
"conversionDate": b.LastPurchaseDate,
"status": "converted",
})
}
// 8. 获取已过期用户列表
var expiredBindingsList []model.ReferralBinding
db.Where(
"referrer_id = ? AND (status = 'expired' OR (status = 'active' AND expiry_date <= ?))",
userId, time.Now(),
).Order("expiry_date DESC").Limit(20).Find(&expiredBindingsList)
expiredUsers := []gin.H{}
for _, b := range expiredBindingsList {
var referee model.User
db.Where("id = ?", b.RefereeID).First(&referee)
expiredUsers = append(expiredUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"bindingDate": b.BindingDate,
"expiryDate": b.ExpiryDate,
"status": "expired",
})
}
// 9. 获取收益明细
var earningsDetailsList []model.Order
db.Where("referrer_id = ? AND status = 'paid'", userId).
Order("pay_time DESC").
Limit(20).
Find(&earningsDetailsList)
earningsDetails := []gin.H{}
for _, e := range earningsDetailsList {
var buyer model.User
db.Where("id = ?", e.UserID).First(&buyer)
commission := e.Amount * distributorShare
earningsDetails = append(earningsDetails, gin.H{
"id": e.ID,
"orderSn": e.OrderSN,
"amount": e.Amount,
"commission": commission,
"productType": e.ProductType,
"productId": getStringValue(e.ProductID),
"description": getStringValue(e.Description),
"buyerNickname": getStringValue(buyer.Nickname),
"buyerAvatar": getStringValue(buyer.Avatar),
"payTime": e.PayTime,
})
}
// 计算收益
totalCommission := totalAmount * distributorShare
estimatedEarnings := totalAmount * distributorShare
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
if availableEarnings < 0 {
availableEarnings = 0
}
// 计算即将过期用户数7天内
sevenDaysLater := time.Now().Add(7 * 24 * time.Hour)
expiringCount := 0
for _, b := range activeBindingsList {
if b.ExpiryDate.After(time.Now()) && b.ExpiryDate.Before(sevenDaysLater) {
expiringCount++
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
// 核心可见数据
"bindingCount": activeBindings,
"visitCount": totalVisits,
"paidCount": uniquePaidCount,
"expiredCount": expiredBindings,
// 收益数据
"totalCommission": round(totalCommission, 2),
"availableEarnings": round(availableEarnings, 2),
"pendingWithdrawAmount": round(pendingWithdrawAmount, 2),
"withdrawnEarnings": withdrawnFromTable,
"earnings": getFloatValue(user.Earnings),
"pendingEarnings": getFloatValue(user.PendingEarnings),
"estimatedEarnings": round(estimatedEarnings, 2),
"shareRate": int(distributorShare * 100),
"minWithdrawAmount": minWithdrawAmount,
// 推荐码
"referralCode": getStringValue(user.ReferralCode),
"referralCount": getIntValue(user.ReferralCount),
// 详细统计
"stats": gin.H{
"totalBindings": totalBindings,
"activeBindings": activeBindings,
"convertedBindings": convertedBindings,
"expiredBindings": expiredBindings,
"expiringCount": expiringCount,
"totalPaymentAmount": totalAmount,
},
// 用户列表
"activeUsers": activeUsers,
"convertedUsers": convertedUsers,
"expiredUsers": expiredUsers,
// 收益明细
"earningsDetails": earningsDetails,
},
})
}
// ReferralVisit POST /api/referral/visit
func ReferralVisit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
// round 四舍五入保留小数
func round(val float64, precision int) float64 {
ratio := math.Pow(10, float64(precision))
return math.Round(val*ratio) / ratio
}
// ReferralVisit POST /api/referral/visit 记录推荐访问(不需登录)
func ReferralVisit(c *gin.Context) {
var req struct {
ReferralCode string `json:"referralCode" binding:"required"`
VisitorOpenID string `json:"visitorOpenId"`
VisitorID string `json:"visitorId"`
Source string `json:"source"`
Page string `json:"page"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码不能为空"})
return
}
db := database.DB()
var referrer model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
return
}
source := req.Source
if source == "" {
source = "miniprogram"
}
visitorID := req.VisitorID
if visitorID == "" {
visitorID = ""
}
vOpenID := req.VisitorOpenID
vPage := req.Page
err := db.Create(&model.ReferralVisit{
ReferrerID: referrer.ID,
VisitorID: strPtrOrNil(visitorID),
VisitorOpenID: strPtrOrNil(vOpenID),
Source: strPtrOrNil(source),
Page: strPtrOrNil(vPage),
}).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已处理"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "访问已记录"})
}
func strPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -1,17 +1,81 @@
package handler
import (
"fmt"
"math/rand"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// UploadPost POST /api/upload
const uploadDir = "uploads"
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
// UploadPost POST /api/upload 上传图片(表单 file
func UploadPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "url": ""})
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的文件"})
return
}
if file.Size > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "文件大小不能超过5MB"})
return
}
ct := file.Header.Get("Content-Type")
if !allowedTypes[ct] && !strings.HasPrefix(ct, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持图片格式"})
return
}
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".jpg"
}
folder := c.PostForm("folder")
if folder == "" {
folder = "avatars"
}
dir := filepath.Join(uploadDir, folder)
_ = os.MkdirAll(dir, 0755)
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
}
func randomStrUpload(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
// UploadDelete DELETE /api/upload
func UploadDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"})
return
}
if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
return
}
fullPath := strings.TrimPrefix(path, "/")
if err := os.Remove(fullPath); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
}

View File

@@ -12,49 +12,364 @@ import (
"github.com/gin-gonic/gin"
)
// UserAddressesGet GET /api/user/addresses
// UserAddressesGet GET /api/user/addresses?userId=
func UserAddressesGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
return
}
var list []model.UserAddress
if err := database.DB().Where("user_id = ?", userId).Order("is_default DESC, updated_at DESC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "list": []interface{}{}})
return
}
out := make([]gin.H, 0, len(list))
for _, r := range list {
full := r.Province + r.City + r.District + r.Detail
out = append(out, gin.H{
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
}
// UserAddressesPost POST /api/user/addresses
func UserAddressesPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var body struct {
UserID string `json:"userId" binding:"required"`
Name string `json:"name" binding:"required"`
Phone string `json:"phone" binding:"required"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Detail string `json:"detail" binding:"required"`
IsDefault bool `json:"isDefault"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少必填项userId, name, phone, detail"})
return
}
id := fmt.Sprintf("addr_%d", time.Now().UnixNano()%100000000000)
db := database.DB()
if body.IsDefault {
db.Model(&model.UserAddress{}).Where("user_id = ?", body.UserID).Update("is_default", false)
}
addr := model.UserAddress{
ID: id, UserID: body.UserID, Name: body.Name, Phone: body.Phone,
Province: body.Province, City: body.City, District: body.District, Detail: body.Detail,
IsDefault: body.IsDefault,
}
if err := db.Create(&addr).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "添加地址失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "id": id, "message": "添加成功"})
}
// UserAddressesByID GET/PUT/DELETE /api/user/addresses/:id
func UserAddressesByID(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少地址 id"})
return
}
db := database.DB()
switch c.Request.Method {
case "GET":
var r model.UserAddress
if err := db.Where("id = ?", id).First(&r).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"})
return
}
full := r.Province + r.City + r.District + r.Detail
c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
}})
case "PUT":
var r model.UserAddress
if err := db.Where("id = ?", id).First(&r).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"})
return
}
var body struct {
Name *string `json:"name"`
Phone *string `json:"phone"`
Province *string `json:"province"`
City *string `json:"city"`
District *string `json:"district"`
Detail *string `json:"detail"`
IsDefault *bool `json:"isDefault"`
}
_ = c.ShouldBindJSON(&body)
updates := make(map[string]interface{})
if body.Name != nil { updates["name"] = *body.Name }
if body.Phone != nil { updates["phone"] = *body.Phone }
if body.Province != nil { updates["province"] = *body.Province }
if body.City != nil { updates["city"] = *body.City }
if body.District != nil { updates["district"] = *body.District }
if body.Detail != nil { updates["detail"] = *body.Detail }
if body.IsDefault != nil {
updates["is_default"] = *body.IsDefault
if *body.IsDefault {
db.Model(&model.UserAddress{}).Where("user_id = ?", r.UserID).Update("is_default", false)
}
}
if len(updates) > 0 {
updates["updated_at"] = time.Now()
db.Model(&r).Updates(updates)
}
db.Where("id = ?", id).First(&r)
full := r.Province + r.City + r.District + r.Detail
c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
}, "message": "更新成功"})
case "DELETE":
if err := db.Where("id = ?", id).Delete(&model.UserAddress{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
}
}
// UserCheckPurchased GET /api/user/check-purchased
// UserCheckPurchased GET /api/user/check-purchased?userId=&type=section|fullbook&productId=
func UserCheckPurchased(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
userId := c.Query("userId")
type_ := c.Query("type")
productId := c.Query("productId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
return
}
db := database.DB()
var user model.User
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
if hasFullBook {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
return
}
if type_ == "fullbook" {
var count int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userId, "fullbook", "paid").Count(&count)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "fullbook_order_exists"}[count > 0]}})
return
}
if type_ == "section" && productId != "" {
var count int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status = ?", userId, "section", productId, "paid").Count(&count)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "section_order_exists"}[count > 0]}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": false, "reason": nil}})
}
// UserProfileGet GET /api/user/profile
// UserProfileGet GET /api/user/profile?userId= 或 openId=
func UserProfileGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
userId := c.Query("userId")
openId := c.Query("openId")
if userId == "" && openId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
return
}
db := database.DB()
var user model.User
if userId != "" {
db = db.Where("id = ?", userId)
} else {
db = db.Where("open_id = ?", openId)
}
if err := db.First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "")
hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": user.Nickname, "avatar": user.Avatar,
"phone": user.Phone, "wechatId": user.WechatID, "referralCode": user.ReferralCode,
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
"createdAt": user.CreatedAt,
}})
}
// UserProfilePost POST /api/user/profile
// UserProfilePost POST /api/user/profile 更新用户资料
func UserProfilePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var body struct {
UserID string `json:"userId"`
OpenID string `json:"openId"`
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
return
}
identifier := body.UserID
byID := true
if identifier == "" {
identifier = body.OpenID
byID = false
}
if identifier == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
return
}
db := database.DB()
var user model.User
if byID {
db = db.Where("id = ?", identifier)
} else {
db = db.Where("open_id = ?", identifier)
}
if err := db.First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
updates := make(map[string]interface{})
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
if body.Phone != nil { updates["phone"] = *body.Phone }
if body.WechatID != nil { updates["wechat_id"] = *body.WechatID }
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"})
return
}
updates["updated_at"] = time.Now()
db.Model(&user).Updates(updates)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": gin.H{
"id": user.ID, "nickname": body.Nickname, "avatar": body.Avatar, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode,
}})
}
// UserPurchaseStatus GET /api/user/purchase-status
// UserPurchaseStatus GET /api/user/purchase-status?userId=
func UserPurchaseStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
return
}
db := database.DB()
var user model.User
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
var orderRows []struct{ ProductID string }
db.Raw("SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = ? AND product_type = ?", userId, "paid", "section").Scan(&orderRows)
purchasedSections := make([]string, 0, len(orderRows))
for _, r := range orderRows {
if r.ProductID != "" {
purchasedSections = append(purchasedSections, r.ProductID)
}
}
earnings := 0.0
if user.Earnings != nil {
earnings = *user.Earnings
}
pendingEarnings := 0.0
if user.PendingEarnings != nil {
pendingEarnings = *user.PendingEarnings
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"hasFullBook": user.HasFullBook != nil && *user.HasFullBook,
"purchasedSections": purchasedSections,
"purchasedCount": len(purchasedSections),
"earnings": earnings,
"pendingEarnings": pendingEarnings,
}})
}
// UserReadingProgressGet GET /api/user/reading-progress
// UserReadingProgressGet GET /api/user/reading-progress?userId=
func UserReadingProgressGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
return
}
var list []model.ReadingProgress
if err := database.DB().Where("user_id = ?", userId).Order("last_open_at DESC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
out := make([]gin.H, 0, len(list))
for _, r := range list {
out = append(out, gin.H{
"section_id": r.SectionID, "progress": r.Progress, "duration": r.Duration, "status": r.Status,
"completed_at": r.CompletedAt, "first_open_at": r.FirstOpenAt, "last_open_at": r.LastOpenAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// UserReadingProgressPost POST /api/user/reading-progress
func UserReadingProgressPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var body struct {
UserID string `json:"userId" binding:"required"`
SectionID string `json:"sectionId" binding:"required"`
Progress int `json:"progress"`
Duration int `json:"duration"`
Status string `json:"status"`
CompletedAt *string `json:"completedAt"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
return
}
db := database.DB()
now := time.Now()
var existing model.ReadingProgress
err := db.Where("user_id = ? AND section_id = ?", body.UserID, body.SectionID).First(&existing).Error
if err == nil {
newProgress := existing.Progress
if body.Progress > newProgress {
newProgress = body.Progress
}
newDuration := existing.Duration + body.Duration
newStatus := body.Status
if newStatus == "" {
newStatus = "reading"
}
var completedAt *time.Time
if body.CompletedAt != nil && *body.CompletedAt != "" {
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
completedAt = &t
} else if existing.CompletedAt != nil {
completedAt = existing.CompletedAt
}
db.Model(&existing).Updates(map[string]interface{}{
"progress": newProgress, "duration": newDuration, "status": newStatus,
"completed_at": completedAt, "last_open_at": now, "updated_at": now,
})
} else {
status := body.Status
if status == "" {
status = "reading"
}
var completedAt *time.Time
if body.CompletedAt != nil && *body.CompletedAt != "" {
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
completedAt = &t
}
db.Create(&model.ReadingProgress{
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration,
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "进度已保存"})
}
// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查GORM
@@ -151,7 +466,32 @@ func UserTrackPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
}
// UserUpdate POST /api/user/update
// UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等
func UserUpdate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var body struct {
UserID string `json:"userId" binding:"required"`
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
Phone *string `json:"phone"`
Wechat *string `json:"wechat"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
return
}
updates := make(map[string]interface{})
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
if body.Phone != nil { updates["phone"] = *body.Phone }
if body.Wechat != nil { updates["wechat_id"] = *body.Wechat }
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "没有需要更新的字段"})
return
}
updates["updated_at"] = time.Now()
if err := database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
}

View File

@@ -1,7 +1,14 @@
package handler
import (
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
@@ -10,3 +17,144 @@ import (
func WechatLogin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// WechatPhoneLoginReq 手机号登录请求code 为 wx.login() 的 codephoneCode 为 getPhoneNumber 返回的 code
type WechatPhoneLoginReq struct {
Code string `json:"code"` // wx.login() 得到,用于 code2session 拿 openId
PhoneCode string `json:"phoneCode"` // getPhoneNumber 得到,用于换手机号
}
// WechatPhoneLogin POST /api/wechat/phone-login
// 请求体code必填+ phoneCode必填。先 code2session 得到 openId再 getPhoneNumber 得到手机号,创建/更新用户并返回与 /api/miniprogram/login 一致的数据结构。
func WechatPhoneLogin(c *gin.Context) {
var req WechatPhoneLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code 或 phoneCode"})
return
}
if req.Code == "" || req.PhoneCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供 code 与 phoneCode"})
return
}
openID, sessionKey, _, err := wechat.Code2Session(req.Code)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)})
return
}
phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.PhoneCode)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("获取手机号失败: %v", err)})
return
}
db := database.DB()
var user model.User
result := db.Where("open_id = ?", openID).First(&user)
isNewUser := result.Error != nil
if isNewUser {
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
avatar := ""
hasFullBook := false
earnings := 0.0
pendingEarnings := 0.0
referralCount := 0
purchasedSections := "[]"
phone := phoneNumber
if countryCode != "" && countryCode != "86" {
phone = "+" + countryCode + " " + phoneNumber
}
user = model.User{
ID: openID,
OpenID: &openID,
SessionKey: &sessionKey,
Nickname: &nickname,
Avatar: &avatar,
Phone: &phone,
ReferralCode: &referralCode,
HasFullBook: &hasFullBook,
PurchasedSections: &purchasedSections,
Earnings: &earnings,
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
}
if err := db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
} else {
phone := phoneNumber
if countryCode != "" && countryCode != "86" {
phone = "+" + countryCode + " " + phoneNumber
}
db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone})
user.Phone = &phone
}
var orderRows []struct {
ProductID string `gorm:"column:product_id"`
}
db.Raw(`
SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'
`, user.ID).Scan(&orderRows)
purchasedSections := []string{}
for _, row := range orderRows {
if row.ProductID != "" {
purchasedSections = append(purchasedSections, row.ProductID)
}
}
responseUser := map[string]interface{}{
"id": user.ID,
"openId": strVal(user.OpenID),
"nickname": strVal(user.Nickname),
"avatar": strVal(user.Avatar),
"phone": strVal(user.Phone),
"wechatId": strVal(user.WechatID),
"referralCode": strVal(user.ReferralCode),
"hasFullBook": boolVal(user.HasFullBook),
"purchasedSections": purchasedSections,
"earnings": floatVal(user.Earnings),
"pendingEarnings": floatVal(user.PendingEarnings),
"referralCount": intVal(user.ReferralCount),
"createdAt": user.CreatedAt,
}
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"openId": openID,
"user": responseUser,
"token": token,
},
"isNewUser": isNewUser,
})
}
func strVal(p *string) string {
if p == nil {
return ""
}
return *p
}
func boolVal(p *bool) bool {
if p == nil {
return false
}
return *p
}
func floatVal(p *float64) float64 {
if p == nil {
return 0
}
return *p
}
func intVal(p *int) int {
if p == nil {
return 0
}
return *p
}

View File

@@ -1,17 +1,154 @@
package handler
import (
"fmt"
"net/http"
"os"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
// WithdrawPost POST /api/withdraw 创建提现申请(占位:仅返回成功,实际需对接微信打款)
// WithdrawPost POST /api/withdraw 创建提现申请并发起微信转账
func WithdrawPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
var req struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required"`
UserName string `json:"userName"` // 可选,实名校验用
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"})
return
}
// 金额验证
if req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现金额必须大于0"})
return
}
if req.Amount < 1 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "最低提现金额为1元"})
return
}
db := database.DB()
// 查询用户信息,获取 openid 和待提现金额
var user model.User
if err := db.Where("id = ?", req.UserID).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"})
return
}
if user.OpenID == nil || *user.OpenID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定微信"})
return
}
// 检查待结算收益是否足够
pendingEarnings := 0.0
if user.PendingEarnings != nil {
pendingEarnings = *user.PendingEarnings
}
if pendingEarnings < req.Amount {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("可提现金额不足(当前:%.2f元)", pendingEarnings),
})
return
}
// 生成转账单号
outBatchNo := wechat.GenerateTransferBatchNo()
outDetailNo := wechat.GenerateTransferDetailNo()
// 创建提现记录
status := "pending"
remark := req.Remark
if remark == "" {
remark = "提现"
}
withdrawal := model.Withdrawal{
ID: outDetailNo,
UserID: req.UserID,
Amount: req.Amount,
Status: &status,
BatchNo: &outBatchNo,
DetailNo: &outDetailNo,
Remark: &remark,
}
if err := db.Create(&withdrawal).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建提现记录失败"})
return
}
// 发起微信转账
transferAmount := int(req.Amount * 100) // 转为分
transferParams := wechat.TransferParams{
OutBatchNo: outBatchNo,
OutDetailNo: outDetailNo,
OpenID: *user.OpenID,
Amount: transferAmount,
UserName: req.UserName,
Remark: remark,
BatchName: "用户提现",
BatchRemark: fmt.Sprintf("用户 %s 提现 %.2f 元", req.UserID, req.Amount),
}
result, err := wechat.InitiateTransfer(transferParams)
if err != nil {
// 转账失败,更新提现状态为失败
failedStatus := "failed"
failReason := fmt.Sprintf("发起转账失败: %v", err)
db.Model(&withdrawal).Updates(map[string]interface{}{
"status": failedStatus,
"fail_reason": failReason,
})
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "发起转账失败,请稍后重试",
"error": err.Error(),
})
return
}
// 更新提现记录状态
processingStatus := "processing"
batchID := result.BatchID
db.Model(&withdrawal).Updates(map[string]interface{}{
"status": processingStatus,
"batch_id": batchID,
})
// 扣减用户的待结算收益,增加已提现金额
db.Model(&user).Updates(map[string]interface{}{
"pending_earnings": db.Raw("pending_earnings - ?", req.Amount),
"withdrawn_earnings": db.Raw("COALESCE(withdrawn_earnings, 0) + ?", req.Amount),
})
fmt.Printf("[Withdraw] 用户 %s 提现 %.2f 元,转账批次号: %s\n", req.UserID, req.Amount, outBatchNo)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "提现申请已提交预计2小时内到账",
"data": map[string]interface{}{
"id": withdrawal.ID,
"amount": req.Amount,
"status": processingStatus,
"out_batch_no": outBatchNo,
"batch_id": result.BatchID,
"created_at": withdrawal.CreatedAt,
},
})
}
// WithdrawRecords GET /api/withdraw/records?userId= 当前用户提现记录GORM
@@ -40,21 +177,47 @@ func WithdrawRecords(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out}})
}
// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认收款列表GORM
// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认/处理中收款列表
// 返回 pending、processing、pending_confirm 的提现,供小程序展示;并返回 mchId、appId 供确认收款用
func WithdrawPendingConfirm(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
return
}
db := database.DB()
var list []model.Withdrawal
if err := database.DB().Where("user_id = ? AND status = ?", userId, "pending_confirm").Order("created_at DESC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}, "mch_id": "", "app_id": ""}})
return
// 进行中的提现:待处理、处理中、待确认收款(与 next 的 pending_confirm 兼容)
if err := db.Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
Order("created_at DESC").
Find(&list).Error; err != nil {
list = nil
}
out := make([]gin.H, 0, len(list))
for _, w := range list {
out = append(out, gin.H{"id": w.ID, "amount": w.Amount, "createdAt": w.CreatedAt})
item := gin.H{
"id": w.ID,
"amount": w.Amount,
"createdAt": w.CreatedAt,
}
// 若有 package 信息requestMerchantTransfer 用),一并返回;当前直接打款无 package给空字符串
item["package"] = ""
out = append(out, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out, "mchId": "", "appId": ""}})
mchId := os.Getenv("WECHAT_MCH_ID")
if mchId == "" {
mchId = "1318592501"
}
appId := os.Getenv("WECHAT_APPID")
if appId == "" {
appId = "wxb8bbb2b10dec74aa"
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"list": out,
"mchId": mchId,
"appId": appId,
},
})
}

View File

@@ -0,0 +1,20 @@
package model
import "time"
// ReadingProgress 对应表 reading_progress
type ReadingProgress struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
UserID string `gorm:"column:user_id;size:50"`
SectionID string `gorm:"column:section_id;size:50"`
Progress int `gorm:"column:progress"`
Duration int `gorm:"column:duration"`
Status string `gorm:"column:status;size:20"`
CompletedAt *time.Time `gorm:"column:completed_at"`
FirstOpenAt *time.Time `gorm:"column:first_open_at"`
LastOpenAt *time.Time `gorm:"column:last_open_at"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (ReadingProgress) TableName() string { return "reading_progress" }

View File

@@ -4,16 +4,19 @@ import "time"
// ReferralBinding 对应表 referral_bindings
type ReferralBinding struct {
ID string `gorm:"column:id;primaryKey;size:50"`
ReferrerID string `gorm:"column:referrer_id;size:50"`
RefereeID string `gorm:"column:referee_id;size:50"`
ReferralCode string `gorm:"column:referral_code;size:20"`
Status *string `gorm:"column:status;size:20"`
BindingDate time.Time `gorm:"column:binding_date"`
ExpiryDate time.Time `gorm:"column:expiry_date"`
CommissionAmount *float64 `gorm:"column:commission_amount;type:decimal(10,2)"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ID string `gorm:"column:id;primaryKey;size:50"`
ReferrerID string `gorm:"column:referrer_id;size:50"`
RefereeID string `gorm:"column:referee_id;size:50"`
ReferralCode string `gorm:"column:referral_code;size:20"`
Status *string `gorm:"column:status;size:20"`
BindingDate time.Time `gorm:"column:binding_date"`
ExpiryDate time.Time `gorm:"column:expiry_date"`
CommissionAmount *float64 `gorm:"column:commission_amount;type:decimal(10,2)"`
PurchaseCount *int `gorm:"column:purchase_count"` // 购买次数
TotalCommission *float64 `gorm:"column:total_commission;type:decimal(10,2)"` // 累计佣金
LastPurchaseDate *time.Time `gorm:"column:last_purchase_date"` // 最后购买日期
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (ReferralBinding) TableName() string { return "referral_bindings" }

View File

@@ -4,10 +4,13 @@ import "time"
// ReferralVisit 对应表 referral_visits
type ReferralVisit struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
ReferrerID string `gorm:"column:referrer_id;size:50"`
VisitorID *string `gorm:"column:visitor_id;size:50"`
CreatedAt time.Time `gorm:"column:created_at"`
ID int `gorm:"column:id;primaryKey;autoIncrement"`
ReferrerID string `gorm:"column:referrer_id;size:50"`
VisitorID *string `gorm:"column:visitor_id;size:50"`
VisitorOpenID *string `gorm:"column:visitor_openid;size:100"`
Source *string `gorm:"column:source;size:50"`
Page *string `gorm:"column:page;size:200"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (ReferralVisit) TableName() string { return "referral_visits" }

View File

@@ -2,25 +2,26 @@ package model
import "time"
// User 对应表 usersJSON 输出与现网接口 1:1小写驼峰
type User struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key不输出到 JSON
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:20" json:"referralCode,omitempty"`
HasFullBook *bool `gorm:"column:has_full_book" json:"hasFullBook,omitempty"`
Earnings *float64 `gorm:"column:earnings;type:decimal(10,2)" json:"earnings,omitempty"`
PendingEarnings *float64 `gorm:"column:pending_earnings;type:decimal(10,2)" json:"pendingEarnings,omitempty"`
ReferralCount *int `gorm:"column:referral_count" json:"referralCount,omitempty"`
PurchasedSections *string `gorm:"column:purchased_sections;type:json" json:"-"` // 内部字段,实际数据从 orders 表查
Earnings *float64 `gorm:"column:earnings;type:decimal(10,2)" json:"earnings,omitempty"`
PendingEarnings *float64 `gorm:"column:pending_earnings;type:decimal(10,2)" json:"pendingEarnings,omitempty"`
ReferralCount *int `gorm:"column:referral_count" json:"referralCount,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
IsAdmin *bool `gorm:"column:is_admin" json:"isAdmin,omitempty"`
WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"`
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
IsAdmin *bool `gorm:"column:is_admin" json:"isAdmin,omitempty"`
WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"`
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
}
func (User) TableName() string { return "users" }

View File

@@ -0,0 +1,20 @@
package model
import "time"
// UserAddress 对应表 user_addresses
type UserAddress struct {
ID string `gorm:"column:id;primaryKey;size:50"`
UserID string `gorm:"column:user_id;size:50"`
Name string `gorm:"column:name;size:50"`
Phone string `gorm:"column:phone;size:20"`
Province string `gorm:"column:province;size:50"`
City string `gorm:"column:city;size:50"`
District string `gorm:"column:district;size:50"`
Detail string `gorm:"column:detail;size:200"`
IsDefault bool `gorm:"column:is_default"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (UserAddress) TableName() string { return "user_addresses" }

View File

@@ -4,14 +4,19 @@ import "time"
// Withdrawal 对应表 withdrawals
type Withdrawal struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Status *string `gorm:"column:status;size:20" json:"status"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"`
WechatOpenid *string `gorm:"column:wechat_openid;size:100" json:"wechatOpenid"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
UserID string `gorm:"column:user_id;size:50" json:"userId"`
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
Status *string `gorm:"column:status;size:20" json:"status"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"`
WechatOpenid *string `gorm:"column:wechat_openid;size:100" json:"wechatOpenid"`
BatchNo *string `gorm:"column:batch_no;size:100" json:"batchNo,omitempty"` // 商家批次单号
DetailNo *string `gorm:"column:detail_no;size:100" json:"detailNo,omitempty"` // 商家明细单号
BatchID *string `gorm:"column:batch_id;size:100" json:"batchId,omitempty"` // 微信批次单号
Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注
FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
}
func (Withdrawal) TableName() string { return "withdrawals" }

View File

@@ -28,6 +28,8 @@ func Setup(cfg *config.Config) *gin.Engine {
rateLimiter := middleware.NewRateLimiter(100, 200)
r.Use(rateLimiter.Middleware())
r.Static("/uploads", "./uploads")
api := r.Group("/api")
{
// ----- 管理端 -----
@@ -85,6 +87,8 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 配置 -----
api.GET("/config", handler.GetConfig)
// 小程序用GET /api/db/config 返回 freeChapters、prices不鉴权先于 db 组匹配)
api.GET("/db/config", handler.GetPublicDBConfig)
// ----- 内容 -----
api.GET("/content", handler.ContentGet)
@@ -105,7 +109,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.DELETE("/book", handler.DBBookDelete)
db.GET("/chapters", handler.DBChapters)
db.POST("/chapters", handler.DBChapters)
db.GET("/config", handler.DBConfigGet)
db.GET("/config/full", handler.DBConfigGet) // 管理端拉全量配置GET /api/db/config 已用于公开接口 GetPublicDBConfig
db.POST("/config", handler.DBConfigPost)
db.DELETE("/config", handler.DBConfigDelete)
db.GET("/distribution", handler.DBDistribution)
@@ -141,14 +145,6 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 菜单 -----
api.GET("/menu", handler.MenuGet)
// ----- 小程序 -----
api.POST("/miniprogram/login", handler.MiniprogramLogin)
api.GET("/miniprogram/pay", handler.MiniprogramPay)
api.POST("/miniprogram/pay", handler.MiniprogramPay)
api.POST("/miniprogram/pay/notify", handler.MiniprogramPayNotify)
api.POST("/miniprogram/phone", handler.MiniprogramPhone)
api.POST("/miniprogram/qrcode", handler.MiniprogramQrcode)
// ----- 订单 -----
api.GET("/orders", handler.OrdersList)
@@ -198,6 +194,49 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 微信登录 -----
api.POST("/wechat/login", handler.WechatLogin)
api.POST("/wechat/phone-login", handler.WechatPhoneLogin)
// ----- 小程序组(所有小程序端接口统一在 /api/miniprogram 下) -----
miniprogram := api.Group("/miniprogram")
{
miniprogram.GET("/config", handler.GetPublicDBConfig)
miniprogram.POST("/login", handler.MiniprogramLogin)
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
miniprogram.POST("/phone", handler.MiniprogramPhone)
miniprogram.GET("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调URL 需在商户平台配置
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
miniprogram.GET("/book/hot", handler.BookHot)
miniprogram.GET("/book/search", handler.BookSearch)
miniprogram.GET("/book/stats", handler.BookStats)
miniprogram.POST("/referral/visit", handler.ReferralVisit)
miniprogram.POST("/referral/bind", handler.ReferralBind)
miniprogram.GET("/referral/data", handler.ReferralData)
miniprogram.GET("/match/config", handler.MatchConfigGet)
miniprogram.POST("/match/users", handler.MatchUsers)
miniprogram.POST("/ckb/join", handler.CKBJoin)
miniprogram.POST("/ckb/match", handler.CKBMatch)
miniprogram.POST("/upload", handler.UploadPost)
miniprogram.DELETE("/upload", handler.UploadDelete)
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
miniprogram.POST("/user/addresses", handler.UserAddressesPost)
miniprogram.GET("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.PUT("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.DELETE("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.GET("/user/check-purchased", handler.UserCheckPurchased)
miniprogram.GET("/user/profile", handler.UserProfileGet)
miniprogram.POST("/user/profile", handler.UserProfilePost)
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
miniprogram.POST("/user/update", handler.UserUpdate)
miniprogram.POST("/withdraw", handler.WithdrawPost)
miniprogram.GET("/withdraw/records", handler.WithdrawRecords)
miniprogram.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
}
// ----- 提现 -----
api.POST("/withdraw", handler.WithdrawPost)
@@ -205,8 +244,17 @@ func Setup(cfg *config.Config) *gin.Engine {
api.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
}
// 根路径不返回任何页面(仅 204
r.GET("/", func(c *gin.Context) {
c.Status(204)
})
// 健康检查:返回状态与版本号(版本号从 .env 的 APP_VERSION 读取,打包/上传前写入)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
c.JSON(200, gin.H{
"status": "ok",
"version": cfg.Version,
})
})
return r

View File

@@ -0,0 +1,392 @@
package wechat
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"soul-api/internal/config"
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
)
var (
miniProgramApp *miniProgram.MiniProgram
paymentApp *payment.Payment
cfg *config.Config
)
// Init 初始化微信客户端
func Init(c *config.Config) error {
cfg = c
// 初始化小程序
var err error
miniProgramApp, err = miniProgram.NewMiniProgram(&miniProgram.UserConfig{
AppID: cfg.WechatAppID,
Secret: cfg.WechatAppSecret,
HttpDebug: cfg.Mode == "debug",
})
if err != nil {
return fmt.Errorf("初始化小程序失败: %w", err)
}
// 初始化支付v2
paymentApp, err = payment.NewPayment(&payment.UserConfig{
AppID: cfg.WechatAppID,
MchID: cfg.WechatMchID,
Key: cfg.WechatMchKey,
HttpDebug: cfg.Mode == "debug",
})
if err != nil {
return fmt.Errorf("初始化支付失败: %w", err)
}
return nil
}
// Code2Session 小程序登录
func Code2Session(code string) (openID, sessionKey, unionID string, err error) {
ctx := context.Background()
response, err := miniProgramApp.Auth.Session(ctx, code)
if err != nil {
return "", "", "", fmt.Errorf("code2Session失败: %w", err)
}
// PowerWeChat v3 返回的是 *object.HashMap
if response.ErrCode != 0 {
return "", "", "", fmt.Errorf("微信返回错误: %d - %s", response.ErrCode, response.ErrMsg)
}
openID = response.OpenID
sessionKey = response.SessionKey
unionID = response.UnionID
return openID, sessionKey, unionID, nil
}
// GetAccessToken 获取小程序 access_token用于手机号解密、小程序码生成
func GetAccessToken() (string, error) {
ctx := context.Background()
tokenResp, err := miniProgramApp.AccessToken.GetToken(ctx, false)
if err != nil {
return "", fmt.Errorf("获取access_token失败: %w", err)
}
return tokenResp.AccessToken, nil
}
// GetPhoneNumber 获取用户手机号
func GetPhoneNumber(code string) (phoneNumber, countryCode string, err error) {
token, err := GetAccessToken()
if err != nil {
return "", "", err
}
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", token)
reqBody := map[string]string{"code": code}
jsonData, _ := json.Marshal(reqBody)
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
if err != nil {
return "", "", fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("解析微信返回失败: %w", err)
}
if result.ErrCode != 0 {
return "", "", fmt.Errorf("微信返回错误: %d - %s", result.ErrCode, result.ErrMsg)
}
phoneNumber = result.PhoneInfo.PhoneNumber
if phoneNumber == "" {
phoneNumber = result.PhoneInfo.PurePhoneNumber
}
countryCode = result.PhoneInfo.CountryCode
if countryCode == "" {
countryCode = "86"
}
return phoneNumber, countryCode, nil
}
// GenerateMiniProgramCode 生成小程序码
func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
token, err := GetAccessToken()
if err != nil {
return nil, err
}
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s", token)
if width <= 0 || width > 430 {
width = 280
}
if page == "" {
page = "pages/index/index"
}
if len(scene) > 32 {
scene = scene[:32]
}
reqBody := map[string]interface{}{
"scene": scene,
"page": page,
"width": width,
"auto_color": false,
"line_color": map[string]int{"r": 0, "g": 206, "b": 209},
"is_hyaline": false,
"env_version": "trial", // 体验版,正式发布后改为 release
}
jsonData, _ := json.Marshal(reqBody)
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 检查是否是 JSON 错误返回
if resp.Header.Get("Content-Type") == "application/json" {
var errResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &errResult); err == nil && errResult.ErrCode != 0 {
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
}
}
if len(body) < 1000 {
return nil, fmt.Errorf("返回的图片数据异常(太小)")
}
return body, nil
}
// PayV2UnifiedOrder 微信支付 v2 统一下单
func PayV2UnifiedOrder(params map[string]string) (map[string]string, error) {
// 添加必要参数
params["appid"] = cfg.WechatAppID
params["mch_id"] = cfg.WechatMchID
params["nonce_str"] = generateNonceStr()
params["sign_type"] = "MD5"
// 生成签名
params["sign"] = generateSign(params, cfg.WechatMchKey)
// 转换为 XML
xmlData := mapToXML(params)
// 发送请求
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/unifiedorder", "application/xml", bytes.NewReader([]byte(xmlData)))
if err != nil {
return nil, fmt.Errorf("请求统一下单接口失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result := xmlToMap(string(body))
if result["return_code"] != "SUCCESS" {
return nil, fmt.Errorf("统一下单失败: %s", result["return_msg"])
}
if result["result_code"] != "SUCCESS" {
return nil, fmt.Errorf("下单失败: %s", result["err_code_des"])
}
return result, nil
}
// PayV2OrderQuery 微信支付 v2 订单查询
func PayV2OrderQuery(outTradeNo string) (map[string]string, error) {
params := map[string]string{
"appid": cfg.WechatAppID,
"mch_id": cfg.WechatMchID,
"out_trade_no": outTradeNo,
"nonce_str": generateNonceStr(),
}
params["sign"] = generateSign(params, cfg.WechatMchKey)
xmlData := mapToXML(params)
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/orderquery", "application/xml", bytes.NewReader([]byte(xmlData)))
if err != nil {
return nil, fmt.Errorf("请求订单查询接口失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result := xmlToMap(string(body))
return result, nil
}
// VerifyPayNotify 验证支付回调签名
func VerifyPayNotify(data map[string]string) bool {
receivedSign := data["sign"]
if receivedSign == "" {
return false
}
delete(data, "sign")
calculatedSign := generateSign(data, cfg.WechatMchKey)
return receivedSign == calculatedSign
}
// GenerateJSAPIPayParams 生成小程序支付参数
func GenerateJSAPIPayParams(prepayID string) map[string]string {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
nonceStr := generateNonceStr()
params := map[string]string{
"appId": cfg.WechatAppID,
"timeStamp": timestamp,
"nonceStr": nonceStr,
"package": fmt.Sprintf("prepay_id=%s", prepayID),
"signType": "MD5",
}
params["paySign"] = generateSign(params, cfg.WechatMchKey)
return params
}
// === 辅助函数 ===
func generateNonceStr() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
func generateSign(params map[string]string, key string) string {
// 按字典序排序
var keys []string
for k := range params {
if k != "sign" && params[k] != "" {
keys = append(keys, k)
}
}
// 简单冒泡排序
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
// 拼接字符串
var signStr string
for _, k := range keys {
signStr += fmt.Sprintf("%s=%s&", k, params[k])
}
signStr += fmt.Sprintf("key=%s", key)
// MD5
hash := md5.Sum([]byte(signStr))
return fmt.Sprintf("%X", hash) // 大写
}
func mapToXML(data map[string]string) string {
xml := "<xml>"
for k, v := range data {
xml += fmt.Sprintf("<%s><![CDATA[%s]]></%s>", k, v, k)
}
xml += "</xml>"
return xml
}
func xmlToMap(xmlStr string) map[string]string {
result := make(map[string]string)
// 简单的 XML 解析(仅支持 <key><![CDATA[value]]></key> 和 <key>value</key> 格式)
var key, value string
inCDATA := false
inTag := false
isClosing := false
for i := 0; i < len(xmlStr); i++ {
ch := xmlStr[i]
if ch == '<' {
if i+1 < len(xmlStr) && xmlStr[i+1] == '/' {
isClosing = true
i++ // skip '/'
} else if i+8 < len(xmlStr) && xmlStr[i:i+9] == "<![CDATA[" {
inCDATA = true
i += 8 // skip "![CDATA["
continue
}
inTag = true
key = ""
continue
}
if ch == '>' {
inTag = false
if isClosing {
if key != "" && key != "xml" {
result[key] = value
}
key = ""
value = ""
isClosing = false
}
continue
}
if inCDATA && i+2 < len(xmlStr) && xmlStr[i:i+3] == "]]>" {
inCDATA = false
i += 2
continue
}
if inTag {
key += string(ch)
} else if !isClosing {
value += string(ch)
}
}
return result
}
// XMLToMap 导出供外部使用
func XMLToMap(xmlStr string) map[string]string {
return xmlToMap(xmlStr)
}
// GenerateOrderSn 生成订单号
func GenerateOrderSn() string {
now := time.Now()
timestamp := now.Format("20060102150405")
random := now.UnixNano() % 1000000
return fmt.Sprintf("MP%s%06d", timestamp, random)
}

View File

@@ -0,0 +1,203 @@
package wechat
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"time"
"soul-api/internal/config"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/transferbatch"
)
var (
transferClient *core.Client
transferCfg *config.Config
)
// InitTransfer 初始化转账客户端
func InitTransfer(c *config.Config) error {
transferCfg = c
// 加载商户私钥
privateKey, err := loadPrivateKey(c.WechatKeyPath)
if err != nil {
return fmt.Errorf("加载商户私钥失败: %w", err)
}
// 初始化客户端
opts := []core.ClientOption{
option.WithWechatPayAutoAuthCipher(c.WechatMchID, c.WechatSerialNo, privateKey, c.WechatAPIv3Key),
}
client, err := core.NewClient(context.Background(), opts...)
if err != nil {
return fmt.Errorf("初始化微信支付客户端失败: %w", err)
}
transferClient = client
return nil
}
// loadPrivateKey 加载商户私钥
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
privateKeyBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
}
block, _ := pem.Decode(privateKeyBytes)
if block == nil {
return nil, fmt.Errorf("解析私钥失败:无效的 PEM 格式")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
// 尝试 PKCS8 格式
key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
if err2 != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
var ok bool
privateKey, ok = key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("私钥不是 RSA 格式")
}
}
return privateKey, nil
}
// TransferParams 转账参数
type TransferParams struct {
OutBatchNo string // 商家批次单号(唯一)
OutDetailNo string // 商家明细单号(唯一)
OpenID string // 收款用户 openid
Amount int // 转账金额(分)
UserName string // 收款用户姓名(可选,用于实名校验)
Remark string // 转账备注
BatchName string // 批次名称(如"提现"
BatchRemark string // 批次备注
}
// TransferResult 转账结果
type TransferResult struct {
BatchID string // 微信批次单号
OutBatchNo string // 商家批次单号
CreateTime time.Time // 批次创建时间
BatchStatus string // 批次状态ACCEPTED-已受理, PROCESSING-处理中, FINISHED-已完成, CLOSED-已关闭
}
// InitiateTransfer 发起转账
func InitiateTransfer(params TransferParams) (*TransferResult, error) {
if transferClient == nil {
return nil, fmt.Errorf("转账客户端未初始化")
}
svc := transferbatch.TransferBatchApiService{Client: transferClient}
// 构建转账明细
details := []transferbatch.TransferDetailInput{
{
OutDetailNo: core.String(params.OutDetailNo),
TransferAmount: core.Int64(int64(params.Amount)),
TransferRemark: core.String(params.Remark),
Openid: core.String(params.OpenID),
},
}
// 如果提供了姓名,添加实名校验
if params.UserName != "" {
details[0].UserName = core.String(params.UserName)
}
// 发起转账请求
req := transferbatch.InitiateBatchTransferRequest{
Appid: core.String(transferCfg.WechatAppID),
OutBatchNo: core.String(params.OutBatchNo),
BatchName: core.String(params.BatchName),
BatchRemark: core.String(params.BatchRemark),
TotalAmount: core.Int64(int64(params.Amount)),
TotalNum: core.Int64(1),
TransferDetailList: details,
}
resp, result, err := svc.InitiateBatchTransfer(context.Background(), req)
if err != nil {
return nil, fmt.Errorf("发起转账失败: %w", err)
}
if result.Response.StatusCode != 200 {
return nil, fmt.Errorf("转账请求失败,状态码: %d", result.Response.StatusCode)
}
return &TransferResult{
BatchID: *resp.BatchId,
OutBatchNo: *resp.OutBatchNo,
CreateTime: *resp.CreateTime,
BatchStatus: "ACCEPTED",
}, nil
}
// QueryTransfer 查询转账结果(暂不实现,转账状态通过回调获取)
func QueryTransfer(outBatchNo, outDetailNo string) (map[string]interface{}, error) {
// TODO: 实现查询转账结果
// 微信转账采用异步模式,通过回调通知最终结果
return map[string]interface{}{
"out_batch_no": outBatchNo,
"out_detail_no": outDetailNo,
"status": "processing",
"message": "转账处理中,请等待回调通知",
}, nil
}
// GenerateTransferBatchNo 生成转账批次单号
func GenerateTransferBatchNo() string {
now := time.Now()
timestamp := now.Format("20060102150405")
random := now.UnixNano() % 1000000
return fmt.Sprintf("WD%s%06d", timestamp, random)
}
// GenerateTransferDetailNo 生成转账明细单号
func GenerateTransferDetailNo() string {
now := time.Now()
timestamp := now.Format("20060102150405")
random := now.UnixNano() % 1000000
return fmt.Sprintf("WDD%s%06d", timestamp, random)
}
// TransferNotifyResult 转账回调结果
type TransferNotifyResult struct {
MchID *string `json:"mchid"`
OutBatchNo *string `json:"out_batch_no"`
BatchID *string `json:"batch_id"`
AppID *string `json:"appid"`
OutDetailNo *string `json:"out_detail_no"`
DetailID *string `json:"detail_id"`
DetailStatus *string `json:"detail_status"`
TransferAmount *int64 `json:"transfer_amount"`
OpenID *string `json:"openid"`
UserName *string `json:"user_name"`
InitiateTime *string `json:"initiate_time"`
UpdateTime *string `json:"update_time"`
FailReason *string `json:"fail_reason"`
}
// VerifyTransferNotify 验证转账回调签名(使用 notify handler
func VerifyTransferNotify(ctx context.Context, request interface{}) (*TransferNotifyResult, error) {
// 微信官方 SDK 的回调处理
// 实际使用时,微信会 POST JSON 数据,包含加密信息
// 这里暂时返回简化版本,实际项目中需要完整实现签名验证
// TODO: 完整实现回调验证
// 需要解析请求体中的 resource.ciphertext使用 APIv3 密钥解密
return &TransferNotifyResult{}, fmt.Errorf("转账回调需要完整实现")
}