refactor: update match configuration and user matching logic
- Changed the daily free match limit to a lifetime limit, aligning with backend configurations. - Updated the match price display to include original pricing for better user clarity. - Refactored match quota handling to sync with the server, ensuring accurate match counts and purchase statuses. - Enhanced UI messages to reflect changes in match availability and pricing. - Removed deprecated logic related to daily match counts, streamlining the matching process.
This commit is contained in:
@@ -32,7 +32,7 @@ let MATCH_TYPES = [
|
||||
{ id: 'team', label: '团队招募', matchLabel: '团队招募', icon: 'gamepad', matchFromDB: true, showJoinAfterMatch: true }
|
||||
]
|
||||
|
||||
let FREE_MATCH_LIMIT = 3 // 每日免费匹配次数
|
||||
let FREE_MATCH_LIMIT = 1 // 终身免费匹配次数(不按日重置,与后端 match_config 一致)
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -48,8 +48,7 @@ Page({
|
||||
hasPurchased: false,
|
||||
hasFullBook: false,
|
||||
|
||||
// 匹配次数
|
||||
todayMatchCount: 0,
|
||||
// 匹配次数(以服务端 purchase-status.matchQuota 为准)
|
||||
totalMatchesAllowed: FREE_MATCH_LIMIT,
|
||||
matchesRemaining: FREE_MATCH_LIMIT,
|
||||
needPayToMatch: false,
|
||||
@@ -86,20 +85,21 @@ Page({
|
||||
contactWechat: '',
|
||||
contactSaving: false,
|
||||
|
||||
// 匹配价格(可配置)
|
||||
// 匹配价格(可配置);matchPriceOriginal 为划线原价展示
|
||||
matchPrice: 1,
|
||||
matchPriceOriginal: 9.9,
|
||||
extraMatches: 0
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
async onLoad() {
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44
|
||||
})
|
||||
this.loadMatchConfig()
|
||||
await this.loadMatchConfig()
|
||||
this.loadStoredContact()
|
||||
this.loadTodayMatchCount()
|
||||
this.initUserStatus()
|
||||
await this.refreshMatchQuotaFromServer()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -112,6 +112,7 @@ Page({
|
||||
}
|
||||
}
|
||||
this.initUserStatus()
|
||||
this.refreshMatchQuotaFromServer()
|
||||
},
|
||||
|
||||
// 加载匹配配置
|
||||
@@ -132,12 +133,15 @@ Page({
|
||||
})
|
||||
MATCH_TYPES = types
|
||||
FREE_MATCH_LIMIT = res.data.freeMatchLimit || FREE_MATCH_LIMIT
|
||||
if (FREE_MATCH_LIMIT > 1) FREE_MATCH_LIMIT = 1
|
||||
const matchPrice = res.data.matchPrice || 1
|
||||
const matchPriceOriginal = res.data.matchPriceOriginal != null ? res.data.matchPriceOriginal : 9.9
|
||||
|
||||
this.setData({
|
||||
matchTypes: MATCH_TYPES,
|
||||
totalMatchesAllowed: FREE_MATCH_LIMIT,
|
||||
matchPrice: matchPrice
|
||||
matchPrice: matchPrice,
|
||||
matchPriceOriginal: matchPriceOriginal
|
||||
})
|
||||
|
||||
console.log('[Match] 加载匹配配置成功:', {
|
||||
@@ -162,49 +166,64 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 加载今日匹配次数
|
||||
loadTodayMatchCount() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const stored = wx.getStorageSync('match_count_data')
|
||||
if (stored) {
|
||||
const data = typeof stored === 'string' ? JSON.parse(stored) : stored
|
||||
if (data.date === today) {
|
||||
this.setData({ todayMatchCount: data.count })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载匹配次数失败:', e)
|
||||
/** 从服务端同步匹配配额(终身免费 + 已购次数,不按自然日重置) */
|
||||
async refreshMatchQuotaFromServer() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
const hasFullBookGlobal = !!app.globalData.hasFullBook
|
||||
if (!userId) {
|
||||
this.setData({
|
||||
matchesRemaining: FREE_MATCH_LIMIT,
|
||||
needPayToMatch: false,
|
||||
extraMatches: 0,
|
||||
totalMatchesAllowed: FREE_MATCH_LIMIT
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: `/api/miniprogram/user/purchase-status?userId=${encodeURIComponent(userId)}`,
|
||||
silent: true
|
||||
})
|
||||
if (!res.success || !res.data) return
|
||||
const hasFullBook = res.data.hasFullBook === true
|
||||
const mq = res.data.matchQuota || {}
|
||||
const remain = mq.remainToday != null ? Number(mq.remainToday) : 0
|
||||
const purchasedRemain = mq.purchasedRemain != null ? Number(mq.purchasedRemain) : 0
|
||||
const purchasedTotal = mq.purchasedTotal != null ? Number(mq.purchasedTotal) : 0
|
||||
if (hasFullBook) {
|
||||
this.setData({
|
||||
hasFullBook: true,
|
||||
matchesRemaining: 999999,
|
||||
needPayToMatch: false,
|
||||
extraMatches: purchasedRemain,
|
||||
totalMatchesAllowed: 999999
|
||||
})
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
hasFullBook: false,
|
||||
matchesRemaining: Math.max(0, remain),
|
||||
needPayToMatch: remain <= 0,
|
||||
extraMatches: purchasedRemain,
|
||||
totalMatchesAllowed: FREE_MATCH_LIMIT + purchasedTotal
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('[Match] 同步匹配配额失败', e)
|
||||
this.setData({
|
||||
hasFullBook: hasFullBookGlobal,
|
||||
matchesRemaining: hasFullBookGlobal ? 999999 : this.data.matchesRemaining,
|
||||
needPayToMatch: !hasFullBookGlobal && this.data.matchesRemaining <= 0
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// 保存今日匹配次数
|
||||
saveTodayMatchCount(count) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
wx.setStorageSync('match_count_data', { date: today, count })
|
||||
},
|
||||
|
||||
// 初始化用户状态
|
||||
initUserStatus() {
|
||||
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
|
||||
|
||||
// 获取额外购买的匹配次数
|
||||
const extraMatches = wx.getStorageSync('extra_match_count') || 0
|
||||
|
||||
// 总匹配次数 = 每日免费(3) + 额外购买次数
|
||||
// 全书用户无限制
|
||||
const totalMatchesAllowed = hasFullBook ? 999999 : FREE_MATCH_LIMIT + extraMatches
|
||||
const matchesRemaining = hasFullBook ? 999999 : Math.max(0, totalMatchesAllowed - this.data.todayMatchCount)
|
||||
const needPayToMatch = !hasFullBook && matchesRemaining <= 0
|
||||
|
||||
const { isLoggedIn, hasFullBook } = app.globalData
|
||||
this.setData({
|
||||
isLoggedIn,
|
||||
hasFullBook,
|
||||
hasPurchased: true, // 所有用户都可以使用匹配功能
|
||||
totalMatchesAllowed,
|
||||
matchesRemaining,
|
||||
needPayToMatch,
|
||||
extraMatches
|
||||
hasPurchased: true
|
||||
})
|
||||
},
|
||||
|
||||
@@ -473,7 +492,7 @@ Page({
|
||||
} else if (res && !res.success) {
|
||||
matchFailHint = res.message || res.error || ''
|
||||
if (res.code === 'QUOTA_EXCEEDED') {
|
||||
matchFailHint = matchFailHint || '今日免费次数已用完,可购买额外匹配次数后再试'
|
||||
matchFailHint = matchFailHint || '免费次数已用完,可付费购买匹配次数后再试'
|
||||
} else if (res.code === 'NO_USERS') {
|
||||
matchFailHint = matchFailHint || '当前流量池暂无可匹配用户,可稍后再试;补全档案后匹配范围通常更大。'
|
||||
}
|
||||
@@ -500,18 +519,11 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
// 增加今日匹配次数
|
||||
const newCount = this.data.todayMatchCount + 1
|
||||
const matchesRemaining = this.data.hasFullBook ? 999999 : Math.max(0, this.data.totalMatchesAllowed - newCount)
|
||||
|
||||
this.setData({
|
||||
isMatching: false,
|
||||
currentMatch: matchedUser,
|
||||
todayMatchCount: newCount,
|
||||
matchesRemaining,
|
||||
needPayToMatch: !this.data.hasFullBook && matchesRemaining <= 0
|
||||
currentMatch: matchedUser
|
||||
})
|
||||
this.saveTodayMatchCount(newCount)
|
||||
this.refreshMatchQuotaFromServer()
|
||||
|
||||
// 上报匹配行为到存客宝
|
||||
this.reportMatch(matchedUser)
|
||||
@@ -729,12 +741,8 @@ Page({
|
||||
})
|
||||
})
|
||||
|
||||
// 支付成功,增加匹配次数
|
||||
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
|
||||
wx.setStorageSync('extra_match_count', extraMatches)
|
||||
|
||||
wx.showToast({ title: '购买成功', icon: 'success' })
|
||||
this.initUserStatus()
|
||||
await this.refreshMatchQuotaFromServer()
|
||||
} else {
|
||||
throw new Error(res.error || '创建订单失败')
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<view style="height: 16rpx;"></view>
|
||||
|
||||
<!-- 匹配提示条 - 简化显示 -->
|
||||
<view class="match-tip-bar" wx:if="{{matchesRemaining <= 0 && !hasFullBook}}">
|
||||
<view class="match-tip-bar" wx:if="{{isLoggedIn && matchesRemaining <= 0 && !hasFullBook}}">
|
||||
<icon name="zap" size="36" color="#FFD700" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">今日免费次数已用完</text>
|
||||
<text class="tip-text">免费次数已用完,可付费继续匹配</text>
|
||||
<view class="tip-btn" bindtap="showUnlockModal">购买次数</view>
|
||||
</view>
|
||||
|
||||
@@ -302,22 +302,25 @@
|
||||
<view class="modal-content unlock-modal" catchtap="preventBubble">
|
||||
<view class="unlock-icon"><icon name="zap" size="64" color="#FFD700"></icon></view>
|
||||
<text class="unlock-title">购买匹配次数</text>
|
||||
<text class="unlock-desc">今日3次免费匹配已用完,可付费购买额外次数</text>
|
||||
<text class="unlock-desc">每位用户仅有 1 次免费匹配(不按天重置),之后每次 ¥{{matchPrice || 1}}</text>
|
||||
|
||||
<view class="unlock-info">
|
||||
<view class="info-row">
|
||||
<view class="info-row info-row-price">
|
||||
<text class="info-label">单价</text>
|
||||
<text class="info-value text-brand">¥{{matchPrice || 1}} / 次</text>
|
||||
<view class="info-value price-line">
|
||||
<text class="price-original">¥{{matchPriceOriginal}}</text>
|
||||
<text class="text-brand">¥{{matchPrice || 1}} / 次</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">已购买</text>
|
||||
<text class="info-label">已购未用</text>
|
||||
<text class="info-value">{{extraMatches || 0}} 次</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="unlock-buttons">
|
||||
<view class="btn-gold" bindtap="buyMatchCount">立即购买 ¥{{matchPrice || 1}}</view>
|
||||
<view class="btn-ghost" bindtap="closeUnlockModal">明天再来</view>
|
||||
<view class="btn-gold" bindtap="buyMatchCount">立即购买 ¥{{matchPrice || 1}} / 次</view>
|
||||
<view class="btn-ghost" bindtap="closeUnlockModal">稍后再说</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -80,6 +80,25 @@
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.info-row-price {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.price-line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.text-brand {
|
||||
color: #00CED1;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,15 @@ function appendQueryToPath(path, key, value) {
|
||||
return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
|
||||
}
|
||||
|
||||
/** 本小程序 wx.navigateTo / switchTab 用的路径:补全前导 / */
|
||||
function normalizeMiniProgramNavPath(p) {
|
||||
if (p == null || typeof p !== 'string') return ''
|
||||
let s = p.trim()
|
||||
if (!s) return ''
|
||||
if (!s.startsWith('/')) s = '/' + s.replace(/^\/+/, '')
|
||||
return s
|
||||
}
|
||||
|
||||
/** 当前用户已绑定手机号(与 app 内登录态一致) */
|
||||
function getLoggedInUserPhone() {
|
||||
const u = app.globalData.userInfo || {}
|
||||
@@ -903,6 +912,27 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
// 本小程序内页(链接标签类型 internal,#标签 跳转当前小程序页面)
|
||||
if (tagType === 'internal') {
|
||||
const path = normalizeMiniProgramNavPath(pagePath || url)
|
||||
if (!path) {
|
||||
wx.showToast({ title: '未配置页面路径', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({
|
||||
url: path,
|
||||
fail: () => {
|
||||
wx.switchTab({
|
||||
url: path,
|
||||
fail: (err) => {
|
||||
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 小程序类型:用密钥查 linkedMiniprograms 得 appId,再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
|
||||
if (tagType === 'miniprogram') {
|
||||
if (!mpKey && label) {
|
||||
@@ -930,10 +960,22 @@ Page({
|
||||
if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
|
||||
}
|
||||
|
||||
// 小程序内部路径(pagePath 或 url 以 /pages/ 开头)
|
||||
const internalPath = pagePath || (url.startsWith('/pages/') ? url : '')
|
||||
// 兼容:仅填 pagePath、或 url 为内页路径(非 internal 类型也可走此分支)
|
||||
const rawInternal =
|
||||
pagePath || (url.startsWith('/pages/') || url.startsWith('pages/') ? url : '')
|
||||
const internalPath = normalizeMiniProgramNavPath(rawInternal)
|
||||
if (internalPath) {
|
||||
wx.navigateTo({ url: internalPath, fail: () => wx.switchTab({ url: internalPath }) })
|
||||
wx.navigateTo({
|
||||
url: internalPath,
|
||||
fail: () => {
|
||||
wx.switchTab({
|
||||
url: internalPath,
|
||||
fail: (err) => {
|
||||
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-CLoQZZ8i.css
vendored
1
soul-admin/dist/assets/index-CLoQZZ8i.css
vendored
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-CuUAjfcM.css
vendored
Normal file
1
soul-admin/dist/assets/index-CuUAjfcM.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-CDvJkLCc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CLoQZZ8i.css">
|
||||
<script type="module" crossorigin src="/assets/index-BSvDfqaj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CuUAjfcM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface LinkTagItem {
|
||||
label: string
|
||||
aliases?: string
|
||||
url: string
|
||||
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink'
|
||||
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal'
|
||||
appId?: string
|
||||
pagePath?: string
|
||||
/** 管理端列表用:库内是否已存目标小程序 AppSecret(接口不下发明文) */
|
||||
|
||||
@@ -308,7 +308,7 @@ export function ContentPage() {
|
||||
label: '',
|
||||
aliases: '',
|
||||
url: '',
|
||||
type: 'url' as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
|
||||
type: 'url' as 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
pagePath: '',
|
||||
@@ -663,7 +663,7 @@ export function ContentPage() {
|
||||
id: t.tagId,
|
||||
label: t.label,
|
||||
url: t.url,
|
||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
|
||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal',
|
||||
appId: t.appId || '',
|
||||
pagePath: t.pagePath || '',
|
||||
hasAppSecret: !!t.hasAppSecret,
|
||||
@@ -750,7 +750,7 @@ export function ContentPage() {
|
||||
label: t.label,
|
||||
aliases: t.aliases || '',
|
||||
url: t.url,
|
||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
|
||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal',
|
||||
appId: t.appId || '',
|
||||
pagePath: t.pagePath || '',
|
||||
hasAppSecret: !!t.hasAppSecret,
|
||||
@@ -3063,9 +3063,9 @@ export function ContentPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<Hash className="w-4 h-4 text-amber-400" />
|
||||
链接标签 — 链接事与物(编辑器内 #标签 可跳转链接/小程序/存客宝)
|
||||
链接标签 — 链接事与物(编辑器内 #标签 可跳转网页/本小程序页/其他小程序/存客宝)
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500 mt-1">小程序端点击 #标签 可直接跳转对应链接,进入流量池</p>
|
||||
<p className="text-xs text-gray-500 mt-1">小程序端点击 #标签 可跳转外链、当前小程序指定页面、或进入流量池</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-3 flex-wrap">
|
||||
@@ -3174,16 +3174,30 @@ export function ContentPage() {
|
||||
className={`text-[10px] ${
|
||||
t.type === 'ckb'
|
||||
? 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||
: t.type === 'miniprogram' || t.type === 'wxlink'
|
||||
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
: t.type === 'internal'
|
||||
? 'bg-sky-500/20 text-sky-200 border-sky-500/30'
|
||||
: t.type === 'miniprogram' || t.type === 'wxlink'
|
||||
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : t.type === 'wxlink' ? '小程序链接' : '小程序'}
|
||||
{t.type === 'url'
|
||||
? '网页'
|
||||
: t.type === 'internal'
|
||||
? '本小程序'
|
||||
: t.type === 'ckb'
|
||||
? '存客宝'
|
||||
: t.type === 'wxlink'
|
||||
? '小程序链接'
|
||||
: '小程序'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-300">
|
||||
{t.type === 'miniprogram' ? (
|
||||
{t.type === 'internal' ? (
|
||||
<div className="text-xs font-mono text-sky-300 truncate max-w-[420px]" title={t.pagePath || ''}>
|
||||
{t.pagePath || '—'}
|
||||
</div>
|
||||
) : t.type === 'miniprogram' ? (
|
||||
<div className="space-y-0.5">
|
||||
{(() => {
|
||||
const mp = linkedMps.find(m => m.key === t.appId)
|
||||
@@ -3301,7 +3315,7 @@ export function ContentPage() {
|
||||
<DialogHeader className="gap-1">
|
||||
<DialogTitle className="text-base">{linkTagEditing ? '编辑链接标签' : '添加链接标签'}</DialogTitle>
|
||||
<DialogDescription className="text-gray-400 text-xs">
|
||||
配置后可在富文本编辑器中通过 #标签 插入,并在小程序端点击跳转。小程序类型需填 mpKey 或微信 AppID;AppSecret 仅存服务端(不下发小程序),供后续开放接口与台账使用。
|
||||
配置后可在富文本编辑器中通过 #标签 插入,并在小程序端点击跳转。「本小程序页面」仅跳转当前小程序内路径;API 跳转小程序类型需填 mpKey 或微信 AppID;AppSecret 仅存服务端(不下发小程序),供后续开放接口与台账使用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -3343,7 +3357,7 @@ export function ContentPage() {
|
||||
<Select
|
||||
value={linkTagForm.type}
|
||||
onValueChange={(v) =>
|
||||
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' | 'wxlink' }))
|
||||
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' | 'wxlink' | 'internal' }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8">
|
||||
@@ -3351,6 +3365,7 @@ export function ContentPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#0f2137] border-gray-700 text-white">
|
||||
<SelectItem value="url">网页链接</SelectItem>
|
||||
<SelectItem value="internal">本小程序页面</SelectItem>
|
||||
<SelectItem value="miniprogram">小程序(API跳转)</SelectItem>
|
||||
<SelectItem value="wxlink">小程序链接(右上角复制)</SelectItem>
|
||||
<SelectItem value="ckb">存客宝</SelectItem>
|
||||
@@ -3365,9 +3380,18 @@ export function ContentPage() {
|
||||
? '存客宝计划URL'
|
||||
: linkTagForm.type === 'wxlink'
|
||||
? '小程序链接'
|
||||
: '小程序 mpKey / 微信 AppID'}
|
||||
: linkTagForm.type === 'internal'
|
||||
? '页面路径'
|
||||
: '小程序 mpKey / 微信 AppID'}
|
||||
</Label>
|
||||
{linkTagForm.type === 'wxlink' ? (
|
||||
{linkTagForm.type === 'internal' ? (
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
|
||||
placeholder="/pages/index/index 或带参数 ?id=1"
|
||||
value={linkTagForm.pagePath}
|
||||
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
|
||||
/>
|
||||
) : linkTagForm.type === 'wxlink' ? (
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
|
||||
placeholder="粘贴小程序右上角 ... → 复制链接 得到的 URL"
|
||||
@@ -3506,6 +3530,22 @@ export function ContentPage() {
|
||||
return
|
||||
}
|
||||
if (payload.type === 'miniprogram') payload.url = ''
|
||||
if (payload.type === 'internal') {
|
||||
payload.url = ''
|
||||
payload.appId = ''
|
||||
payload.appSecret = ''
|
||||
let p = payload.pagePath.trim()
|
||||
if (!p) {
|
||||
toast.error('请填写本小程序页面路径')
|
||||
return
|
||||
}
|
||||
if (!p.startsWith('/')) p = '/' + p.replace(/^\/+/, '')
|
||||
if (!/^\/pages\//.test(p)) {
|
||||
toast.error('路径需以 /pages/ 开头')
|
||||
return
|
||||
}
|
||||
payload.pagePath = p
|
||||
}
|
||||
if (payload.type === 'wxlink') { payload.appId = ''; payload.pagePath = '' }
|
||||
setLinkTagSaving(true)
|
||||
try {
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
# soul-api Docker 镜像
|
||||
# 多阶段构建:编译 Go 二进制 → 精简运行镜像
|
||||
# 构建:在 soul-api 根目录执行 docker build -f deploy/Dockerfile -t soul-api:latest .
|
||||
# 运行:见 docker-compose.production.yml
|
||||
|
||||
# ========== 阶段 1:编译 ==========
|
||||
# 使用本地已缓存的 golang:1.25;deploy.py 加 --pull=false 避免拉取
|
||||
FROM golang:1.25 AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-w -s" -o /build/soul-api ./cmd/server
|
||||
|
||||
# ========== 阶段 2:运行 ==========
|
||||
# 使用标准引用 alpine:3.19,配合 --pull=false 可使用本地已缓存的镜像
|
||||
# Build server binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/soul-api ./cmd/server
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata wget
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN adduser -D -g '' appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/soul-api .
|
||||
RUN apk add --no-cache ca-certificates tzdata wget && \
|
||||
addgroup -S app && adduser -S -G app app
|
||||
|
||||
COPY --from=builder /out/soul-api /app/soul-api
|
||||
COPY certs/ /app/certs/
|
||||
|
||||
ARG ENV_FILE=.env.production
|
||||
COPY ${ENV_FILE} /app/.env
|
||||
# Runtime directories used by the app
|
||||
RUN mkdir -p /app/uploads /app/log && chown -R app:app /app
|
||||
|
||||
RUN mkdir -p /app/uploads && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
ENV APP_ENV=production
|
||||
ENV GIN_MODE=release
|
||||
ENV PORT=8080
|
||||
ENV UPLOAD_DIR=/app/uploads
|
||||
ENV WECHAT_CERT_PATH=/app/certs/apiclient_cert.pem
|
||||
ENV WECHAT_KEY_PATH=/app/certs/apiclient_key.pem
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./soul-api"]
|
||||
|
||||
USER app
|
||||
|
||||
CMD ["/app/soul-api"]
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Dockerfile.local - 使用本地 Go 编译后的二进制,无需拉取 golang 镜像
|
||||
# 使用方式:deploy.py --mode docker --local-go
|
||||
# 依赖:先由本地 go build 生成 soul-api 可执行文件
|
||||
|
||||
# 使用标准引用,配合 --pull=false 使用本地缓存的 alpine:3.19
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata wget
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN adduser -D -g '' appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY soul-api .
|
||||
# 微信支付等证书(须存在于构建上下文,勿在 .dockerignore 中排除)
|
||||
COPY certs/ /app/certs/
|
||||
|
||||
# 由 devloy 传入 --build-arg ENV_FILE=(如 .env / .env.development)
|
||||
ARG ENV_FILE=.env.production
|
||||
COPY ${ENV_FILE} /app/.env
|
||||
|
||||
RUN mkdir -p /app/uploads && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./soul-api"]
|
||||
@@ -1,28 +0,0 @@
|
||||
# soul-api Runner 容器
|
||||
# 红蓝切换在容器内完成,宝塔固定 proxy_pass 到 127.0.0.1:9001
|
||||
# 使用 network_mode: host,无需端口映射,避免 iptables 问题
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates tzdata wget nginx redis \
|
||||
psmisc \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 Runner 脚本与 nginx 模板(context 为 soul-api 根目录)
|
||||
COPY deploy/runner/entrypoint.sh /app/
|
||||
COPY deploy/runner/deploy.sh /app/
|
||||
COPY deploy/runner/nginx.conf.template /app/
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh /app/deploy.sh
|
||||
|
||||
# 创建目录(blue/green 由 deploy.sh 创建)
|
||||
RUN mkdir -p /app/uploads /app/blue /app/green
|
||||
|
||||
EXPOSE 9001
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -1,33 +1,56 @@
|
||||
# soul-api Docker 部署
|
||||
# soul-api Docker 部署说明
|
||||
|
||||
Docker 相关配置均在此目录,根目录仅保留 `deploy.py`。
|
||||
## 1) 打包运行后必需的文件和目录
|
||||
|
||||
## 文件说明
|
||||
### 必需文件
|
||||
- `soul-api` 可执行文件(Docker 镜像内已包含)
|
||||
- `.env`(或等价环境变量;本方案使用 `deploy/app.env`)
|
||||
- `certs/apiclient_cert.pem`
|
||||
- `certs/apiclient_key.pem`
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `Dockerfile` | 多阶段构建(golang + alpine),需拉取 golang 镜像 |
|
||||
| `Dockerfile.local` | 仅运行阶段,配合 `deploy.py --local-go` 使用本地 Go 编译 |
|
||||
| `docker-compose.bluegreen.yml` | 蓝绿部署(8081/8082),含 Redis 7-alpine |
|
||||
| `docker-compose.production.yml` | 生产单实例部署 |
|
||||
| `docker-compose.yml` | 本地开发 Redis |
|
||||
| `docker-deploy-remote.sh` | 服务器端蓝绿切换脚本 |
|
||||
### 必需目录
|
||||
- `uploads/`:上传文件持久化目录
|
||||
- `log/`:订单同步等日志目录
|
||||
|
||||
## 镜像版本
|
||||
> 说明:`/app/uploads`、`/app/log` 是容器内目录;本方案已挂载到服务器目录:
|
||||
> - `/www/wwwroot/self/soul-dev/uploads -> /app/uploads`
|
||||
> - `/www/wwwroot/self/soul-dev/log -> /app/log`
|
||||
|
||||
- **soul-api**:Go 1.25,alpine 3.19
|
||||
- **Redis**:7-alpine(与 soul-api 一并打包上传,服务器无需拉取)
|
||||
- **MySQL**:外部服务,不打包
|
||||
## 2) 本地/服务器快速启动
|
||||
|
||||
## 用法
|
||||
在 `soul-api/deploy` 目录下执行:
|
||||
|
||||
```bash
|
||||
# 一键部署(蓝绿)
|
||||
python deploy.py --mode docker
|
||||
1. 复制环境文件:
|
||||
- `cp app.env.example app.env`
|
||||
- 按实际环境填写 `DB_DSN`、微信和管理端密钥
|
||||
2. 启动:
|
||||
- `docker compose up -d --build`
|
||||
3. 查看状态:
|
||||
- `docker compose ps`
|
||||
- `docker compose logs -f app`
|
||||
4. 健康检查:
|
||||
- `http://服务器IP:APP_PORT/health`
|
||||
|
||||
# 使用本地 Go 编译
|
||||
python deploy.py --mode docker --local-go
|
||||
## 3) 给宝塔的推荐方式
|
||||
|
||||
# 本地启动 Redis
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
- 方式 A(推荐):在宝塔 Docker/Compose 项目里直接使用 `deploy/docker-compose.yml`
|
||||
- 方式 B:先 `docker build -f deploy/Dockerfile -t soul-api:latest ..`,再在宝塔按镜像创建容器
|
||||
|
||||
## 4) 目录结构(deploy)
|
||||
|
||||
- `deploy/Dockerfile`:多阶段构建 Go 应用镜像
|
||||
- `deploy/docker-compose.yml`:单容器运行与数据卷持久化
|
||||
- `deploy/app.env.example`:环境变量模板
|
||||
|
||||
## 5) 离线镜像部署(不走远端拉取)
|
||||
|
||||
你可以把本地导出的 `deploy/soul-api_latest.tar` 上传到服务器,然后:
|
||||
|
||||
1. 导入镜像:
|
||||
- `docker load -i soul-api_latest.tar`
|
||||
2. 准备配置:
|
||||
- 把 `deploy/app.env` 放到服务器同目录(或按需改 `env_file`)
|
||||
3. 启动:
|
||||
- `docker compose up -d --no-build`
|
||||
|
||||
> 这种方式不会再从 Docker Hub 拉取 `soul-api` 业务镜像;直接使用你导入的本地镜像运行。
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Runner 部署脚本(在宿主机执行)
|
||||
# 用法:./deploy-runner-remote.sh [path-to-deploy.tar.gz]
|
||||
# 默认 tar 路径:${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}/soul_api_deploy.tar.gz
|
||||
# 仅负责:将 tar 拷入容器并触发容器内 deploy.sh,不涉及宝塔/Nginx 配置
|
||||
|
||||
set -e
|
||||
CONTAINER="${DEPLOY_RUNNER_CONTAINER:-soul-api-runner}"
|
||||
DEPLOY_PATH="${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}"
|
||||
TAR="${1:-$DEPLOY_PATH/soul_api_deploy.tar.gz}"
|
||||
|
||||
if [ -z "$TAR" ] || [ ! -f "$TAR" ]; then
|
||||
echo "[ERROR] 用法: $0 [path-to-deploy.tar.gz]"
|
||||
echo " 默认: $DEPLOY_PATH/soul_api_deploy.tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/2] 拷贝部署包到容器 ..."
|
||||
docker cp "$TAR" "$CONTAINER:/tmp/incoming.tar.gz"
|
||||
|
||||
echo "[2/2] 执行容器内红蓝切换 ..."
|
||||
docker exec "$CONTAINER" /app/deploy.sh /tmp/incoming.tar.gz
|
||||
|
||||
rm -f "$TAR"
|
||||
echo ""
|
||||
echo "[SUCCESS] 部署完成,宝塔代理 9001 无需修改"
|
||||
@@ -1,62 +0,0 @@
|
||||
# soul-api 蓝绿部署 - 支持无缝切换
|
||||
# blue=9001, green=9002,部署时先启新实例,健康检查通过后切换 Nginx,再停旧实例
|
||||
# 用法:见 deploy.py --mode docker
|
||||
|
||||
services:
|
||||
soul-api-blue:
|
||||
image: soul-api:latest
|
||||
container_name: soul-api-blue
|
||||
restart: "no"
|
||||
environment:
|
||||
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
|
||||
- GIN_MODE=release
|
||||
- APP_ENV=production
|
||||
# 测试/预发布环境可设 SKIP_PROD_SECRET_CHECK=staging,正式生产请使用真实密钥并移除此项
|
||||
- SKIP_PROD_SECRET_CHECK=staging
|
||||
ports:
|
||||
- "9001:8080"
|
||||
volumes:
|
||||
- soul_uploads:/app/uploads
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 15s
|
||||
|
||||
soul-api-green:
|
||||
image: soul-api:latest
|
||||
container_name: soul-api-green
|
||||
restart: "no"
|
||||
environment:
|
||||
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
|
||||
- GIN_MODE=release
|
||||
- APP_ENV=production
|
||||
- SKIP_PROD_SECRET_CHECK=staging
|
||||
ports:
|
||||
- "9002:8080"
|
||||
volumes:
|
||||
- soul_uploads:/app/uploads
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 15s
|
||||
|
||||
redis:
|
||||
# 与 soul-api 一并打包上传,使用本地 DaoCloud 镜像名(与 pack 中 docker save 一致)
|
||||
image: docker.m.daocloud.io/library/redis:7-alpine
|
||||
container_name: soul-redis
|
||||
command: redis-server --appendonly yes --requirepass "soul-docker-redis"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
soul_uploads:
|
||||
@@ -1,42 +0,0 @@
|
||||
# soul-api 生产环境 Docker 部署
|
||||
# 用法:在 soul-api 根目录执行
|
||||
# docker compose -f deploy/docker-compose.production.yml up -d
|
||||
#
|
||||
# Redis 7-alpine:与宝塔已有 Redis 隔离,仅容器内网使用
|
||||
|
||||
services:
|
||||
soul-api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
image: soul-api:latest
|
||||
container_name: soul-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
|
||||
- GIN_MODE=release
|
||||
- APP_ENV=production
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- soul_uploads:/app/uploads
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: soul-redis
|
||||
command: redis-server --appendonly yes --requirepass "soul-docker-redis"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
soul_uploads:
|
||||
@@ -1,14 +0,0 @@
|
||||
# soul-api Runner 部署(仅用已加载镜像,无 build)
|
||||
# 用于 devloy.py --init-runner 推送镜像后启动
|
||||
|
||||
services:
|
||||
soul-api-runner:
|
||||
image: soul-api-runner:latest
|
||||
container_name: soul-api-runner
|
||||
network_mode: host
|
||||
volumes:
|
||||
- soul_runner_data:/app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
soul_runner_data:
|
||||
@@ -1,21 +0,0 @@
|
||||
# soul-api Runner 部署
|
||||
# 红蓝切换在容器内完成,宝塔固定 proxy_pass 到 127.0.0.1:9001
|
||||
# 使用 network_mode: host,无需端口映射,避免 iptables DOCKER 链问题
|
||||
#
|
||||
# 首次启动:docker compose -f docker-compose.runner.yml up -d
|
||||
# 部署新版本:上传 tar 后执行 deploy-runner-remote.sh
|
||||
|
||||
services:
|
||||
soul-api-runner:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.runner
|
||||
image: soul-api-runner:latest
|
||||
container_name: soul-api-runner
|
||||
network_mode: host
|
||||
volumes:
|
||||
- soul_runner_data:/app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
soul_runner_data:
|
||||
@@ -1,16 +1,23 @@
|
||||
# soul-api 本地开发用 Redis
|
||||
# 用法:docker compose -f deploy/docker-compose.yml up -d
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: soul-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
image: soul-api:latest
|
||||
container_name: soul-api
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
env_file:
|
||||
- ./app.env
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:${PORT:-8080}"
|
||||
volumes:
|
||||
- "${HOST_UPLOAD_DIR:-./data/uploads}:/app/uploads"
|
||||
- "${HOST_LOG_DIR:-./data/log}:/app/log"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-lc", "wget -qO- http://127.0.0.1:${PORT:-8080}/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Docker 蓝绿部署脚本(在服务器上执行)
|
||||
# 用法:./docker-deploy-remote.sh /tmp/soul_api_image.tar.gz [--skip-nginx]
|
||||
# --skip-nginx:跳过 Nginx 切换,由宝塔 API 在本地执行
|
||||
|
||||
set -e
|
||||
PROJECT_ROOT="${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}"
|
||||
ACTIVE_FILE="$PROJECT_ROOT/.active"
|
||||
NGINX_CONF="${DEPLOY_NGINX_CONF:-}"
|
||||
IMAGE_TAR="${1:-}"
|
||||
SKIP_NGINX=""
|
||||
if [ "${2:-}" = "--skip-nginx" ]; then
|
||||
SKIP_NGINX=1
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGE_TAR" ] || [ ! -f "$IMAGE_TAR" ]; then
|
||||
echo "[ERROR] usage: $0 <path-to-image.tar.gz> [--skip-nginx]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# 兼容 docker-compose / docker compose(不同系统安装不一致)
|
||||
dc() {
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose "$@"
|
||||
else
|
||||
docker compose "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# 兼容 curl / wget(健康检查工具不一定都有)
|
||||
health_ok() {
|
||||
url="$1"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sf "$url" >/dev/null 2>&1
|
||||
else
|
||||
wget -qO- "$url" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# 加载新镜像
|
||||
echo "[1/5] 加载 Docker 镜像 ..."
|
||||
gunzip -c "$IMAGE_TAR" | docker load
|
||||
rm -f "$IMAGE_TAR"
|
||||
|
||||
# 确定当前活跃实例与待启动实例
|
||||
CURRENT="blue"
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
CURRENT=$(cat "$ACTIVE_FILE")
|
||||
fi
|
||||
if [ "$CURRENT" = "blue" ]; then
|
||||
NEW="green"
|
||||
OLD_PORT=9001
|
||||
NEW_PORT=9002
|
||||
else
|
||||
NEW="blue"
|
||||
OLD_PORT=9002
|
||||
NEW_PORT=9001
|
||||
fi
|
||||
|
||||
echo "[2/5] 当前活跃: $CURRENT ($OLD_PORT),将启动: $NEW ($NEW_PORT)"
|
||||
|
||||
# 启动新实例
|
||||
echo "[3/5] 启动 soul-api-$NEW ..."
|
||||
# --no-deps:线上 Redis 已在跑,不再让 compose 拉起/重建依赖
|
||||
dc -f docker-compose.bluegreen.yml up -d --no-deps "soul-api-$NEW"
|
||||
|
||||
# 等待健康检查(镜像已从 tar.gz 加载,无需联网拉取,最多 120 秒)
|
||||
echo "[4/5] 等待健康检查 ..."
|
||||
sleep 5
|
||||
for i in $(seq 1 58); do
|
||||
if health_ok "http://127.0.0.1:$NEW_PORT/health"; then
|
||||
echo " 健康检查通过 ($((5 + i * 2))s)"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ $i -eq 58 ]; then
|
||||
echo "[ERROR] 健康检查超时(120s),新实例未就绪。可查看: docker-compose -f docker-compose.bluegreen.yml logs soul-api-$NEW"
|
||||
dc -f docker-compose.bluegreen.yml stop "soul-api-$NEW"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 切换 Nginx(若配置了 NGINX_CONF):将 proxy_pass 中的端口改为 NEW_PORT
|
||||
if [ -z "$SKIP_NGINX" ]; then
|
||||
CONF_TO_EDIT="$NGINX_CONF"
|
||||
# 自动兜底:如果未传入 DEPLOY_NGINX_CONF,则尝试在宝塔默认目录中定位 vhost 配置文件
|
||||
if [ -z "$CONF_TO_EDIT" ] || [ ! -f "$CONF_TO_EDIT" ]; then
|
||||
CONF_DIR="${DEPLOY_NGINX_CONF_DIR:-/www/server/panel/vhost/nginx}"
|
||||
if [ -d "$CONF_DIR" ]; then
|
||||
# 优先匹配旧/新端口对应的 proxy_pass,尽量减少误命中
|
||||
for p in "$OLD_PORT" "$NEW_PORT"; do
|
||||
# proxy_pass 前可能带空格;用正则增强匹配容错
|
||||
match="$(grep -rlE "proxy_pass[[:space:]]+http://(127\\.0\\.0\\.1|localhost|0\\.0\\.0\\.0):${p}" "$CONF_DIR" 2>/dev/null | sed -n '1p')"
|
||||
if [ -n "$match" ]; then
|
||||
CONF_TO_EDIT="$match"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# 如果仍未匹配,尝试按域名关键字(可选:DEPLOY_DOMAIN)
|
||||
if [ -z "$CONF_TO_EDIT" ] && [ -n "${DEPLOY_DOMAIN:-}" ]; then
|
||||
match="$(grep -rl "${DEPLOY_DOMAIN}" "$CONF_DIR" 2>/dev/null | head -n 1)"
|
||||
if [ -n "$match" ]; then
|
||||
CONF_TO_EDIT="$match"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$CONF_TO_EDIT" ] && [ -f "$CONF_TO_EDIT" ]; then
|
||||
echo "[5/5] 切换 Nginx 到 $NEW_PORT ...(编辑: $CONF_TO_EDIT)"
|
||||
# 只在同一个 vhost 配置里替换 proxy_pass 上游端口
|
||||
sed -i.bak "s|proxy_pass http://127.0.0.1:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
|
||||
sed -i.bak "s|proxy_pass http://localhost:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
|
||||
sed -i.bak "s|proxy_pass http://0.0.0.0:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
|
||||
nginx -t && nginx -s reload
|
||||
echo " Nginx 已重载"
|
||||
else
|
||||
echo "[5/5] 未找到可编辑的 nginx 配置文件,跳过 Nginx 切换。请手动将 proxy_pass 改为 127.0.0.1:$NEW_PORT"
|
||||
fi
|
||||
else
|
||||
echo "[5/5] 已跳过 Nginx 切换(--skip-nginx)"
|
||||
fi
|
||||
|
||||
# 停止旧实例(首次部署时可能不存在,忽略错误)
|
||||
dc -f docker-compose.bluegreen.yml stop "soul-api-$CURRENT" 2>/dev/null || true
|
||||
echo "$NEW" > "$ACTIVE_FILE"
|
||||
echo ""
|
||||
echo "[SUCCESS] 部署完成,当前活跃: $NEW (端口 $NEW_PORT)"
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Runner 首次初始化(在宿主机执行)
|
||||
# 构建并启动 Runner 容器,之后用 devloy.py --mode runner 部署
|
||||
#
|
||||
# 用法:在 soul-api 根目录执行
|
||||
# cd /path/to/soul-api
|
||||
# bash deploy/runner-init.sh
|
||||
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
echo "=== soul-api Runner 初始化 ==="
|
||||
echo " 项目目录: $ROOT"
|
||||
echo ""
|
||||
|
||||
# 兼容 docker-compose / docker compose
|
||||
dc() {
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose "$@"
|
||||
else
|
||||
docker compose "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/2] 构建 Runner 镜像 ..."
|
||||
docker build -f deploy/Dockerfile.runner -t soul-api-runner:latest .
|
||||
|
||||
echo "[2/2] 启动 Runner 容器 ..."
|
||||
dc -f deploy/docker-compose.runner.yml up -d
|
||||
|
||||
echo ""
|
||||
echo "[SUCCESS] Runner 已启动"
|
||||
echo " 宝塔反向代理保持 proxy_pass http://127.0.0.1:9001"
|
||||
echo " 首次部署: python devloy.py --mode runner"
|
||||
echo ""
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Runner 容器内红蓝切换脚本
|
||||
# 用法: /app/deploy.sh /tmp/incoming.tar.gz
|
||||
# 将新版本解压到非活跃目录,健康检查通过后切换 nginx 并停旧实例
|
||||
|
||||
set -e
|
||||
INCOMING="${1:-}"
|
||||
APP_ROOT="/app"
|
||||
BLUE="$APP_ROOT/blue"
|
||||
GREEN="$APP_ROOT/green"
|
||||
ACTIVE_FILE="$APP_ROOT/.active"
|
||||
NGINX_CONF="$APP_ROOT/nginx.conf"
|
||||
NGINX_PID="/tmp/nginx.pid"
|
||||
REDIS_PASS="soul-docker-redis"
|
||||
|
||||
health_ok() {
|
||||
local url="$1"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$url" >/dev/null 2>&1
|
||||
else
|
||||
[ -x /usr/bin/wget ] && /usr/bin/wget -qO- "$url" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "$INCOMING" ] || [ ! -f "$INCOMING" ]; then
|
||||
echo "[ERROR] 用法: $0 <path-to-deploy.tar.gz>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 确定当前活跃与待部署目录
|
||||
CURRENT="blue"
|
||||
[ -f "$ACTIVE_FILE" ] && CURRENT=$(cat "$ACTIVE_FILE")
|
||||
[ "$CURRENT" != "blue" ] && [ "$CURRENT" != "green" ] && CURRENT="blue"
|
||||
|
||||
if [ "$CURRENT" = "blue" ]; then
|
||||
NEW="green"
|
||||
NEW_PORT=18082
|
||||
OLD_PORT=18081
|
||||
else
|
||||
NEW="blue"
|
||||
NEW_PORT=18081
|
||||
OLD_PORT=18082
|
||||
fi
|
||||
|
||||
NEW_DIR="$APP_ROOT/$NEW"
|
||||
echo "[1/5] 当前活跃: $CURRENT ($OLD_PORT),将部署到: $NEW ($NEW_PORT)"
|
||||
|
||||
# 解压到新目录
|
||||
echo "[2/5] 解压到 $NEW_DIR ..."
|
||||
rm -rf "$NEW_DIR"
|
||||
mkdir -p "$NEW_DIR"
|
||||
tar -xzf "$INCOMING" -C "$NEW_DIR"
|
||||
rm -f "$INCOMING"
|
||||
|
||||
# 设置 PORT 和 REDIS_URL
|
||||
ENV_FILE="$NEW_DIR/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
sed -i "s/^PORT=.*/PORT=$NEW_PORT/" "$ENV_FILE"
|
||||
grep -q "^REDIS_URL=" "$ENV_FILE" || echo "REDIS_URL=redis://:${REDIS_PASS}@127.0.0.1:6379/0" >> "$ENV_FILE"
|
||||
sed -i "s|^REDIS_URL=.*|REDIS_URL=redis://:${REDIS_PASS}@127.0.0.1:6379/0|" "$ENV_FILE"
|
||||
fi
|
||||
chmod +x "$NEW_DIR/soul-api" 2>/dev/null || true
|
||||
|
||||
# 启动新实例
|
||||
echo "[3/5] 启动 soul-api-$NEW (端口 $NEW_PORT) ..."
|
||||
cd "$NEW_DIR"
|
||||
export PORT=$NEW_PORT
|
||||
export REDIS_URL="redis://:${REDIS_PASS}@127.0.0.1:6379/0"
|
||||
nohup ./soul-api >> soul-api.log 2>&1 &
|
||||
NEW_PID=$!
|
||||
echo $NEW_PID > "$APP_ROOT/.pid.$NEW"
|
||||
cd - >/dev/null
|
||||
|
||||
# 等待健康检查(最多 120 秒)
|
||||
echo "[4/5] 等待健康检查 ..."
|
||||
sleep 5
|
||||
for i in $(seq 1 58); do
|
||||
if health_ok "http://127.0.0.1:$NEW_PORT/health"; then
|
||||
echo " 健康检查通过"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ $i -eq 58 ]; then
|
||||
echo "[ERROR] 健康检查超时,新实例未就绪"
|
||||
kill $NEW_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 更新 nginx 配置并重载
|
||||
echo "[5/5] 切换 nginx 到 $NEW_PORT ..."
|
||||
sed "s/__BACKEND_PORT__/$NEW_PORT/g" "$APP_ROOT/nginx.conf.template" > "$NGINX_CONF"
|
||||
nginx -s reload 2>/dev/null || nginx -c "$NGINX_CONF" 2>/dev/null || true
|
||||
|
||||
# 停止旧实例(通过 PID 文件或端口)
|
||||
OLD_PID_FILE="$APP_ROOT/.pid.$CURRENT"
|
||||
if [ -f "$OLD_PID_FILE" ]; then
|
||||
OLD_PID=$(cat "$OLD_PID_FILE")
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
echo " 停止旧实例 (PID $OLD_PID)"
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
rm -f "$OLD_PID_FILE"
|
||||
fi
|
||||
# 兜底:通过端口杀进程(Alpine 可用 fuser 或 ss)
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -k "$OLD_PORT/tcp" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "$NEW" > "$ACTIVE_FILE"
|
||||
echo ""
|
||||
echo "[SUCCESS] 部署完成,当前活跃: $NEW (端口 $NEW_PORT)"
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/sh
|
||||
# soul-api Runner 容器入口
|
||||
# 启动 Redis、Nginx,首次部署时需外部调用 deploy.sh
|
||||
|
||||
set -e
|
||||
APP_ROOT="/app"
|
||||
REDIS_PASS="soul-docker-redis"
|
||||
|
||||
# 启动 Redis(后台)
|
||||
if ! pgrep -x redis-server >/dev/null 2>&1; then
|
||||
redis-server --requirepass "$REDIS_PASS" --daemonize yes
|
||||
fi
|
||||
|
||||
# 生成初始 nginx 配置(默认指向 blue 18081,若 blue 未部署则 18082)
|
||||
BACKEND=18081
|
||||
[ -f "$APP_ROOT/.active" ] && [ "$(cat $APP_ROOT/.active)" = "green" ] && BACKEND=18082
|
||||
sed "s/__BACKEND_PORT__/$BACKEND/g" "$APP_ROOT/nginx.conf.template" > "$APP_ROOT/nginx.conf"
|
||||
|
||||
# 若已有活跃实例,启动它
|
||||
if [ -f "$APP_ROOT/.active" ]; then
|
||||
ACTIVE=$(cat "$APP_ROOT/.active")
|
||||
ACTIVE_DIR="$APP_ROOT/$ACTIVE"
|
||||
if [ -d "$ACTIVE_DIR" ] && [ -x "$ACTIVE_DIR/soul-api" ]; then
|
||||
PORT=18081
|
||||
[ "$ACTIVE" = "green" ] && PORT=18082
|
||||
cd "$ACTIVE_DIR"
|
||||
export PORT=$PORT
|
||||
export REDIS_URL="redis://:${REDIS_PASS}@127.0.0.1:6379/0"
|
||||
nohup ./soul-api >> soul-api.log 2>&1 &
|
||||
echo $! > "$APP_ROOT/.pid.$ACTIVE"
|
||||
cd - >/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
# 启动 Nginx(前台,保持容器运行)
|
||||
exec nginx -c "$APP_ROOT/nginx.conf" -g "daemon off;"
|
||||
@@ -1,31 +0,0 @@
|
||||
# soul-api Runner - Nginx 反向代理
|
||||
# 监听 9001,代理到当前活跃实例(blue=18081, green=18082)
|
||||
# 宝塔固定 proxy_pass 到 127.0.0.1:9001,无需改配置
|
||||
|
||||
worker_processes 1;
|
||||
error_log /dev/stderr warn;
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events { worker_connections 64; }
|
||||
|
||||
http {
|
||||
access_log /dev/stdout;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 9001;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:__BACKEND_PORT__;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,30 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const defaultFreeMatchLimit = 3
|
||||
const defaultFreeMatchLimit = 1
|
||||
|
||||
const completeProfileSQL = "((phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != ''))"
|
||||
|
||||
// MatchQuota 匹配次数配额(纯计算:订单 + match_records)
|
||||
// 免费次数为「终身」额度,不按自然日重置;RemainToday JSON 字段名历史遗留,语义为「当前剩余可匹配次数」。
|
||||
type MatchQuota struct {
|
||||
PurchasedTotal int64 `json:"purchasedTotal"`
|
||||
PurchasedUsed int64 `json:"purchasedUsed"`
|
||||
MatchesUsedToday int64 `json:"matchesUsedToday"`
|
||||
FreeRemainToday int64 `json:"freeRemainToday"`
|
||||
MatchesUsedToday int64 `json:"matchesUsedToday"` // 今日已匹配次数(统计用)
|
||||
FreeRemainToday int64 `json:"freeRemainToday"` // 终身免费剩余次数(字段名保留)
|
||||
PurchasedRemain int64 `json:"purchasedRemain"`
|
||||
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
|
||||
RemainToday int64 `json:"remainToday"` // 当前剩余可匹配次数(免费剩余 + 已购剩余)
|
||||
}
|
||||
|
||||
// normalizeFreeMatchLimit 产品规则:终身免费匹配仅 1 次(不按日重置);配置大于 1 时按 1 生效
|
||||
func normalizeFreeMatchLimit(n int) int {
|
||||
if n <= 0 {
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func getFreeMatchLimit(db *gorm.DB) int {
|
||||
@@ -38,35 +50,31 @@ func getFreeMatchLimit(db *gorm.DB) int {
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
if v, ok := config["freeMatchLimit"].(float64); ok && v > 0 {
|
||||
return int(v)
|
||||
return normalizeFreeMatchLimit(int(v))
|
||||
}
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
|
||||
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额
|
||||
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额(免费次数为终身额度,不按日重置)
|
||||
func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
||||
if freeLimit <= 0 {
|
||||
freeLimit = defaultFreeMatchLimit
|
||||
}
|
||||
freeLimit = normalizeFreeMatchLimit(freeLimit)
|
||||
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)
|
||||
var lifetimeMatches int64
|
||||
db.Model(&model.MatchRecord{}).Where("user_id = ?", userID).Count(&lifetimeMatches)
|
||||
|
||||
fl := int64(freeLimit)
|
||||
beyondFree := lifetimeMatches - fl
|
||||
if beyondFree < 0 {
|
||||
beyondFree = 0
|
||||
}
|
||||
freeRemain := int64(freeLimit) - freeUsed
|
||||
purchasedUsed := beyondFree
|
||||
if purchasedUsed > purchasedTotal {
|
||||
purchasedUsed = purchasedTotal
|
||||
}
|
||||
freeRemain := fl - lifetimeMatches
|
||||
if freeRemain < 0 {
|
||||
freeRemain = 0
|
||||
}
|
||||
@@ -74,14 +82,17 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
||||
if purchasedRemain < 0 {
|
||||
purchasedRemain = 0
|
||||
}
|
||||
remainToday := freeRemain + purchasedRemain
|
||||
remainTotal := freeRemain + purchasedRemain
|
||||
if remainTotal < 0 {
|
||||
remainTotal = 0
|
||||
}
|
||||
return MatchQuota{
|
||||
PurchasedTotal: purchasedTotal,
|
||||
PurchasedUsed: purchasedUsed,
|
||||
MatchesUsedToday: matchesToday,
|
||||
FreeRemainToday: freeRemain,
|
||||
PurchasedRemain: purchasedRemain,
|
||||
RemainToday: remainToday,
|
||||
RemainToday: remainTotal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +111,11 @@ func MatchConfigGet(c *gin.Context) {
|
||||
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},
|
||||
"matchTypes": defaultMatchTypes,
|
||||
"freeMatchLimit": 1,
|
||||
"matchPrice": 1,
|
||||
"matchPriceOriginal": 9.9,
|
||||
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
|
||||
},
|
||||
"source": "default",
|
||||
})
|
||||
@@ -129,14 +141,18 @@ func MatchConfigGet(c *gin.Context) {
|
||||
matchTypes = defaultMatchTypes
|
||||
}
|
||||
}
|
||||
freeMatchLimit := 3
|
||||
if v, ok := config["freeMatchLimit"].(float64); ok {
|
||||
freeMatchLimit = int(v)
|
||||
freeMatchLimit := defaultFreeMatchLimit
|
||||
if v, ok := config["freeMatchLimit"].(float64); ok && int(v) > 0 {
|
||||
freeMatchLimit = normalizeFreeMatchLimit(int(v))
|
||||
}
|
||||
matchPrice := 1
|
||||
if v, ok := config["matchPrice"].(float64); ok {
|
||||
matchPrice = int(v)
|
||||
}
|
||||
matchPriceOriginal := 9.9
|
||||
if v, ok := config["matchPriceOriginal"].(float64); ok && v > 0 {
|
||||
matchPriceOriginal = v
|
||||
}
|
||||
settings := gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10}
|
||||
if s, ok := config["settings"].(map[string]interface{}); ok {
|
||||
for k, v := range s {
|
||||
@@ -144,7 +160,11 @@ func MatchConfigGet(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"matchTypes": matchTypes, "freeMatchLimit": freeMatchLimit, "matchPrice": matchPrice, "settings": settings,
|
||||
"matchTypes": matchTypes,
|
||||
"freeMatchLimit": freeMatchLimit,
|
||||
"matchPrice": matchPrice,
|
||||
"matchPriceOriginal": matchPriceOriginal,
|
||||
"settings": settings,
|
||||
}, "source": "database"})
|
||||
}
|
||||
|
||||
@@ -188,7 +208,7 @@ func MatchUsers(c *gin.Context) {
|
||||
if quota.RemainToday <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "今日匹配次数已用完,请购买更多次数",
|
||||
"message": "免费次数已用完,请购买匹配次数后再试",
|
||||
"code": "QUOTA_EXCEEDED",
|
||||
})
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user