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:
Alex-larget
2026-04-01 17:09:45 +08:00
parent bdd0795434
commit 360bba697b
26 changed files with 374 additions and 781 deletions

View File

@@ -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 || '创建订单失败')
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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接口不下发明文 */

View File

@@ -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 AppIDAppSecret 使
# API mpKey AppIDAppSecret 使
</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 {

View File

@@ -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.25deploy.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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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.25alpine 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` 业务镜像;直接使用你导入的本地镜像运行。

View File

@@ -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 无需修改"

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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)"

View File

@@ -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 ""

View File

@@ -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)"

View File

@@ -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;"

View File

@@ -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;
}
}
}

View File

@@ -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