优化小程序推荐码处理逻辑,支持通过扫码场景解析推荐码和初始章节ID。新增获取用户邀请码的功能以便于分享。更新分享配置,确保分享时自动带上推荐码。调整部分页面逻辑以提升用户体验。

This commit is contained in:
乘风
2026-02-12 15:09:52 +08:00
parent c57866ffe0
commit 448e908855
40 changed files with 1068 additions and 318 deletions

View File

@@ -1,18 +1,13 @@
# 服务(监听端口,修改后重启 soul-api 生效)
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
PORT=8080
GIN_MODE=debug
# 版本号(打包 zip 前改这里,上传后访问 /health 可看到)
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
APP_VERSION=0.0.0
# 数据air库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 可选:管理端鉴权密钥(若用 JWT
# JWT_SECRET=your-secret
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
@@ -22,9 +17,20 @@ WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify
# 微信转账配置API v3
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
WECHAT_KEY_PATH=certs/apiclient_key.pem
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
WECHAT_TRANSFER_URL=https://soul.quwanzhi.com/api/payment/wechat/transfer/notify
WECHAT_TRANSFER_URL=https://souladmin.quwanzhi.com/api/payment/wechat/transfer/notify
# 管理端登录(与 next-project 一致,默认 admin / admin123
# ADMIN_USERNAME=admin
# ADMIN_PASSWORD=admin123
# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值含 localhost、soul.quwanzhi.com
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com

View File

@@ -32,6 +32,5 @@ WECHAT_TRANSFER_URL=https://soul.quwanzhi.com/api/payment/wechat/transfer/notify
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 可选:CORS 允许的源逗号分隔。未设置时默认含 localhost:5174 与 soul.quwanzhi.com
# 宝塔部署时若前端在别的域名,在此追加,例如:
# CORS_ORIGINS=http://localhost:5174,https://soul.quwanzhi.com,https://admin.quwanzhi.com
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值(含 localhostsoul.quwanzhi.com
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com

View File

@@ -1,14 +1,72 @@
package handler
import (
"context"
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
// CronSyncOrders GET/POST /api/cron/sync-orders
// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
func CronSyncOrders(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
db := database.DB()
var createdOrders []model.Order
// 只处理最近 24 小时内创建的未支付订单
cutoff := time.Now().Add(-24 * time.Hour)
if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
synced := 0
ctx := context.Background()
for _, o := range createdOrders {
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
if err != nil {
fmt.Printf("[SyncOrders] 查询订单 %s 失败: %v\n", o.OrderSN, err)
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 {
fmt.Printf("[SyncOrders] 更新订单 %s 失败: %v\n", o.OrderSN, err)
continue
}
synced++
fmt.Printf("[SyncOrders] 补齐漏单: %s, amount=%.2f\n", o.OrderSN, totalAmount)
// 同步后续逻辑(全书、分销等)
pt := "fullbook"
if o.ProductType != "" {
pt = o.ProductType
}
if pt == "fullbook" {
db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true)
}
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"synced": synced,
"total": len(createdOrders),
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -425,15 +426,21 @@ func MiniprogramPayNotify(c *gin.Context) {
TransactionID: &transactionID,
PayTime: &now,
}
db.Create(&order)
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()
db.Model(&order).Updates(map[string]interface{}{
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)
@@ -666,6 +673,30 @@ func MiniprogramQrcode(c *gin.Context) {
})
}
// 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+/"

View File

@@ -215,6 +215,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调URL 需在商户平台配置
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
miniprogram.GET("/book/hot", handler.BookHot)

View File

@@ -210,10 +210,23 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
if page == "" {
page = "pages/index/index"
}
// 微信建议 scene 仅含英文字母、数字;& 和 = 可能导致异常,将 & 转为 _ 再传给微信
scene = strings.ReplaceAll(scene, "&", "_")
if len(scene) > 32 {
scene = scene[:32]
}
envVersion := "release"
if cfg != nil && cfg.WechatMiniProgramState != "" {
switch cfg.WechatMiniProgramState {
case "developer":
envVersion = "develop"
case "trial":
envVersion = "trial"
default:
envVersion = "release"
}
}
reqBody := map[string]interface{}{
"scene": scene,
"page": page,
@@ -221,9 +234,8 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
"auto_color": false,
"line_color": map[string]int{"r": 0, "g": 206, "b": 209},
"is_hyaline": false,
"env_version": "trial", // 体验版,正式发布后改为 release
"env_version": envVersion,
}
jsonData, _ := json.Marshal(reqBody)
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
if err != nil {
@@ -232,22 +244,17 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 检查是否是 JSON 错误返回
if resp.Header.Get("Content-Type") == "application/json" {
var errResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &errResult); err == nil && errResult.ErrCode != 0 {
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
}
// 无论 Content-Type先尝试按 JSON 解析:微信错误时返回小体积 JSON否则会误报「图片数据异常(太小)」
var errResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if json.Unmarshal(body, &errResult) == nil && errResult.ErrCode != 0 {
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
}
if len(body) < 1000 {
return nil, fmt.Errorf("返回的图片数据异常(太小)")
return nil, fmt.Errorf("返回的图片数据异常(太小),可能未发布对应版本或参数错误")
}
return body, nil
}

View File

@@ -0,0 +1,12 @@
#!/bin/bash
# 订单对账防漏单 - 宝塔定时任务用
# 建议每 10 分钟执行一次
URL="${SYNC_ORDERS_URL:-https://soul.quwanzhi.com/api/cron/sync-orders}"
curl -s -X GET "$URL" \
-H "User-Agent: Baota-Cron/1.0" \
--connect-timeout 10 \
--max-time 30
echo ""

Binary file not shown.