feat(tongji): 接入小游戏真实周榜与微信分享

This commit is contained in:
大哥大哥的大哥哥
2026-07-17 18:02:00 +08:00
parent 62467dc01c
commit 7192c4db8c
8 changed files with 1014 additions and 32 deletions
+3 -1
View File
@@ -230,7 +230,9 @@
"navigationStyle": "custom",
"navigationBarTitleText": "控糖消消乐",
"backgroundColor": "#eefbf4",
"disableScroll": false
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
]
@@ -0,0 +1,36 @@
# 控糖消消乐平台接入说明
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
- 受邀进入只记录一次轻量分享访问,不自动建立家庭或好友绑定。
## 接口
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key``learned_count``score``ended`
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`
## 部署顺序
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php``TcmController.php`
3. 重新构建并上传小程序前端。
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
## 上线前检查
- 用男女各两个测试账号进入,确认被分入对应性别组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -0,0 +1,244 @@
import { ref } from 'vue'
const EMPTY_BOARD = {
week_start: '',
week_end: '',
sex_label: '同行',
group_size: 7,
member_count: 0,
players: [],
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
invite_code: ''
}
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
function createSessionKey() {
const random = Math.random().toString(36).slice(2, 12)
return `game_${Date.now().toString(36)}_${random}`
}
function readPendingPayloads() {
try {
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
if (!Array.isArray(stored)) return []
return stored.filter(item => (
item
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
&& Number(item.learned_count) >= 0
)).slice(-8)
} catch (_) {
return []
}
}
/**
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
*/
export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
let sessionKey = createSessionKey()
let syncTimer = null
let syncing = false
let queuedPayloads = readPendingPayloads()
let connectPromise = null
function persistPendingPayloads() {
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
}
async function api(request) {
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
return proxy.apiUrl(request, false)
}
function applyLeaderboard(data, confirmedForSession = '') {
if (!data || !Array.isArray(data.players)) return
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
if (!inactiveSessionResponse) {
leaderboard.value = {
...EMPTY_BOARD,
...data,
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
players: data.players
}
}
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
}
connected.value = true
syncStatus.value = 'synced'
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET'
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data)
return res.data
}
async function acceptShare(inviteCode) {
const code = String(inviteCode || '').trim().toUpperCase()
if (!code) return
try {
await api({
url: '/api/tcm/gameAcceptShare',
method: 'POST',
data: { invite_code: code }
})
} catch (_) {
// 分享关系是辅助能力,不阻断进入游戏。
}
}
async function connect(inviteCode = '') {
if (connectPromise) return connectPromise
connectPromise = (async () => {
loading.value = true
try {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('登录失败')
await acceptShare(inviteCode)
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
return false
} finally {
loading.value = false
connectPromise = null
}
})()
return connectPromise
}
function beginSession() {
if (syncTimer) clearTimeout(syncTimer)
sessionKey = createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
learned_count: Math.max(0, Number(learnedCount) || 0),
score: Math.max(0, Number(score) || 0),
ended: ended ? 1 : 0
}
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
if (existingIndex >= 0) {
const existing = queuedPayloads[existingIndex]
queuedPayloads[existingIndex] = {
...nextPayload,
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
score: Math.max(existing.score, nextPayload.score),
ended: Math.max(existing.ended, nextPayload.ended)
}
} else {
queuedPayloads.push(nextPayload)
}
persistPendingPayloads()
syncStatus.value = 'pending'
if (syncTimer) clearTimeout(syncTimer)
if (ended) {
flushProgress()
} else {
syncTimer = setTimeout(flushProgress, 1000)
}
}
async function flushProgress() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
if (syncing || !queuedPayloads.length) return
const payload = queuedPayloads[0]
syncing = true
syncStatus.value = 'syncing'
try {
if (!connected.value) {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('未登录')
}
const res = await api({
url: '/api/tcm/gameSubmitProgress',
method: 'POST',
data: payload
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '成绩保存失败')
}
applyLeaderboard(res.data, payload.session_key)
queuedPayloads = queuedPayloads.filter(item => item.session_key !== payload.session_key)
persistPendingPayloads()
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
syncing = false
if (queuedPayloads.length && connected.value) {
setTimeout(flushProgress, 80)
}
}
}
async function syncAndRefresh(learnedCount, score) {
queueProgress(learnedCount, score, false)
await flushProgress()
if (!connected.value) {
await connect()
if (queuedPayloads.length) await flushProgress()
} else {
try { await refreshLeaderboard() } catch (_) {}
}
}
async function recordShare() {
try {
if (!connected.value && !(await connect())) return
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
if (res?.code === 1 && res.data?.invite_code) {
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
}
} catch (_) {
// 分享本身仍可进行,统计失败不影响用户。
}
}
function dispose() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
persistPendingPayloads()
}
return {
connected,
loading,
syncStatus,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
queueProgress,
flushProgress,
syncAndRefresh,
refreshLeaderboard,
recordShare,
dispose
}
}
@@ -321,6 +321,7 @@
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
@@ -334,6 +335,7 @@
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
@@ -353,6 +355,8 @@
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-weekly-share::after { border: 0; }
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
@@ -389,6 +393,8 @@
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-share-btn::after { border: 0; }
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
@keyframes egPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
+131 -31
View File
@@ -197,9 +197,9 @@
<view>
<view class="eg-weekly-title-line">
<text class="eg-weekly-title">本周7人同行榜</text>
<text class="eg-weekly-preview">界面预览</text>
<text class="eg-weekly-preview" :class="{ 'is-offline': !platformConnected }">{{ weeklyStatusLabel }}</text>
</view>
<text class="eg-weekly-sub">{{ weeklyRangeLabel }} · 每周一更新</text>
<text class="eg-weekly-sub">{{ weeklyRangeLabel }} · {{ weeklySexLabel }} · {{ weeklyMemberCount }}/7</text>
</view>
<view class="eg-weekly-medal">{{ weeklyMyRank }}</view>
</view>
@@ -225,7 +225,10 @@
:class="{ 'is-me': player.isMe }"
>
<text class="eg-weekly-place">{{ player.rank }}</text>
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">{{ player.avatar }}</view>
<view class="eg-weekly-avatar" :class="`is-tone-${player.tone}`">
<image v-if="player.avatarUrl" class="eg-weekly-avatar-image" :src="player.avatarUrl" mode="aspectFill" />
<text v-else>{{ player.avatar }}</text>
</view>
<text class="eg-weekly-name">{{ player.name }}</text>
<text v-if="player.isMe" class="eg-weekly-me"></text>
<text class="eg-weekly-count">{{ player.count }}</text>
@@ -242,7 +245,8 @@
</view>
<view class="eg-weekly-start" @tap="closeWeeklyRank">开始消除</view>
<text class="eg-weekly-footnote">当前为排行效果预览真实好友数据接入后替换</text>
<button class="eg-weekly-share" open-type="share">邀请亲友一起认糖</button>
<text class="eg-weekly-footnote">使用平台昵称参加真实周榜每周一重新分组分享不会自动绑定家庭关系</text>
</view>
</view>
@@ -284,6 +288,7 @@
<view class="eg-result-score"><text>本局得分</text><text>{{ score }}</text></view>
<view class="eg-result-best"><text>历史最高</text><text>{{ bestScore }}</text></view>
<view class="eg-primary-btn" @tap="startGame">再玩一局</view>
<button class="eg-share-btn" open-type="share">分享本局认糖成绩</button>
<view class="eg-secondary-btn" @tap="goBack">返回血糖周报</view>
</view>
</view>
@@ -292,10 +297,12 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, getCurrentInstance, ref } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline, onShow, onUnload } from '@dcloudio/uni-app'
import TongjiIcon from './components/TongjiIcon.vue'
import { useGameSfx } from './composables/useGameSfx.js'
import { useGamePlatform } from './composables/useGamePlatform.js'
import { useTongjiAuth } from '../composables/useTongjiAuth.js'
const FOOD_IMAGE_MODULES = import.meta.glob('./assets/food/*.jpg', {
eager: true,
@@ -318,20 +325,6 @@ const COMBO_PITY_TURNS = 6
const MEGA_COMBO_SCORE = 300
const DANGER_DROP_CHANCE = 0.05 + GAME_DIFFICULTY * 0.01
const COLUMN_GROUP_BIAS = 0.86 - GAME_DIFFICULTY * 0.028
const WEEKLY_PREVIEW_BASE = 208
const WEEKLY_PREVIEW_PLAYERS = [
{ id: 'li', name: '李阿姨', avatar: '李', count: 286, tone: 1 },
{ id: 'health', name: '健康每一天', avatar: '健', count: 248, tone: 2 },
{ id: 'wang', name: '王师傅', avatar: '王', count: 226, tone: 3 },
{ id: 'me', name: '我', avatar: '我', count: 0, tone: 4, isMe: true },
{ id: 'happy', name: '快乐生活', avatar: '乐', count: 191, tone: 5 },
{ id: 'chen', name: '陈叔', avatar: '陈', count: 174, tone: 6 },
{ id: 'sunny', name: '天天好心情', avatar: '晴', count: 152, tone: 7 }
]
const WEEKLY_START_RANK = WEEKLY_PREVIEW_PLAYERS
.map(player => ({ ...player, count: player.isMe ? WEEKLY_PREVIEW_BASE : player.count }))
.sort((a, b) => b.count - a.count)
.findIndex(player => player.isMe) + 1
const CHEER_MESSAGES = [
{ source: '同行鼓励', text: '你需要加油哦!再向前一点,就能超过上一名。', tone: 'warm' },
{ source: '同行鼓励', text: '你太棒了!今天也在认真认识食物。', tone: 'bright' },
@@ -404,6 +397,23 @@ const {
warmUp: warmUpSfx
} = useGameSfx()
const { proxy } = getCurrentInstance()
const { ensureLoggedIn } = useTongjiAuth(proxy)
const {
connected: platformConnected,
loading: platformLoading,
syncStatus: platformSyncStatus,
leaderboard: platformLeaderboard,
confirmedSessionLearned,
connect: connectPlatform,
beginSession: beginPlatformSession,
queueProgress: queuePlatformProgress,
flushProgress: flushPlatformProgress,
syncAndRefresh: syncAndRefreshPlatform,
recordShare: recordPlatformShare,
dispose: disposePlatform
} = useGamePlatform(proxy, ensureLoggedIn)
const board = ref([])
const selected = ref(-1)
const score = ref(0)
@@ -441,7 +451,6 @@ const reshuffleDone = ref(false)
const reshuffling = ref(false)
const confettiVisible = ref(false)
const weeklyRankVisible = ref(false)
const weeklyPreviewProgress = ref(0)
const cheerIndex = ref(0)
const CONFETTI_COLORS = ['#ff3154', '#ffd928', '#28c76f', '#4d8dff', '#a855f7', '#ff7a1a']
const confettiPieces = Array.from({ length: 34 }, (_, index) => ({
@@ -466,6 +475,7 @@ let taskRewardTimer = null
let pendingThemeMatchCount = 0
let taskRewardStatusUntil = 0
let sfxGesturePrimed = false
let incomingInviteCode = ''
const currentTheme = computed(() => FOOD_GROUPS[themeIndex.value % FOOD_GROUPS.length])
const currentTask = computed(() => {
@@ -483,13 +493,38 @@ const highCount = computed(() => board.value.filter(tile => tile && foodOf(tile.
const boardRows = computed(() => Array.from({ length: ROWS }, (_, row) => (
board.value.slice(row * COLS, row * COLS + COLS).map((tile, col) => ({ ...tile, index: row * COLS + col }))
)))
const weeklyMyCount = computed(() => WEEKLY_PREVIEW_BASE + weeklyPreviewProgress.value)
const weeklyRankRows = computed(() => WEEKLY_PREVIEW_PLAYERS
.map(player => ({ ...player, count: player.isMe ? weeklyMyCount.value : player.count }))
.sort((a, b) => b.count - a.count)
.map((player, index) => ({ ...player, rank: index + 1 })))
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 7)
const weeklyRankRise = computed(() => Math.max(0, WEEKLY_START_RANK - weeklyMyRank.value))
const pendingWeeklyLearned = computed(() => Math.max(0, learned.value - confirmedSessionLearned.value))
const weeklyMyCount = computed(() => Number(platformLeaderboard.value.me?.count || 0) + pendingWeeklyLearned.value)
const weeklyRankRows = computed(() => {
const remote = Array.isArray(platformLeaderboard.value.players)
? platformLeaderboard.value.players
: []
const userData = uni.getStorageSync('userData') || {}
const fallbackName = String(userData.nickname || '我')
const source = remote.length
? remote.map((player, index) => ({
...player,
isMe: !!(player.is_me || player.isMe),
avatarUrl: player.avatar || '',
avatar: String(player.name || '同行').slice(0, 1),
tone: index % 7 + 1,
count: player.is_me || player.isMe ? weeklyMyCount.value : Number(player.count || 0)
}))
: [{
id: 'local-me',
name: fallbackName,
avatar: fallbackName.slice(0, 1) || '我',
avatarUrl: userData.avatar || '',
count: weeklyMyCount.value,
tone: 1,
isMe: true
}]
return source
.sort((a, b) => b.count - a.count || Number(b.best_score || 0) - Number(a.best_score || 0))
.map((player, index) => ({ ...player, rank: index + 1 }))
})
const weeklyMyRank = computed(() => weeklyRankRows.value.find(player => player.isMe)?.rank || 1)
const weeklyRankRise = computed(() => 0)
const weeklyIsFirst = computed(() => weeklyMyRank.value === 1)
const weeklyDistance = computed(() => {
const mineIndex = weeklyRankRows.value.findIndex(player => player.isMe)
@@ -497,17 +532,60 @@ const weeklyDistance = computed(() => {
return Math.max(1, weeklyRankRows.value[mineIndex - 1].count - weeklyMyCount.value + 1)
})
const currentCheer = computed(() => CHEER_MESSAGES[cheerIndex.value % CHEER_MESSAGES.length])
const weeklyRangeLabel = computed(() => getWeekRangeLabel(new Date()))
const weeklyRangeLabel = computed(() => {
const start = platformLeaderboard.value.week_start
const end = platformLeaderboard.value.week_end
if (!start || !end) return getWeekRangeLabel(new Date())
return `${formatWeekDate(start)}${formatWeekDate(end)}`
})
const weeklySexLabel = computed(() => platformLeaderboard.value.sex_label || '同行')
const weeklyMemberCount = computed(() => Math.max(1, Number(platformLeaderboard.value.member_count || weeklyRankRows.value.length)))
const weeklyStatusLabel = computed(() => {
if (platformLoading.value) return '连接中'
if (!platformConnected.value) return '离线记录中'
if (platformSyncStatus.value === 'pending' || platformSyncStatus.value === 'syncing') return '保存中'
return '真实周榜'
})
onLoad(() => {
onShareAppMessage(() => {
recordPlatformShare()
return {
title: gameShareTitle(),
path: gameSharePath()
}
})
onShareTimeline(() => {
recordPlatformShare()
const inviteCode = String(platformLeaderboard.value.invite_code || '')
return {
title: gameShareTitle(),
query: inviteCode ? `from=share&invite_code=${encodeURIComponent(inviteCode)}` : 'from=share'
}
})
onLoad((options = {}) => {
const info = uni.getSystemInfoSync()
safeTop.value = info.safeAreaInsets?.top || info.statusBarHeight || 20
bestScore.value = Number(uni.getStorageSync('tongji_endless_best') || 0)
incomingInviteCode = String(options.invite_code || '').trim()
startGame()
weeklyRankVisible.value = true
connectPlatform(incomingInviteCode)
})
onShow(() => {
connectPlatform(incomingInviteCode).then((ok) => {
if (ok) flushPlatformProgress()
})
})
onUnload(() => {
if (learned.value > 0 || gameOver.value) {
queuePlatformProgress(learned.value, score.value, gameOver.value)
flushPlatformProgress()
}
disposePlatform()
if (hintTimer) clearTimeout(hintTimer)
if (jackpotTimer) clearTimeout(jackpotTimer)
if (confettiTimer) clearTimeout(confettiTimer)
@@ -534,6 +612,25 @@ function getWeekRangeLabel(now) {
return `${label(monday)}${label(sunday)}`
}
function formatWeekDate(value) {
const parts = String(value).split('-').map(Number)
if (parts.length !== 3) return String(value)
return `${parts[1]}${parts[2]}`
}
function gameShareTitle() {
if (learned.value > 0) {
return `我在控糖消消乐认识了${learned.value}份食物,一起来挑战吧`
}
return '控糖消消乐:边玩边认识常见食物'
}
function gameSharePath() {
const inviteCode = String(platformLeaderboard.value.invite_code || '')
const query = inviteCode ? `?from=share&invite_code=${encodeURIComponent(inviteCode)}` : '?from=share'
return `/tongji/endless-game/index${query}`
}
function openWeeklyRank() {
primeSfxFromGesture()
if (gameOver.value) return
@@ -542,6 +639,7 @@ function openWeeklyRank() {
return
}
weeklyRankVisible.value = true
syncAndRefreshPlatform(learned.value, score.value)
playSfx('select', .72)
}
@@ -560,6 +658,7 @@ function cycleCheer() {
function startGame() {
if (hintTimer) clearTimeout(hintTimer)
beginPlatformSession()
themeIndex.value = 0
board.value = buildPlayableBoard(openingKeysForTheme(currentTheme.value))
selected.value = -1
@@ -798,6 +897,7 @@ async function trySwap(a, b, soundPlayed = false) {
endedFood.value = foodOf(highTile.key)
saveBest()
gameOver.value = true
queuePlatformProgress(learned.value, score.value, true)
busy.value = false
return
}
@@ -872,10 +972,10 @@ async function resolveMatches(initialGroups, movedIndex) {
vibrate(largestMatch >= 5 || wave >= 3 ? 'medium' : 'light')
turnConsumed += removedNormal.length
learned.value += removedNormal.length
weeklyPreviewProgress.value += removedNormal.length
updateFoodTask(removedNormal)
const multiplier = feastMoves.value > 0 ? 2 : 1
score.value += (removedNormal.length * 12 + Math.max(0, wave - 1) * 20 + madeSpecial * 35) * multiplier
queuePlatformProgress(learned.value, score.value, false)
await wait(specialEffects.hasFire ? 330 : 210)
const next = [...board.value]
remove.forEach(index => { next[index] = null })
@@ -21,6 +21,7 @@ use app\api\logic\tcm\DailyFamilyLikeLogic;
use app\api\logic\tcm\DailyShareLogic;
use app\api\logic\tcm\DailyPhoneLogic;
use app\api\logic\tcm\DailyDietAiLogic;
use app\api\logic\tcm\GamePlatformLogic;
use app\adminapi\logic\ConfigLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\BloodRecord;
@@ -39,6 +40,54 @@ class TcmController extends BaseApiController
* @var array
*/
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
/** 获取当前登录用户的控糖消消乐每周7人同行榜。 */
public function gameWeeklyLeaderboard()
{
$result = GamePlatformLogic::leaderboard((int) $this->userId);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/** 幂等上报一局的认糖进度和分数。 */
public function gameSubmitProgress()
{
$result = GamePlatformLogic::submitProgress((int) $this->userId, [
'session_key' => (string) $this->request->post('session_key', ''),
'learned_count' => (int) $this->request->post('learned_count', 0),
'score' => (int) $this->request->post('score', 0),
'ended' => (int) $this->request->post('ended', 0),
]);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/** 记录发起分享,返回本周不包含用户ID的分享码。 */
public function gameRecordShare()
{
$result = GamePlatformLogic::recordShare((int) $this->userId);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/** 登录用户从微信分享卡片进入时,记录一次轻量同行关系。 */
public function gameAcceptShare()
{
$result = GamePlatformLogic::acceptShare(
(int) $this->userId,
(string) $this->request->post('invite_code', '')
);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/**
* @notes 获取患者签名(供小程序调用)
@@ -0,0 +1,472 @@
<?php
namespace app\api\logic\tcm;
use app\common\service\FileService;
use think\facade\Db;
use think\facade\Log;
/**
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
*/
class GamePlatformLogic
{
private const GROUP_SIZE = 7;
private const MAX_SESSION_LEARNED = 20000;
private const MAX_SCORE = 100000000;
protected static string $error = '';
public static function getError(): string
{
return self::$error;
}
protected static function fail(string $message): bool
{
self::$error = $message;
return false;
}
/**
* 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
*/
public static function leaderboard(int $userId): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::fail('请先登录');
}
try {
Db::startTrans();
$score = self::ensureWeeklyScore($userId, self::weekStart());
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
Db::commit();
return self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
} catch (\Throwable $e) {
Db::rollback();
self::logException('leaderboard', $e);
return self::fail('同行榜暂时不可用,请稍后再试');
}
}
/**
* 上报每局绝对进度。session_key + learned_count 共同保证网络重试不会重复计分。
*
* @param array{session_key:string,learned_count:int,score:int,ended:int|bool} $params
*/
public static function submitProgress(int $userId, array $params): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::fail('请先登录');
}
$sessionKey = trim((string) ($params['session_key'] ?? ''));
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
return self::fail('游戏局标识无效');
}
$learned = max(0, min(self::MAX_SESSION_LEARNED, (int) ($params['learned_count'] ?? 0)));
$scoreValue = max(0, min(self::MAX_SCORE, (int) ($params['score'] ?? 0)));
$ended = !empty($params['ended']) ? 1 : 0;
$weekStart = self::weekStart();
try {
Db::startTrans();
$weeklyScore = self::ensureWeeklyScore($userId, $weekStart);
$session = Db::name('tcm_game_session')
->where('session_key', $sessionKey)
->lock(true)
->find();
$now = time();
if ($session && (int) $session['user_id'] !== $userId) {
Db::rollback();
return self::fail('游戏局标识已被使用');
}
if (!$session) {
$sessionId = Db::name('tcm_game_session')->insertGetId([
'session_key' => $sessionKey,
'user_id' => $userId,
'week_start' => $weekStart,
'learned_count' => 0,
'last_score' => 0,
'ended' => 0,
'create_time' => $now,
'update_time' => $now,
]);
$session = [
'id' => $sessionId,
'week_start' => $weekStart,
'learned_count' => 0,
'last_score' => 0,
'ended' => 0,
];
}
$sessionWeek = (string) $session['week_start'];
if ($sessionWeek !== $weekStart) {
$weeklyScore = self::ensureWeeklyScore($userId, $sessionWeek);
}
$confirmedLearned = (int) $session['learned_count'];
$nextLearned = max($confirmedLearned, $learned);
$delta = $nextLearned - $confirmedLearned;
$wasEnded = (int) $session['ended'] === 1;
$markEnded = $wasEnded || $ended === 1;
Db::name('tcm_game_session')->where('id', (int) $session['id'])->update([
'learned_count' => $nextLearned,
'last_score' => max((int) $session['last_score'], $scoreValue),
'ended' => $markEnded ? 1 : 0,
'update_time' => $now,
]);
$weeklyLearned = (int) $weeklyScore['learned_count'] + $delta;
$weeklyBest = max((int) $weeklyScore['best_score'], $scoreValue);
$gamesPlayed = (int) $weeklyScore['games_played'] + (!$wasEnded && $ended === 1 ? 1 : 0);
Db::name('tcm_game_weekly_score')->where('id', (int) $weeklyScore['id'])->update([
'learned_count' => $weeklyLearned,
'best_score' => $weeklyBest,
'games_played' => $gamesPlayed,
'update_time' => $now,
]);
$inviteCode = self::ensureShareInvite($userId, $sessionWeek);
Db::commit();
$result = self::buildLeaderboard((int) $weeklyScore['group_id'], $userId, $inviteCode, $sessionWeek);
$result['confirmed_session_learned'] = $nextLearned;
return $result;
} catch (\Throwable $e) {
Db::rollback();
self::logException('submitProgress', $e);
return self::fail('成绩保存失败,请稍后再试');
}
}
/** 记录用户发起一次微信分享,并返回当前分享码。 */
public static function recordShare(int $userId): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::fail('请先登录');
}
try {
Db::startTrans();
$score = self::ensureWeeklyScore($userId, self::weekStart());
Db::name('tcm_game_weekly_score')->where('id', (int) $score['id'])->inc('share_count')->update([
'update_time' => time(),
]);
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
Db::commit();
return ['invite_code' => $inviteCode];
} catch (\Throwable $e) {
Db::rollback();
self::logException('recordShare', $e);
return self::fail('分享记录失败');
}
}
/**
* 记录从分享卡片进入。仅记录一次轻量同行关系,不做家庭/好友强绑定。
*/
public static function acceptShare(int $userId, string $inviteCode): array|false
{
self::$error = '';
$inviteCode = strtoupper(trim($inviteCode));
if ($userId <= 0) {
return self::fail('请先登录');
}
if (!preg_match('/^[A-F0-9]{12}$/', $inviteCode)) {
return self::fail('分享码无效');
}
try {
$invite = Db::name('tcm_game_share_invite')->where('invite_code', $inviteCode)->find();
if (!$invite) {
return self::fail('分享已失效');
}
$inviterUserId = (int) $invite['user_id'];
if ($inviterUserId === $userId) {
return ['accepted' => false, 'message' => '这是您自己的分享'];
}
Db::startTrans();
$exists = Db::name('tcm_game_share_visit')
->where('inviter_user_id', $inviterUserId)
->where('visitor_user_id', $userId)
->lock(true)
->find();
if (!$exists) {
Db::name('tcm_game_share_visit')->insert([
'invite_code' => $inviteCode,
'inviter_user_id' => $inviterUserId,
'visitor_user_id' => $userId,
'create_time' => time(),
]);
Db::name('tcm_game_share_invite')->where('id', (int) $invite['id'])->inc('open_count')->update([
'update_time' => time(),
]);
}
Db::commit();
$inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find();
return [
'accepted' => !$exists,
'message' => '已加入控糖消消乐',
'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId),
];
} catch (\Throwable $e) {
Db::rollback();
self::logException('acceptShare', $e);
return self::fail('分享关系记录失败');
}
}
private static function ensureWeeklyScore(int $userId, string $weekStart): array
{
$existing = Db::name('tcm_game_weekly_score')
->where('week_start', $weekStart)
->where('user_id', $userId)
->lock(true)
->find();
$profile = self::userProfile($userId);
$now = time();
if ($existing) {
$profileChanged = (string) $existing['nickname'] !== $profile['nickname']
|| (string) $existing['avatar'] !== $profile['avatar'];
if ($profileChanged) {
Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
'nickname' => $profile['nickname'],
'avatar' => $profile['avatar'],
'update_time'=> $now,
]);
$existing['nickname'] = $profile['nickname'];
$existing['avatar'] = $profile['avatar'];
}
return $existing;
}
$group = Db::name('tcm_game_weekly_group')
->where('week_start', $weekStart)
->where('sex', $profile['sex'])
->where('member_count', '<', self::GROUP_SIZE)
->order('group_no', 'asc')
->lock(true)
->find();
if (!$group) {
$maxGroupNo = (int) Db::name('tcm_game_weekly_group')
->where('week_start', $weekStart)
->where('sex', $profile['sex'])
->max('group_no');
$groupNo = $maxGroupNo + 1;
$groupId = Db::name('tcm_game_weekly_group')->insertGetId([
'week_start' => $weekStart,
'sex' => $profile['sex'],
'group_no' => $groupNo,
'member_count'=> 0,
'create_time' => $now,
'update_time' => $now,
]);
$group = ['id' => $groupId, 'member_count' => 0];
}
$scoreId = Db::name('tcm_game_weekly_score')->insertGetId([
'group_id' => (int) $group['id'],
'week_start' => $weekStart,
'user_id' => $userId,
'learned_count' => 0,
'best_score' => 0,
'games_played' => 0,
'share_count' => 0,
'nickname' => $profile['nickname'],
'avatar' => $profile['avatar'],
'sex' => $profile['sex'],
'create_time' => $now,
'update_time' => $now,
]);
Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([
'member_count' => min(self::GROUP_SIZE, (int) $group['member_count'] + 1),
'update_time' => $now,
]);
return [
'id' => $scoreId,
'group_id' => (int) $group['id'],
'week_start' => $weekStart,
'user_id' => $userId,
'learned_count' => 0,
'best_score' => 0,
'games_played' => 0,
'share_count' => 0,
'nickname' => $profile['nickname'],
'avatar' => $profile['avatar'],
'sex' => $profile['sex'],
];
}
private static function buildLeaderboard(
int $groupId,
int $userId,
string $inviteCode,
?string $weekStart = null
): array {
$weekStart = $weekStart ?: self::weekStart();
$rows = Db::name('tcm_game_weekly_score')
->where('group_id', $groupId)
->where('week_start', $weekStart)
->order('learned_count', 'desc')
->order('best_score', 'desc')
->order('create_time', 'asc')
->limit(self::GROUP_SIZE)
->select()
->toArray();
$players = [];
$myIndex = 0;
foreach ($rows as $index => $row) {
$isMe = (int) $row['user_id'] === $userId;
if ($isMe) {
$myIndex = $index;
}
$players[] = [
// 前端只需要稳定列表键,不暴露平台内部 user_id。
'id' => (int) $row['id'],
'name' => self::displayName((string) $row['nickname'], (int) $row['user_id']),
'avatar' => self::avatarUrl((string) $row['avatar']),
'count' => (int) $row['learned_count'],
'best_score' => (int) $row['best_score'],
'rank' => $index + 1,
'is_me' => $isMe,
];
}
$me = $players[$myIndex] ?? [
'count' => 0,
'rank' => 1,
'best_score' => 0,
];
$distance = $myIndex > 0
? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1)
: 0;
$group = Db::name('tcm_game_weekly_group')->where('id', $groupId)->find();
$sex = (int) ($group['sex'] ?? 0);
return [
'week_start' => $weekStart,
'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')),
'sex' => $sex,
'sex_label' => $sex === 1 ? '男士同行' : ($sex === 2 ? '女士同行' : '同行'),
'group_size' => self::GROUP_SIZE,
'member_count'=> count($players),
'players' => $players,
'me' => [
'count' => (int) ($me['count'] ?? 0),
'rank' => (int) ($me['rank'] ?? 1),
'best_score' => (int) ($me['best_score'] ?? 0),
'distance' => $distance,
'is_first' => $myIndex === 0,
],
'invite_code' => $inviteCode,
];
}
/** @return array{nickname:string,avatar:string,sex:int} */
private static function userProfile(int $userId): array
{
$user = Db::name('user')
->where('id', $userId)
->whereNull('delete_time')
->field('id,sn,nickname,avatar,sex')
->find();
if (!$user) {
throw new \RuntimeException('用户不存在');
}
$sex = (int) ($user['sex'] ?? 0);
if ($sex !== 1 && $sex !== 2) {
$gender = Db::name('diagnosis_view_records')->alias('v')
->join('tcm_diagnosis d', 'd.id = v.diagnosis_id')
->where('v.user_id', $userId)
->whereNull('v.delete_time')
->whereNull('d.delete_time')
->order('v.id', 'desc')
->value('d.gender');
if ($gender !== null && $gender !== '') {
$sex = (int) $gender === 1 ? 1 : 2;
}
}
return [
'nickname' => self::displayName((string) ($user['nickname'] ?? ''), $userId),
'avatar' => (string) ($user['avatar'] ?? ''),
'sex' => in_array($sex, [1, 2], true) ? $sex : 0,
];
}
private static function displayName(string $nickname, int $userId): string
{
$nickname = trim(strip_tags($nickname));
if ($nickname === '') {
return '控糖好友' . substr((string) $userId, -2);
}
return mb_substr($nickname, 0, 12);
}
private static function avatarUrl(string $avatar): string
{
return $avatar === '' ? '' : FileService::getFileUrl($avatar);
}
private static function ensureShareInvite(int $userId, string $weekStart): string
{
$existing = Db::name('tcm_game_share_invite')
->where('week_start', $weekStart)
->where('user_id', $userId)
->find();
if ($existing) {
return (string) $existing['invite_code'];
}
for ($attempt = 0; $attempt < 5; $attempt++) {
$code = strtoupper(bin2hex(random_bytes(6)));
if (Db::name('tcm_game_share_invite')->where('invite_code', $code)->find()) {
continue;
}
$now = time();
Db::name('tcm_game_share_invite')->insert([
'invite_code' => $code,
'user_id' => $userId,
'week_start' => $weekStart,
'open_count' => 0,
'create_time' => $now,
'update_time' => $now,
]);
return $code;
}
throw new \RuntimeException('分享码生成失败');
}
private static function weekStart(?int $timestamp = null): string
{
$timestamp = $timestamp ?: time();
$day = (int) date('N', $timestamp);
return date('Y-m-d', strtotime('-' . ($day - 1) . ' days', $timestamp));
}
private static function logException(string $action, \Throwable $e): void
{
Log::error(sprintf(
'tcm endless game %s failed: %s at %s:%d',
$action,
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
}
}
@@ -0,0 +1,73 @@
-- 控糖消消乐:真实用户周榜、幂等局记录与轻量分享访问关系
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_group` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`week_start` date NOT NULL COMMENT '周一日期',
`sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '同行组性别:0未知 1男 2女',
`group_no` int unsigned NOT NULL DEFAULT 1 COMMENT '同周同性别的分组序号',
`member_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '组内人数,最多7人',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_week_sex_group` (`week_start`, `sex`, `group_no`),
KEY `idx_week_sex_capacity` (`week_start`, `sex`, `member_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周7人同行分组';
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_score` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`group_id` int unsigned NOT NULL COMMENT '周同行分组ID',
`week_start` date NOT NULL COMMENT '周一日期',
`user_id` int unsigned NOT NULL COMMENT '小程序用户ID',
`learned_count` int unsigned NOT NULL DEFAULT 0 COMMENT '本周认糖数量',
`best_score` int unsigned NOT NULL DEFAULT 0 COMMENT '本周单局最高分',
`games_played` int unsigned NOT NULL DEFAULT 0 COMMENT '本周完成局数',
`share_count` int unsigned NOT NULL DEFAULT 0 COMMENT '本周发起分享次数',
`nickname` varchar(32) NOT NULL DEFAULT '' COMMENT '周榜昵称快照',
`avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '周榜头像快照',
`sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '性别快照:0未知 1男 2女',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_week_user` (`week_start`, `user_id`),
KEY `idx_group_rank` (`group_id`, `learned_count`, `best_score`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周认糖成绩';
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_session` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`session_key` varchar(64) NOT NULL COMMENT '客户端每局随机标识,用于重试去重',
`user_id` int unsigned NOT NULL COMMENT '小程序用户ID',
`week_start` date NOT NULL COMMENT '该局计入的周一日期',
`learned_count` int unsigned NOT NULL DEFAULT 0 COMMENT '本局已确认认糖数量',
`last_score` int unsigned NOT NULL DEFAULT 0 COMMENT '本局最新分数',
`ended` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '是否已经结束并计入完成局数',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_key` (`session_key`),
KEY `idx_user_week` (`user_id`, `week_start`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐局进度去重';
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_share_invite` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`invite_code` varchar(16) NOT NULL COMMENT '不包含用户ID的分享码',
`user_id` int unsigned NOT NULL COMMENT '分享人小程序用户ID',
`week_start` date NOT NULL COMMENT '分享码所属周',
`open_count` int unsigned NOT NULL DEFAULT 0 COMMENT '去重后的受邀打开人数',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_invite_code` (`invite_code`),
UNIQUE KEY `uk_week_user` (`week_start`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐微信分享码';
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_share_visit` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`invite_code` varchar(16) NOT NULL COMMENT '来源分享码',
`inviter_user_id` int unsigned NOT NULL COMMENT '分享人用户ID',
`visitor_user_id` int unsigned NOT NULL COMMENT '受邀打开用户ID',
`create_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_inviter_visitor` (`inviter_user_id`, `visitor_user_id`),
KEY `idx_visitor` (`visitor_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐分享打开记录(不做强绑定)';