更新
This commit is contained in:
@@ -1,152 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/auth"
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminCheck GET /api/admin 鉴权检查(JWT:Authorization Bearer 或 Cookie),已登录返回 success 或概览占位
|
||||
func AdminCheck(c *gin.Context) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
token := auth.GetAdminJWTFromRequest(c.Request)
|
||||
if _, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"content": gin.H{
|
||||
"totalChapters": 0, "totalWords": 0, "publishedChapters": 0, "draftChapters": 0,
|
||||
"lastUpdate": nil,
|
||||
},
|
||||
"payment": gin.H{
|
||||
"totalRevenue": 0, "todayRevenue": 0, "totalOrders": 0, "todayOrders": 0, "averagePrice": 0,
|
||||
},
|
||||
"referral": gin.H{
|
||||
"totalReferrers": 0, "activeReferrers": 0, "totalCommission": 0, "paidCommission": 0, "pendingCommission": 0,
|
||||
},
|
||||
"users": gin.H{
|
||||
"totalUsers": 0, "purchasedUsers": 0, "activeUsers": 0, "todayNewUsers": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminLogin POST /api/admin 登录(优先校验 admin_users 表,表空时回退 ADMIN_USERNAME/PASSWORD 并自动初始化)
|
||||
func AdminLogin(c *gin.Context) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "配置未加载"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
username := trimSpace(body.Username)
|
||||
password := body.Password
|
||||
db := database.DB()
|
||||
|
||||
// 1. 尝试从 admin_users 表校验
|
||||
var u model.AdminUser
|
||||
err := db.Where("username = ?", username).First(&u).Error
|
||||
if err == nil {
|
||||
if u.Status != "active" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "账号已禁用"})
|
||||
return
|
||||
}
|
||||
if bcryptErr := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); bcryptErr != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, u.Username, u.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"user": gin.H{"id": u.ID, "username": u.Username, "role": u.Role, "name": u.Name},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 表内无匹配:若表为空且 env 账号正确,则创建初始 super_admin 并登录
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "系统错误"})
|
||||
return
|
||||
}
|
||||
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
if username != cfg.AdminUsername || password != cfg.AdminPassword {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
// 表为空时初始化超级管理员
|
||||
var cnt int64
|
||||
if db.Model(&model.AdminUser{}).Count(&cnt).Error != nil || cnt > 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
|
||||
return
|
||||
}
|
||||
initial := model.AdminUser{
|
||||
Username: cfg.AdminUsername,
|
||||
PasswordHash: string(hash),
|
||||
Role: "super_admin",
|
||||
Name: "卡若",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&initial).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
|
||||
return
|
||||
}
|
||||
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, initial.Username, initial.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"user": gin.H{"id": initial.ID, "username": initial.Username, "role": initial.Role, "name": initial.Name},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminLogout POST /api/admin/logout 服务端无状态,仅返回成功;前端需清除本地 token
|
||||
func AdminLogout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start := 0
|
||||
for start < len(s) && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
end := len(s)
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树:part -> chapters -> sections
|
||||
func AdminChaptersList(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"structure": []interface{}{}, "stats": nil}})
|
||||
return
|
||||
}
|
||||
type section struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Status string `json:"status"`
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
}
|
||||
type chapter struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Sections []section `json:"sections"`
|
||||
}
|
||||
type part struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Chapters []chapter `json:"chapters"`
|
||||
}
|
||||
partMap := make(map[string]*part)
|
||||
chapterMap := make(map[string]map[string]*chapter)
|
||||
for _, row := range list {
|
||||
if partMap[row.PartID] == nil {
|
||||
partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}}
|
||||
chapterMap[row.PartID] = make(map[string]*chapter)
|
||||
}
|
||||
p := partMap[row.PartID]
|
||||
if chapterMap[row.PartID][row.ChapterID] == nil {
|
||||
ch := chapter{ID: row.ChapterID, Title: row.ChapterTitle, Sections: []section{}}
|
||||
p.Chapters = append(p.Chapters, ch)
|
||||
chapterMap[row.PartID][row.ChapterID] = &p.Chapters[len(p.Chapters)-1]
|
||||
}
|
||||
ch := chapterMap[row.PartID][row.ChapterID]
|
||||
price := 1.0
|
||||
if row.Price != nil {
|
||||
price = *row.Price
|
||||
}
|
||||
isFree := false
|
||||
if row.IsFree != nil {
|
||||
isFree = *row.IsFree
|
||||
}
|
||||
st := "published"
|
||||
if row.Status != nil {
|
||||
st = *row.Status
|
||||
}
|
||||
ch.Sections = append(ch.Sections, section{
|
||||
ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st,
|
||||
EditionStandard: row.EditionStandard, EditionPremium: row.EditionPremium,
|
||||
})
|
||||
}
|
||||
structure := make([]part, 0, len(partMap))
|
||||
for _, p := range partMap {
|
||||
structure = append(structure, *p)
|
||||
}
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminChaptersAction POST/PUT/DELETE /api/admin/chapters
|
||||
func AdminChaptersAction(c *gin.Context) {
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
ID string `json:"id"`
|
||||
ChapterID string `json:"chapterId"` // 前端兼容:section id
|
||||
SectionTitle string `json:"sectionTitle"`
|
||||
Ids []string `json:"ids"` // reorder:新顺序的 section id 列表
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
Status *string `json:"status"`
|
||||
EditionStandard *bool `json:"editionStandard"`
|
||||
EditionPremium *bool `json:"editionPremium"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
resolveID := func() string {
|
||||
if body.ID != "" {
|
||||
return body.ID
|
||||
}
|
||||
return body.ChapterID
|
||||
}
|
||||
db := database.DB()
|
||||
if body.Action == "updatePrice" {
|
||||
id := resolveID()
|
||||
if id != "" && body.Price != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", id).Update("price", *body.Price)
|
||||
}
|
||||
}
|
||||
if body.Action == "toggleFree" {
|
||||
id := resolveID()
|
||||
if id != "" && body.IsFree != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", id).Update("is_free", *body.IsFree)
|
||||
}
|
||||
}
|
||||
if body.Action == "updateStatus" {
|
||||
id := resolveID()
|
||||
if id != "" && body.Status != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", id).Update("status", *body.Status)
|
||||
}
|
||||
}
|
||||
if body.Action == "rename" {
|
||||
id := resolveID()
|
||||
if id != "" && body.SectionTitle != "" {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", id).Update("section_title", body.SectionTitle)
|
||||
}
|
||||
}
|
||||
if body.Action == "delete" {
|
||||
id := resolveID()
|
||||
if id != "" {
|
||||
db.Where("id = ?", id).Delete(&model.Chapter{})
|
||||
}
|
||||
}
|
||||
if body.Action == "reorder" && len(body.Ids) > 0 {
|
||||
for i, id := range body.Ids {
|
||||
if id != "" {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if body.Action == "updateEdition" {
|
||||
id := resolveID()
|
||||
if id != "" {
|
||||
updates := make(map[string]interface{})
|
||||
if body.EditionStandard != nil {
|
||||
updates["edition_standard"] = *body.EditionStandard
|
||||
}
|
||||
if body.EditionPremium != nil {
|
||||
updates["edition_premium"] = *body.EditionPremium
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminDistributionOverview GET /api/admin/distribution/overview(全部使用 GORM,无 Raw SQL)
|
||||
func AdminDistributionOverview(c *gin.Context) {
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
todayEnd := todayStart.Add(24 * time.Hour)
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
db := database.DB()
|
||||
overview := gin.H{
|
||||
"todayClicks": 0, "todayBindings": 0, "todayConversions": 0, "todayEarnings": 0,
|
||||
"monthClicks": 0, "monthBindings": 0, "monthConversions": 0, "monthEarnings": 0,
|
||||
"totalClicks": 0, "totalBindings": 0, "totalConversions": 0, "totalEarnings": 0,
|
||||
"expiringBindings": 0, "pendingWithdrawals": 0, "pendingWithdrawAmount": 0,
|
||||
"conversionRate": "0.00", "totalDistributors": 0, "activeDistributors": 0,
|
||||
}
|
||||
|
||||
// 订单:仅用 Where + Count / Select(Sum) 参数化
|
||||
var totalOrders int64
|
||||
db.Model(&model.Order{}).Where("status = ?", "paid").Count(&totalOrders)
|
||||
var totalAmount float64
|
||||
db.Model(&model.Order{}).Where("status = ?", "paid").Select("COALESCE(SUM(amount),0)").Scan(&totalAmount)
|
||||
var todayOrders int64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Count(&todayOrders)
|
||||
var todayAmount float64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Select("COALESCE(SUM(amount),0)").Scan(&todayAmount)
|
||||
var monthOrders int64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Count(&monthOrders)
|
||||
var monthAmount float64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Select("COALESCE(SUM(amount),0)").Scan(&monthAmount)
|
||||
overview["totalEarnings"] = totalAmount
|
||||
overview["todayEarnings"] = todayAmount
|
||||
overview["monthEarnings"] = monthAmount
|
||||
|
||||
// 绑定:全部 GORM Where
|
||||
var totalBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Count(&totalBindings)
|
||||
var converted int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ?", "converted").Count(&converted)
|
||||
var todayBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where("binding_date >= ? AND binding_date < ?", todayStart, todayEnd).Count(&todayBindings)
|
||||
var todayConv int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ? AND binding_date < ?", "converted", todayStart, todayEnd).Count(&todayConv)
|
||||
var monthBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where("binding_date >= ?", monthStart).Count(&monthBindings)
|
||||
var monthConv int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ?", "converted", monthStart).Count(&monthConv)
|
||||
expiringEnd := now.Add(7 * 24 * time.Hour)
|
||||
var expiring int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ? AND expiry_date > ? AND expiry_date <= ?", "active", now, expiringEnd).Count(&expiring)
|
||||
overview["totalBindings"] = totalBindings
|
||||
overview["totalConversions"] = converted
|
||||
overview["todayBindings"] = todayBindings
|
||||
overview["todayConversions"] = todayConv
|
||||
overview["monthBindings"] = monthBindings
|
||||
overview["monthConversions"] = monthConv
|
||||
overview["expiringBindings"] = expiring
|
||||
|
||||
// 访问数
|
||||
var visitTotal int64
|
||||
db.Model(&model.ReferralVisit{}).Count(&visitTotal)
|
||||
overview["totalClicks"] = visitTotal
|
||||
if visitTotal > 0 && converted > 0 {
|
||||
overview["conversionRate"] = formatPercent(float64(converted)/float64(visitTotal)*100)
|
||||
}
|
||||
|
||||
// 提现待处理
|
||||
var pendCount int64
|
||||
db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Count(&pendCount)
|
||||
var pendSum float64
|
||||
db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Select("COALESCE(SUM(amount),0)").Scan(&pendSum)
|
||||
overview["pendingWithdrawals"] = pendCount
|
||||
overview["pendingWithdrawAmount"] = pendSum
|
||||
|
||||
// 分销商
|
||||
var distTotal int64
|
||||
db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ?", "").Count(&distTotal)
|
||||
var distActive int64
|
||||
db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ? AND earnings > ?", "", 0).Count(&distActive)
|
||||
overview["totalDistributors"] = distTotal
|
||||
overview["activeDistributors"] = distActive
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "overview": overview})
|
||||
}
|
||||
|
||||
func formatPercent(v float64) string {
|
||||
return fmt.Sprintf("%.2f", v) + "%"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminContent GET/POST/PUT/DELETE /api/admin/content
|
||||
func AdminContent(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AdminPayment GET/POST/PUT/DELETE /api/admin/payment
|
||||
func AdminPayment(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AdminReferral GET/POST/PUT/DELETE /api/admin/referral
|
||||
func AdminReferral(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminWithdrawalsList GET /api/admin/withdrawals(支持分页 page、pageSize,筛选 status)
|
||||
func AdminWithdrawalsList(c *gin.Context) {
|
||||
statusFilter := c.Query("status")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Withdrawal{})
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
q = q.Where("status = ?", statusFilter)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var list []model.Withdrawal
|
||||
query := db.Order("created_at DESC")
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
query = query.Where("status = ?", statusFilter)
|
||||
}
|
||||
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}})
|
||||
return
|
||||
}
|
||||
userIds := make([]string, 0, len(list))
|
||||
seen := make(map[string]bool)
|
||||
for _, w := range list {
|
||||
if !seen[w.UserID] {
|
||||
seen[w.UserID] = true
|
||||
userIds = append(userIds, w.UserID)
|
||||
}
|
||||
}
|
||||
var users []model.User
|
||||
if len(userIds) > 0 {
|
||||
database.DB().Where("id IN ?", userIds).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
withdrawals := make([]gin.H, 0, len(list))
|
||||
for _, w := range list {
|
||||
u := userMap[w.UserID]
|
||||
userName := "未知用户"
|
||||
var userAvatar *string
|
||||
account := "未绑定微信号"
|
||||
if w.WechatID != nil && *w.WechatID != "" {
|
||||
account = *w.WechatID
|
||||
}
|
||||
if u != nil {
|
||||
if u.Nickname != nil {
|
||||
userName = *u.Nickname
|
||||
}
|
||||
userAvatar = u.Avatar
|
||||
if u.WechatID != nil && *u.WechatID != "" {
|
||||
account = *u.WechatID
|
||||
}
|
||||
}
|
||||
st := "pending"
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
if st == "success" {
|
||||
st = "completed"
|
||||
} else if st == "failed" {
|
||||
st = "rejected"
|
||||
} else if st == "pending_confirm" {
|
||||
st = "pending_confirm"
|
||||
}
|
||||
}
|
||||
userConfirmedAt := interface{}(nil)
|
||||
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
|
||||
userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
withdrawals = append(withdrawals, gin.H{
|
||||
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": userAvatar,
|
||||
"amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
|
||||
"method": "wechat", "account": account,
|
||||
"userConfirmedAt": userConfirmedAt,
|
||||
})
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
var pendingCount, successCount, failedCount int64
|
||||
var pendingAmount, successAmount float64
|
||||
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Count(&pendingCount)
|
||||
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Select("COALESCE(SUM(amount), 0)").Scan(&pendingAmount)
|
||||
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Count(&successCount)
|
||||
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Select("COALESCE(SUM(amount), 0)").Scan(&successAmount)
|
||||
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"failed", "rejected"}).Count(&failedCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "withdrawals": withdrawals,
|
||||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
"stats": gin.H{
|
||||
"total": total, "pendingCount": pendingCount, "pendingAmount": pendingAmount,
|
||||
"successCount": successCount, "successAmount": successAmount, "failedCount": failedCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
|
||||
// approve:先调微信转账接口打款,成功则标为 processing,失败则标为 failed 并返回错误。
|
||||
// 若未初始化微信转账客户端,则仅将状态标为 success(线下打款后批准)。
|
||||
// reject:直接标为 failed。
|
||||
func AdminWithdrawalsAction(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
|
||||
return
|
||||
}
|
||||
reason := body.ErrorMessage
|
||||
if reason == "" {
|
||||
reason = body.Reason
|
||||
}
|
||||
if reason == "" && body.Action == "reject" {
|
||||
reason = "管理员拒绝"
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
now := time.Now()
|
||||
|
||||
switch body.Action {
|
||||
case "reject":
|
||||
err := db.Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"error_message": reason,
|
||||
"fail_reason": reason,
|
||||
"processed_at": now,
|
||||
}).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已拒绝"})
|
||||
return
|
||||
|
||||
case "approve":
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
if st != "pending" && st != "processing" && st != "pending_confirm" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "当前状态不允许批准"})
|
||||
return
|
||||
}
|
||||
|
||||
openID := ""
|
||||
if w.WechatOpenid != nil && *w.WechatOpenid != "" {
|
||||
openID = *w.WechatOpenid
|
||||
}
|
||||
if openID == "" {
|
||||
var u model.User
|
||||
if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil {
|
||||
openID = *u.OpenID
|
||||
}
|
||||
}
|
||||
if openID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户未绑定微信 openid,无法打款"})
|
||||
return
|
||||
}
|
||||
|
||||
// 批准前二次校验可提现金额,与申请时口径一致,防止退款/冲正后超额打款
|
||||
available, _, _, _, _ := computeAvailableWithdraw(db, w.UserID)
|
||||
if w.Amount > available {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "用户当前可提现不足,无法批准",
|
||||
"message": fmt.Sprintf("用户当前可提现 ¥%.2f,本笔申请 ¥%.2f,可能因退款/冲正导致。请核对后再批或联系用户。", available, w.Amount),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用微信转账接口:按提现手续费扣除后打款,例如申请100元、手续费5%则实际打款95元
|
||||
remark := "提现"
|
||||
if w.Remark != nil && *w.Remark != "" {
|
||||
remark = *w.Remark
|
||||
}
|
||||
withdrawFee := 0.0
|
||||
var refCfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
|
||||
var refVal map[string]interface{}
|
||||
if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil {
|
||||
if v, ok := refVal["withdrawFee"].(float64); ok {
|
||||
withdrawFee = v / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
actualAmount := w.Amount * (1 - withdrawFee)
|
||||
if actualAmount < 0.01 {
|
||||
actualAmount = 0.01
|
||||
}
|
||||
amountFen := int(actualAmount * 100)
|
||||
if amountFen < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现金额异常"})
|
||||
return
|
||||
}
|
||||
outBillNo := w.ID // 商户单号,回调时 out_bill_no 即此值,用于更新该条提现
|
||||
params := wechat.FundAppTransferParams{
|
||||
OutBillNo: outBillNo,
|
||||
OpenID: openID,
|
||||
Amount: amountFen,
|
||||
Remark: remark,
|
||||
NotifyURL: "", // 由 wechat 包从配置读取 WechatTransferURL
|
||||
TransferSceneId: "1005",
|
||||
}
|
||||
|
||||
result, err := wechat.InitiateTransferByFundApp(params)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
fmt.Printf("[AdminWithdrawals] 发起转账失败 id=%s: %s\n", body.ID, errMsg)
|
||||
// 未初始化或未配置转账:仅标记为已打款并提示线下处理
|
||||
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
|
||||
_ = db.Model(&w).Updates(map[string]interface{}{
|
||||
"status": "success",
|
||||
"processed_at": now,
|
||||
}).Error
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已标记为已打款。当前未接入微信转账,请线下打款。",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 微信接口报错或其它失败:把微信/具体原因返回给管理端展示,不返回「微信处理中」
|
||||
failMsg := errMsg
|
||||
_ = db.Model(&w).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"fail_reason": failMsg,
|
||||
"error_message": failMsg,
|
||||
"processed_at": now,
|
||||
}).Error
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "发起打款失败",
|
||||
"message": failMsg, // 管理端直接展示微信报错信息(如 IP 白名单、参数错误等)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 防护:微信未返回商户单号时也按失败返回,避免管理端显示「已发起打款」却无单号
|
||||
if result.OutBillNo == "" {
|
||||
failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志"
|
||||
_ = db.Model(&w).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"fail_reason": failMsg,
|
||||
"error_message": failMsg,
|
||||
"processed_at": now,
|
||||
}).Error
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "发起打款失败",
|
||||
"message": failMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 打款已受理(微信同步返回),立即落库:商户单号、微信单号、package_info、按 state 设 status(不依赖回调)
|
||||
fmt.Printf("[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s state=%s\n", body.ID, result.OutBillNo, result.TransferBillNo, result.State)
|
||||
rowStatus := "processing"
|
||||
if result.State == "WAIT_USER_CONFIRM" {
|
||||
rowStatus = "pending_confirm" // 待用户在小程序点击确认收款,回调在用户确认后才触发
|
||||
}
|
||||
upd := map[string]interface{}{
|
||||
"status": rowStatus,
|
||||
"detail_no": result.OutBillNo,
|
||||
"batch_no": result.OutBillNo,
|
||||
"batch_id": result.TransferBillNo,
|
||||
"processed_at": now,
|
||||
}
|
||||
if result.PackageInfo != "" {
|
||||
upd["package_info"] = result.PackageInfo
|
||||
}
|
||||
if err := db.Model(&w).Updates(upd).Error; err != nil {
|
||||
fmt.Printf("[AdminWithdrawals] 更新提现状态失败 id=%s: %v\n", body.ID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新状态失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// 发起转账成功后发订阅消息(异步,失败不影响接口返回)
|
||||
if openID != "" {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); err != nil {
|
||||
fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", body.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已发起打款,微信处理中",
|
||||
"data": gin.H{
|
||||
"out_bill_no": result.OutBillNo,
|
||||
"transfer_bill_no": result.TransferBillNo,
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminWithdrawalsSync POST /api/admin/withdrawals/sync 主动向微信查询转账结果并更新状态(无回调时的备选)
|
||||
// body: { "id": "提现记录id" } 同步单条;不传 id 或 id 为空则同步所有 processing/pending_confirm
|
||||
func AdminWithdrawalsSync(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
db := database.DB()
|
||||
var list []model.Withdrawal
|
||||
if body.ID != "" {
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
list = []model.Withdrawal{w}
|
||||
} else {
|
||||
if err := db.Where("status IN ?", []string{"processing", "pending_confirm"}).
|
||||
Find(&list).Error; err != nil || len(list) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "暂无待同步记录", "synced": 0})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
synced := 0
|
||||
for _, w := range list {
|
||||
batchNo := ""
|
||||
detailNo := ""
|
||||
if w.BatchNo != nil {
|
||||
batchNo = *w.BatchNo
|
||||
}
|
||||
if w.DetailNo != nil {
|
||||
detailNo = *w.DetailNo
|
||||
}
|
||||
if detailNo == "" {
|
||||
continue
|
||||
}
|
||||
var status, failReason string
|
||||
// FundApp 单笔:batch_no == detail_no 时用商户单号查询
|
||||
if batchNo == detailNo {
|
||||
state, _, fail, err := wechat.QueryTransferByOutBill(detailNo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
status = state
|
||||
failReason = fail
|
||||
} else {
|
||||
res, err := wechat.QueryTransfer(batchNo, detailNo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := res["detail_status"].(string); ok {
|
||||
status = s
|
||||
}
|
||||
if s, ok := res["fail_reason"].(string); ok {
|
||||
failReason = s
|
||||
}
|
||||
}
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch status {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL":
|
||||
up["status"] = "failed"
|
||||
if failReason != "" {
|
||||
up["fail_reason"] = failReason
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&model.Withdrawal{}).Where("id = ?", w.ID).Updates(up).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
synced++
|
||||
fmt.Printf("[AdminWithdrawals] 同步状态 id=%s -> %s\n", w.ID, up["status"])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已向微信查询并更新",
|
||||
"synced": synced,
|
||||
"total": len(list),
|
||||
})
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthLogin POST /api/auth/login
|
||||
func AuthLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AuthResetPassword POST /api/auth/reset-password
|
||||
func AuthResetPassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
|
||||
var excludeParts = []string{"序言", "尾声", "附录"}
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
if c.Query("excludeFixed") == "1" {
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
}
|
||||
var list []model.Chapter
|
||||
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接)
|
||||
func BookChapterByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("id = ?", id)
|
||||
})
|
||||
}
|
||||
|
||||
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
|
||||
func BookChapterByMID(c *gin.Context) {
|
||||
midStr := c.Param("mid")
|
||||
if midStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 mid"})
|
||||
return
|
||||
}
|
||||
mid, err := strconv.Atoi(midStr)
|
||||
if err != nil || mid < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "mid 必须为正整数"})
|
||||
return
|
||||
}
|
||||
findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("mid = ?", mid)
|
||||
})
|
||||
}
|
||||
|
||||
// findChapterAndRespond 按条件查章节并返回统一格式
|
||||
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
var ch model.Chapter
|
||||
db := database.DB()
|
||||
if err := whereFn(db).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"data": ch,
|
||||
"content": ch.Content,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"partTitle": ch.PartTitle,
|
||||
"id": ch.ID,
|
||||
"mid": ch.MID,
|
||||
"sectionTitle": ch.SectionTitle,
|
||||
}
|
||||
if ch.IsFree != nil {
|
||||
out["isFree"] = *ch.IsFree
|
||||
}
|
||||
if ch.Price != nil {
|
||||
out["price"] = *ch.Price
|
||||
// 价格为 0 元则自动视为免费
|
||||
if *ch.Price == 0 {
|
||||
out["isFree"] = true
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// BookChapters GET/POST/PUT/DELETE /api/book/chapters(与 app/api/book/chapters 一致,用 GORM)
|
||||
func BookChapters(c *gin.Context) {
|
||||
db := database.DB()
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
partId := c.Query("partId")
|
||||
status := c.Query("status")
|
||||
if status == "" {
|
||||
status = "published"
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 500 {
|
||||
pageSize = 100
|
||||
}
|
||||
q := db.Model(&model.Chapter{})
|
||||
if partId != "" {
|
||||
q = q.Where("part_id = ?", partId)
|
||||
}
|
||||
if status != "" && status != "all" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var list []model.Chapter
|
||||
q.Order("sort_order ASC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
},
|
||||
})
|
||||
return
|
||||
case http.MethodPost:
|
||||
var body model.Chapter
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.ID == "" || body.PartID == "" || body.ChapterID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要字段 id/partId/chapterId"})
|
||||
return
|
||||
}
|
||||
if err := db.Create(&body).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
|
||||
return
|
||||
case http.MethodPut:
|
||||
var body model.Chapter
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
|
||||
"content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
|
||||
"sort_order": body.SortOrder, "status": body.Status,
|
||||
}
|
||||
if body.EditionStandard != nil {
|
||||
updates["edition_standard"] = body.EditionStandard
|
||||
}
|
||||
if body.EditionPremium != nil {
|
||||
updates["edition_premium"] = body.EditionPremium
|
||||
}
|
||||
if err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
case http.MethodDelete:
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
|
||||
// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录
|
||||
func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
q := db.Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var all []model.Chapter
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 从 reading_progress 统计阅读量
|
||||
ids := make([]string, 0, len(all))
|
||||
for _, c := range all {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
var counts []struct {
|
||||
SectionID string `gorm:"column:section_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
|
||||
Where("section_id IN ?", ids).Group("section_id").Scan(&counts)
|
||||
countMap := make(map[string]int64)
|
||||
for _, r := range counts {
|
||||
countMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
// 按阅读量降序、同量按 updated_at 降序
|
||||
type withSort struct {
|
||||
ch model.Chapter
|
||||
cnt int64
|
||||
}
|
||||
withCnt := make([]withSort, 0, len(all))
|
||||
for _, c := range all {
|
||||
withCnt = append(withCnt, withSort{ch: c, cnt: countMap[c.ID]})
|
||||
}
|
||||
for i := 0; i < len(withCnt)-1; i++ {
|
||||
for j := i + 1; j < len(withCnt); j++ {
|
||||
if withCnt[j].cnt > withCnt[i].cnt ||
|
||||
(withCnt[j].cnt == withCnt[i].cnt && withCnt[j].ch.UpdatedAt.After(withCnt[i].ch.UpdatedAt)) {
|
||||
withCnt[i], withCnt[j] = withCnt[j], withCnt[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]model.Chapter, 0, limit)
|
||||
for i := 0; i < limit && i < len(withCnt); i++ {
|
||||
out = append(out, withCnt[i].ch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
|
||||
func BookHot(c *gin.Context) {
|
||||
list := bookHotChaptersSorted(database.DB(), 10)
|
||||
if len(list) == 0 {
|
||||
// 兜底:按 sort_order 取前 10,同样排除序言/尾声/附录
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签)
|
||||
func BookRecommended(c *gin.Context) {
|
||||
list := bookHotChaptersSorted(database.DB(), 3)
|
||||
if len(list) == 0 {
|
||||
// 兜底:按 updated_at 取前 3,同样排除序言/尾声/附录
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
q.Order("updated_at DESC, id ASC").Limit(3).Find(&list)
|
||||
}
|
||||
tags := []string{"热门", "推荐", "精选"}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for i, ch := range list {
|
||||
tag := "精选"
|
||||
if i < len(tags) {
|
||||
tag = tags[i]
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "partTitle": ch.PartTitle,
|
||||
"chapterTitle": ch.ChapterTitle, "tag": tag,
|
||||
"isFree": ch.IsFree, "price": ch.Price, "isNew": ch.IsNew,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// BookLatestChapters GET /api/book/latest-chapters
|
||||
func BookLatestChapters(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
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) {
|
||||
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, "mid": ch.MID, "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
|
||||
func BookStats(c *gin.Context) {
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
|
||||
}
|
||||
|
||||
// BookSync GET/POST /api/book/sync
|
||||
func BookSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"})
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
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": "创业合伙,创业伙伴"}
|
||||
|
||||
// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait,空值跳过,按键升序拼接值,MD5(拼接串) 再 MD5(结果+apiKey)
|
||||
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 {
|
||||
v := params[k]
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
concat += val
|
||||
case float64:
|
||||
concat += strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case int:
|
||||
concat += strconv.Itoa(val)
|
||||
case int64:
|
||||
concat += strconv.FormatInt(val, 10)
|
||||
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) {
|
||||
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"`
|
||||
CanHelp string `json:"canHelp"` // 资源对接:我能帮到你什么
|
||||
NeedHelp string `json:"needHelp"` // 资源对接:我需要什么帮助
|
||||
}
|
||||
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 == "" {
|
||||
remark := "用户通过创业实验APP申请" + ckbSourceMap[body.Type]
|
||||
if body.Type == "investor" && (body.CanHelp != "" || body.NeedHelp != "") {
|
||||
remark = fmt.Sprintf("能帮:%s 需要:%s", body.CanHelp, body.NeedHelp)
|
||||
}
|
||||
params["remark"] = remark
|
||||
}
|
||||
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)
|
||||
sourceData := map[string]interface{}{
|
||||
"joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID,
|
||||
"device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if body.Type == "investor" {
|
||||
if body.CanHelp != "" {
|
||||
sourceData["canHelp"] = body.CanHelp
|
||||
}
|
||||
if body.NeedHelp != "" {
|
||||
sourceData["needHelp"] = body.NeedHelp
|
||||
}
|
||||
}
|
||||
params["portrait"] = map[string]interface{}{
|
||||
"type": 4, "source": 0,
|
||||
"sourceData": sourceData,
|
||||
"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 {
|
||||
// 资源对接:同步更新用户资料中的 help_offer、help_need、phone、wechat_id
|
||||
if body.Type == "investor" && body.UserID != "" {
|
||||
updates := map[string]interface{}{}
|
||||
if body.CanHelp != "" {
|
||||
updates["help_offer"] = body.CanHelp
|
||||
}
|
||||
if body.NeedHelp != "" {
|
||||
updates["help_need"] = body.NeedHelp
|
||||
}
|
||||
if body.Phone != "" {
|
||||
updates["phone"] = body.Phone
|
||||
}
|
||||
if body.Wechat != "" {
|
||||
updates["wechat_id"] = body.Wechat
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates)
|
||||
}
|
||||
}
|
||||
msg := "成功加入" + ckbSourceMap[body.Type]
|
||||
if result.Message == "已存在" {
|
||||
msg = "您已加入,我们会尽快联系您"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
|
||||
return
|
||||
}
|
||||
errMsg := result.Message
|
||||
if errMsg == "" {
|
||||
errMsg = "加入失败,请稍后重试"
|
||||
}
|
||||
// 打印 CKB 原始响应便于排查
|
||||
fmt.Printf("[CKBJoin] 失败 type=%s wechat=%s code=%d message=%s raw=%s\n",
|
||||
body.Type, body.Wechat, result.Code, result.Message, string(b))
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
|
||||
}
|
||||
|
||||
// CKBMatch POST /api/ckb/match
|
||||
func CKBMatch(c *gin.Context) {
|
||||
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
|
||||
func CKBSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetConfig GET /api/config 从 system_config 读取并合并(与 app/api/config 结构一致)
|
||||
func GetConfig(c *gin.Context) {
|
||||
var list []model.SystemConfig
|
||||
if err := database.DB().Order("config_key ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
|
||||
"siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
|
||||
})
|
||||
return
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
|
||||
"siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
|
||||
"authorInfo": gin.H{}, "marketing": gin.H{}, "system": gin.H{},
|
||||
}
|
||||
for _, row := range list {
|
||||
var val interface{}
|
||||
_ = json.Unmarshal(row.ConfigValue, &val)
|
||||
switch row.ConfigKey {
|
||||
case "site_config", "siteConfig":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["siteConfig"] = m
|
||||
}
|
||||
case "menu_config", "menuConfig":
|
||||
out["menuConfig"] = val
|
||||
case "page_config", "pageConfig":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["pageConfig"] = m
|
||||
}
|
||||
case "payment_methods", "paymentMethods":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["paymentMethods"] = m
|
||||
}
|
||||
case "live_qr_codes", "liveQRCodes":
|
||||
out["liveQRCodes"] = val
|
||||
case "author_info", "authorInfo":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["authorInfo"] = m
|
||||
}
|
||||
case "marketing":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["marketing"] = m
|
||||
}
|
||||
case "system":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["system"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ContentGet GET /api/content
|
||||
func ContentGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
syncOrdersLogger *log.Logger
|
||||
syncOrdersLoggerOnce sync.Once
|
||||
)
|
||||
|
||||
// syncOrdersLogf 将订单同步日志写入 log/sync-orders.log,不输出到控制台
|
||||
func syncOrdersLogf(format string, args ...interface{}) {
|
||||
syncOrdersLoggerOnce.Do(func() {
|
||||
_ = os.MkdirAll("log", 0755)
|
||||
f, err := os.OpenFile(filepath.Join("log", "sync-orders.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
syncOrdersLogger = log.New(io.Discard, "", 0)
|
||||
return
|
||||
}
|
||||
syncOrdersLogger = log.New(f, "[SyncOrders] ", log.Ldate|log.Ltime)
|
||||
})
|
||||
if syncOrdersLogger != nil {
|
||||
syncOrdersLogger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// SyncOrdersLogf 供 main 等调用,将订单同步相关日志写入 log/sync-orders.log
|
||||
func SyncOrdersLogf(format string, args ...interface{}) {
|
||||
syncOrdersLogf(format, args...)
|
||||
}
|
||||
|
||||
// RunSyncOrders 订单对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
|
||||
// 可被 HTTP 接口和内置定时任务调用。days 为查询范围(天),建议 7。
|
||||
func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) {
|
||||
if days < 1 {
|
||||
days = 7
|
||||
}
|
||||
if days > 30 {
|
||||
days = 30
|
||||
}
|
||||
db := database.DB()
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
var createdOrders []model.Order
|
||||
if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = len(createdOrders)
|
||||
|
||||
for _, o := range createdOrders {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return synced, total, ctx.Err()
|
||||
default:
|
||||
}
|
||||
tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
|
||||
if qerr != nil {
|
||||
syncOrdersLogf("查询订单 %s 失败: %v", o.OrderSN, qerr)
|
||||
continue
|
||||
}
|
||||
if tradeState != "SUCCESS" {
|
||||
continue
|
||||
}
|
||||
// 微信已支付,本地未更新 → 补齐
|
||||
totalAmount := float64(totalFee) / 100
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
syncOrdersLogf("更新订单 %s 失败: %v", o.OrderSN, err)
|
||||
continue
|
||||
}
|
||||
synced++
|
||||
syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount)
|
||||
|
||||
// 同步后续逻辑(全书、VIP、分销等,与 PayNotify 一致)
|
||||
pt := "fullbook"
|
||||
if o.ProductType != "" {
|
||||
pt = o.ProductType
|
||||
}
|
||||
productID := ""
|
||||
if o.ProductID != nil {
|
||||
productID = *o.ProductID
|
||||
}
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
|
||||
switch pt {
|
||||
case "fullbook":
|
||||
db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true)
|
||||
syncOrdersLogf("用户已购全书: %s", o.UserID)
|
||||
case "vip":
|
||||
expireDate := now.AddDate(0, 0, 365)
|
||||
db.Model(&model.User{}).Where("id = ?", o.UserID).Updates(map[string]interface{}{
|
||||
"is_vip": true,
|
||||
"vip_expire_date": expireDate,
|
||||
"vip_activated_at": now,
|
||||
})
|
||||
syncOrdersLogf("用户 VIP 已激活: %s, 过期日=%s", o.UserID, expireDate.Format("2006-01-02"))
|
||||
case "match":
|
||||
syncOrdersLogf("用户购买匹配次数: %s", o.UserID)
|
||||
case "section":
|
||||
syncOrdersLogf("用户购买章节: %s - %s", o.UserID, productID)
|
||||
}
|
||||
|
||||
// 取消同商品未支付订单(与 PayNotify 一致)
|
||||
db.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = ? AND order_sn != ?",
|
||||
o.UserID, pt, productID, "created", o.OrderSN,
|
||||
).Delete(&model.Order{})
|
||||
|
||||
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o)
|
||||
}
|
||||
return synced, total, nil
|
||||
}
|
||||
|
||||
// CronSyncOrders GET/POST /api/cron/sync-orders
|
||||
// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
|
||||
// 支持 ?days=7 扩展时间范围,默认 7 天
|
||||
func CronSyncOrders(c *gin.Context) {
|
||||
days := 7
|
||||
if d := c.Query("days"); d != "" {
|
||||
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 30 {
|
||||
days = n
|
||||
}
|
||||
}
|
||||
synced, total, err := RunSyncOrders(c.Request.Context(), days)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"synced": synced,
|
||||
"total": total,
|
||||
"days": days,
|
||||
})
|
||||
}
|
||||
|
||||
// CronUnbindExpired GET/POST /api/cron/unbind-expired
|
||||
func CronUnbindExpired(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,330 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// listSelectCols 列表/导出不加载 content,大幅加速
|
||||
var listSelectCols = []string{
|
||||
"id", "section_title", "price", "is_free", "is_new",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
||||
type sectionListItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree,omitempty"`
|
||||
IsNew *bool `json:"isNew,omitempty"` // stitch_soul:标记最新新增
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
FilePath *string `json:"filePath,omitempty"`
|
||||
}
|
||||
|
||||
// DBBookAction GET/POST/PUT /api/db/book
|
||||
func DBBookAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
action := c.Query("action")
|
||||
id := c.Query("id")
|
||||
switch action {
|
||||
case "list":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
sections = append(sections, sectionListItem{
|
||||
ID: r.ID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
case "read":
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
if err := db.Where("id = ?", id).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
price := 1.0
|
||||
if ch.Price != nil {
|
||||
price = *ch.Price
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"section": gin.H{
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
},
|
||||
})
|
||||
return
|
||||
case "export":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
sections = append(sections, sectionListItem{
|
||||
ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree, IsNew: r.IsNew,
|
||||
PartID: r.PartID, PartTitle: r.PartTitle, ChapterID: r.ChapterID, ChapterTitle: r.ChapterTitle,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
|
||||
return
|
||||
}
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Data []importItem `json:"data"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
switch body.Action {
|
||||
case "sync":
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"})
|
||||
return
|
||||
case "import":
|
||||
imported, failed := 0, 0
|
||||
for _, item := range body.Data {
|
||||
price := 1.0
|
||||
if item.Price != nil {
|
||||
price = *item.Price
|
||||
}
|
||||
isFree := false
|
||||
if item.IsFree != nil {
|
||||
isFree = *item.IsFree
|
||||
}
|
||||
wordCount := len(item.Content)
|
||||
status := "published"
|
||||
ch := model.Chapter{
|
||||
ID: item.ID,
|
||||
PartID: strPtr(item.PartID, "part-1"),
|
||||
PartTitle: strPtr(item.PartTitle, "未分类"),
|
||||
ChapterID: strPtr(item.ChapterID, "chapter-1"),
|
||||
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
|
||||
SectionTitle: item.Title,
|
||||
Content: item.Content,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
}
|
||||
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
err = db.Create(&ch).Error
|
||||
} else if err == nil {
|
||||
err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||
"section_title": ch.SectionTitle,
|
||||
"content": ch.Content,
|
||||
"word_count": ch.WordCount,
|
||||
"is_free": ch.IsFree,
|
||||
"price": ch.Price,
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
|
||||
return
|
||||
}
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
// reorder:新顺序,支持跨篇跨章时附带 partId/chapterId
|
||||
IDs []string `json:"ids"`
|
||||
Items []reorderItem `json:"items"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
IsNew *bool `json:"isNew"` // stitch_soul:标记最新新增
|
||||
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
|
||||
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.Action == "reorder" {
|
||||
// 立即返回成功,后台异步执行排序更新
|
||||
if len(body.Items) > 0 {
|
||||
items := make([]reorderItem, len(body.Items))
|
||||
copy(items, body.Items)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
go func() {
|
||||
db := database.DB()
|
||||
for i, it := range items {
|
||||
if it.ID == "" {
|
||||
continue
|
||||
}
|
||||
up := map[string]interface{}{"sort_order": i}
|
||||
if it.PartID != "" {
|
||||
up["part_id"] = it.PartID
|
||||
}
|
||||
if it.PartTitle != "" {
|
||||
up["part_title"] = it.PartTitle
|
||||
}
|
||||
if it.ChapterID != "" {
|
||||
up["chapter_id"] = it.ChapterID
|
||||
}
|
||||
if it.ChapterTitle != "" {
|
||||
up["chapter_title"] = it.ChapterTitle
|
||||
}
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
if len(body.IDs) > 0 {
|
||||
ids := make([]string, len(body.IDs))
|
||||
copy(ids, body.IDs)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
go func() {
|
||||
db := database.DB()
|
||||
for i, id := range ids {
|
||||
if id != "" {
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
|
||||
}
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
price := 1.0
|
||||
if body.Price != nil {
|
||||
price = *body.Price
|
||||
}
|
||||
isFree := false
|
||||
if body.IsFree != nil {
|
||||
isFree = *body.IsFree
|
||||
}
|
||||
wordCount := len(body.Content)
|
||||
updates := map[string]interface{}{
|
||||
"section_title": body.Title,
|
||||
"content": body.Content,
|
||||
"word_count": wordCount,
|
||||
"price": price,
|
||||
"is_free": isFree,
|
||||
}
|
||||
if body.IsNew != nil {
|
||||
updates["is_new"] = *body.IsNew
|
||||
}
|
||||
if body.EditionStandard != nil {
|
||||
updates["edition_standard"] = *body.EditionStandard
|
||||
}
|
||||
if body.EditionPremium != nil {
|
||||
updates["edition_premium"] = *body.EditionPremium
|
||||
}
|
||||
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
|
||||
type reorderItem struct {
|
||||
ID string `json:"id"`
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
}
|
||||
|
||||
type importItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
PartID *string `json:"partId"`
|
||||
PartTitle *string `json:"partTitle"`
|
||||
ChapterID *string `json:"chapterId"`
|
||||
ChapterTitle *string `json:"chapterTitle"`
|
||||
}
|
||||
|
||||
func strPtr(s *string, def string) string {
|
||||
if s != nil && *s != "" {
|
||||
return *s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// DBBookDelete DELETE /api/db/book
|
||||
func DBBookDelete(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DistributionGet POST /api/distribution GET/POST/PUT
|
||||
func DistributionGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DistributionAutoWithdrawConfig GET/POST/DELETE /api/distribution/auto-withdraw-config
|
||||
func DistributionAutoWithdrawConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DistributionMessages GET/POST /api/distribution/messages
|
||||
func DistributionMessages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DocGenerate POST /api/documentation/generate
|
||||
func DocGenerate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const defaultFreeMatchLimit = 3
|
||||
|
||||
// MatchQuota 匹配次数配额(纯计算:订单 + match_records)
|
||||
type MatchQuota struct {
|
||||
PurchasedTotal int64 `json:"purchasedTotal"`
|
||||
PurchasedUsed int64 `json:"purchasedUsed"`
|
||||
MatchesUsedToday int64 `json:"matchesUsedToday"`
|
||||
FreeRemainToday int64 `json:"freeRemainToday"`
|
||||
PurchasedRemain int64 `json:"purchasedRemain"`
|
||||
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
|
||||
}
|
||||
|
||||
func getFreeMatchLimit(db *gorm.DB) int {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil {
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err != nil {
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
if v, ok := config["freeMatchLimit"].(float64); ok && v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
|
||||
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额
|
||||
func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
||||
if freeLimit <= 0 {
|
||||
freeLimit = defaultFreeMatchLimit
|
||||
}
|
||||
var purchasedTotal int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userID, "match", "paid").Count(&purchasedTotal)
|
||||
var matchesToday int64
|
||||
db.Model(&model.MatchRecord{}).Where("user_id = ? AND created_at >= CURDATE()", userID).Count(&matchesToday)
|
||||
// 历史每日超出免费部分之和 = 已消耗的购买次数
|
||||
var purchasedUsed int64
|
||||
db.Raw(`
|
||||
SELECT COALESCE(SUM(cnt - ?), 0) FROM (
|
||||
SELECT DATE(created_at) AS d, COUNT(*) AS cnt
|
||||
FROM match_records WHERE user_id = ?
|
||||
GROUP BY DATE(created_at)
|
||||
HAVING cnt > ?
|
||||
) t
|
||||
`, freeLimit, userID, freeLimit).Scan(&purchasedUsed)
|
||||
freeUsed := matchesToday
|
||||
if freeUsed > int64(freeLimit) {
|
||||
freeUsed = int64(freeLimit)
|
||||
}
|
||||
freeRemain := int64(freeLimit) - freeUsed
|
||||
if freeRemain < 0 {
|
||||
freeRemain = 0
|
||||
}
|
||||
purchasedRemain := purchasedTotal - purchasedUsed
|
||||
if purchasedRemain < 0 {
|
||||
purchasedRemain = 0
|
||||
}
|
||||
remainToday := freeRemain + purchasedRemain
|
||||
return MatchQuota{
|
||||
PurchasedTotal: purchasedTotal,
|
||||
PurchasedUsed: purchasedUsed,
|
||||
MatchesUsedToday: matchesToday,
|
||||
FreeRemainToday: freeRemain,
|
||||
PurchasedRemain: purchasedRemain,
|
||||
RemainToday: remainToday,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
func MatchConfigPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// MatchUsers POST /api/match/users
|
||||
func MatchUsers(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
// 全书用户无限制,否则校验今日剩余次数
|
||||
var user model.User
|
||||
skipQuota := false
|
||||
if err := db.Where("id = ?", body.UserID).First(&user).Error; err == nil {
|
||||
skipQuota = user.HasFullBook != nil && *user.HasFullBook
|
||||
}
|
||||
if !skipQuota {
|
||||
freeLimit := getFreeMatchLimit(db)
|
||||
quota := GetMatchQuota(db, body.UserID, freeLimit)
|
||||
if quota.RemainToday <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "今日匹配次数已用完,请购买更多次数",
|
||||
"code": "QUOTA_EXCEEDED",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 只匹配已绑定微信或手机号的用户
|
||||
var users []model.User
|
||||
q := db.Where("id != ?", body.UserID).
|
||||
Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))")
|
||||
if err := q.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, "code": "NO_USERS"})
|
||||
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
|
||||
}
|
||||
intro := "来自Soul创业派对的伙伴"
|
||||
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
|
||||
tag := matchLabels[body.MatchType]
|
||||
if tag == "" {
|
||||
tag = "找伙伴"
|
||||
}
|
||||
// 写入匹配记录(含发起者的 phone/wechat_id 便于后续联系)
|
||||
rec := model.MatchRecord{
|
||||
ID: fmt.Sprintf("mr_%d", time.Now().UnixNano()),
|
||||
UserID: body.UserID,
|
||||
MatchedUserID: r.ID,
|
||||
MatchType: body.MatchType,
|
||||
}
|
||||
if body.MatchType == "" {
|
||||
rec.MatchType = "partner"
|
||||
}
|
||||
if body.Phone != "" {
|
||||
rec.Phone = &body.Phone
|
||||
}
|
||||
if body.WechatID != "" {
|
||||
rec.WechatID = &body.WechatID
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
fmt.Printf("[MatchUsers] 写入 match_records 失败: %v\n", err)
|
||||
}
|
||||
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,96 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选)
|
||||
func DBMatchRecordsList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
matchType := c.Query("matchType")
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
q := db.Model(&model.MatchRecord{})
|
||||
if matchType != "" {
|
||||
q = q.Where("match_type = ?", matchType)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var records []model.MatchRecord
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "records": []interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range records {
|
||||
userIDs[r.UserID] = true
|
||||
userIDs[r.MatchedUserID] = true
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
if len(ids) > 0 {
|
||||
database.DB().Where("id IN ?", ids).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
|
||||
getStr := func(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
u := userMap[r.UserID]
|
||||
mu := userMap[r.MatchedUserID]
|
||||
userAvatar := ""
|
||||
matchedUserAvatar := ""
|
||||
if u != nil && u.Avatar != nil {
|
||||
userAvatar = *u.Avatar
|
||||
}
|
||||
if mu != nil && mu.Avatar != nil {
|
||||
matchedUserAvatar = *mu.Avatar
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "matchedUserId": r.MatchedUserID,
|
||||
"matchType": r.MatchType, "phone": getStr(r.Phone), "wechatId": getStr(r.WechatID),
|
||||
"userNickname": getStr(u.Nickname),
|
||||
"matchedNickname": getStr(mu.Nickname),
|
||||
"userAvatar": userAvatar,
|
||||
"matchedUserAvatar": matchedUserAvatar,
|
||||
"matchScore": r.MatchScore,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "records": out,
|
||||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MenuGet GET /api/menu
|
||||
func MenuGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
}
|
||||
@@ -1,831 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
orderPollLogger *log.Logger
|
||||
orderPollLoggerOnce sync.Once
|
||||
)
|
||||
|
||||
// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log,不输出到控制台
|
||||
func orderPollLogf(format string, args ...interface{}) {
|
||||
orderPollLoggerOnce.Do(func() {
|
||||
_ = os.MkdirAll("log", 0755)
|
||||
f, err := os.OpenFile(filepath.Join("log", "order-poll.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
orderPollLogger = log.New(io.Discard, "", 0)
|
||||
return
|
||||
}
|
||||
orderPollLogger = log.New(f, "[OrderPoll] ", log.Ldate|log.Ltime)
|
||||
})
|
||||
if orderPollLogger != nil {
|
||||
orderPollLogger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// MiniprogramLogin POST /api/miniprogram/login
|
||||
func MiniprogramLogin(c *gin.Context) {
|
||||
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) {
|
||||
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()
|
||||
|
||||
// 查询用户的有效推荐人(先查 binding,再查 referralCode)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 有推荐人时应用好友优惠(无论是 binding 还是 referralCode)
|
||||
finalAmount := req.Amount
|
||||
if referrerID != nil {
|
||||
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 if req.ProductType == "vip" {
|
||||
description = "卡若创业派对VIP年度会员(365天)"
|
||||
} else if req.ProductType == "match" {
|
||||
description = "购买匹配次数"
|
||||
} else {
|
||||
description = fmt.Sprintf("章节购买-%s", req.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取客户端 IP
|
||||
clientIP := c.ClientIP()
|
||||
if clientIP == "" {
|
||||
clientIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
// 插入订单到数据库
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = req.OpenID
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
switch req.ProductType {
|
||||
case "vip":
|
||||
productID = "vip_annual"
|
||||
case "match":
|
||||
productID = "match"
|
||||
default:
|
||||
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)
|
||||
}
|
||||
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
|
||||
ctx := c.Request.Context()
|
||||
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, description, attach)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)})
|
||||
return
|
||||
}
|
||||
payParams, err := wechat.GetJSAPIPayParams(prepayID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("生成支付参数失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, orderSn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"status": "unknown",
|
||||
"orderSn": orderSn,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := "paying"
|
||||
switch tradeState {
|
||||
case "SUCCESS":
|
||||
status = "paid"
|
||||
// 若微信已支付,主动同步到本地 orders(不等 PayNotify),便于购买次数即时生效
|
||||
db := database.DB()
|
||||
var order model.Order
|
||||
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" {
|
||||
now := time.Now()
|
||||
db.Model(&order).Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
})
|
||||
orderPollLogf("主动同步订单已支付: %s", orderSn)
|
||||
}
|
||||
case "CLOSED", "REVOKED", "PAYERROR":
|
||||
status = "failed"
|
||||
case "REFUND":
|
||||
status = "refunded"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"status": status,
|
||||
"orderSn": orderSn,
|
||||
"transactionId": transactionID,
|
||||
"totalFee": totalFee,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramPayNotify POST /api/miniprogram/pay/notify(v3 支付回调,PowerWeChat 验签解密)
|
||||
func MiniprogramPayNotify(c *gin.Context) {
|
||||
resp, err := wechat.HandlePayNotify(c.Request, func(orderSn, transactionID string, totalFee int, attachStr, openID string) error {
|
||||
totalAmount := float64(totalFee) / 100
|
||||
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 attachStr != "" {
|
||||
_ = json.Unmarshal([]byte(attachStr), &attach)
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
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,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("create order: %w", err)
|
||||
}
|
||||
} else if *order.Status != "paid" {
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
if err := db.Model(&order).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
}).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("update order: %w", err)
|
||||
}
|
||||
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 == "vip" {
|
||||
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at(排序:后付款在前)
|
||||
expireDate := time.Now().AddDate(0, 0, 365)
|
||||
vipActivatedAt := time.Now()
|
||||
if order.PayTime != nil {
|
||||
vipActivatedAt = *order.PayTime
|
||||
}
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{
|
||||
"is_vip": true,
|
||||
"vip_expire_date": expireDate,
|
||||
"vip_activated_at": vipActivatedAt,
|
||||
})
|
||||
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", buyerUserID, orderSn)
|
||||
} 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 {
|
||||
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"
|
||||
}
|
||||
db.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductType, productID, orderSn,
|
||||
).Delete(&model.Order{})
|
||||
processReferralCommission(db, buyerUserID, totalAmount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[PayNotify] 处理回调失败: %v\n", err)
|
||||
c.String(http.StatusOK, failResponse())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
c.Header(k, v[0])
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
// 处理分销佣金(会员订单 20%/10%,内容订单 90%)
|
||||
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
|
||||
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
|
||||
}
|
||||
// 确保 order 有 referrer_id(补记订单可能缺失)
|
||||
if order != nil && (order.ReferrerID == nil || *order.ReferrerID == "") {
|
||||
order.ReferrerID = &binding.ReferrerID
|
||||
db.Model(order).Update("referrer_id", binding.ReferrerID)
|
||||
}
|
||||
// 构建用于计算的 order(若为 nil 则用 binding 信息)
|
||||
calcOrder := order
|
||||
if calcOrder == nil {
|
||||
calcOrder = &model.Order{Amount: amount, ProductType: "unknown", ReferrerID: &binding.ReferrerID}
|
||||
}
|
||||
commission := computeOrderCommission(db, calcOrder, nil)
|
||||
if commission <= 0 {
|
||||
fmt.Printf("[PayNotify] 佣金为 0,跳过分佣: orderSn=%s\n", orderSn)
|
||||
return
|
||||
}
|
||||
newPurchaseCount := binding.PurchaseCount + 1
|
||||
newTotalCommission := binding.TotalCommission + commission
|
||||
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f\n",
|
||||
binding.ReferrerID, amount, commission)
|
||||
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 = COALESCE(purchase_count, 0) + 1,
|
||||
total_commission = COALESCE(total_commission, 0) + ?
|
||||
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) {
|
||||
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
|
||||
func MiniprogramQrcode(c *gin.Context) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramQrcodeImage GET /api/miniprogram/qrcode/image?scene=xxx&page=xxx&width=280
|
||||
// 直接返回 image/png,供小程序 wx.downloadFile 使用,便于开发工具与真机统一用 tempFilePath 绘制
|
||||
func MiniprogramQrcodeImage(c *gin.Context) {
|
||||
scene := c.Query("scene")
|
||||
if scene == "" {
|
||||
scene = "soul"
|
||||
}
|
||||
page := c.DefaultQuery("page", "pages/read/read")
|
||||
width, _ := strconv.Atoi(c.DefaultQuery("width", "280"))
|
||||
if width <= 0 {
|
||||
width = 280
|
||||
}
|
||||
imageData, err := wechat.GenerateMiniProgramCode(scene, page, width)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("生成小程序码失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Data(http.StatusOK, "image/png", imageData)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// MiniprogramUsers GET /api/miniprogram/users 小程序-用户列表/单个(首页超级个体补充、会员详情回退)
|
||||
// 支持 ?limit=20 返回列表;?id=xxx 返回单个。返回 { success, data } 格式
|
||||
func MiniprogramUsers(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
if id != "" {
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||||
return
|
||||
}
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
|
||||
id, "paid", "fullbook", "vip").Count(&cnt)
|
||||
// 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示
|
||||
item := gin.H{
|
||||
"id": user.ID,
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getStringValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"vipName": getStringValue(user.VipName),
|
||||
"vipAvatar": getStringValue(user.VipAvatar),
|
||||
"vipContact": getStringValue(user.VipContact),
|
||||
"vipProject": getStringValue(user.VipProject),
|
||||
"vipBio": getStringValue(user.VipBio),
|
||||
"mbti": getStringValue(user.Mbti),
|
||||
"region": getStringValue(user.Region),
|
||||
"industry": getStringValue(user.Industry),
|
||||
"position": getStringValue(user.Position),
|
||||
"businessScale": getStringValue(user.BusinessScale),
|
||||
"skills": getStringValue(user.Skills),
|
||||
"storyBestMonth": getStringValue(user.StoryBestMonth),
|
||||
"storyAchievement": getStringValue(user.StoryAchievement),
|
||||
"storyTurning": getStringValue(user.StoryTurning),
|
||||
"helpOffer": getStringValue(user.HelpOffer),
|
||||
"helpNeed": getStringValue(user.HelpNeed),
|
||||
"projectIntro": getStringValue(user.ProjectIntro),
|
||||
"is_vip": cnt > 0,
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
|
||||
return
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
db.Order("created_at DESC").Limit(limit).Find(&users)
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
|
||||
u.ID, "paid", "fullbook", "vip").Count(&cnt)
|
||||
list = append(list, gin.H{
|
||||
"id": u.ID,
|
||||
"nickname": getStringValue(u.Nickname),
|
||||
"avatar": getStringValue(u.Avatar),
|
||||
"is_vip": cnt > 0,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrdersList GET /api/orders(带用户昵称/头像/手机号,分销佣金按配置比例计算;支持分页 page、pageSize,筛选 status,搜索 search)
|
||||
func OrdersList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
statusFilter := c.Query("status")
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
// 预加载 referral_config,避免订单循环内 N+1 查询
|
||||
var refCfgRow model.SystemConfig
|
||||
refCfg := (*model.SystemConfig)(nil)
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&refCfgRow).Error; err == nil {
|
||||
refCfg = &refCfgRow
|
||||
}
|
||||
|
||||
// 构建带筛选的查询(count 与 list 共用条件)
|
||||
applyOrdersFilter := func(q *gorm.DB) *gorm.DB {
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
if statusFilter == "completed" {
|
||||
q = q.Where("status IN ?", []string{"paid", "completed"})
|
||||
} else {
|
||||
q = q.Where("status = ?", statusFilter)
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
q = q.Where("order_sn LIKE ? OR id LIKE ? OR user_id IN (SELECT id FROM users WHERE COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?)",
|
||||
pattern, pattern, pattern, pattern, pattern)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
var total int64
|
||||
var totalRevenue, todayRevenue float64
|
||||
var orders []model.Order
|
||||
var ordersErr error
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 并行:count、营收统计、订单列表
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
applyOrdersFilter(db.Model(&model.Order{})).Count(&total)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0)").
|
||||
Where("status IN ?", []string{"paid", "completed"}).Scan(&totalRevenue)
|
||||
todayStart := time.Now().Truncate(24 * time.Hour)
|
||||
todayEnd := todayStart.Add(24 * time.Hour)
|
||||
db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0)").
|
||||
Where("status IN ? AND created_at >= ? AND created_at < ?", []string{"paid", "completed"}, todayStart, todayEnd).
|
||||
Scan(&todayRevenue)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
query := applyOrdersFilter(db.Model(&model.Order{}))
|
||||
ordersErr = query.Order("created_at DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&orders).Error
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if ordersErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": ordersErr.Error(), "orders": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "orders": []interface{}{},
|
||||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 收集订单中的 user_id、referrer_id,查用户信息
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range orders {
|
||||
if o.UserID != "" {
|
||||
userIDs[o.UserID] = true
|
||||
}
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
userIDs[*o.ReferrerID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Where("id IN ?", ids).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
|
||||
getStr := func(s *string) string {
|
||||
if s == nil || *s == "" {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(orders))
|
||||
for _, o := range orders {
|
||||
// 序列化订单为基础字段
|
||||
b, _ := json.Marshal(o)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
// 用户信息
|
||||
if u := userMap[o.UserID]; u != nil {
|
||||
m["userNickname"] = getStr(u.Nickname)
|
||||
m["userPhone"] = getStr(u.Phone)
|
||||
m["userAvatar"] = getStr(u.Avatar)
|
||||
} else {
|
||||
m["userNickname"] = ""
|
||||
m["userPhone"] = ""
|
||||
m["userAvatar"] = ""
|
||||
}
|
||||
// 推荐人信息
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
if u := userMap[*o.ReferrerID]; u != nil {
|
||||
m["referrerNickname"] = getStr(u.Nickname)
|
||||
m["referrerCode"] = getStr(u.ReferralCode)
|
||||
}
|
||||
}
|
||||
// 分销佣金:仅对已支付且存在推荐人的订单,按 computeOrderCommission(会员 20%/10%,内容 90%)
|
||||
status := getStr(o.Status)
|
||||
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
var refUser *model.User
|
||||
if u := userMap[*o.ReferrerID]; u != nil {
|
||||
refUser = u
|
||||
}
|
||||
m["referrerEarnings"] = computeOrderCommission(db, &o, refUser, refCfg)
|
||||
} else {
|
||||
m["referrerEarnings"] = nil
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "orders": out,
|
||||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
"totalRevenue": totalRevenue, "todayRevenue": todayRevenue,
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramOrders GET /api/miniprogram/orders 小程序-当前用户订单列表(按 userId 过滤,返回 data)
|
||||
func MiniprogramOrders(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var orders []model.Order
|
||||
if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&orders).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(orders))
|
||||
for _, o := range orders {
|
||||
desc := ""
|
||||
if o.Description != nil {
|
||||
desc = *o.Description
|
||||
}
|
||||
productID := ""
|
||||
if o.ProductID != nil {
|
||||
productID = *o.ProductID
|
||||
}
|
||||
status := "created"
|
||||
if o.Status != nil {
|
||||
status = *o.Status
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": o.ID, "order_sn": o.OrderSN, "user_id": o.UserID,
|
||||
"product_id": productID, "product_type": o.ProductType,
|
||||
"product_name": desc, "section_id": productID,
|
||||
"amount": o.Amount, "status": status,
|
||||
"created_at": o.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// AdminOrderRefund PUT /api/admin/orders/refund 管理端-订单退款(仅支持已支付订单,调用微信支付退款)
|
||||
func AdminOrderRefund(c *gin.Context) {
|
||||
var req struct {
|
||||
OrderSn string `json:"orderSn" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少订单号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var order model.Order
|
||||
if err := db.Where("order_sn = ?", req.OrderSn).First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单不存在"})
|
||||
return
|
||||
}
|
||||
status := ""
|
||||
if order.Status != nil {
|
||||
status = *order.Status
|
||||
}
|
||||
if status != "paid" && status != "completed" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅支持已支付订单退款"})
|
||||
return
|
||||
}
|
||||
transactionID := ""
|
||||
if order.TransactionID != nil {
|
||||
transactionID = *order.TransactionID
|
||||
}
|
||||
if transactionID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单缺少微信支付单号,无法退款"})
|
||||
return
|
||||
}
|
||||
totalCents := int(order.Amount * 100)
|
||||
if totalCents < 1 {
|
||||
totalCents = 1
|
||||
}
|
||||
if err := wechat.RefundOrder(context.Background(), order.OrderSN, transactionID, totalCents, req.Reason); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "微信退款失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
refunded := "refunded"
|
||||
updates := map[string]interface{}{"status": refunded}
|
||||
if req.Reason != "" {
|
||||
updates["refund_reason"] = req.Reason
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "退款成功但更新订单状态失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "退款成功"})
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PaymentAlipayNotify POST /api/payment/alipay/notify
|
||||
func PaymentAlipayNotify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentCallback POST /api/payment/callback
|
||||
func PaymentCallback(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentCreateOrder POST /api/payment/create-order
|
||||
func PaymentCreateOrder(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentMethods GET /api/payment/methods
|
||||
func PaymentMethods(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
}
|
||||
|
||||
// PaymentQuery GET /api/payment/query
|
||||
func PaymentQuery(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentStatusOrderSn GET /api/payment/status/:orderSn
|
||||
func PaymentStatusOrderSn(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentVerify POST /api/payment/verify
|
||||
func PaymentVerify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentWechatNotify POST /api/payment/wechat/notify
|
||||
func PaymentWechatNotify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// logWechatTransferCallback 写入微信转账回调日志到 wechat_callback_logs
|
||||
func logWechatTransferCallback(db *gorm.DB, outBillNo, transferBillNo, state, failReason, outBatchNo, handlerResult, handlerError string) {
|
||||
entry := model.WechatCallbackLog{
|
||||
CallbackType: "transfer",
|
||||
OutDetailNo: outBillNo,
|
||||
TransferBillNo: transferBillNo,
|
||||
State: state,
|
||||
FailReason: failReason,
|
||||
OutBatchNo: outBatchNo,
|
||||
HandlerResult: handlerResult,
|
||||
HandlerError: handlerError,
|
||||
}
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
fmt.Printf("[TransferNotify] 写回调日志失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PaymentWechatTransferNotify POST /api/payment/wechat/transfer/notify
|
||||
// 使用 PowerWeChat 验签、解密密文后更新提现状态,并返回微信要求的应答;同时写入 wechat_callback_logs
|
||||
// GET 同一路径时仅返回 200 与说明(便于探活或浏览器访问,不写库)
|
||||
func PaymentWechatTransferNotify(c *gin.Context) {
|
||||
if c.Request.Method == "GET" {
|
||||
c.String(http.StatusOK, "转账结果通知请使用 POST")
|
||||
return
|
||||
}
|
||||
fmt.Printf("[TransferNotify] 收到微信转账回调请求 method=%s path=%s\n", c.Request.Method, c.Request.URL.Path)
|
||||
resp, err := wechat.HandleTransferNotify(c.Request, func(outBillNo, transferBillNo, state, failReason string) error {
|
||||
fmt.Printf("[TransferNotify] 解密成功: out_bill_no=%s, transfer_bill_no=%s, state=%s\n", outBillNo, transferBillNo, state)
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("detail_no = ?", outBillNo).First(&w).Error; err != nil {
|
||||
fmt.Printf("[TransferNotify] 未找到 detail_no=%s 的提现记录: %v\n", outBillNo, err)
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, "", "success", "未找到提现记录")
|
||||
return nil
|
||||
}
|
||||
outBatchNo := ""
|
||||
if w.BatchNo != nil {
|
||||
outBatchNo = *w.BatchNo
|
||||
}
|
||||
cur := ""
|
||||
if w.Status != nil {
|
||||
cur = *w.Status
|
||||
}
|
||||
if cur != "processing" && cur != "pending_confirm" {
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "状态已变更跳过")
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch state {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL", "CANCELLED":
|
||||
up["status"] = "failed"
|
||||
if failReason != "" {
|
||||
up["fail_reason"] = failReason
|
||||
}
|
||||
default:
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "")
|
||||
return nil
|
||||
}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "fail", err.Error())
|
||||
return fmt.Errorf("更新提现状态失败: %w", err)
|
||||
}
|
||||
fmt.Printf("[TransferNotify] 已更新提现 id=%s -> status=%s\n", w.ID, up["status"])
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[TransferNotify] 验签/解密/处理失败: %v\n", err)
|
||||
db := database.DB()
|
||||
logWechatTransferCallback(db, "", "", "", "", "", "fail", err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
c.Header(k, v[0])
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
const defaultBindingDays = 30
|
||||
|
||||
// ReferralBind POST /api/referral/bind 推荐码绑定(新绑定/续期/切换)
|
||||
func ReferralBind(c *gin.Context) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 获取分销配置(与 soul-admin 推广设置一致)
|
||||
distributorShare := 0.9
|
||||
minWithdrawAmount := 10.0
|
||||
bindingDays := defaultBindingDays
|
||||
userDiscount := 5
|
||||
withdrawFee := 5.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
|
||||
}
|
||||
if days, ok := config["bindingDays"].(float64); ok && days > 0 {
|
||||
bindingDays = int(days)
|
||||
}
|
||||
if discount, ok := config["userDiscount"].(float64); ok {
|
||||
userDiscount = int(discount)
|
||||
}
|
||||
if fee, ok := config["withdrawFee"].(float64); ok {
|
||||
withdrawFee = fee
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 []model.Order
|
||||
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
|
||||
|
||||
totalAmount := 0.0
|
||||
totalCommission := 0.0
|
||||
uniqueUsers := make(map[string]bool)
|
||||
for i := range paidOrders {
|
||||
totalAmount += paidOrders[i].Amount
|
||||
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
|
||||
uniqueUsers[paidOrders[i].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. 提现统计(与小程序可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核)
|
||||
// 待审核 = pending + processing + pending_confirm,与 /api/withdraw/pending-confirm 口径一致
|
||||
var pendingWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
|
||||
Scan(&pendingWithdraw)
|
||||
|
||||
var successWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status = ?", userId, "success").
|
||||
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 i := range earningsDetailsList {
|
||||
e := &earningsDetailsList[i]
|
||||
var buyer model.User
|
||||
db.Where("id = ?", e.UserID).First(&buyer)
|
||||
|
||||
commission := computeOrderCommission(db, e, nil)
|
||||
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 已按订单逐条计算)
|
||||
estimatedEarnings := totalCommission
|
||||
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,
|
||||
"bindingDays": bindingDays,
|
||||
"userDiscount": userDiscount,
|
||||
"withdrawFee": withdrawFee,
|
||||
|
||||
// 推荐码
|
||||
"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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// round 四舍五入保留小数
|
||||
func round(val float64, precision int) float64 {
|
||||
ratio := math.Pow(10, float64(precision))
|
||||
return math.Round(val*ratio) / ratio
|
||||
}
|
||||
|
||||
// MyEarnings GET /api/miniprogram/earnings 仅返回「我的收益」卡片所需数据(累计、可提现、推荐人数),用于我的页展示与刷新
|
||||
func MyEarnings(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
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 paidOrders []model.Order
|
||||
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
|
||||
totalCommission := 0.0
|
||||
for i := range paidOrders {
|
||||
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
|
||||
}
|
||||
var pendingWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
|
||||
Scan(&pendingWithdraw)
|
||||
var successWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status = ?", userId, "success").
|
||||
Scan(&successWithdraw)
|
||||
pendingWithdrawAmount := pendingWithdraw.Total
|
||||
withdrawnFromTable := successWithdraw.Total
|
||||
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
|
||||
if availableEarnings < 0 {
|
||||
availableEarnings = 0
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"totalCommission": round(totalCommission, 2),
|
||||
"availableEarnings": round(availableEarnings, 2),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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,79 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// computeOrderCommission 按订单计算应付给推广者的佣金
|
||||
// 会员订单:推广者会员 20%、非会员 10%;内容订单:90%(好友优惠 5% 仅针对内容)
|
||||
// order: 已支付订单,需有 product_type、amount、referrer_id
|
||||
// referrerUser: 推广者用户信息,用于判断 is_vip(可为 nil,会查库)
|
||||
// preloadConfig: 可选,预加载的 referral_config,避免 N+1 查询
|
||||
func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model.User, preloadConfig ...*model.SystemConfig) float64 {
|
||||
if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" {
|
||||
return 0
|
||||
}
|
||||
// 读取推广配置
|
||||
distributorShare := 0.9
|
||||
userDiscount := 0.0
|
||||
vipOrderShareVip := 20.0
|
||||
vipOrderShareNonVip := 10.0
|
||||
var cfg *model.SystemConfig
|
||||
if len(preloadConfig) > 0 && preloadConfig[0] != nil {
|
||||
cfg = preloadConfig[0]
|
||||
} else if row, err := (func() (*model.SystemConfig, error) {
|
||||
var r model.SystemConfig
|
||||
e := db.Where("config_key = ?", "referral_config").First(&r).Error
|
||||
return &r, e
|
||||
})(); err == nil {
|
||||
cfg = row
|
||||
}
|
||||
if cfg != 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 disc, ok := config["userDiscount"].(float64); ok {
|
||||
userDiscount = disc / 100
|
||||
}
|
||||
if v, ok := config["vipOrderShareVip"].(float64); ok {
|
||||
vipOrderShareVip = v / 100
|
||||
}
|
||||
if v, ok := config["vipOrderShareNonVip"].(float64); ok {
|
||||
vipOrderShareNonVip = v / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
// 会员订单:无好友优惠,按推广者是否会员分 20%/10%
|
||||
if order.ProductType == "vip" {
|
||||
base := order.Amount
|
||||
var referrer model.User
|
||||
if referrerUser != nil {
|
||||
referrer = *referrerUser
|
||||
} else if err := db.Where("id = ?", *order.ReferrerID).First(&referrer).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
isVip := referrer.IsVip != nil && *referrer.IsVip
|
||||
if referrer.VipExpireDate != nil && referrer.VipExpireDate.Before(time.Now()) {
|
||||
isVip = false
|
||||
}
|
||||
if isVip {
|
||||
return base * vipOrderShareVip
|
||||
}
|
||||
return base * vipOrderShareNonVip
|
||||
}
|
||||
// 内容订单:若有推荐人且 userDiscount>0,反推原价;否则按实付
|
||||
commissionBase := order.Amount
|
||||
if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) {
|
||||
if (1 - userDiscount) > 0 {
|
||||
commissionBase = order.Amount / (1 - userDiscount)
|
||||
}
|
||||
}
|
||||
return commissionBase * distributorShare
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// escapeLike 转义 LIKE 中的 % _ \,防止注入与通配符滥用
|
||||
func escapeLike(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "%", "\\%")
|
||||
s = strings.ReplaceAll(s, "_", "\\_")
|
||||
return s
|
||||
}
|
||||
|
||||
// SearchGet GET /api/search?q= 从 chapters 表搜索(GORM,参数化)
|
||||
func SearchGet(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入搜索关键词"})
|
||||
return
|
||||
}
|
||||
pattern := "%" + escapeLike(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(50).
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
|
||||
return
|
||||
}
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
matchType := "content"
|
||||
score := 5
|
||||
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
|
||||
matchType = "title"
|
||||
score = 10
|
||||
}
|
||||
snippet := ""
|
||||
pos := strings.Index(strings.ToLower(ch.Content), lowerQ)
|
||||
if pos >= 0 && len(ch.Content) > 0 {
|
||||
start := pos - 50
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := pos + utf8.RuneCountInString(q) + 50
|
||||
if end > len(ch.Content) {
|
||||
end = len(ch.Content)
|
||||
}
|
||||
snippet = ch.Content[start:end]
|
||||
if start > 0 {
|
||||
snippet = "..." + snippet
|
||||
}
|
||||
if end < len(ch.Content) {
|
||||
snippet = snippet + "..."
|
||||
}
|
||||
}
|
||||
price := 1.0
|
||||
if ch.Price != nil {
|
||||
price = *ch.Price
|
||||
}
|
||||
results = append(results, gin.H{
|
||||
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"keyword": q, "total": len(results), "results": results},
|
||||
})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SyncGet GET /api/sync
|
||||
func SyncGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// SyncPost POST /api/sync
|
||||
func SyncPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// SyncPut PUT /api/sync
|
||||
func SyncPut(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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": "删除成功"})
|
||||
}
|
||||
@@ -1,609 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UserAddressesGet GET /api/user/addresses?userId=
|
||||
func UserAddressesGet(c *gin.Context) {
|
||||
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) {
|
||||
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) {
|
||||
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, "data": 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?userId=&type=section|fullbook&productId=
|
||||
func UserCheckPurchased(c *gin.Context) {
|
||||
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?userId= 或 openId=
|
||||
func UserProfileGet(c *gin.Context) {
|
||||
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
|
||||
q := db.Select("id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code",
|
||||
"has_full_book", "earnings", "pending_earnings", "referral_count", "created_at",
|
||||
"mbti", "region", "industry", "position", "business_scale", "skills",
|
||||
"story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro")
|
||||
if userId != "" {
|
||||
q = q.Where("id = ?", userId)
|
||||
} else {
|
||||
q = q.Where("open_id = ?", openId)
|
||||
}
|
||||
if err := q.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
|
||||
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
|
||||
resp := gin.H{
|
||||
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
|
||||
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
|
||||
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
|
||||
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
|
||||
"createdAt": user.CreatedAt,
|
||||
// P3 资料扩展:统一返回所有表单字段,空值用 "" 便于前端回显
|
||||
"mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry),
|
||||
"position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills),
|
||||
"storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement),
|
||||
"storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed),
|
||||
"projectIntro": str(user.ProjectIntro),
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
|
||||
}
|
||||
|
||||
// UserProfilePost POST /api/user/profile 更新用户资料
|
||||
func UserProfilePost(c *gin.Context) {
|
||||
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"`
|
||||
Mbti *string `json:"mbti"`
|
||||
Region *string `json:"region"`
|
||||
Industry *string `json:"industry"`
|
||||
Position *string `json:"position"`
|
||||
BusinessScale *string `json:"businessScale"`
|
||||
Skills *string `json:"skills"`
|
||||
StoryBestMonth *string `json:"storyBestMonth"`
|
||||
StoryAchievement *string `json:"storyAchievement"`
|
||||
StoryTurning *string `json:"storyTurning"`
|
||||
HelpOffer *string `json:"helpOffer"`
|
||||
HelpNeed *string `json:"helpNeed"`
|
||||
ProjectIntro *string `json:"projectIntro"`
|
||||
}
|
||||
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 body.Mbti != nil { updates["mbti"] = *body.Mbti }
|
||||
if body.Region != nil { updates["region"] = *body.Region }
|
||||
if body.Industry != nil { updates["industry"] = *body.Industry }
|
||||
if body.Position != nil { updates["position"] = *body.Position }
|
||||
if body.BusinessScale != nil { updates["business_scale"] = *body.BusinessScale }
|
||||
if body.Skills != nil { updates["skills"] = *body.Skills }
|
||||
if body.StoryBestMonth != nil { updates["story_best_month"] = *body.StoryBestMonth }
|
||||
if body.StoryAchievement != nil { updates["story_achievement"] = *body.StoryAchievement }
|
||||
if body.StoryTurning != nil { updates["story_turning"] = *body.StoryTurning }
|
||||
if body.HelpOffer != nil { updates["help_offer"] = *body.HelpOffer }
|
||||
if body.HelpNeed != nil { updates["help_need"] = *body.HelpNeed }
|
||||
if body.ProjectIntro != nil { updates["project_intro"] = *body.ProjectIntro }
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
db.Model(&user).Updates(updates)
|
||||
// 重新查询并返回与 GET 一致的完整资料结构,空值统一为 ""
|
||||
profileCols := []string{"id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code", "created_at",
|
||||
"mbti", "region", "industry", "position", "business_scale", "skills",
|
||||
"story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro"}
|
||||
if err := database.DB().Select(profileCols).Where("id = ?", user.ID).First(&user).Error; err == nil {
|
||||
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
|
||||
resp := gin.H{
|
||||
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
|
||||
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
|
||||
"createdAt": user.CreatedAt,
|
||||
"mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry),
|
||||
"position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills),
|
||||
"storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement),
|
||||
"storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed),
|
||||
"projectIntro": str(user.ProjectIntro),
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": resp})
|
||||
} else {
|
||||
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?userId=
|
||||
func UserPurchaseStatus(c *gin.Context) {
|
||||
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
|
||||
MID int
|
||||
}
|
||||
db.Raw(`SELECT DISTINCT o.product_id, c.mid FROM orders o
|
||||
LEFT JOIN chapters c ON c.id = o.product_id
|
||||
WHERE o.user_id = ? AND o.status = ? AND o.product_type = ?`, userId, "paid", "section").Scan(&orderRows)
|
||||
purchasedSections := make([]string, 0, len(orderRows))
|
||||
sectionMidMap := make(map[string]int)
|
||||
for _, r := range orderRows {
|
||||
if r.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, r.ProductID)
|
||||
if r.MID > 0 {
|
||||
sectionMidMap[r.ProductID] = r.MID
|
||||
}
|
||||
}
|
||||
}
|
||||
// 是否有推荐人(被推荐绑定,可享好友优惠)
|
||||
var refCount int64
|
||||
db.Model(&model.ReferralBinding{}).Where("referee_id = ? AND status = ?", userId, "active").
|
||||
Where("expiry_date > ?", time.Now()).Count(&refCount)
|
||||
hasReferrer := refCount > 0
|
||||
|
||||
// 匹配次数配额:纯计算(订单 + match_records)
|
||||
freeLimit := getFreeMatchLimit(db)
|
||||
matchQuota := GetMatchQuota(db, userId, freeLimit)
|
||||
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,
|
||||
"sectionMidMap": sectionMidMap,
|
||||
"purchasedCount": len(purchasedSections),
|
||||
"hasReferrer": hasReferrer,
|
||||
"matchCount": matchQuota.PurchasedTotal,
|
||||
"matchQuota": gin.H{
|
||||
"purchasedTotal": matchQuota.PurchasedTotal,
|
||||
"purchasedUsed": matchQuota.PurchasedUsed,
|
||||
"matchesUsedToday": matchQuota.MatchesUsedToday,
|
||||
"freeRemainToday": matchQuota.FreeRemainToday,
|
||||
"purchasedRemain": matchQuota.PurchasedRemain,
|
||||
"remainToday": matchQuota.RemainToday,
|
||||
},
|
||||
"earnings": earnings,
|
||||
"pendingEarnings": pendingEarnings,
|
||||
}})
|
||||
}
|
||||
|
||||
// UserReadingProgressGet GET /api/user/reading-progress?userId=
|
||||
func UserReadingProgressGet(c *gin.Context) {
|
||||
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) {
|
||||
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)
|
||||
func UserTrackGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
phone := c.Query("phone")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
if userId == "" && phone == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
if userId == "" && phone != "" {
|
||||
var u model.User
|
||||
if err := db.Where("phone = ?", phone).First(&u).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
userId = u.ID
|
||||
}
|
||||
var tracks []model.UserTrack
|
||||
if err := db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": []interface{}{}, "stats": gin.H{}, "total": 0})
|
||||
return
|
||||
}
|
||||
stats := make(map[string]int)
|
||||
formatted := make([]gin.H, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
stats[t.Action]++
|
||||
target := ""
|
||||
if t.Target != nil {
|
||||
target = *t.Target
|
||||
}
|
||||
if t.ChapterID != nil && target == "" {
|
||||
target = *t.ChapterID
|
||||
}
|
||||
formatted = append(formatted, gin.H{
|
||||
"id": t.ID, "action": t.Action, "target": target, "chapterTitle": t.ChapterID,
|
||||
"createdAt": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)})
|
||||
}
|
||||
|
||||
// UserTrackPost POST /api/user/track 记录行为(GORM)
|
||||
func UserTrackPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
ExtraData interface{} `json:"extraData"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.UserID == "" && body.Phone == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
|
||||
return
|
||||
}
|
||||
if body.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
userId := body.UserID
|
||||
if userId == "" {
|
||||
var u model.User
|
||||
if err := db.Where("phone = ?", body.Phone).First(&u).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
userId = u.ID
|
||||
}
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
chID := body.Target
|
||||
if body.Action == "view_chapter" {
|
||||
chID = body.Target
|
||||
}
|
||||
t := model.UserTrack{
|
||||
ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target,
|
||||
}
|
||||
if body.Target != "" {
|
||||
t.ChapterID = &chID
|
||||
}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
|
||||
}
|
||||
|
||||
// UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等
|
||||
func UserUpdate(c *gin.Context) {
|
||||
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,356 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 默认 VIP 价格与权益(与 next-project 一致)
|
||||
const defaultVipPrice = 1980
|
||||
|
||||
var defaultVipRights = []string{
|
||||
"智能纪要 - 每天推送派对精华",
|
||||
"会议纪要库 - 所有场次会议纪要",
|
||||
"案例库 - 30-100个创业项目案例",
|
||||
"链接资源 - 进群聊天链接资源",
|
||||
"解锁全部章节内容(365天)",
|
||||
"匹配所有创业伙伴",
|
||||
"创业老板排行榜展示",
|
||||
"专属VIP标识",
|
||||
}
|
||||
|
||||
// isVipFromUsers 从 users 表判断是否 VIP(is_vip=1 且 vip_expire_date>NOW)
|
||||
func isVipFromUsers(db *gorm.DB, userID string) (bool, *time.Time) {
|
||||
var u struct {
|
||||
IsVip *bool
|
||||
VipExpireDate *time.Time
|
||||
}
|
||||
err := db.Table("users").Select("is_vip", "vip_expire_date").Where("id = ?", userID).First(&u).Error
|
||||
if err != nil || u.IsVip == nil || !*u.IsVip || u.VipExpireDate == nil {
|
||||
return false, nil
|
||||
}
|
||||
if u.VipExpireDate.Before(time.Now()) {
|
||||
return false, nil
|
||||
}
|
||||
return true, u.VipExpireDate
|
||||
}
|
||||
|
||||
// isVipFromOrders 从 orders 表判断是否 VIP(兜底)
|
||||
func isVipFromOrders(db *gorm.DB, userID string) (bool, *time.Time) {
|
||||
var order model.Order
|
||||
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
|
||||
userID, "paid", "completed", "fullbook", "vip").
|
||||
Order("pay_time DESC").First(&order).Error
|
||||
if err != nil || order.PayTime == nil {
|
||||
return false, nil
|
||||
}
|
||||
exp := order.PayTime.AddDate(0, 0, 365)
|
||||
if exp.Before(time.Now()) {
|
||||
return false, nil
|
||||
}
|
||||
return true, &exp
|
||||
}
|
||||
|
||||
// VipStatus GET /api/miniprogram/vip/status 小程序-查询用户 VIP 状态
|
||||
// 优先 users 表(is_vip、vip_expire_date),无则从 orders 兜底
|
||||
func VipStatus(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 1. 优先 users 表
|
||||
isVip, expireDate := isVipFromUsers(db, userID)
|
||||
if !isVip {
|
||||
// 2. 兜底:从 orders 查
|
||||
isVip, expireDate = isVipFromOrders(db, userID)
|
||||
if !isVip {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"isVip": false,
|
||||
"daysRemaining": 0,
|
||||
"expireDate": "",
|
||||
"profile": gin.H{"vipName": "", "vipProject": "", "vipContact": "", "vipAvatar": "", "vipBio": ""},
|
||||
"price": float64(defaultVipPrice),
|
||||
"rights": defaultVipRights,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 查用户 VIP 资料(profile)
|
||||
var user model.User
|
||||
_ = db.Where("id = ?", userID).First(&user).Error
|
||||
profile := buildVipProfile(&user)
|
||||
|
||||
daysRemaining := 0
|
||||
expStr := ""
|
||||
if expireDate != nil {
|
||||
daysRemaining = int(expireDate.Sub(time.Now()).Hours()/24) + 1
|
||||
if daysRemaining < 0 {
|
||||
daysRemaining = 0
|
||||
}
|
||||
expStr = expireDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"isVip": true,
|
||||
"daysRemaining": daysRemaining,
|
||||
"expireDate": expStr,
|
||||
"profile": profile,
|
||||
"price": float64(defaultVipPrice),
|
||||
"rights": defaultVipRights,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildVipProfile 仅从 vip_* 字段构建会员资料,不混入用户信息(nickname/avatar/phone/wechat_id)
|
||||
// 返回字段与 users 表 vip_* 对应,统一 vipName/vipProject/vipContact/vipAvatar/vipBio
|
||||
func buildVipProfile(u *model.User) gin.H {
|
||||
return gin.H{
|
||||
"vipName": getStr(u.VipName),
|
||||
"vipProject": getStr(u.VipProject),
|
||||
"vipContact": getStr(u.VipContact),
|
||||
"vipAvatar": getStr(u.VipAvatar),
|
||||
"vipBio": getStr(u.VipBio),
|
||||
}
|
||||
}
|
||||
|
||||
func getStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// VipProfileGet GET /api/miniprogram/vip/profile 小程序-获取 VIP 资料
|
||||
func VipProfileGet(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, 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.StatusOK, gin.H{"success": true, "data": gin.H{"vipName": "", "vipProject": "", "vipContact": "", "vipAvatar": "", "vipBio": ""}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": buildVipProfile(&user),
|
||||
})
|
||||
}
|
||||
|
||||
// VipProfilePost POST /api/miniprogram/vip/profile 小程序-更新 VIP 资料
|
||||
// 请求/响应字段与 users 表 vip_* 一致:vipName/vipProject/vipContact/vipAvatar/vipBio
|
||||
func VipProfilePost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
VipName string `json:"vipName"`
|
||||
VipProject string `json:"vipProject"`
|
||||
VipContact string `json:"vipContact"`
|
||||
VipAvatar string `json:"vipAvatar"`
|
||||
VipBio string `json:"vipBio"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 校验是否 VIP(users 或 orders)
|
||||
isVip, _ := isVipFromUsers(db, req.UserID)
|
||||
if !isVip {
|
||||
isVip, _ = isVipFromOrders(db, req.UserID)
|
||||
}
|
||||
if !isVip {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅VIP会员可填写资料"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.VipName != "" {
|
||||
updates["vip_name"] = req.VipName
|
||||
}
|
||||
if req.VipProject != "" {
|
||||
updates["vip_project"] = req.VipProject
|
||||
}
|
||||
if req.VipContact != "" {
|
||||
updates["vip_contact"] = req.VipContact
|
||||
}
|
||||
if req.VipAvatar != "" {
|
||||
updates["vip_avatar"] = req.VipAvatar
|
||||
}
|
||||
if req.VipBio != "" {
|
||||
updates["vip_bio"] = req.VipBio
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无更新内容"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&model.User{}).Where("id = ?", req.UserID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料已更新"})
|
||||
}
|
||||
|
||||
// VipMembers GET /api/miniprogram/vip/members 小程序-VIP 会员列表(无 id 返回列表;有 id 返回单个)
|
||||
// 优先 users 表(is_vip=1 且 vip_expire_date>NOW),无则从 orders 兜底
|
||||
func VipMembers(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 && n <= 100 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
if id != "" {
|
||||
// 单个:优先 users 表
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||||
return
|
||||
}
|
||||
isVip, _ := isVipFromUsers(db, id)
|
||||
if !isVip {
|
||||
isVip, _ = isVipFromOrders(db, id)
|
||||
}
|
||||
if !isVip {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "会员不存在或已过期"})
|
||||
return
|
||||
}
|
||||
item := formatVipMember(&user, true)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
|
||||
return
|
||||
}
|
||||
|
||||
// 列表:优先 users 表(is_vip=1 且 vip_expire_date>NOW),排序:vip_sort 优先(小在前),否则 vip_activated_at DESC
|
||||
var users []model.User
|
||||
err := db.Table("users").
|
||||
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort").
|
||||
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
|
||||
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
|
||||
Limit(limit).
|
||||
Find(&users).Error
|
||||
|
||||
if err != nil || len(users) == 0 {
|
||||
// 兜底:从 orders 查
|
||||
var userIDs []string
|
||||
db.Model(&model.Order{}).Select("DISTINCT user_id").
|
||||
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
|
||||
Pluck("user_id", &userIDs)
|
||||
if len(userIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
db.Where("id IN ?", userIDs).Find(&users)
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
list = append(list, formatVipMember(&users[i], true))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
||||
}
|
||||
|
||||
// formatVipMember 构建会员展示数据;优先 vip_*,无则回退到用户 nickname/avatar
|
||||
// 用于首页超级个体、创业老板排行、会员详情页等场景;含 P3 资料扩展以对接 member-detail
|
||||
func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
name := ""
|
||||
if u.VipName != nil && *u.VipName != "" {
|
||||
name = *u.VipName
|
||||
}
|
||||
if name == "" && u.Nickname != nil && *u.Nickname != "" {
|
||||
name = *u.Nickname
|
||||
}
|
||||
if name == "" {
|
||||
name = "创业者"
|
||||
}
|
||||
avatar := ""
|
||||
if u.VipAvatar != nil && *u.VipAvatar != "" {
|
||||
avatar = *u.VipAvatar
|
||||
}
|
||||
if avatar == "" && u.Avatar != nil && *u.Avatar != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
project := getStringValue(u.VipProject)
|
||||
if project == "" {
|
||||
project = getStringValue(u.ProjectIntro)
|
||||
}
|
||||
bio := ""
|
||||
if u.VipBio != nil {
|
||||
bio = *u.VipBio
|
||||
}
|
||||
contact := ""
|
||||
if u.VipContact != nil {
|
||||
contact = *u.VipContact
|
||||
}
|
||||
if contact == "" {
|
||||
contact = getStringValue(u.Phone)
|
||||
}
|
||||
vipRole := ""
|
||||
if u.VipRole != nil {
|
||||
vipRole = *u.VipRole
|
||||
}
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"name": name,
|
||||
"nickname": name,
|
||||
"avatar": avatar,
|
||||
"vip_name": name,
|
||||
"vipName": name,
|
||||
"vipRole": vipRole,
|
||||
"vip_avatar": avatar,
|
||||
"vipAvatar": avatar,
|
||||
"vipProject": project,
|
||||
"vip_project": project,
|
||||
"vipContact": contact,
|
||||
"vip_contact": contact,
|
||||
"vipBio": bio,
|
||||
"wechatId": getStringValue(u.WechatID),
|
||||
"wechat_id": getStringValue(u.WechatID),
|
||||
"phone": getStringValue(u.Phone),
|
||||
"mbti": getStringValue(u.Mbti),
|
||||
"region": getStringValue(u.Region),
|
||||
"industry": getStringValue(u.Industry),
|
||||
"position": getStringValue(u.Position),
|
||||
"businessScale": getStringValue(u.BusinessScale),
|
||||
"business_scale": getStringValue(u.BusinessScale),
|
||||
"skills": getStringValue(u.Skills),
|
||||
"storyBestMonth": getStringValue(u.StoryBestMonth),
|
||||
"story_best_month": getStringValue(u.StoryBestMonth),
|
||||
"storyAchievement": getStringValue(u.StoryAchievement),
|
||||
"story_achievement": getStringValue(u.StoryAchievement),
|
||||
"storyTurning": getStringValue(u.StoryTurning),
|
||||
"story_turning": getStringValue(u.StoryTurning),
|
||||
"helpOffer": getStringValue(u.HelpOffer),
|
||||
"help_offer": getStringValue(u.HelpOffer),
|
||||
"helpNeed": getStringValue(u.HelpNeed),
|
||||
"help_need": getStringValue(u.HelpNeed),
|
||||
"projectIntro": getStringValue(u.ProjectIntro),
|
||||
"project_intro": getStringValue(u.ProjectIntro),
|
||||
"is_vip": isVip,
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBVipRolesList GET /api/db/vip-roles 角色列表(管理端 Set VIP 下拉用)
|
||||
func DBVipRolesList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var roles []model.VipRole
|
||||
if err := db.Order("sort ASC, id ASC").Find(&roles).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": roles})
|
||||
}
|
||||
|
||||
// DBVipRolesAction POST /api/db/vip-roles 新增角色;PUT 更新;DELETE 删除
|
||||
func DBVipRolesAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
method := c.Request.Method
|
||||
|
||||
if method == "POST" {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 不能为空"})
|
||||
return
|
||||
}
|
||||
role := model.VipRole{Name: body.Name, Sort: body.Sort}
|
||||
if err := db.Create(&role).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": role})
|
||||
return
|
||||
}
|
||||
|
||||
if method == "PUT" {
|
||||
var body struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
Name *string `json:"name"`
|
||||
Sort *int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if body.Name != nil {
|
||||
updates["name"] = *body.Name
|
||||
}
|
||||
if body.Sort != nil {
|
||||
updates["sort"] = *body.Sort
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&model.VipRole{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
|
||||
return
|
||||
}
|
||||
|
||||
if method == "DELETE" {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
|
||||
return
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(&model.VipRole{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// WechatLogin POST /api/wechat/login
|
||||
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,363 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// computeAvailableWithdraw 与小程序 / referral 页可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核
|
||||
// 佣金按订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%)
|
||||
func computeAvailableWithdraw(db *gorm.DB, userID string) (available, totalCommission, withdrawn, pending float64, minAmount float64) {
|
||||
minAmount = 10
|
||||
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 != nil {
|
||||
if m, ok := config["minWithdrawAmount"].(float64); ok {
|
||||
minAmount = m
|
||||
}
|
||||
}
|
||||
}
|
||||
var orders []model.Order
|
||||
db.Where("referrer_id = ? AND status = ?", userID, "paid").Find(&orders)
|
||||
for i := range orders {
|
||||
totalCommission += computeOrderCommission(db, &orders[i], nil)
|
||||
}
|
||||
var w struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).Where("user_id = ? AND status = ?", userID, "success").
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&w)
|
||||
withdrawn = w.Total
|
||||
db.Model(&model.Withdrawal{}).Where("user_id = ? AND status IN ?", userID, []string{"pending", "processing", "pending_confirm"}).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&w)
|
||||
pending = w.Total
|
||||
available = math.Max(0, totalCommission-withdrawn-pending)
|
||||
return available, totalCommission, withdrawn, pending, minAmount
|
||||
}
|
||||
|
||||
// generateWithdrawID 生成提现单号(不依赖 wechat 包)
|
||||
func generateWithdrawID() string {
|
||||
return fmt.Sprintf("WD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
|
||||
// WithdrawPost POST /api/withdraw 创建提现申请(仅落库待审核,不调用微信打款接口)
|
||||
// 可提现逻辑与小程序 referral 页一致;二次查库校验防止超额。打款由管理端审核后手动/后续接入官方接口再处理。
|
||||
func WithdrawPost(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
available, _, _, _, minWithdrawAmount := computeAvailableWithdraw(db, req.UserID)
|
||||
if req.Amount > available {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("可提现金额不足(当前可提现:%.2f元)", available),
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Amount < minWithdrawAmount {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("最低提现金额为%.0f元", minWithdrawAmount),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
withdrawID := generateWithdrawID()
|
||||
status := "pending"
|
||||
// 根据 user_id 已查到的用户信息,填充提现表所需字段;仅写入表中存在的列,避免 remark 等列不存在报错
|
||||
wechatID := user.WechatID
|
||||
if (wechatID == nil || *wechatID == "") && user.OpenID != nil && *user.OpenID != "" {
|
||||
wechatID = user.OpenID
|
||||
}
|
||||
withdrawal := model.Withdrawal{
|
||||
ID: withdrawID,
|
||||
UserID: req.UserID,
|
||||
Amount: req.Amount,
|
||||
Status: &status,
|
||||
WechatOpenid: user.OpenID,
|
||||
WechatID: wechatID,
|
||||
}
|
||||
if err := db.Select("ID", "UserID", "Amount", "Status", "WechatOpenid", "WechatID").Create(&withdrawal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "创建提现记录失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "提现申请已提交,审核通过后将打款至您的微信零钱",
|
||||
"data": map[string]interface{}{
|
||||
"id": withdrawal.ID,
|
||||
"amount": req.Amount,
|
||||
"status": "pending",
|
||||
"created_at": withdrawal.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminWithdrawTest GET/POST /api/admin/withdraw-test 提现测试接口,供 curl 等调试用
|
||||
// 参数:userId(默认 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg)、amount(默认 1)
|
||||
// 测试时忽略最低提现额限制,仅校验可提现余额与用户存在
|
||||
func AdminWithdrawTest(c *gin.Context) {
|
||||
userID := c.DefaultQuery("userId", "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg")
|
||||
amountStr := c.DefaultQuery("amount", "1")
|
||||
var amount float64
|
||||
if _, err := fmt.Sscanf(amountStr, "%f", &amount); err != nil || amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "amount 须为正数"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
available, _, _, _, _ := computeAvailableWithdraw(db, userID)
|
||||
if amount > available {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("可提现金额不足(当前可提现:%.2f元)", available),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", 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
|
||||
}
|
||||
|
||||
withdrawID := generateWithdrawID()
|
||||
status := "pending"
|
||||
wechatID := user.WechatID
|
||||
if (wechatID == nil || *wechatID == "") && user.OpenID != nil && *user.OpenID != "" {
|
||||
wechatID = user.OpenID
|
||||
}
|
||||
withdrawal := model.Withdrawal{
|
||||
ID: withdrawID,
|
||||
UserID: userID,
|
||||
Amount: amount,
|
||||
Status: &status,
|
||||
WechatOpenid: user.OpenID,
|
||||
WechatID: wechatID,
|
||||
}
|
||||
if err := db.Select("ID", "UserID", "Amount", "Status", "WechatOpenid", "WechatID").Create(&withdrawal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "创建提现记录失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "提现测试已提交",
|
||||
"data": map[string]interface{}{
|
||||
"id": withdrawal.ID,
|
||||
"userId": userID,
|
||||
"amount": amount,
|
||||
"status": "pending",
|
||||
"created_at": withdrawal.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawRecords GET /api/withdraw/records?userId= 当前用户提现记录(GORM)
|
||||
func WithdrawRecords(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
var list []model.Withdrawal
|
||||
if err := database.DB().Where("user_id = ?", userId).Order("created_at DESC").Limit(100).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}}})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, w := range list {
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
canReceive := st == "processing" || st == "pending_confirm"
|
||||
out = append(out, gin.H{
|
||||
"id": w.ID, "amount": w.Amount, "status": st,
|
||||
"createdAt": w.CreatedAt, "processedAt": w.ProcessedAt,
|
||||
"canReceive": canReceive,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out}})
|
||||
}
|
||||
|
||||
// WithdrawConfirmInfo GET /api/miniprogram/withdraw/confirm-info?id= 获取某条提现的领取零钱参数(mchId/appId/package),供 wx.requestMerchantTransfer 使用
|
||||
func WithdrawConfirmInfo(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 id"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", id).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
if st != "processing" && st != "pending_confirm" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前状态不可领取"})
|
||||
return
|
||||
}
|
||||
mchId := os.Getenv("WECHAT_MCH_ID")
|
||||
if mchId == "" {
|
||||
mchId = "1318592501"
|
||||
}
|
||||
appId := os.Getenv("WECHAT_APPID")
|
||||
if appId == "" {
|
||||
appId = "wxb8bbb2b10dec74aa"
|
||||
}
|
||||
packageInfo := ""
|
||||
if w.PackageInfo != nil && *w.PackageInfo != "" {
|
||||
packageInfo = *w.PackageInfo
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"mchId": mchId,
|
||||
"appId": appId,
|
||||
"package": packageInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认收款列表(仅审核通过后)
|
||||
// 只返回 processing、pending_confirm,供「我的」页「待确认收款」展示;pending 为待审核,不在此列表
|
||||
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
|
||||
// 仅审核已通过、等待用户确认收款的:processing(微信处理中)、pending_confirm(待用户点确认收款)
|
||||
if err := db.Where("user_id = ? AND status IN ?", userId, []string{"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 {
|
||||
item := gin.H{
|
||||
"id": w.ID,
|
||||
"amount": w.Amount,
|
||||
"createdAt": w.CreatedAt,
|
||||
}
|
||||
if w.PackageInfo != nil && *w.PackageInfo != "" {
|
||||
item["package"] = *w.PackageInfo
|
||||
} else {
|
||||
item["package"] = ""
|
||||
}
|
||||
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
|
||||
item["userConfirmedAt"] = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
|
||||
} else {
|
||||
item["userConfirmedAt"] = nil
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawConfirmReceived POST /api/miniprogram/withdraw/confirm-received 用户确认收款(记录已点击确认)
|
||||
// body: { "withdrawalId": "xxx", "userId": "xxx" },仅本人可操作;更新 user_confirmed_at 并将状态置为 success,该条不再出现在待确认收款列表
|
||||
func WithdrawConfirmReceived(c *gin.Context) {
|
||||
var req struct {
|
||||
WithdrawalID string `json:"withdrawalId" binding:"required"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawalId 或 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ? AND user_id = ?", req.WithdrawalID, req.UserID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "提现记录不存在或无权操作"})
|
||||
return
|
||||
}
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
// 仅处理中或待确认的可标记「用户已确认收款」
|
||||
if st != "processing" && st != "pending_confirm" && st != "success" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前状态不可确认收款"})
|
||||
return
|
||||
}
|
||||
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认过"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
// 更新为已确认收款,并将状态置为 success,待确认列表只含 processing/pending_confirm,故该条会从列表中移除
|
||||
up := map[string]interface{}{"user_confirmed_at": now, "status": "success"}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已记录确认收款"})
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat/transferv3"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getTransferV3Client 从 config 创建文档 V3 转账 Client(独立于 PowerWeChat)
|
||||
func getTransferV3Client() (*transferv3.Client, error) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config not loaded")
|
||||
}
|
||||
key, err := transferv3.LoadPrivateKeyFromPath(cfg.WechatKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load private key: %w", err)
|
||||
}
|
||||
return transferv3.NewClient(cfg.WechatMchID, cfg.WechatAppID, cfg.WechatSerialNo, key), nil
|
||||
}
|
||||
|
||||
// WithdrawV3Initiate POST /api/v3/withdraw/initiate 根据文档发起商家转账到零钱(V3 独立实现)
|
||||
// body: { "withdrawal_id": "xxx" },需先存在 pending 的提现记录
|
||||
func WithdrawV3Initiate(c *gin.Context) {
|
||||
var req struct {
|
||||
WithdrawalID string `json:"withdrawal_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
if st != "pending" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "仅支持 pending 状态发起"})
|
||||
return
|
||||
}
|
||||
|
||||
openID := ""
|
||||
if w.WechatOpenid != nil && *w.WechatOpenid != "" {
|
||||
openID = *w.WechatOpenid
|
||||
}
|
||||
if openID == "" {
|
||||
var u model.User
|
||||
if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil {
|
||||
openID = *u.OpenID
|
||||
}
|
||||
}
|
||||
if openID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定 openid"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "配置未加载"})
|
||||
return
|
||||
}
|
||||
|
||||
outBatchNo := fmt.Sprintf("WD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
|
||||
outDetailNo := fmt.Sprintf("WDD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
|
||||
amountFen := int(w.Amount * 100)
|
||||
if amountFen < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额异常"})
|
||||
return
|
||||
}
|
||||
|
||||
batchRemark := fmt.Sprintf("提现 %.2f 元", w.Amount)
|
||||
if len([]rune(batchRemark)) > 32 {
|
||||
batchRemark = "用户提现"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"appid": cfg.WechatAppID,
|
||||
"out_batch_no": outBatchNo,
|
||||
"batch_name": "用户提现",
|
||||
"batch_remark": batchRemark,
|
||||
"total_amount": amountFen,
|
||||
"total_num": 1,
|
||||
"transfer_scene_id": "1005",
|
||||
"transfer_detail_list": []map[string]interface{}{
|
||||
{
|
||||
"out_detail_no": outDetailNo,
|
||||
"transfer_amount": amountFen,
|
||||
"transfer_remark": "提现",
|
||||
"openid": openID,
|
||||
},
|
||||
},
|
||||
}
|
||||
if cfg.WechatTransferURL != "" {
|
||||
body["notify_url"] = cfg.WechatTransferURL
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
client, err := getTransferV3Client()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respBody, statusCode, err := client.PostBatches(bodyBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode < 200 || statusCode >= 300 {
|
||||
var errResp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &errResp)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": errResp.Message,
|
||||
"code": errResp.Code,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var respData struct {
|
||||
OutBatchNo string `json:"out_batch_no"`
|
||||
BatchID string `json:"batch_id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
BatchStatus string `json:"batch_status"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &respData)
|
||||
|
||||
now := time.Now()
|
||||
processingStatus := "processing"
|
||||
_ = db.Model(&w).Updates(map[string]interface{}{
|
||||
"status": processingStatus,
|
||||
"batch_no": outBatchNo,
|
||||
"detail_no": outDetailNo,
|
||||
"batch_id": respData.BatchID,
|
||||
"processed_at": now,
|
||||
}).Error
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已发起打款,微信处理中",
|
||||
"data": gin.H{
|
||||
"out_batch_no": outBatchNo,
|
||||
"batch_id": respData.BatchID,
|
||||
"batch_status": respData.BatchStatus,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawV3Notify POST /api/v3/withdraw/notify 文档 V3 转账结果回调(验签可选,解密后更新状态)
|
||||
func WithdrawV3Notify(c *gin.Context) {
|
||||
rawBody, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "body read error"})
|
||||
return
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(rawBody, &envelope); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "invalid json"})
|
||||
return
|
||||
}
|
||||
|
||||
resource, _ := envelope["resource"].(map[string]interface{})
|
||||
if resource == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "no resource"})
|
||||
return
|
||||
}
|
||||
|
||||
ciphertext, _ := resource["ciphertext"].(string)
|
||||
nonceStr, _ := resource["nonce"].(string)
|
||||
assoc, _ := resource["associated_data"].(string)
|
||||
if ciphertext == "" || nonceStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "missing ciphertext/nonce"})
|
||||
return
|
||||
}
|
||||
if assoc == "" {
|
||||
assoc = "mch_payment"
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg == nil || len(cfg.WechatAPIv3Key) != 32 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "config or apiv3 key invalid"})
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := transferv3.DecryptResourceJSON(ciphertext, nonceStr, assoc, []byte(cfg.WechatAPIv3Key))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "decrypt failed"})
|
||||
return
|
||||
}
|
||||
|
||||
outBillNo, _ := decrypted["out_bill_no"].(string)
|
||||
state, _ := decrypted["state"].(string)
|
||||
failReason, _ := decrypted["fail_reason"].(string)
|
||||
if outBillNo == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("detail_no = ?", outBillNo).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
cur := ""
|
||||
if w.Status != nil {
|
||||
cur = *w.Status
|
||||
}
|
||||
if cur != "processing" && cur != "pending_confirm" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch state {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL", "CANCELLED":
|
||||
up["status"] = "failed"
|
||||
if failReason != "" {
|
||||
up["fail_reason"] = failReason
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "update failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
}
|
||||
|
||||
// WithdrawV3Query POST /api/v3/withdraw/query 主动查询转账结果并更新(文档:按商户批次/明细单号查询)
|
||||
// body: { "withdrawal_id": "xxx" }
|
||||
func WithdrawV3Query(c *gin.Context) {
|
||||
var req struct {
|
||||
WithdrawalID string `json:"withdrawal_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
batchNo := ""
|
||||
detailNo := ""
|
||||
if w.BatchNo != nil {
|
||||
batchNo = *w.BatchNo
|
||||
}
|
||||
if w.DetailNo != nil {
|
||||
detailNo = *w.DetailNo
|
||||
}
|
||||
if batchNo == "" || detailNo == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未发起过微信转账"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := getTransferV3Client()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respBody, statusCode, err := client.GetTransferDetail(batchNo, detailNo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode != 200 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": string(respBody),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var detail struct {
|
||||
DetailStatus string `json:"detail_status"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &detail)
|
||||
|
||||
now := time.Now()
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch strings.ToUpper(detail.DetailStatus) {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL":
|
||||
up["status"] = "failed"
|
||||
if detail.FailReason != "" {
|
||||
up["fail_reason"] = detail.FailReason
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "查询成功,状态未终态",
|
||||
"detail_status": detail.DetailStatus,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已同步状态",
|
||||
"detail_status": detail.DetailStatus,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user