优化小程序推荐码处理逻辑,支持通过扫码场景解析推荐码和初始章节ID。新增获取用户邀请码的功能以便于分享。更新分享配置,确保分享时自动带上推荐码。调整部分页面逻辑以提升用户体验。
This commit is contained in:
@@ -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
|
||||
# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
|
||||
WECHAT_CERT_PATH=certs/apiclient_cert.pem
|
||||
# 私钥(线上用 OSS):https://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
|
||||
|
||||
@@ -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:允许的源,逗号分隔。未设置时使用默认值(含 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+/"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
12
soul-api/scripts/sync-orders.sh
Normal file
12
soul-api/scripts/sync-orders.sh
Normal 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.
Reference in New Issue
Block a user