更新小程序API路径,统一为/api/miniprogram前缀,确保与后端一致性。同时,调整微信支付相关配置,增强系统的灵活性和可维护性。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
|
||||
@@ -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": "更新成功"})
|
||||
}
|
||||
|
||||
@@ -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() 的 code,phoneCode 为 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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
20
soul-api/internal/model/reading_progress.go
Normal file
20
soul-api/internal/model/reading_progress.go
Normal 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" }
|
||||
@@ -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" }
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -2,25 +2,26 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
|
||||
// User 对应表 users,JSON 输出与现网接口 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" }
|
||||
|
||||
20
soul-api/internal/model/user_address.go
Normal file
20
soul-api/internal/model/user_address.go
Normal 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" }
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
392
soul-api/internal/wechat/miniprogram.go
Normal file
392
soul-api/internal/wechat/miniprogram.go
Normal 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)
|
||||
}
|
||||
203
soul-api/internal/wechat/transfer.go
Normal file
203
soul-api/internal/wechat/transfer.go
Normal 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("转账回调需要完整实现")
|
||||
}
|
||||
Reference in New Issue
Block a user