新增技术文档,详细描述了项目的技术栈、配置、鉴权与安全、数据层等内容。同时,更新小程序页面以支持收益数据的加载与刷新功能,优化用户体验。新增收益接口以返回用户的累计收益和可提现金额,并调整相关逻辑以确保数据准确性。
This commit is contained in:
@@ -20,8 +20,10 @@ Page({
|
||||
totalSections: 62,
|
||||
readCount: 0,
|
||||
referralCount: 0,
|
||||
earnings: 0,
|
||||
pendingEarnings: 0,
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: true,
|
||||
earningsRefreshing: false,
|
||||
|
||||
// 阅读统计
|
||||
totalReadTime: 0,
|
||||
@@ -123,7 +125,7 @@ Page({
|
||||
const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId
|
||||
const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
|
||||
|
||||
// 先设基础信息;收益数据由 loadReferralEarnings 从推广中心同源接口拉取并覆盖
|
||||
// 先设基础信息;收益由 loadMyEarnings 专用接口拉取,加载前用 - 占位
|
||||
this.setData({
|
||||
isLoggedIn: true,
|
||||
userInfo,
|
||||
@@ -131,12 +133,13 @@ Page({
|
||||
userWechat,
|
||||
readCount: Math.min(app.getReadCount(), this.data.totalSections || 62),
|
||||
referralCount: userInfo.referralCount || 0,
|
||||
earnings: '0.00',
|
||||
pendingEarnings: '0.00',
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: true,
|
||||
recentChapters: recentList,
|
||||
totalReadTime: Math.floor(Math.random() * 200) + 50
|
||||
})
|
||||
this.loadReferralEarnings()
|
||||
this.loadMyEarnings()
|
||||
this.loadPendingConfirm()
|
||||
} else {
|
||||
this.setData({
|
||||
@@ -145,8 +148,9 @@ Page({
|
||||
userIdShort: '',
|
||||
readCount: app.getReadCount(),
|
||||
referralCount: 0,
|
||||
earnings: '0.00',
|
||||
pendingEarnings: '0.00',
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: false,
|
||||
recentChapters: []
|
||||
})
|
||||
}
|
||||
@@ -250,32 +254,48 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 从与推广中心相同的接口拉取收益数据并更新展示(累计收益 = totalCommission,可提现 = 累计-已提现-待审核)
|
||||
async loadReferralEarnings() {
|
||||
// 专用接口:拉取「我的收益」卡片数据(累计、可提现、推荐人数)
|
||||
async loadMyEarnings() {
|
||||
const userInfo = app.globalData.userInfo
|
||||
if (!app.globalData.isLoggedIn || !userInfo || !userInfo.id) return
|
||||
|
||||
if (!app.globalData.isLoggedIn || !userInfo || !userInfo.id) {
|
||||
this.setData({ earningsLoading: false })
|
||||
return
|
||||
}
|
||||
const formatMoney = (num) => (typeof num === 'number' ? num.toFixed(2) : '0.00')
|
||||
|
||||
try {
|
||||
const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id)
|
||||
if (!res || !res.success || !res.data) return
|
||||
|
||||
const res = await app.request('/api/miniprogram/earnings?userId=' + userInfo.id)
|
||||
if (!res || !res.success || !res.data) {
|
||||
this.setData({ earningsLoading: false, earnings: '0.00', pendingEarnings: '0.00' })
|
||||
return
|
||||
}
|
||||
const d = res.data
|
||||
const totalCommissionNum = d.totalCommission || 0
|
||||
const withdrawnNum = d.withdrawnEarnings || 0
|
||||
const pendingWithdrawNum = d.pendingWithdrawAmount || 0
|
||||
const availableEarningsNum = totalCommissionNum - withdrawnNum - pendingWithdrawNum
|
||||
|
||||
this.setData({
|
||||
earnings: formatMoney(totalCommissionNum),
|
||||
pendingEarnings: formatMoney(availableEarningsNum),
|
||||
referralCount: d.referralCount || d.stats?.totalBindings || this.data.referralCount
|
||||
earnings: formatMoney(d.totalCommission),
|
||||
pendingEarnings: formatMoney(d.availableEarnings),
|
||||
referralCount: d.referralCount ?? this.data.referralCount,
|
||||
earningsLoading: false,
|
||||
earningsRefreshing: false
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('[My] 拉取推广收益失败:', e && e.message)
|
||||
console.log('[My] 拉取我的收益失败:', e && e.message)
|
||||
this.setData({
|
||||
earningsLoading: false,
|
||||
earningsRefreshing: false,
|
||||
earnings: '0.00',
|
||||
pendingEarnings: '0.00'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// 点击刷新图标:刷新我的收益
|
||||
async refreshEarnings() {
|
||||
if (!this.data.isLoggedIn) return
|
||||
if (this.data.earningsRefreshing) return
|
||||
this.setData({ earningsRefreshing: true })
|
||||
wx.showToast({ title: '刷新中...', icon: 'loading', duration: 2000 })
|
||||
await this.loadMyEarnings()
|
||||
wx.showToast({ title: '已刷新', icon: 'success' })
|
||||
},
|
||||
|
||||
// 微信原生获取头像(button open-type="chooseAvatar" 回调)
|
||||
async onChooseAvatar(e) {
|
||||
|
||||
@@ -97,27 +97,26 @@
|
||||
<view class="bg-decoration bg-decoration-brand"></view>
|
||||
|
||||
<view class="earnings-content">
|
||||
<!-- 标题行 -->
|
||||
<!-- 标题行:右侧为刷新图标 -->
|
||||
<view class="earnings-header">
|
||||
<view class="earnings-title-wrap">
|
||||
<text class="earnings-icon">💰</text>
|
||||
<text class="earnings-title">我的收益</text>
|
||||
</view>
|
||||
<view class="earnings-link" bindtap="goToReferral">
|
||||
<text class="link-text brand-color">推广中心</text>
|
||||
<text class="link-arrow brand-color">↗</text>
|
||||
<view class="earnings-refresh-wrap" bindtap="refreshEarnings">
|
||||
<text class="earnings-refresh-icon {{earningsRefreshing ? 'earnings-refresh-spin' : ''}}">↻</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收益数据 -->
|
||||
<!-- 收益数据:加载中显示 - 占位 -->
|
||||
<view class="earnings-data">
|
||||
<view class="earnings-main">
|
||||
<text class="earnings-label">累计收益</text>
|
||||
<text class="earnings-amount-large gold-gradient">¥{{earnings}}</text>
|
||||
<text class="earnings-amount-large gold-gradient">{{earningsLoading ? '-' : '¥' + earnings}}</text>
|
||||
</view>
|
||||
<view class="earnings-secondary">
|
||||
<text class="earnings-label">可提现</text>
|
||||
<text class="earnings-amount-medium">¥{{pendingEarnings}}</text>
|
||||
<text class="earnings-amount-medium">{{earningsLoading ? '-' : '¥' + pendingEarnings}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -471,6 +471,36 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 我的收益 - 刷新图标 */
|
||||
.earnings-refresh-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 206, 209, 0.15);
|
||||
}
|
||||
|
||||
.earnings-refresh-wrap:active {
|
||||
background: rgba(0, 206, 209, 0.3);
|
||||
}
|
||||
|
||||
.earnings-refresh-icon {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #00CED1;
|
||||
}
|
||||
|
||||
.earnings-refresh-spin {
|
||||
animation: earnings-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes earnings-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 收益数据 */
|
||||
.earnings-data {
|
||||
display: flex;
|
||||
|
||||
@@ -142,6 +142,28 @@ def set_env_port(env_path, port=DEPLOY_PORT):
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
def set_env_mini_program_state(env_path, state):
|
||||
"""将 .env 中的 WECHAT_MINI_PROGRAM_STATE 设为 developer/formal(打包前按环境覆盖)"""
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
key = "WECHAT_MINI_PROGRAM_STATE"
|
||||
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
found = False
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if "=" in s and s.split("=", 1)[0].strip() == key:
|
||||
new_lines.append("%s=%s\n" % (key, state))
|
||||
found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
if not found:
|
||||
new_lines.append("%s=%s\n" % (key, state))
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
def pack_deploy(root, binary_path, include_env=True):
|
||||
"""打包二进制和 .env 为 tar.gz"""
|
||||
print("[2/4] 打包部署文件 ...")
|
||||
@@ -160,7 +182,8 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
|
||||
if os.path.isfile(staging_env):
|
||||
set_env_port(staging_env, DEPLOY_PORT)
|
||||
print(" [已设置] PORT=%s(部署用)" % DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "developer")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % DEPLOY_PORT)
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
@@ -276,11 +299,13 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
|
||||
ok = restart_via_bt_api(cfg)
|
||||
if not ok and restart_method in ("auto", "ssh"):
|
||||
# SSH:用 setsid nohup 避免断开杀进程,多等几秒再检测
|
||||
# SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目
|
||||
restart_cmd = (
|
||||
"cd %s && pkill -f './soul-api' 2>/dev/null; sleep 2; "
|
||||
"setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
|
||||
"sleep 3; pgrep -f './soul-api' >/dev/null && echo RESTART_OK || echo RESTART_FAIL"
|
||||
"cd %s && T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
|
||||
"[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && kill $p 2>/dev/null; done; "
|
||||
"sleep 2; setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
|
||||
"sleep 3; T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
|
||||
"[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && echo RESTART_OK && exit 0; done; echo RESTART_FAIL"
|
||||
) % project_path
|
||||
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
|
||||
@@ -142,6 +142,28 @@ def set_env_port(env_path, port=DEPLOY_PORT):
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
def set_env_mini_program_state(env_path, state):
|
||||
"""将 .env 中的 WECHAT_MINI_PROGRAM_STATE 设为 developer/formal(打包前按环境覆盖)"""
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
key = "WECHAT_MINI_PROGRAM_STATE"
|
||||
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
found = False
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if "=" in s and s.split("=", 1)[0].strip() == key:
|
||||
new_lines.append("%s=%s\n" % (key, state))
|
||||
found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
if not found:
|
||||
new_lines.append("%s=%s\n" % (key, state))
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
def pack_deploy(root, binary_path, include_env=True):
|
||||
"""打包二进制和 .env 为 tar.gz"""
|
||||
print("[2/4] 打包部署文件 ...")
|
||||
@@ -160,7 +182,8 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
|
||||
if os.path.isfile(staging_env):
|
||||
set_env_port(staging_env, DEPLOY_PORT)
|
||||
print(" [已设置] PORT=%s(部署用)" % DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "formal")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal(正式环境)" % DEPLOY_PORT)
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
@@ -276,11 +299,13 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
|
||||
ok = restart_via_bt_api(cfg)
|
||||
if not ok and restart_method in ("auto", "ssh"):
|
||||
# SSH:用 setsid nohup 避免断开杀进程,多等几秒再检测
|
||||
# SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目
|
||||
restart_cmd = (
|
||||
"cd %s && pkill -f './soul-api' 2>/dev/null; sleep 2; "
|
||||
"setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
|
||||
"sleep 3; pgrep -f './soul-api' >/dev/null && echo RESTART_OK || echo RESTART_FAIL"
|
||||
"cd %s && T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
|
||||
"[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && kill $p 2>/dev/null; done; "
|
||||
"sleep 2; setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
|
||||
"sleep 3; T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
|
||||
"[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && echo RESTART_OK && exit 0; done; echo RESTART_FAIL"
|
||||
) % project_path
|
||||
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
|
||||
@@ -18,11 +18,12 @@ type Config struct {
|
||||
Version string // APP_VERSION,打包/部署前写在 .env,/health 返回
|
||||
|
||||
// 微信小程序配置
|
||||
WechatAppID string
|
||||
WechatAppSecret string
|
||||
WechatMchID string
|
||||
WechatMchKey string
|
||||
WechatNotifyURL string
|
||||
WechatAppID string
|
||||
WechatAppSecret string
|
||||
WechatMchID string
|
||||
WechatMchKey string
|
||||
WechatNotifyURL string
|
||||
WechatMiniProgramState string // 订阅消息跳转版本:developer/formal,从 .env WECHAT_MINI_PROGRAM_STATE 读取
|
||||
|
||||
// 微信转账配置(API v3)
|
||||
WechatAPIv3Key string
|
||||
@@ -119,6 +120,10 @@ func Load() (*Config, error) {
|
||||
if wechatNotifyURL == "" {
|
||||
wechatNotifyURL = "https://soul.quwanzhi.com/api/miniprogram/pay/notify" // 默认回调地址
|
||||
}
|
||||
wechatMiniProgramState := os.Getenv("WECHAT_MINI_PROGRAM_STATE")
|
||||
if wechatMiniProgramState != "developer" && wechatMiniProgramState != "trial" {
|
||||
wechatMiniProgramState = "formal" // 默认正式版
|
||||
}
|
||||
|
||||
// 转账配置
|
||||
wechatAPIv3Key := os.Getenv("WECHAT_APIV3_KEY")
|
||||
@@ -162,12 +167,13 @@ func Load() (*Config, error) {
|
||||
TrustedProxies: []string{"127.0.0.1", "::1"},
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
Version: version,
|
||||
WechatAppID: wechatAppID,
|
||||
WechatAppSecret: wechatAppSecret,
|
||||
WechatMchID: wechatMchID,
|
||||
WechatMchKey: wechatMchKey,
|
||||
WechatNotifyURL: wechatNotifyURL,
|
||||
WechatAPIv3Key: wechatAPIv3Key,
|
||||
WechatAppID: wechatAppID,
|
||||
WechatAppSecret: wechatAppSecret,
|
||||
WechatMchID: wechatMchID,
|
||||
WechatMchKey: wechatMchKey,
|
||||
WechatNotifyURL: wechatNotifyURL,
|
||||
WechatMiniProgramState: wechatMiniProgramState,
|
||||
WechatAPIv3Key: wechatAPIv3Key,
|
||||
WechatCertPath: wechatCertPath,
|
||||
WechatKeyPath: wechatKeyPath,
|
||||
WechatSerialNo: wechatSerialNo,
|
||||
|
||||
@@ -429,6 +429,67 @@ func round(val float64, precision int) float64 {
|
||||
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()
|
||||
distributorShare := 0.9
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
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 []struct {
|
||||
Amount float64
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("amount").
|
||||
Where("referrer_id = ? AND status = 'paid'", userId).
|
||||
Find(&paidOrders)
|
||||
totalAmount := 0.0
|
||||
for _, o := range paidOrders {
|
||||
totalAmount += o.Amount
|
||||
}
|
||||
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)
|
||||
totalCommission := totalAmount * distributorShare
|
||||
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 {
|
||||
|
||||
@@ -223,6 +223,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/referral/visit", handler.ReferralVisit)
|
||||
miniprogram.POST("/referral/bind", handler.ReferralBind)
|
||||
miniprogram.GET("/referral/data", handler.ReferralData)
|
||||
miniprogram.GET("/earnings", handler.MyEarnings)
|
||||
miniprogram.GET("/match/config", handler.MatchConfigGet)
|
||||
miniprogram.POST("/match/users", handler.MatchUsers)
|
||||
miniprogram.POST("/ckb/join", handler.CKBJoin)
|
||||
|
||||
@@ -419,8 +419,8 @@ func SendWithdrawSubscribeMessage(ctx context.Context, openID string, amount flo
|
||||
"thing8": object.HashMap{"value": thing8},
|
||||
}
|
||||
state := "formal"
|
||||
if cfg != nil && cfg.Mode == "debug" {
|
||||
state = "developer"
|
||||
if cfg != nil && cfg.WechatMiniProgramState != "" {
|
||||
state = cfg.WechatMiniProgramState
|
||||
}
|
||||
_, err := miniProgramApp.SubscribeMessage.Send(ctx, &subrequest.RequestSubscribeMessageSend{
|
||||
ToUser: openID,
|
||||
|
||||
Binary file not shown.
54
技术文档.md
Normal file
54
技术文档.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# soul-api Go 技术栈
|
||||
|
||||
## 语言与运行时
|
||||
|
||||
- **Go 1.25**
|
||||
|
||||
## Web 框架与 HTTP
|
||||
|
||||
- **Gin**(`github.com/gin-gonic/gin`):HTTP 路由与请求处理
|
||||
- **gin-contrib/cors**:跨域
|
||||
- **unrolled/secure**:安全头(HTTPS 重定向、HSTS 等,在 `middleware.Secure()` 中使用)
|
||||
|
||||
## 数据层
|
||||
|
||||
- **GORM**(`gorm.io/gorm`):ORM
|
||||
- **GORM MySQL 驱动**(`gorm.io/driver/mysql`):连接 MySQL
|
||||
- **go-sql-driver/mysql**:底层 MySQL 驱动(GORM 间接依赖)
|
||||
|
||||
## 微信生态
|
||||
|
||||
- **PowerWeChat**(`github.com/ArtisanCloud/PowerWeChat/v3`):微信开放能力(小程序、支付、商家转账等)
|
||||
- **PowerLibs**(`github.com/ArtisanCloud/PowerLibs/v3`):PowerWeChat 依赖
|
||||
|
||||
## 配置与环境
|
||||
|
||||
- **godotenv**(`github.com/joho/godotenv`):从 `.env` 加载环境变量
|
||||
- 业务配置集中在 `internal/config`,通过 `config.Load()` 读取
|
||||
|
||||
## 鉴权与安全
|
||||
|
||||
- **golang-jwt/jwt/v5**:管理端 JWT 签发与校验(`internal/auth/adminjwt.go`)
|
||||
- 管理端路由使用 `middleware.AdminAuth()` 做 JWT 校验
|
||||
|
||||
## 工具与间接依赖
|
||||
|
||||
- **golang.org/x/time**:时间/限流相关(如 `rate`)
|
||||
- **gin-contrib/sse**:SSE(Gin 间接)
|
||||
- **bytedance/sonic**:JSON 编解码(Gin 默认)
|
||||
- **go-playground/validator**:请求体校验(Gin 的 `ShouldBindJSON` 等)
|
||||
- **redis/go-redis**:仅在依赖图中出现(PowerWeChat 等间接引入),项目代码中未直接使用 Redis
|
||||
|
||||
## 项目结构(技术栈视角)
|
||||
|
||||
| 层级 | 技术/约定 |
|
||||
|----------|------------|
|
||||
| 入口 | `cmd/server/main.go`,标准库 `net/http` + Gin |
|
||||
| 路由 | `internal/router`,Gin Group(`/api`、`/admin`、`/miniprogram` 等) |
|
||||
| 中间件 | CORS、Secure、限流(`middleware.RateLimiter`)、管理端 JWT |
|
||||
| 业务逻辑 | `internal/handler`,GORM + `internal/model` |
|
||||
| 数据访问 | `internal/database` 提供 `DB() *gorm.DB`,统一用 GORM |
|
||||
| 微信相关 | `internal/wechat`(小程序、支付、转账等封装) |
|
||||
| 开发工具 | `.air.toml` 热重载、Makefile |
|
||||
|
||||
整体上是一个 **Gin + GORM + MySQL + 微信 PowerWeChat + JWT 管理端鉴权** 的 Go 后端,面向小程序与管理端 API。
|
||||
Reference in New Issue
Block a user