perf: 支持约课以及消息推送能力
This commit is contained in:
@@ -105,13 +105,13 @@
|
|||||||
<view class="btn-outline" @tap="handleCancel">
|
<view class="btn-outline" @tap="handleCancel">
|
||||||
<text class="btn-outline-text">取消</text>
|
<text class="btn-outline-text">取消</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<button
|
||||||
class="btn-confirm"
|
class="btn-confirm"
|
||||||
:class="{ disabled: !selectedMembershipId }"
|
:class="{ disabled: !selectedMembershipId }"
|
||||||
@tap="handleConfirm"
|
@tap="handleConfirm"
|
||||||
>
|
>
|
||||||
<text class="btn-confirm-text">确认预约</text>
|
<text class="btn-confirm-text">确认预约</text>
|
||||||
</view>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
|
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -134,6 +135,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectedMembershipId = ref<string>('')
|
const selectedMembershipId = ref<string>('')
|
||||||
|
const requestingSubscribe = ref(false)
|
||||||
|
|
||||||
// Auto-select the first membership when popup opens or memberships list changes
|
// Auto-select the first membership when popup opens or memberships list changes
|
||||||
watch(
|
watch(
|
||||||
@@ -150,8 +152,22 @@ const selectedMembership = computed(() =>
|
|||||||
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleConfirm() {
|
async function handleConfirm() {
|
||||||
if (!props.timeSlot || !selectedMembershipId.value) return
|
if (!props.timeSlot || !selectedMembershipId.value) return
|
||||||
|
|
||||||
|
if (requestingSubscribe.value) return
|
||||||
|
requestingSubscribe.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestBookingCreatedSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '订阅消息授权失败'
|
||||||
|
uni.showToast({ title: message, icon: 'none' })
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
requestingSubscribe.value = false
|
||||||
|
}
|
||||||
|
|
||||||
emit('confirm', {
|
emit('confirm', {
|
||||||
timeSlotId: props.timeSlot.id,
|
timeSlotId: props.timeSlot.id,
|
||||||
membershipId: selectedMembershipId.value,
|
membershipId: selectedMembershipId.value,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
|
import { getErrorMessage } from '../utils/auth'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'scroll-to-card-shop'): void
|
(e: 'scroll-to-card-shop'): void
|
||||||
@@ -71,8 +72,8 @@ async function handleLogin() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.loginWithSetup()
|
await userStore.loginWithSetup()
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ import type {
|
|||||||
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { isSlotPast } from '../../utils/format'
|
import { isSlotPast } from '../../utils/format'
|
||||||
import {
|
import {
|
||||||
@@ -357,8 +358,8 @@ async function handleSlotBook() {
|
|||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
handleSlotBook()
|
handleSlotBook()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pila
|
|||||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
@@ -208,8 +209,8 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
onBookTap(slot)
|
onBookTap(slot)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -182,10 +182,12 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { get, post } from '../../utils/request'
|
import { get, post } from '../../utils/request'
|
||||||
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
|
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -287,8 +289,8 @@ async function handleBuy() {
|
|||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
handleBuy()
|
handleBuy()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -334,6 +336,7 @@ async function doPurchase() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Payment succeeded — refresh memberships then navigate
|
// Payment succeeded — refresh memberships then navigate
|
||||||
|
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
||||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||||||
await userStore.fetchMemberships()
|
await userStore.fetchMemberships()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -208,12 +208,14 @@ import {
|
|||||||
FlashSaleOrderStatus,
|
FlashSaleOrderStatus,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import type { FlashSaleDetail } from '@mp-pilates/shared'
|
import type { FlashSaleDetail } from '@mp-pilates/shared'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../../utils/format'
|
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { useFlashSaleStore } from '../../stores/flash-sale'
|
import { useFlashSaleStore } from '../../stores/flash-sale'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { post } from '../../utils/request'
|
import { post } from '../../utils/request'
|
||||||
|
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const flashSaleStore = useFlashSaleStore()
|
const flashSaleStore = useFlashSaleStore()
|
||||||
@@ -343,8 +345,8 @@ async function handleAction() {
|
|||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
await loadDetail() // refresh participation status
|
await loadDetail() // refresh participation status
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -395,6 +397,7 @@ async function doPurchase() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
||||||
uni.showToast({ title: '抢购成功!', icon: 'success' })
|
uni.showToast({ title: '抢购成功!', icon: 'success' })
|
||||||
await userStore.fetchMemberships()
|
await userStore.fetchMemberships()
|
||||||
await loadDetail() // refresh status
|
await loadDetail() // refresh status
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import UserCard from '../../components/UserCard.vue'
|
import UserCard from '../../components/UserCard.vue'
|
||||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
@@ -72,8 +73,8 @@ async function handleLogin() {
|
|||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
await userStore.fetchStats()
|
await userStore.fetchStats()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loginLoading.value = false
|
loginLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
|||||||
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
||||||
import { get, put } from '../utils/request'
|
import { get, put } from '../utils/request'
|
||||||
import { ROUTES } from '../utils/routes'
|
import { ROUTES } from '../utils/routes'
|
||||||
|
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// State
|
// State
|
||||||
@@ -59,6 +60,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return
|
||||||
try {
|
try {
|
||||||
user.value = await get<UserProfileResponse>('/user/profile')
|
user.value = await get<UserProfileResponse>('/user/profile')
|
||||||
|
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch profile failed:', err)
|
console.error('Fetch profile failed:', err)
|
||||||
}
|
}
|
||||||
@@ -97,6 +99,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
authLogout()
|
authLogout()
|
||||||
|
resetSubscriptionMessageTemplateCache()
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
stats.value = null
|
stats.value = null
|
||||||
|
|||||||
@@ -7,13 +7,66 @@ interface LoginResponse {
|
|||||||
readonly isNewUser: boolean
|
readonly isNewUser: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UniErrorLike {
|
||||||
|
readonly errMsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WechatPrivacyApi {
|
||||||
|
requirePrivacyAuthorize?: (options: {
|
||||||
|
success: () => void
|
||||||
|
fail: (err: UniErrorLike) => void
|
||||||
|
}) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrivacyError(err?: UniErrorLike): Error {
|
||||||
|
const errMsg = err?.errMsg || ''
|
||||||
|
|
||||||
|
if (errMsg.includes('cancel') || errMsg.includes('deny') || errMsg.includes('disagree')) {
|
||||||
|
return new Error('请先同意隐私保护指引')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error('隐私授权失败,请重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePrivacyAuthorization(): Promise<void> {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const wechat = (globalThis as typeof globalThis & { wx?: WechatPrivacyApi }).wx
|
||||||
|
if (!wechat || typeof wechat.requirePrivacyAuthorize !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirePrivacyAuthorize = wechat.requirePrivacyAuthorize
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
requirePrivacyAuthorize({
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (err: UniErrorLike) => reject(buildPrivacyError(err)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err instanceof Error && err.message) {
|
||||||
|
return err.message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
export async function wxLogin(): Promise<LoginResponse> {
|
export async function wxLogin(): Promise<LoginResponse> {
|
||||||
|
await ensurePrivacyAuthorization()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Step 1:静默登录,获取 code
|
// Step 1:静默登录,获取 code
|
||||||
uni.login({
|
uni.login({
|
||||||
provider: 'weixin',
|
provider: 'weixin',
|
||||||
success: async (loginRes) => {
|
success: async (loginRes) => {
|
||||||
try {
|
try {
|
||||||
|
if (!loginRes.code) {
|
||||||
|
reject(new Error('微信登录失败,请重试'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: 发送登录请求
|
// Step 2: 发送登录请求
|
||||||
// 注:uni.getUserProfile 已被微信废弃(基础库 2.27.1+),
|
// 注:uni.getUserProfile 已被微信废弃(基础库 2.27.1+),
|
||||||
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
||||||
|
|||||||
188
packages/app/src/utils/wechat-subscription.ts
Normal file
188
packages/app/src/utils/wechat-subscription.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
SubscriptionMessageScene,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import type {
|
||||||
|
ReportSubscriptionMessageRequestDto,
|
||||||
|
SubscriptionMessageRequestItem,
|
||||||
|
SubscriptionMessageTemplate,
|
||||||
|
SubscriptionMessageTemplateConfig,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { post } from './request'
|
||||||
|
|
||||||
|
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
||||||
|
|
||||||
|
type RequestSubscribeMessageSuccess = Record<string, TemplateResult | undefined> & {
|
||||||
|
errMsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestSubscribeMessageFail {
|
||||||
|
errMsg?: string
|
||||||
|
errCode?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyDebugPayload(payload: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload)
|
||||||
|
} catch {
|
||||||
|
return String(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubscribeDebugContext() {
|
||||||
|
try {
|
||||||
|
const systemInfo = uni.getSystemInfoSync()
|
||||||
|
const host = systemInfo.host as { env?: string } | string | undefined
|
||||||
|
return {
|
||||||
|
platform: systemInfo.platform,
|
||||||
|
hostEnv: typeof host === 'object' && host ? host.env : undefined,
|
||||||
|
app: systemInfo.appName,
|
||||||
|
system: systemInfo.system,
|
||||||
|
language: systemInfo.language,
|
||||||
|
version: systemInfo.version,
|
||||||
|
SDKVersion: systemInfo.SDKVersion,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
platform: 'unknown',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubscribeError(err: RequestSubscribeMessageFail, scene: SubscriptionMessageScene, templateIds: string[]): Error {
|
||||||
|
const debugContext = getSubscribeDebugContext()
|
||||||
|
const rawMessage = (err.errMsg || '').trim()
|
||||||
|
|
||||||
|
if (!rawMessage && debugContext.platform === 'devtools') {
|
||||||
|
return new Error(
|
||||||
|
`开发者工具当前环境不支持订阅消息唤起。请退出游客模式,使用已登录的微信开发者工具并在真机中重试。调试信息: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(
|
||||||
|
`订阅消息授权失败: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_CONFIG_STORAGE_KEY = 'subscriptionMessageTemplateConfig'
|
||||||
|
|
||||||
|
let cachedConfig: SubscriptionMessageTemplateConfig | null = null
|
||||||
|
|
||||||
|
function isMpWeixin(): boolean {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(result?: TemplateResult): SubscriptionMessageRequestItem['result'] | null {
|
||||||
|
if (result === 'accept' || result === 'reject' || result === 'ban' || result === 'filter') {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
|
||||||
|
if (!stored || !Array.isArray(stored.templates)) {
|
||||||
|
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: SubscriptionMessageTemplateConfig = {
|
||||||
|
templates: stored.templates.filter((item) => item.templateId),
|
||||||
|
}
|
||||||
|
cachedConfig = config
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheSubscriptionMessageTemplateConfig(config: SubscriptionMessageTemplateConfig): SubscriptionMessageTemplateConfig {
|
||||||
|
const normalized: SubscriptionMessageTemplateConfig = {
|
||||||
|
templates: config.templates.filter((item) => item.templateId),
|
||||||
|
}
|
||||||
|
cachedConfig = normalized
|
||||||
|
uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplatesByScene(
|
||||||
|
config: SubscriptionMessageTemplateConfig,
|
||||||
|
scene: SubscriptionMessageScene,
|
||||||
|
): SubscriptionMessageTemplate[] {
|
||||||
|
return config.templates.filter((item) => item.scene === scene && item.templateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportResults(requests: SubscriptionMessageRequestItem[]): Promise<void> {
|
||||||
|
if (requests.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ReportSubscriptionMessageRequestDto = { requests }
|
||||||
|
await post('/user/subscription-messages/report', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
if (!isMpWeixin()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await fetchTemplateConfig()
|
||||||
|
const templates = getTemplatesByScene(config, scene)
|
||||||
|
if (templates.length === 0) {
|
||||||
|
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateIds = templates.map((item) => item.templateId)
|
||||||
|
const debugContext = getSubscribeDebugContext()
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
|
||||||
|
|
||||||
|
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
|
||||||
|
uni.requestSubscribeMessage({
|
||||||
|
tmplIds: templateIds,
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response: res, templateIds, debugContext }))
|
||||||
|
resolve(res as RequestSubscribeMessageSuccess)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
|
||||||
|
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const requests = templates
|
||||||
|
.map<SubscriptionMessageRequestItem | null>((item) => {
|
||||||
|
const normalized = normalizeResult(result[item.templateId])
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateId: item.templateId,
|
||||||
|
scene: item.scene,
|
||||||
|
result: normalized,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
||||||
|
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result, requests, templateIds, debugContext }))
|
||||||
|
|
||||||
|
await reportResults(requests)
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOrderPaidSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSubscriptionMessageTemplateCache(): void {
|
||||||
|
cachedConfig = null
|
||||||
|
uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY)
|
||||||
|
}
|
||||||
@@ -19,3 +19,5 @@ API_BASE_URL=https://focus.richarjiang.com/
|
|||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
||||||
@@ -52,9 +52,9 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
"ts",
|
||||||
"js",
|
"js",
|
||||||
"json",
|
"json"
|
||||||
"ts"
|
|
||||||
],
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
"^@mp-pilates/shared$": "<rootDir>/../../shared/src/index.ts",
|
||||||
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
|
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,10 +80,36 @@ model User {
|
|||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
flashSaleOrders FlashSaleOrder[]
|
flashSaleOrders FlashSaleOrder[]
|
||||||
|
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SubscriptionMessageConsent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
templateId String @map("template_id")
|
||||||
|
scene String
|
||||||
|
totalRequestCount Int @default(0) @map("total_request_count")
|
||||||
|
acceptCount Int @default(0) @map("accept_count")
|
||||||
|
rejectCount Int @default(0) @map("reject_count")
|
||||||
|
banCount Int @default(0) @map("ban_count")
|
||||||
|
filterCount Int @default(0) @map("filter_count")
|
||||||
|
sentCount Int @default(0) @map("sent_count")
|
||||||
|
lastResult String @map("last_result")
|
||||||
|
lastRequestedAt DateTime @default(now()) @map("last_requested_at")
|
||||||
|
lastSentAt DateTime? @map("last_sent_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@unique([userId, templateId, scene])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([scene])
|
||||||
|
@@map("subscription_message_consents")
|
||||||
|
}
|
||||||
|
|
||||||
model CardType {
|
model CardType {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { BookingService } from '../booking.service'
|
|||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
import { MembershipService } from '../../membership/membership.service'
|
import { MembershipService } from '../../membership/membership.service'
|
||||||
import { StudioService } from '../../studio/studio.service'
|
import { StudioService } from '../../studio/studio.service'
|
||||||
|
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
||||||
|
|
||||||
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -138,6 +139,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
|||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
bookingStatusHistory: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +152,7 @@ describe('BookingService', () => {
|
|||||||
let service: BookingService
|
let service: BookingService
|
||||||
let prisma: jest.Mocked<PrismaService>
|
let prisma: jest.Mocked<PrismaService>
|
||||||
let studioService: jest.Mocked<StudioService>
|
let studioService: jest.Mocked<StudioService>
|
||||||
|
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock }
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -172,6 +177,9 @@ describe('BookingService', () => {
|
|||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -188,35 +196,91 @@ describe('BookingService', () => {
|
|||||||
getInfo: jest.fn(),
|
getInfo: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: SubscriptionMessageService,
|
||||||
|
useValue: {
|
||||||
|
sendBookingConfirmedMessage: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
service = module.get<BookingService>(BookingService)
|
service = module.get<BookingService>(BookingService)
|
||||||
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
||||||
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
||||||
|
subscriptionMessageService = module.get(SubscriptionMessageService)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks())
|
afterEach(() => jest.clearAllMocks())
|
||||||
|
|
||||||
|
describe('confirmBooking', () => {
|
||||||
|
it('sends booking confirmed subscription message after admin confirmation', async () => {
|
||||||
|
const tx = buildTxMock({
|
||||||
|
bookingStatusHistory: { create: jest.fn() },
|
||||||
|
})
|
||||||
|
tx.booking.findUnique.mockResolvedValue({
|
||||||
|
...mockConfirmedBooking,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
timeSlot: mockOpenSlot,
|
||||||
|
membership: mockActiveMembership,
|
||||||
|
})
|
||||||
|
tx.booking.update.mockResolvedValue({
|
||||||
|
...mockConfirmedBooking,
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
confirmedAt: new Date('2099-12-30T00:00:00Z'),
|
||||||
|
})
|
||||||
|
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1, status: TimeSlotStatus.OPEN })
|
||||||
|
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
||||||
|
|
||||||
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
|
...mockConfirmedBooking,
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
confirmedAt: new Date('2099-12-30T00:00:00Z'),
|
||||||
|
timeSlot: mockOpenSlot,
|
||||||
|
membership: mockActiveMembership,
|
||||||
|
})
|
||||||
|
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||||
|
studioService.getInfo.mockResolvedValue({
|
||||||
|
...mockStudioConfig,
|
||||||
|
name: 'FocusCore Pilates',
|
||||||
|
})
|
||||||
|
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||||
|
|
||||||
|
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||||
|
|
||||||
|
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalledWith({
|
||||||
|
openid: 'openid-001',
|
||||||
|
bookingId: MOCK_BOOKING_ID,
|
||||||
|
bookingContent: '预约已确认',
|
||||||
|
bookingTime: '2099-12-31 09:00',
|
||||||
|
courseName: 'FocusCore Pilates',
|
||||||
|
bookingPeriod: '2099-12-31 09:00~10:00',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ─── createBooking ────────────────────────────────────────────────────────
|
// ─── createBooking ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('createBooking', () => {
|
describe('createBooking', () => {
|
||||||
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
|
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
|
||||||
|
|
||||||
it('creates booking, increments bookedCount, and deducts membership (TIMES card)', async () => {
|
it('creates booking in pending confirmation status', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null) // no duplicate
|
tx.booking.findFirst.mockResolvedValue(null) // no duplicate
|
||||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue({
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
...mockConfirmedBooking,
|
||||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
})
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
|
|
||||||
// Mock the re-fetch after transaction
|
// Mock the re-fetch after transaction
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
...mockConfirmedBooking,
|
...mockConfirmedBooking,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
timeSlot: mockOpenSlot,
|
timeSlot: mockOpenSlot,
|
||||||
membership: mockActiveMembership,
|
membership: mockActiveMembership,
|
||||||
})
|
})
|
||||||
@@ -229,55 +293,45 @@ describe('BookingService', () => {
|
|||||||
userId: MOCK_USER_ID,
|
userId: MOCK_USER_ID,
|
||||||
timeSlotId: MOCK_SLOT_ID,
|
timeSlotId: MOCK_SLOT_ID,
|
||||||
membershipId: MOCK_MEMBERSHIP_ID,
|
membershipId: MOCK_MEMBERSHIP_ID,
|
||||||
status: BookingStatus.CONFIRMED,
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// bookedCount incremented from 0 → 1, still OPEN (capacity 5)
|
expect(tx.timeSlot.update).not.toHaveBeenCalled()
|
||||||
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({ bookedCount: 1, status: TimeSlotStatus.OPEN }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// membership deducted from 5 → 4
|
|
||||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
remainingTimes: 4,
|
|
||||||
status: MembershipStatus.ACTIVE,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toBeDefined()
|
expect(result).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets slot to FULL when bookedCount reaches capacity', async () => {
|
it('records booking status history when user creates a booking', async () => {
|
||||||
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
|
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
|
||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue({
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL })
|
...mockConfirmedBooking,
|
||||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
})
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
...mockConfirmedBooking,
|
...mockConfirmedBooking,
|
||||||
timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL },
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
timeSlot: nearFullSlot,
|
||||||
membership: mockActiveMembership,
|
membership: mockActiveMembership,
|
||||||
})
|
})
|
||||||
|
|
||||||
await service.createBooking(MOCK_USER_ID, dto)
|
await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
// bookedCount 4+1 = 5 = capacity → FULL
|
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
|
||||||
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: expect.objectContaining({ bookedCount: 5, status: TimeSlotStatus.FULL }),
|
data: expect.objectContaining({
|
||||||
|
toStatus: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
operatorId: MOCK_USER_ID,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -306,34 +360,29 @@ describe('BookingService', () => {
|
|||||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks membership as USED_UP when remainingTimes hits 0', async () => {
|
it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => {
|
||||||
const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 }
|
|
||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(lastTimeMembership)
|
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue({
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
...mockConfirmedBooking,
|
||||||
tx.membership.update.mockResolvedValue({ ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP })
|
membershipId: mockMembershipNoTimes.id,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
})
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
...mockConfirmedBooking,
|
...mockConfirmedBooking,
|
||||||
|
membershipId: mockMembershipNoTimes.id,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
timeSlot: mockOpenSlot,
|
timeSlot: mockOpenSlot,
|
||||||
membership: { ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP },
|
membership: mockMembershipNoTimes,
|
||||||
})
|
})
|
||||||
|
|
||||||
await service.createBooking(MOCK_USER_ID, dto)
|
await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
remainingTimes: 0,
|
|
||||||
status: MembershipStatus.USED_UP,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws BadRequestException when slot is FULL', async () => {
|
it('throws BadRequestException when slot is FULL', async () => {
|
||||||
@@ -374,20 +423,6 @@ describe('BookingService', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws BadRequestException when TIMES membership has 0 remaining', async () => {
|
|
||||||
const tx = buildTxMock()
|
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
|
||||||
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
|
||||||
|
|
||||||
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
|
|
||||||
BadRequestException,
|
|
||||||
)
|
|
||||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws NotFoundException when timeSlot does not exist', async () => {
|
it('throws NotFoundException when timeSlot does not exist', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(null)
|
tx.timeSlot.findUnique.mockResolvedValue(null)
|
||||||
@@ -662,7 +697,7 @@ describe('BookingService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
userId: MOCK_USER_ID,
|
userId: MOCK_USER_ID,
|
||||||
status: BookingStatus.CONFIRMED,
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
}),
|
}),
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ timeSlot: { date: 'asc' } },
|
{ timeSlot: { date: 'asc' } },
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { BookingController } from './booking.controller'
|
|||||||
import { BookingService } from './booking.service'
|
import { BookingService } from './booking.service'
|
||||||
import { MembershipModule } from '../membership/membership.module'
|
import { MembershipModule } from '../membership/membership.module'
|
||||||
import { StudioModule } from '../studio/studio.module'
|
import { StudioModule } from '../studio/studio.module'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MembershipModule, StudioModule],
|
imports: [MembershipModule, StudioModule, UserModule],
|
||||||
controllers: [BookingController],
|
controllers: [BookingController],
|
||||||
providers: [BookingService],
|
providers: [BookingService],
|
||||||
exports: [BookingService],
|
exports: [BookingService],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } fro
|
|||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { MembershipService } from '../membership/membership.service'
|
import { MembershipService } from '../membership/membership.service'
|
||||||
import { StudioService } from '../studio/studio.service'
|
import { StudioService } from '../studio/studio.service'
|
||||||
|
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||||
@@ -48,6 +49,7 @@ export class BookingService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly membershipService: MembershipService,
|
private readonly membershipService: MembershipService,
|
||||||
private readonly studioService: StudioService,
|
private readonly studioService: StudioService,
|
||||||
|
private readonly subscriptionMessageService: SubscriptionMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Create Booking ──────────────────────────────────────────────────────
|
// ─── Create Booking ──────────────────────────────────────────────────────
|
||||||
@@ -235,7 +237,9 @@ export class BookingService {
|
|||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
return this.fetchBookingWithRelations(booking.id)
|
const confirmedBooking = await this.fetchBookingWithRelations(booking.id)
|
||||||
|
await this.trySendBookingConfirmedSubscriptionMessage(confirmedBooking)
|
||||||
|
return confirmedBooking
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||||
@@ -566,4 +570,34 @@ export class BookingService {
|
|||||||
|
|
||||||
return { ...booking } as BookingWithRelations
|
return { ...booking } as BookingWithRelations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async trySendBookingConfirmedSubscriptionMessage(
|
||||||
|
booking: BookingWithRelations,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: booking.userId },
|
||||||
|
select: { openid: true },
|
||||||
|
})
|
||||||
|
if (!user?.openid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const studio = await this.studioService.getInfo()
|
||||||
|
const bookingDate = booking.timeSlot.date
|
||||||
|
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
|
||||||
|
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
|
||||||
|
|
||||||
|
await this.subscriptionMessageService.sendBookingConfirmedMessage({
|
||||||
|
openid: user.openid,
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingContent: '预约已确认',
|
||||||
|
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
|
||||||
|
courseName: studio.name || '普拉提课程',
|
||||||
|
bookingPeriod: `${dateLabel} ${periodLabel}`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send booking confirmed subscription message failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Test, TestingModule } from '@nestjs/testing'
|
|||||||
import { NotFoundException } from '@nestjs/common'
|
import { NotFoundException } from '@nestjs/common'
|
||||||
import { UserService } from '../user.service'
|
import { UserService } from '../user.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
|
import {
|
||||||
|
MembershipStatus,
|
||||||
|
BookingStatus,
|
||||||
|
UserRole,
|
||||||
|
SubscriptionMessageScene,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -48,11 +54,22 @@ const mockPrisma = {
|
|||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
subscriptionMessageConsent: {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
booking: {
|
booking: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue = '') => {
|
||||||
|
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed'
|
||||||
|
return defaultValue
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -65,6 +82,7 @@ describe('UserService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
UserService,
|
UserService,
|
||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
@@ -101,6 +119,15 @@ describe('UserService', () => {
|
|||||||
avatarUrl: 'https://example.com/avatar.png',
|
avatarUrl: 'https://example.com/avatar.png',
|
||||||
role: UserRole.MEMBER,
|
role: UserRole.MEMBER,
|
||||||
activeMembershipCount: 3,
|
activeMembershipCount: 3,
|
||||||
|
subscriptionMessageTemplates: {
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
createdAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
createdAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -112,6 +139,89 @@ describe('UserService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reportSubscriptionMessageRequests', () => {
|
||||||
|
it('aggregates and returns subscription consent stats', async () => {
|
||||||
|
mockPrisma.subscriptionMessageConsent.upsert.mockResolvedValue(undefined)
|
||||||
|
mockPrisma.subscriptionMessageConsent.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
totalRequestCount: 2,
|
||||||
|
acceptCount: 1,
|
||||||
|
rejectCount: 1,
|
||||||
|
banCount: 0,
|
||||||
|
filterCount: 0,
|
||||||
|
sentCount: 0,
|
||||||
|
lastResult: 'reject',
|
||||||
|
lastRequestedAt: new Date('2024-01-03T00:00:00Z'),
|
||||||
|
lastSentAt: null,
|
||||||
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2024-01-03T00:00:00Z'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await service.reportSubscriptionMessageRequests('user-1', [
|
||||||
|
{
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
result: 'reject',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(mockPrisma.subscriptionMessageConsent.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
userId_templateId_scene: {
|
||||||
|
userId: 'user-1',
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: 'user-1',
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
totalRequestCount: 1,
|
||||||
|
acceptCount: 0,
|
||||||
|
rejectCount: 1,
|
||||||
|
banCount: 0,
|
||||||
|
filterCount: 0,
|
||||||
|
sentCount: 0,
|
||||||
|
lastResult: 'reject',
|
||||||
|
lastRequestedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
totalRequestCount: { increment: 1 },
|
||||||
|
acceptCount: { increment: 0 },
|
||||||
|
rejectCount: { increment: 1 },
|
||||||
|
banCount: { increment: 0 },
|
||||||
|
filterCount: { increment: 0 },
|
||||||
|
lastResult: 'reject',
|
||||||
|
lastRequestedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
totalRequestCount: 2,
|
||||||
|
acceptCount: 1,
|
||||||
|
rejectCount: 1,
|
||||||
|
banCount: 0,
|
||||||
|
filterCount: 0,
|
||||||
|
sentCount: 0,
|
||||||
|
lastResult: 'reject',
|
||||||
|
lastRequestedAt: '2024-01-03T00:00:00.000Z',
|
||||||
|
lastSentAt: null,
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-03T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// updateProfile
|
// updateProfile
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -145,6 +255,7 @@ describe('UserService', () => {
|
|||||||
expect(result.nickname).toBe('Bob')
|
expect(result.nickname).toBe('Bob')
|
||||||
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
||||||
expect(result.activeMembershipCount).toBe(1)
|
expect(result.activeMembershipCount).toBe(1)
|
||||||
|
expect(result.subscriptionMessageTemplates.templates).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('only includes provided fields in the update payload', async () => {
|
it('only includes provided fields in the update payload', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator'
|
||||||
|
import { Type } from 'class-transformer'
|
||||||
|
import {
|
||||||
|
SubscriptionMessageScene,
|
||||||
|
SUBSCRIPTION_MESSAGE_REQUEST_RESULTS,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
export class SubscriptionMessageRequestItemDto {
|
||||||
|
@IsString()
|
||||||
|
readonly templateId!: string
|
||||||
|
|
||||||
|
@IsEnum(SubscriptionMessageScene)
|
||||||
|
readonly scene!: SubscriptionMessageScene
|
||||||
|
|
||||||
|
@IsIn(SUBSCRIPTION_MESSAGE_REQUEST_RESULTS)
|
||||||
|
readonly result!: (typeof SUBSCRIPTION_MESSAGE_REQUEST_RESULTS)[number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportSubscriptionMessageRequestDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(10)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SubscriptionMessageRequestItemDto)
|
||||||
|
readonly requests!: SubscriptionMessageRequestItemDto[]
|
||||||
|
}
|
||||||
168
packages/server/src/user/subscription-message.service.ts
Normal file
168
packages/server/src/user/subscription-message.service.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
interface BookingConfirmedTemplatePayload {
|
||||||
|
readonly openid: string
|
||||||
|
readonly bookingId: string
|
||||||
|
readonly bookingContent: string
|
||||||
|
readonly bookingTime: string
|
||||||
|
readonly courseName: string
|
||||||
|
readonly bookingPeriod: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WechatAccessTokenResponse {
|
||||||
|
access_token?: string
|
||||||
|
expires_in?: number
|
||||||
|
errcode?: number
|
||||||
|
errmsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WechatSubscribeSendResponse {
|
||||||
|
errcode?: number
|
||||||
|
errmsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyDebugPayload(payload: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload)
|
||||||
|
} catch {
|
||||||
|
return String(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SubscriptionMessageService {
|
||||||
|
private readonly logger = new Logger(SubscriptionMessageService.name)
|
||||||
|
private accessTokenCache: { token: string; expireAt: number } | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getBookingConfirmedTemplateId(): string {
|
||||||
|
return this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
||||||
|
const templateId = this.getBookingConfirmedTemplateId()
|
||||||
|
if (!templateId) {
|
||||||
|
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED is not configured, skip sending subscription message')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const consent = await this.prisma.subscriptionMessageConsent.findFirst({
|
||||||
|
where: {
|
||||||
|
user: { openid: payload.openid },
|
||||||
|
templateId,
|
||||||
|
scene: 'BOOKING_CREATED',
|
||||||
|
acceptCount: { gt: 0 },
|
||||||
|
totalRequestCount: { gt: 0 },
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ lastRequestedAt: 'desc' },
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!consent) {
|
||||||
|
this.logger.warn(`No subscription quota found: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId, scene: 'BOOKING_CREATED' })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consent.sentCount >= consent.acceptCount) {
|
||||||
|
this.logger.warn(`Subscription quota exhausted: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await this.getAccessToken()
|
||||||
|
const page = `/pages/booking/detail?id=${payload.bookingId}`
|
||||||
|
const requestBody = {
|
||||||
|
touser: payload.openid,
|
||||||
|
template_id: templateId,
|
||||||
|
page,
|
||||||
|
data: {
|
||||||
|
thing1: { value: payload.bookingContent.slice(0, 20) },
|
||||||
|
time2: { value: payload.bookingTime.slice(0, 20) },
|
||||||
|
thing25: { value: payload.courseName.slice(0, 20) },
|
||||||
|
time35: { value: payload.bookingPeriod.slice(0, 20) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, requestBody, consentId: consent.id })}`)
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text()
|
||||||
|
this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, bookingId: payload.bookingId, templateId, requestBody })}`)
|
||||||
|
throw new InternalServerErrorException('调用微信订阅消息接口失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as WechatSubscribeSendResponse
|
||||||
|
if (result.errcode && result.errcode !== 0) {
|
||||||
|
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, requestBody, response: result, consentId: consent.id })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, response: result, consentId: consent.id })}`)
|
||||||
|
|
||||||
|
await this.prisma.subscriptionMessageConsent.update({
|
||||||
|
where: { id: consent.id },
|
||||||
|
data: {
|
||||||
|
sentCount: { increment: 1 },
|
||||||
|
lastSentAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessToken(): Promise<string> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.accessTokenCache && this.accessTokenCache.expireAt > now) {
|
||||||
|
return this.accessTokenCache.token
|
||||||
|
}
|
||||||
|
|
||||||
|
const appId = this.configService.getOrThrow<string>('WX_APPID')
|
||||||
|
const secret = this.configService.getOrThrow<string>('WX_SECRET')
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${secret}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text()
|
||||||
|
this.logger.error(`WeChat access_token http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText })}`)
|
||||||
|
throw new InternalServerErrorException('获取微信 access_token 失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as WechatAccessTokenResponse
|
||||||
|
if (!data.access_token || !data.expires_in) {
|
||||||
|
this.logger.error(`WeChat access_token invalid response: ${stringifyDebugPayload(data)}`)
|
||||||
|
throw new InternalServerErrorException(data.errmsg || '微信 access_token 返回异常')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`WeChat access_token refreshed: ${stringifyDebugPayload({ expiresIn: data.expires_in })}`)
|
||||||
|
|
||||||
|
this.accessTokenCache = {
|
||||||
|
token: data.access_token,
|
||||||
|
expireAt: now + Math.max(data.expires_in - 300, 60) * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.access_token
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
import { UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
@@ -16,6 +17,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
|
|||||||
import { UserService } from './user.service'
|
import { UserService } from './user.service'
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto'
|
import { UpdateProfileDto } from './dto/update-profile.dto'
|
||||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||||
|
import { ReportSubscriptionMessageRequestDto } from './dto/report-subscription-message.dto'
|
||||||
|
|
||||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||||
|
|
||||||
@@ -42,6 +44,19 @@ export class UserController {
|
|||||||
return this.userService.getStats(userId)
|
return this.userService.getStats(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('user/subscription-messages/templates')
|
||||||
|
getSubscriptionMessageTemplates() {
|
||||||
|
return this.userService.getSubscriptionMessageTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('user/subscription-messages/report')
|
||||||
|
reportSubscriptionMessageRequests(
|
||||||
|
@CurrentUser('sub') userId: string,
|
||||||
|
@Body() dto: ReportSubscriptionMessageRequestDto,
|
||||||
|
) {
|
||||||
|
return this.userService.reportSubscriptionMessageRequests(userId, dto.requests)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Admin: Member Management ─────────────────────────────────────────────
|
// ─── Admin: Member Management ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('admin/members')
|
@Get('admin/members')
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
|
import { ConfigModule } from '@nestjs/config'
|
||||||
import { AuthModule } from '../auth/auth.module'
|
import { AuthModule } from '../auth/auth.module'
|
||||||
import { UserController } from './user.controller'
|
import { UserController } from './user.controller'
|
||||||
import { UserService } from './user.service'
|
import { UserService } from './user.service'
|
||||||
|
import { SubscriptionMessageService } from './subscription-message.service'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, ConfigModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService, SubscriptionMessageService],
|
||||||
exports: [UserService],
|
exports: [UserService, SubscriptionMessageService],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
|||||||
@@ -1,14 +1,47 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common'
|
import { Injectable, NotFoundException } from '@nestjs/common'
|
||||||
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
import {
|
||||||
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
MembershipStatus,
|
||||||
|
BookingStatus,
|
||||||
|
UserRole,
|
||||||
|
CardTypeCategory,
|
||||||
|
SubscriptionMessageScene,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import type {
|
||||||
|
PaginatedData,
|
||||||
|
UserProfileResponse,
|
||||||
|
UserStatsResponse,
|
||||||
|
SubscriptionMessageConsentSummary,
|
||||||
|
SubscriptionMessageRequestItem,
|
||||||
|
SubscriptionMessageRequestResult,
|
||||||
|
SubscriptionMessageTemplateConfig,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||||
|
|
||||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||||
|
|
||||||
|
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
|
||||||
|
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||||
|
return {
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||||
|
},
|
||||||
|
].filter((item) => item.templateId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getProfile(userId: string): Promise<UserProfileResponse> {
|
async getProfile(userId: string): Promise<UserProfileResponse> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
@@ -35,6 +68,7 @@ export class UserService {
|
|||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
role: user.role as UserRole,
|
role: user.role as UserRole,
|
||||||
activeMembershipCount: user._count.memberships,
|
activeMembershipCount: user._count.memberships,
|
||||||
|
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,10 +101,86 @@ export class UserService {
|
|||||||
avatarUrl: updated.avatarUrl,
|
avatarUrl: updated.avatarUrl,
|
||||||
role: updated.role as UserRole,
|
role: updated.role as UserRole,
|
||||||
activeMembershipCount: updated._count.memberships,
|
activeMembershipCount: updated._count.memberships,
|
||||||
|
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||||
createdAt: updated.createdAt.toISOString(),
|
createdAt: updated.createdAt.toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSubscriptionMessageTemplates(): SubscriptionMessageTemplateConfig {
|
||||||
|
return this.buildSubscriptionTemplateConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportSubscriptionMessageRequests(
|
||||||
|
userId: string,
|
||||||
|
requests: readonly SubscriptionMessageRequestItem[],
|
||||||
|
): Promise<SubscriptionMessageConsentSummary[]> {
|
||||||
|
if (requests.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
requests.map((item) => this.prisma.subscriptionMessageConsent.upsert({
|
||||||
|
where: {
|
||||||
|
userId_templateId_scene: {
|
||||||
|
userId,
|
||||||
|
templateId: item.templateId,
|
||||||
|
scene: item.scene,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
templateId: item.templateId,
|
||||||
|
scene: item.scene,
|
||||||
|
totalRequestCount: 1,
|
||||||
|
acceptCount: item.result === 'accept' ? 1 : 0,
|
||||||
|
rejectCount: item.result === 'reject' ? 1 : 0,
|
||||||
|
banCount: item.result === 'ban' ? 1 : 0,
|
||||||
|
filterCount: item.result === 'filter' ? 1 : 0,
|
||||||
|
sentCount: 0,
|
||||||
|
lastResult: item.result,
|
||||||
|
lastRequestedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
totalRequestCount: { increment: 1 },
|
||||||
|
acceptCount: { increment: item.result === 'accept' ? 1 : 0 },
|
||||||
|
rejectCount: { increment: item.result === 'reject' ? 1 : 0 },
|
||||||
|
banCount: { increment: item.result === 'ban' ? 1 : 0 },
|
||||||
|
filterCount: { increment: item.result === 'filter' ? 1 : 0 },
|
||||||
|
lastResult: item.result,
|
||||||
|
lastRequestedAt: new Date(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const summaries = await this.prisma.subscriptionMessageConsent.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
OR: requests.map((item) => ({
|
||||||
|
templateId: item.templateId,
|
||||||
|
scene: item.scene,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return summaries.map((item: SubscriptionMessageConsentRecord) => ({
|
||||||
|
userId: item.userId,
|
||||||
|
templateId: item.templateId,
|
||||||
|
scene: item.scene as SubscriptionMessageScene,
|
||||||
|
totalRequestCount: item.totalRequestCount,
|
||||||
|
acceptCount: item.acceptCount,
|
||||||
|
rejectCount: item.rejectCount,
|
||||||
|
banCount: item.banCount,
|
||||||
|
filterCount: item.filterCount,
|
||||||
|
sentCount: item.sentCount,
|
||||||
|
lastResult: item.lastResult as SubscriptionMessageRequestResult,
|
||||||
|
lastRequestedAt: item.lastRequestedAt.toISOString(),
|
||||||
|
lastSentAt: item.lastSentAt?.toISOString() ?? null,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async getStats(userId: string): Promise<UserStatsResponse> {
|
async getStats(userId: string): Promise<UserStatsResponse> {
|
||||||
const completedBookings = await this.prisma.booking.findMany({
|
const completedBookings = await this.prisma.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ export const DATE_SELECTOR_DAYS = 7
|
|||||||
|
|
||||||
/** 星期映射 */
|
/** 星期映射 */
|
||||||
export const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'] as const
|
export const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'] as const
|
||||||
|
|
||||||
|
/** 微信订阅消息请求结果 */
|
||||||
|
export const SUBSCRIPTION_MESSAGE_REQUEST_RESULTS = ['accept', 'reject', 'ban', 'filter'] as const
|
||||||
|
|||||||
@@ -58,3 +58,9 @@ export enum FlashSaleOrderStatus {
|
|||||||
PAID = 'PAID',
|
PAID = 'PAID',
|
||||||
EXPIRED = 'EXPIRED',
|
EXPIRED = 'EXPIRED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Subscribe Message =====
|
||||||
|
export enum SubscriptionMessageScene {
|
||||||
|
ORDER_PAID = 'ORDER_PAID',
|
||||||
|
BOOKING_CREATED = 'BOOKING_CREATED',
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
OrderStatus,
|
OrderStatus,
|
||||||
FlashSaleStatus,
|
FlashSaleStatus,
|
||||||
FlashSaleOrderStatus,
|
FlashSaleOrderStatus,
|
||||||
|
SubscriptionMessageScene,
|
||||||
} from './enums'
|
} from './enums'
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
@@ -19,6 +20,7 @@ export {
|
|||||||
TIME_PERIODS,
|
TIME_PERIODS,
|
||||||
DATE_SELECTOR_DAYS,
|
DATE_SELECTOR_DAYS,
|
||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
|
SUBSCRIPTION_MESSAGE_REQUEST_RESULTS,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -63,6 +65,12 @@ export type {
|
|||||||
CreateFlashSaleDto,
|
CreateFlashSaleDto,
|
||||||
UpdateFlashSaleDto,
|
UpdateFlashSaleDto,
|
||||||
FlashSalePurchaseResponse,
|
FlashSalePurchaseResponse,
|
||||||
|
SubscriptionMessageRequestResult,
|
||||||
|
SubscriptionMessageRequestItem,
|
||||||
|
SubscriptionMessageTemplate,
|
||||||
|
SubscriptionMessageTemplateConfig,
|
||||||
|
ReportSubscriptionMessageRequestDto,
|
||||||
|
SubscriptionMessageConsentSummary,
|
||||||
} from './types/index'
|
} from './types/index'
|
||||||
|
|
||||||
export { FlashSalePhase } from './types/index'
|
export { FlashSalePhase } from './types/index'
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
export type { User, UserProfileResponse, UpdateProfileDto, UserStatsResponse } from './user'
|
export type { User, UserProfileResponse, UpdateProfileDto, UserStatsResponse } from './user'
|
||||||
|
export type {
|
||||||
|
SubscriptionMessageRequestResult,
|
||||||
|
SubscriptionMessageRequestItem,
|
||||||
|
SubscriptionMessageTemplate,
|
||||||
|
SubscriptionMessageTemplateConfig,
|
||||||
|
ReportSubscriptionMessageRequestDto,
|
||||||
|
SubscriptionMessageConsentSummary,
|
||||||
|
} from './subscription'
|
||||||
export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type'
|
export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type'
|
||||||
export type { Membership, MembershipWithCardType } from './membership'
|
export type { Membership, MembershipWithCardType } from './membership'
|
||||||
export type { WeekTemplate, WeekTemplateInput } from './week-template'
|
export type { WeekTemplate, WeekTemplateInput } from './week-template'
|
||||||
|
|||||||
41
packages/shared/src/types/subscription.ts
Normal file
41
packages/shared/src/types/subscription.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { SubscriptionMessageScene } from '../enums'
|
||||||
|
import type { SUBSCRIPTION_MESSAGE_REQUEST_RESULTS } from '../constants'
|
||||||
|
|
||||||
|
export type SubscriptionMessageRequestResult = typeof SUBSCRIPTION_MESSAGE_REQUEST_RESULTS[number]
|
||||||
|
|
||||||
|
export interface SubscriptionMessageRequestItem {
|
||||||
|
readonly templateId: string
|
||||||
|
readonly scene: SubscriptionMessageScene
|
||||||
|
readonly result: SubscriptionMessageRequestResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionMessageTemplate {
|
||||||
|
readonly templateId: string
|
||||||
|
readonly scene: SubscriptionMessageScene
|
||||||
|
readonly description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionMessageTemplateConfig {
|
||||||
|
readonly templates: SubscriptionMessageTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSubscriptionMessageRequestDto {
|
||||||
|
readonly requests: SubscriptionMessageRequestItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionMessageConsentSummary {
|
||||||
|
readonly userId: string
|
||||||
|
readonly templateId: string
|
||||||
|
readonly scene: SubscriptionMessageScene
|
||||||
|
readonly totalRequestCount: number
|
||||||
|
readonly acceptCount: number
|
||||||
|
readonly rejectCount: number
|
||||||
|
readonly banCount: number
|
||||||
|
readonly filterCount: number
|
||||||
|
readonly sentCount: number
|
||||||
|
readonly lastResult: SubscriptionMessageRequestResult
|
||||||
|
readonly lastRequestedAt: string
|
||||||
|
readonly lastSentAt: string | null
|
||||||
|
readonly createdAt: string
|
||||||
|
readonly updatedAt: string
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { UserRole } from '../enums'
|
import { UserRole } from '../enums'
|
||||||
|
import type { SubscriptionMessageTemplateConfig } from './subscription'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
@@ -19,6 +20,7 @@ export interface UserProfileResponse {
|
|||||||
readonly avatarUrl: string | null
|
readonly avatarUrl: string | null
|
||||||
readonly role: UserRole
|
readonly role: UserRole
|
||||||
readonly activeMembershipCount: number
|
readonly activeMembershipCount: number
|
||||||
|
readonly subscriptionMessageTemplates: SubscriptionMessageTemplateConfig
|
||||||
readonly createdAt: string
|
readonly createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user