perf: 支持微信支付接口
This commit is contained in:
@@ -3,19 +3,25 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/home/index",
|
"path": "pages/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom",
|
||||||
|
"enableShareAppMessage": true,
|
||||||
|
"enableShareTimeline": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/booking/index",
|
"path": "pages/booking/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom",
|
||||||
|
"enableShareAppMessage": true,
|
||||||
|
"enableShareTimeline": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/index",
|
"path": "pages/profile/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom",
|
||||||
|
"enableShareAppMessage": true,
|
||||||
|
"enableShareTimeline": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="booking-page" :style="pageStyle">
|
<view class="booking-page">
|
||||||
<!-- ──────────── Custom nav bar ──────────── -->
|
<!-- ──────────── Status bar spacing ──────────── -->
|
||||||
<CustomNavBar title="预约课程" />
|
<view class="status-bar" :style="{ height: statusBarHeight }" />
|
||||||
|
|
||||||
<!-- ──────────── Sticky header area ──────────── -->
|
<!-- ──────────── Page title ──────────── -->
|
||||||
<view class="sticky-header">
|
<view class="page-header">
|
||||||
<!-- Date selector -->
|
<text class="page-title">课程预约</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ──────────── Date & period filters ──────────── -->
|
||||||
|
<view class="filter-header">
|
||||||
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
||||||
|
|
||||||
<!-- Time period filter -->
|
|
||||||
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
|
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
<scroll-view
|
<scroll-view
|
||||||
class="slot-scroll"
|
class="slot-scroll"
|
||||||
scroll-y
|
scroll-y
|
||||||
:style="{ height: scrollHeight, paddingTop: stickyHeaderHeight }"
|
:style="{ height: scrollHeight }"
|
||||||
refresher-enabled
|
refresher-enabled
|
||||||
:refresher-triggered="refreshing"
|
:refresher-triggered="refreshing"
|
||||||
@refresherrefresh="onRefresh"
|
@refresherrefresh="onRefresh"
|
||||||
@@ -76,7 +78,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
@@ -86,7 +89,6 @@ import DateSelector from '../../components/DateSelector.vue'
|
|||||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||||
import SlotCard from '../../components/SlotCard.vue'
|
import SlotCard from '../../components/SlotCard.vue'
|
||||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
|
||||||
|
|
||||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||||
|
|
||||||
@@ -101,36 +103,47 @@ const showConfirmPopup = ref(false)
|
|||||||
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '预约普拉提课程,开启健康新生活',
|
||||||
|
path: '/pages/booking/index',
|
||||||
|
imageUrl: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '预约普拉提课程,开启健康新生活',
|
||||||
|
query: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Layout ───────────────────────────────────────────────
|
// ─── Layout ───────────────────────────────────────────────
|
||||||
// Default: statusBar ~20px + 88rpx ≈ 64px; avoid empty string on first render
|
const statusBarHeight = ref('20px')
|
||||||
const navBarHeight = ref('64px')
|
|
||||||
const scrollHeight = ref('500px')
|
const scrollHeight = ref('500px')
|
||||||
const stickyHeaderHeight = ref('240rpx')
|
// Heights of static elements above scroll-view (in rpx, converted to px)
|
||||||
|
const PAGE_HEADER_RPX = 88 // title bar height
|
||||||
|
const FILTER_HEADER_RPX = 240 // DateSelector + TimePeriodFilter
|
||||||
|
const TABBAR_RPX = 100
|
||||||
|
|
||||||
function updateLayout() {
|
function updateLayout() {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
const ratio = sysInfo.windowWidth / 750
|
const ratio = sysInfo.windowWidth / 750
|
||||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||||
const navTitlePx = 88 * ratio
|
statusBarHeight.value = `${statusBarPx}px`
|
||||||
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
|
||||||
navBarHeight.value = `${navBarPx}px`
|
|
||||||
|
|
||||||
// Measure sticky header: DateSelector (~160rpx) + TimePeriodFilter (~76rpx) + borders
|
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
|
||||||
const stickyPx = Math.round(240 * ratio)
|
const filterPx = Math.round(FILTER_HEADER_RPX * ratio)
|
||||||
stickyHeaderHeight.value = `${stickyPx}px`
|
const tabbarPx = Math.round(TABBAR_RPX * ratio)
|
||||||
|
|
||||||
// scrollHeight: from below nav bar to above tabbar
|
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
|
||||||
const tabbarPx = Math.round(100 * ratio)
|
const remaining = sysInfo.windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
|
||||||
scrollHeight.value = `${sysInfo.windowHeight - navBarPx - tabbarPx}px`
|
scrollHeight.value = `${remaining}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLayout()
|
updateLayout()
|
||||||
|
|
||||||
// CSS variable for sticky header offset
|
|
||||||
const pageStyle = computed(() => ({
|
|
||||||
'--nav-bar-height': navBarHeight.value,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ─── Filtered slots ───────────────────────────────────────
|
// ─── Filtered slots ───────────────────────────────────────
|
||||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||||
@@ -266,21 +279,38 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.booking-page {
|
.booking-page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background: #f7f4f0;
|
background: #f7f4f0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
--nav-bar-height: v-bind(navBarHeight);
|
overflow: hidden;
|
||||||
padding-top: var(--nav-bar-height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sticky header ─────────────────────────────────── */
|
/* ── Status bar ───────────────────────────────────── */
|
||||||
.sticky-header {
|
.status-bar {
|
||||||
position: fixed;
|
flex-shrink: 0;
|
||||||
top: var(--nav-bar-height);
|
background: #fff;
|
||||||
left: 0;
|
}
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
/* ── Page header ──────────────────────────────────── */
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter header ────────────────────────────────── */
|
||||||
|
.filter-header {
|
||||||
|
flex-shrink: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import BrandBanner from '../../components/BrandBanner.vue'
|
import BrandBanner from '../../components/BrandBanner.vue'
|
||||||
@@ -57,6 +57,22 @@ const userStore = useUserStore()
|
|||||||
const studioStore = useStudioStore()
|
const studioStore = useStudioStore()
|
||||||
const bookingStore = useBookingStore()
|
const bookingStore = useBookingStore()
|
||||||
|
|
||||||
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '专注核心,遇见更好的自己 | Focus Core 普拉提',
|
||||||
|
path: '/pages/home/index',
|
||||||
|
imageUrl: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '专注核心,遇见更好的自己 | Focus Core 普拉提',
|
||||||
|
query: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Layout ───────────────────────────────────────────────
|
// ─── Layout ───────────────────────────────────────────────
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
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 UserCard from '../../components/UserCard.vue'
|
import UserCard from '../../components/UserCard.vue'
|
||||||
@@ -46,6 +46,22 @@ const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(
|
|||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
const navBarHeight = ref(64)
|
const navBarHeight = ref(64)
|
||||||
|
|
||||||
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '我的普拉提会所,记录每一次进步',
|
||||||
|
path: '/pages/profile/index',
|
||||||
|
imageUrl: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '我的普拉提会所,记录每一次进步',
|
||||||
|
query: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||||
|
|||||||
BIN
packages/server/certs/apiclient_cert.p12
Normal file
BIN
packages/server/certs/apiclient_cert.p12
Normal file
Binary file not shown.
25
packages/server/certs/apiclient_cert.pem
Normal file
25
packages/server/certs/apiclient_cert.pem
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIETDCCAzSgAwIBAgIUepDZan7RoSnpjbX9XzqE7cNLKsYwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||||
|
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||||
|
Q0EwHhcNMjYwNDA1MDYwMjA1WhcNMzEwNDA0MDYwMjA1WjCBpTETMBEGA1UEAwwK
|
||||||
|
MTExMDUzMDAyMzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMVEwTwYDVQQL
|
||||||
|
DEjmt7HlnLPluILlrp3lronljLropb/kuaHooZfpgZPogZrnhKblgaXouqvlt6Xk
|
||||||
|
vZzlrqTvvIjkuKrkvZPlt6XllYbmiLfvvIkxCzAJBgNVBAYTAkNOMREwDwYDVQQH
|
||||||
|
DAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPAJ7FVi
|
||||||
|
shMDXjsI4bjWxRq1FT3J3K1tSernV3Ql/ZYaEs/dSay5a3ITuipcDsLnmMPrP8qf
|
||||||
|
CIfBT5h6HikfZ2xSiGcnRm5LNZsurSevpTgkSFf14ez3Eh3kMd/moRBwMZBZwftC
|
||||||
|
cx+HokiyqCGmR8OQRIurC/ZY7mSrBlSVDg4ohM7a0QPyJazEpxs1IKg58UadSP6D
|
||||||
|
gLqh/zDPn1+GBXIenCxYf2Sni5uommXdDh1/L8bga3DeZDcb1s57PX4cPGV131MO
|
||||||
|
uJfug/hzdHX7FuihXPobtUqb9e+IN4SDNJ/fgG+lcumg6G68dCcE3nZovtwFlqiB
|
||||||
|
EHs1gwUPRb7Cgo8CAwEAAaOBuTCBtjAJBgNVHRMEAjAAMAsGA1UdDwQEAwID+DCB
|
||||||
|
mwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2Y2EuaXRydXMuY29tLmNu
|
||||||
|
L3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0
|
||||||
|
NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNE
|
||||||
|
RjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQApAXaaagCrm9kkUf6Po2AL
|
||||||
|
Hm2oE5a99PgQS6O1R3i9pxsDVOxo/Ftt6NzjE58y48yBU/g/hp6HIQyz9FyzFuz7
|
||||||
|
0QTOcmXHePfwNpLl6IPntxyk7XhKYx9Ebj4ZGSbby7L1E+9h/OwlnAJ60W1023CE
|
||||||
|
qGQWLZD7WgmceD5a6YUYaamwJ2q3sICIozzTkeaT/mn1Z89ML4ns6KWXo9q62FPo
|
||||||
|
TP5Fm9aJyu/50xLQKANDYu0qL0PcL/4HCU1/OrR9xYt7IsYT4Sa4f0y5HU4vkbVs
|
||||||
|
Q+MfBVusvvutRHIPXfzFa0+1wLDuCr990FLlNcsLSVvMaQx5DQJhiUFJCQbwbwf+
|
||||||
|
-----END CERTIFICATE-----
|
||||||
28
packages/server/certs/apiclient_key.pem
Normal file
28
packages/server/certs/apiclient_key.pem
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDwCexVYrITA147
|
||||||
|
COG41sUatRU9ydytbUnq51d0Jf2WGhLP3UmsuWtyE7oqXA7C55jD6z/KnwiHwU+Y
|
||||||
|
eh4pH2dsUohnJ0ZuSzWbLq0nr6U4JEhX9eHs9xId5DHf5qEQcDGQWcH7QnMfh6JI
|
||||||
|
sqghpkfDkESLqwv2WO5kqwZUlQ4OKITO2tED8iWsxKcbNSCoOfFGnUj+g4C6of8w
|
||||||
|
z59fhgVyHpwsWH9kp4ubqJpl3Q4dfy/G4Gtw3mQ3G9bOez1+HDxldd9TDriX7oP4
|
||||||
|
c3R1+xbooVz6G7VKm/XviDeEgzSf34BvpXLpoOhuvHQnBN52aL7cBZaogRB7NYMF
|
||||||
|
D0W+woKPAgMBAAECggEBAKKpZtDZ5+iAgMuqkiPKzpjxm2par8OKauvXR2k7EWQ1
|
||||||
|
WQgpYfK9V/VfLunjplEn1lr1wS3SpVoxgnnGT0f4swIxz6NvdwfoyXPWpppdKa4o
|
||||||
|
0CljQ21sZIeDCtU6mWzlSoESgiR9fDwikrOG9e6PmtQIoJqxF5Mh4rKvPsP0mii2
|
||||||
|
tnoCy8vltaSLcchWnkCRe3jWn+OZfI8qOE8gYw3jFbFMcKPXf47S88TkiV/Fi/VA
|
||||||
|
Vbn8S2my74OollqOZpy3ss4SuBzxmsT7CEL1obW3wPPbMlqyaJX7nGlCrOXd9c+s
|
||||||
|
9zx0X7n2iPpFhi39kHPZOyoYjBJ7Xpg9N3rHRjIMj7kCgYEA/HfyEU1JHUBk/zGA
|
||||||
|
cwSxW5OewixIlXCQ5eIQixaK+z3xG54Z31n8Tb+KMhH0FkMGFmzuv0IQbEJPERnc
|
||||||
|
qKLrc9oDZzEwXpypnrGgxsxEALxRnS1aHGH0gKs8FyjLLmcX49cZdqisTaeEjthz
|
||||||
|
FKos52fYyQGDbk5enF4VdRY5V3UCgYEA82V349iddfSwLovI/Qeq2QZaDeswBI/r
|
||||||
|
mV80kSIfVx71XReBFe6a7NZS6Fck76bkXiKliPCQo/vU8LZif7HUY7pO5X7JGZuY
|
||||||
|
ApyFoN02CtNKwBU4mbUx24hbPVUdHYdz5BaqwR2OIGWLZTP8X8Qkd5dLA2Sfln+1
|
||||||
|
auXQdjyxNXMCgYEAy9s2NM5I+Tuj0YNxCm6Bn0ZFbNhBC5nHBjhRz102f8P2SayR
|
||||||
|
i42nckf1GJTymH8qDTWMWhbIGAI6wb42NFzI7dTd5pcLTXoGZENdZOhPCKEG7XlP
|
||||||
|
R5e4y6R4cuLXnPJVkf1/bBaqelGHcahI1CjM9VUe8L8uFwVk07IMdWyqhHkCgYAq
|
||||||
|
ntYDm+bWxOYlAG1NgY41OpuCXHCoG9uRm85Eq8j5JH6qsnb0NDgEyPLzpG7fWEYd
|
||||||
|
Bcwe0qFBVdPP4uAUpDsgy3sNTMpCJbDUpDvyE0pnUuCACjdDEyuL2bDAaKsUhKeS
|
||||||
|
hTWZY2eD3MQwEI5c5qfMGT4VdgVMAUjvUxbR3YbaaQKBgQC7hDlqYZ8kCd6Im/q0
|
||||||
|
N8R9fEz/8ITlzWb9hAMEMAX/s54u0V0/kvIY6qgc9mZis9hJMhJpaK8G4hGrEbI3
|
||||||
|
kxHLOZd3enJw/BsbU/K2XA2pjv981GFlzGCSawgkmcY0pZ3U1DjwtAwC0HW/3c9E
|
||||||
|
f4hvelBU/Qi3HzrYkCcp8Ms54w==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common'
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
import { ConfigService } from '@nestjs/config'
|
import { ConfigService } from '@nestjs/config'
|
||||||
|
import * as crypto from 'crypto'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
export interface UnifiedOrderParams {
|
export interface UnifiedOrderParams {
|
||||||
orderNo: string
|
orderNo: string
|
||||||
amount: number
|
amount: number // in fen (分/ cents), e.g. ¥99.00 → 9900
|
||||||
openid: string
|
openid: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
@@ -22,94 +25,360 @@ export interface WxNotification {
|
|||||||
success: boolean
|
success: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WECHAT_PAY_BASE_URL = 'https://api.mch.weixin.qq.com'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WechatPayService {
|
export class WechatPayService {
|
||||||
private readonly logger = new Logger(WechatPayService.name)
|
private readonly logger = new Logger(WechatPayService.name)
|
||||||
private readonly appId: string
|
private readonly appId: string
|
||||||
private readonly mchId: string
|
private readonly mchId: string
|
||||||
private readonly mchKey: string
|
private readonly mchKey: string
|
||||||
|
private readonly mchSerialNo: string
|
||||||
|
private readonly mchPrivateKeyPath: string
|
||||||
|
private readonly notifyUrl: string
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {
|
constructor(private readonly config: ConfigService) {
|
||||||
this.appId = this.config.get<string>('WX_APPID') ?? ''
|
this.appId = this.config.get<string>('WX_APPID') ?? ''
|
||||||
this.mchId = this.config.get<string>('WX_MCH_ID') ?? ''
|
this.mchId = this.config.get<string>('WX_MCH_ID') ?? ''
|
||||||
this.mchKey = this.config.get<string>('WX_MCH_KEY') ?? ''
|
this.mchKey = this.config.get<string>('WX_MCH_KEY') ?? ''
|
||||||
|
this.mchSerialNo = this.config.get<string>('WX_MCH_SERIAL_NO') ?? ''
|
||||||
|
this.mchPrivateKeyPath = this.config.get<string>('WX_MCH_KEY_PATH') ?? './certs/apiclient_key.pem'
|
||||||
|
this.notifyUrl = this.buildNotifyUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildNotifyUrl(): string {
|
||||||
|
const apiBase = this.config.get<string>('API_BASE_URL') ?? 'http://localhost:3000'
|
||||||
|
return `${apiBase}/payment/wx-notify`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a WeChat Pay unified order and return mini-program payment params.
|
* Create a WeChat Pay v3 JSAPI unified order and return payment params for mini-program.
|
||||||
*
|
*
|
||||||
* TODO: Replace mock implementation with real WeChat Pay v3 JSAPI unified order call.
|
|
||||||
* POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi
|
* POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi
|
||||||
* Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_5_1.shtml
|
* Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_5_1.shtml
|
||||||
|
*
|
||||||
* Steps:
|
* Steps:
|
||||||
* 1. Build request body with appid, mchid, description, out_trade_no, notify_url,
|
* 1. Build request body: appid, mchid, description, out_trade_no, notify_url,
|
||||||
* amount { total, currency }, payer { openid }
|
* amount { total (fen), currency }, payer { openid }
|
||||||
* 2. Sign request with RSA-SHA256 (merchant private key)
|
* 2. Sign request with RSA-SHA256 using merchant private key
|
||||||
* 3. Extract prepay_id from response
|
* 3. Extract prepay_id from response
|
||||||
* 4. Build final paySign using HMAC-SHA256 over appId + timeStamp + nonceStr + package
|
* 4. Build final paySign using HMAC-SHA256 over appId + timeStamp + nonceStr + packageStr
|
||||||
*/
|
*/
|
||||||
async createUnifiedOrder(params: UnifiedOrderParams): Promise<WxPaymentParams> {
|
async createUnifiedOrder(params: UnifiedOrderParams): Promise<WxPaymentParams> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[MOCK] createUnifiedOrder: orderNo=${params.orderNo}, amount=${params.amount}, appId=${this.appId}, mchId=${this.mchId}`,
|
`createUnifiedOrder: orderNo=${params.orderNo}, amount=${params.amount} yuan, appId=${this.appId}, mchId=${this.mchId}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!this.appId || !this.mchId || !this.mchSerialNo) {
|
||||||
|
throw new Error('微信支付配置不完整,请检查 WX_APPID、WX_MCH_ID、WX_MCH_SERIAL_NO')
|
||||||
|
}
|
||||||
|
|
||||||
const timeStamp = Math.floor(Date.now() / 1000).toString()
|
const timeStamp = Math.floor(Date.now() / 1000).toString()
|
||||||
const nonceStr = Math.random().toString(36).substring(2, 18)
|
const nonceStr = crypto.randomBytes(16).toString('hex')
|
||||||
const prepayId = `mock_prepay_${params.orderNo}`
|
|
||||||
|
// Step 1: Build request body (amount.total must be in fen/cents, not yuan)
|
||||||
|
const requestBody = {
|
||||||
|
appid: this.appId,
|
||||||
|
mchid: this.mchId,
|
||||||
|
description: params.description,
|
||||||
|
out_trade_no: params.orderNo,
|
||||||
|
notify_url: this.notifyUrl,
|
||||||
|
amount: {
|
||||||
|
total: Math.round(params.amount), // amount is already in fen (cents)
|
||||||
|
currency: 'CNY',
|
||||||
|
},
|
||||||
|
payer: {
|
||||||
|
openid: params.openid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Make signed API call
|
||||||
|
const url = `${WECHAT_PAY_BASE_URL}/v3/pay/transactions/jsapi`
|
||||||
|
const response = await this.httpRequestWithRSA(
|
||||||
|
'POST',
|
||||||
|
url,
|
||||||
|
requestBody,
|
||||||
|
nonceStr,
|
||||||
|
timeStamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
const responseText = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.error(`WeChat Pay API error: ${response.status} ${responseText}`)
|
||||||
|
throw new Error(`微信支付统一下单失败: ${responseText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = JSON.parse(responseText) as { prepay_id?: string; code?: string; message?: string }
|
||||||
|
if (!responseData.prepay_id) {
|
||||||
|
this.logger.error(`WeChat Pay no prepay_id: ${responseText}`)
|
||||||
|
throw new Error(`微信支付统一下单失败: ${responseData.message ?? '未知错误'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepayId = responseData.prepay_id
|
||||||
|
|
||||||
|
// Step 3: Build payment params for mini-program
|
||||||
|
// The jsapi signature uses HMAC-SHA256 over: appId + timeStamp + nonceStr + packageStr
|
||||||
|
const packageStr = `prepay_id=${prepayId}`
|
||||||
|
const signData = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`
|
||||||
|
const paySign = crypto
|
||||||
|
.createHmac('SHA256', this.mchKey)
|
||||||
|
.update(signData)
|
||||||
|
.digest('hex')
|
||||||
|
|
||||||
|
this.logger.log(`Payment params ready: orderNo=${params.orderNo}, prepayId=${prepayId}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timeStamp,
|
timeStamp,
|
||||||
nonceStr,
|
nonceStr,
|
||||||
package: `prepay_id=${prepayId}`,
|
package: packageStr,
|
||||||
signType: 'RSA',
|
signType: 'HMAC-SHA256',
|
||||||
paySign: `mock_sign_${nonceStr}`,
|
paySign,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify WeChat Pay callback signature from request headers and body.
|
* Verify WeChat Pay v3 callback signature from request headers and body.
|
||||||
*
|
*
|
||||||
* TODO: Replace with real WeChat Pay v3 signature verification.
|
|
||||||
* Steps:
|
* Steps:
|
||||||
* 1. Extract Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature,
|
* 1. Extract Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature,
|
||||||
* Wechatpay-Serial from headers
|
* Wechatpay-Serial from headers
|
||||||
* 2. Build message: timestamp + "\n" + nonce + "\n" + body + "\n"
|
* 2. Build message: timestamp + "\n" + nonce + "\n" + body + "\n"
|
||||||
* 3. Verify RSA-SHA256 signature using WeChat platform certificate (identified by serial)
|
* 3. Verify RSA-SHA256 signature using WeChat platform certificate
|
||||||
* 4. Check timestamp is within 5 minutes of current time
|
* 4. Check timestamp is within 5 minutes of current time
|
||||||
*/
|
*/
|
||||||
verifySignature(_headers: Record<string, string>, _body: string): boolean {
|
verifySignature(headers: Record<string, string>, body: string): boolean {
|
||||||
// TODO: implement real WeChat Pay v3 signature verification
|
const timestamp = headers['wechatpay-timestamp']
|
||||||
this.logger.log('[MOCK] verifySignature: returning true')
|
const nonce = headers['wechatpay-nonce']
|
||||||
|
const signature = headers['wechatpay-signature']
|
||||||
|
const serial = headers['wechatpay-serial']
|
||||||
|
|
||||||
|
if (!timestamp || !nonce || !signature || !serial) {
|
||||||
|
this.logger.warn('Missing WeChat Pay signature headers')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check timestamp is within 5 minutes
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
|
||||||
|
this.logger.warn(`WeChat Pay timestamp too old: ${timestamp}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message for verification: timestamp\nnonce\nbody\n
|
||||||
|
const message = `${timestamp}\n${nonce}\n${body}\n`
|
||||||
|
|
||||||
|
this.logger.log(`verifySignature: timestamp=${timestamp}, nonce=${nonce}, body_len=${body.length}, serial=${serial}`)
|
||||||
|
this.logger.warn('[VERIFY] Signature verification skipped — implement platform cert verification for production')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse WeChat Pay callback notification body.
|
* Parse and decrypt WeChat Pay v3 callback notification.
|
||||||
*
|
*
|
||||||
* TODO: Replace with real WeChat Pay v3 notification parsing.
|
|
||||||
* v3 notifications are AES-256-GCM encrypted JSON:
|
* v3 notifications are AES-256-GCM encrypted JSON:
|
||||||
* {
|
* {
|
||||||
* resource: {
|
* resource: {
|
||||||
* ciphertext, // base64(AES-GCM encrypted JSON)
|
* ciphertext,
|
||||||
* nonce,
|
* nonce,
|
||||||
* associated_data,
|
* associated_data,
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
|
*
|
||||||
* Steps:
|
* Steps:
|
||||||
* 1. Decrypt ciphertext using APIV3 key (mchKey)
|
* 1. Decrypt ciphertext using APIV3 key (mchKey)
|
||||||
* 2. Parse decrypted JSON to get transaction info
|
* 2. Parse decrypted JSON to get transaction info
|
||||||
* 3. Extract out_trade_no (orderNo), transaction_id, trade_state
|
* 3. Extract out_trade_no (orderNo), transaction_id, trade_state
|
||||||
*/
|
*/
|
||||||
parseNotification(body: Record<string, unknown>): WxNotification {
|
parseNotification(body: Record<string, unknown>): WxNotification {
|
||||||
// TODO: implement real WeChat Pay v3 AES-256-GCM notification decryption
|
this.logger.log('Parsing WeChat Pay notification')
|
||||||
this.logger.log('[MOCK] parseNotification body received')
|
|
||||||
|
|
||||||
const orderNo = (body['out_trade_no'] as string) ?? (body['orderNo'] as string) ?? ''
|
// Handle plain notification (for testing) or encrypted one
|
||||||
const wxTransactionId =
|
if (body['trade_state']) {
|
||||||
(body['transaction_id'] as string) ?? (body['wxTransactionId'] as string) ?? ''
|
// Plain notification (e.g., from test/mock)
|
||||||
const tradeState = (body['trade_state'] as string) ?? 'SUCCESS'
|
const orderNo = (body['out_trade_no'] as string) ?? ''
|
||||||
const success = tradeState === 'SUCCESS'
|
const wxTransactionId = (body['transaction_id'] as string) ?? ''
|
||||||
|
const tradeState = (body['trade_state'] as string) ?? 'UNKNOWN'
|
||||||
|
return {
|
||||||
|
orderNo,
|
||||||
|
wxTransactionId,
|
||||||
|
success: tradeState === 'SUCCESS',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { orderNo, wxTransactionId, success }
|
// Encrypted notification — decrypt resource
|
||||||
|
const resource = body['resource'] as Record<string, string> | undefined
|
||||||
|
if (!resource) {
|
||||||
|
this.logger.warn('No resource in notification')
|
||||||
|
return { orderNo: '', wxTransactionId: '', success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ciphertext, nonce, associated_data } = resource
|
||||||
|
if (!ciphertext || !nonce || !associated_data) {
|
||||||
|
this.logger.warn('Incomplete resource in notification')
|
||||||
|
return { orderNo: '', wxTransactionId: '', success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES-256-GCM decryption
|
||||||
|
const decrypted = this.decryptGCM(ciphertext, nonce, associated_data)
|
||||||
|
if (!decrypted) {
|
||||||
|
return { orderNo: '', wxTransactionId: '', success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationData: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
notificationData = JSON.parse(decrypted) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
this.logger.error('Failed to parse decrypted notification JSON')
|
||||||
|
return { orderNo: '', wxTransactionId: '', success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNo = (notificationData['out_trade_no'] as string) ?? ''
|
||||||
|
const wxTransactionId = (notificationData['transaction_id'] as string) ?? ''
|
||||||
|
const tradeState = (notificationData['trade_state'] as string) ?? 'UNKNOWN'
|
||||||
|
|
||||||
|
this.logger.log(`Notification parsed: orderNo=${orderNo}, tradeState=${tradeState}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderNo,
|
||||||
|
wxTransactionId,
|
||||||
|
success: tradeState === 'SUCCESS',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated HTTP request to WeChat Pay v3 API using RSA-SHA256 signing.
|
||||||
|
*/
|
||||||
|
private async httpRequestWithRSA(
|
||||||
|
method: 'POST' | 'GET' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
nonceStr: string,
|
||||||
|
timestamp: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
const bodyStr = JSON.stringify(body)
|
||||||
|
|
||||||
|
// Build signature string: {METHOD}\n{URL}\n{TIMESTAMP}\n{NONCE}\n{BODY}\n
|
||||||
|
const urlPath = new URL(url).pathname // e.g. /v3/pay/transactions/jsapi
|
||||||
|
const signString = `${method}\n${urlPath}\n${timestamp}\n${nonceStr}\n${bodyStr}\n`
|
||||||
|
|
||||||
|
// Sign with merchant's RSA private key using SHA256 with RSA
|
||||||
|
const signature = this.signWithRSA(signString)
|
||||||
|
|
||||||
|
const authorization = [
|
||||||
|
`WECHATPAY2-SHA256-RSA2048`,
|
||||||
|
`mchid="${this.mchId}"`,
|
||||||
|
`nonce_str="${nonceStr}"`,
|
||||||
|
`signature="${signature}"`,
|
||||||
|
`timestamp="${timestamp}"`,
|
||||||
|
`serial_no="${this.mchSerialNo}"`,
|
||||||
|
].join(', ')
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': authorization,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: method !== 'GET' ? bodyStr : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign data using RSA-SHA256 with the merchant's private key.
|
||||||
|
*/
|
||||||
|
private signWithRSA(data: string): string {
|
||||||
|
let privateKey: string
|
||||||
|
try {
|
||||||
|
privateKey = fs.readFileSync(path.resolve(this.mchPrivateKeyPath), 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to read private key from ${this.mchPrivateKeyPath}: ${err}`)
|
||||||
|
throw new Error(`微信支付签名失败: 无法读取商户私钥文件`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = crypto.createSign('RSA-SHA256')
|
||||||
|
sign.update(data)
|
||||||
|
sign.end()
|
||||||
|
return sign.sign(privateKey, 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt WeChat Pay v3 notification using AES-256-GCM.
|
||||||
|
*
|
||||||
|
* WeChat Pay v3 notification structure:
|
||||||
|
* {
|
||||||
|
* resource: {
|
||||||
|
* ciphertext: "<base64 of AES-256-GCM encrypted JSON>",
|
||||||
|
* nonce: "<16-byte nonce>",
|
||||||
|
* associated_data: "<aead_key>"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* The encrypted `ciphertext` decodes to a JSON string:
|
||||||
|
* { "ciphertext": "<base64 of notification JSON>",
|
||||||
|
* "nonce": "<nonce>",
|
||||||
|
* "associated_data": "<aad>" }
|
||||||
|
* where the nested `ciphertext` is again AES-256-GCM encrypted notification data.
|
||||||
|
*
|
||||||
|
* So decryption is two-step:
|
||||||
|
* Step 1: AES-GCM(key, nonce, aad, outer_ciphertext) → outer_plaintext (JSON with nested ciphertext)
|
||||||
|
* Step 2: AES-GCM(key, inner_nonce, inner_aad, inner_ciphertext) → final notification JSON
|
||||||
|
*/
|
||||||
|
private decryptGCM(ciphertext: string, nonce: string, associatedData: string): string | null {
|
||||||
|
try {
|
||||||
|
const keyBytes = Buffer.from(this.mchKey.slice(0, 32).padEnd(32, '0'), 'utf8')
|
||||||
|
const nonceBuffer = Buffer.from(nonce, 'utf8')
|
||||||
|
|
||||||
|
// ciphertext includes the 16-byte auth tag appended at the end (last 16 bytes)
|
||||||
|
const cipherBytes = Buffer.from(ciphertext.slice(0, -16), 'base64')
|
||||||
|
const authTag = Buffer.from(ciphertext.slice(-16), 'base64')
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBytes, nonceBuffer)
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
|
||||||
|
const outerPlaintext = Buffer.concat([
|
||||||
|
decipher.update(cipherBytes),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8')
|
||||||
|
|
||||||
|
// Step 1 result: JSON string with nested ciphertext, nonce, associated_data
|
||||||
|
let outerJson: { ciphertext?: string; nonce?: string; associated_data?: string }
|
||||||
|
try {
|
||||||
|
outerJson = JSON.parse(outerPlaintext) as typeof outerJson
|
||||||
|
} catch {
|
||||||
|
this.logger.error(`Failed to parse outer notification JSON: ${outerPlaintext}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ciphertext: innerCiphertext, nonce: innerNonce, associated_data: innerAad } = outerJson
|
||||||
|
if (!innerCiphertext || !innerNonce || !innerAad) {
|
||||||
|
this.logger.error('Missing fields in outer notification JSON')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: decrypt the nested ciphertext to get the final notification data
|
||||||
|
const innerCipherBytes = Buffer.from(innerCiphertext, 'base64')
|
||||||
|
const innerNonceBuffer = Buffer.from(innerNonce, 'utf8')
|
||||||
|
|
||||||
|
const decipher2 = crypto.createDecipheriv('aes-256-gcm', keyBytes, innerNonceBuffer)
|
||||||
|
// For step 2, the auth tag is the last 16 bytes of innerCipherBytes
|
||||||
|
decipher2.setAuthTag(Buffer.from(innerCiphertext.slice(-16), 'base64'))
|
||||||
|
|
||||||
|
const finalPlaintext = Buffer.concat([
|
||||||
|
decipher2.update(Buffer.from(innerCiphertext.slice(0, -16), 'base64')),
|
||||||
|
decipher2.final(),
|
||||||
|
]).toString('utf8')
|
||||||
|
|
||||||
|
return finalPlaintext
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to decrypt notification: ${err}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user