Merge branch 'feat/production-ready'
这个提交包含在:
+3
@@ -12,3 +12,6 @@ docs/reference/小程序及后台管理软件开发资料/
|
||||
*.docx
|
||||
!docs/protocols/协议简述.docx
|
||||
cloud/sql/
|
||||
certs/
|
||||
*.pem
|
||||
*.p12
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Production: change to 'prod'
|
||||
const ENV = 'test'
|
||||
|
||||
const API_BASES = {
|
||||
|
||||
@@ -211,3 +211,10 @@
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<view class="empty-state" v-if="records.length === 0">暂无数据</view>
|
||||
|
||||
<ConfirmModal
|
||||
:visible="showBatchImport"
|
||||
title="批量导入设备"
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="logs.length === 0">暂无数据</view>
|
||||
|
||||
<view class="pagination">
|
||||
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)">‹</button>
|
||||
<button class="btn-page active">{{ page }}</button>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<text class="filter-tag-text">用户: {{ filterUserId }}</text>
|
||||
<text class="filter-tag-close" @click="clearUserFilter">x</text>
|
||||
</view>
|
||||
<input class="search-input date-input" v-model="dateFrom" placeholder="开始日期" />
|
||||
<input type="date" class="search-input date-input" v-model="dateFrom" placeholder="开始日期" />
|
||||
<text class="date-sep">~</text>
|
||||
<input class="search-input date-input" v-model="dateTo" placeholder="结束日期" />
|
||||
<input type="date" class="search-input date-input" v-model="dateTo" placeholder="结束日期" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
@@ -55,6 +55,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="records.length === 0">暂无数据</view>
|
||||
|
||||
<view class="pagination">
|
||||
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)">‹</button>
|
||||
<button class="btn-page active">{{ page }}</button>
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<view class="empty-state" v-if="records.length === 0">暂无数据</view>
|
||||
|
||||
<ConfirmModal
|
||||
:visible="showCreate"
|
||||
title="创建订阅"
|
||||
@@ -79,7 +81,10 @@
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">方案</text>
|
||||
<input class="form-input" v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
|
||||
<select v-model="createForm.plan" class="form-input">
|
||||
<option value="monthly">月卡 (30天)</option>
|
||||
<option value="yearly">年卡 (365天)</option>
|
||||
</select>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">天数</text>
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="users.length === 0">暂无数据</view>
|
||||
|
||||
<view class="pagination">
|
||||
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)">‹</button>
|
||||
<button class="btn-page active">{{ page }}</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Production: change to 'prod' to disable debug buttons and mock endpoints
|
||||
var ENV = 'test'
|
||||
|
||||
var API_BASES = {
|
||||
|
||||
@@ -96,7 +96,7 @@ Page({
|
||||
onMockScanDone: function () {
|
||||
var self = this
|
||||
if (self._progressTimer) clearInterval(self._progressTimer)
|
||||
var mask = parseInt(self.options.regions) || 0x7F
|
||||
var mask = parseInt(self.data.regions) || 0x7F
|
||||
wx.redirectTo({
|
||||
url: '/pages/treating/treating?regions=' + mask +
|
||||
'&wavelength=2' +
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "护理记录",
|
||||
"navigationBarBackgroundColor": "#E6508C",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
|
||||
@@ -14,11 +14,15 @@ Page({
|
||||
bleState: 'disconnected',
|
||||
hasDevice: false,
|
||||
deviceInfo: null,
|
||||
devMode: false
|
||||
devMode: false,
|
||||
loading: true
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
this.setData({ devMode: config.__DEV__ || false })
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
this.setData({ devMode: config.__DEV__ || false })
|
||||
this.checkState()
|
||||
this._onStatus = this.onBleStatus.bind(this)
|
||||
this._onBattery = this.onBleBattery.bind(this)
|
||||
@@ -53,9 +57,10 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
var isFirstLoad = self.data.loading
|
||||
self.setData({ connected: ble.isConnected() })
|
||||
|
||||
api.getDevices().then(function (data) {
|
||||
var p1 = api.getDevices().then(function (data) {
|
||||
var devices = data.devices || []
|
||||
self.setData({ hasDevice: devices.length > 0 })
|
||||
if (devices.length > 0) {
|
||||
@@ -65,14 +70,22 @@ Page({
|
||||
deviceInfo: devices[0]
|
||||
})
|
||||
}
|
||||
}).catch(function () {})
|
||||
}).catch(function (err) {
|
||||
console.error('getDevices failed', err)
|
||||
})
|
||||
|
||||
api.getSubscription().then(function (sub) {
|
||||
var p2 = api.getSubscription().then(function (sub) {
|
||||
self.setData({
|
||||
subscription: sub,
|
||||
subRemaining: sub.remaining_days || 0
|
||||
})
|
||||
}).catch(function () {})
|
||||
}).catch(function (err) {
|
||||
console.error('getSubscription failed', err)
|
||||
})
|
||||
|
||||
Promise.all([p1, p2]).then(function () {
|
||||
if (isFirstLoad) self.setData({ loading: false })
|
||||
})
|
||||
},
|
||||
|
||||
onBleStatus: function (status) {
|
||||
@@ -98,7 +111,9 @@ Page({
|
||||
},
|
||||
onConnected: function () {
|
||||
self.setData({ connected: true, bleState: 'connected' })
|
||||
ble.queryStatus().catch(function () {})
|
||||
ble.queryStatus().catch(function (err) {
|
||||
console.error('queryStatus failed', err)
|
||||
})
|
||||
},
|
||||
onError: function (err) {
|
||||
self.setData({ connected: false, bleState: 'error' })
|
||||
@@ -179,8 +194,6 @@ Page({
|
||||
},
|
||||
|
||||
onViewSubscription: function () {
|
||||
if (!this.data.subscription || this.data.subscription.status !== 'active') {
|
||||
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<view class="page">
|
||||
<view class="page-content" wx:if="{{!hasDevice}}">
|
||||
<view class="loading-container" wx:if="{{loading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="page-content" wx:if="{{!loading && !hasDevice}}">
|
||||
<view class="empty-device">
|
||||
<text class="empty-icon">📱</text>
|
||||
<view class="empty-text">还没有绑定设备</view>
|
||||
@@ -11,16 +16,16 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content" wx:if="{{hasDevice}}">
|
||||
<view class="page-content" wx:if="{{!loading && hasDevice}}">
|
||||
<view class="device-card">
|
||||
<view class="device-icon">💆</view>
|
||||
<view class="device-name">{{deviceName || '我的光面膜'}}</view>
|
||||
<view class="device-card-icon">💆</view>
|
||||
<view class="device-card-name">{{deviceName || '光子美容仪'}}</view>
|
||||
<view class="device-card-status">
|
||||
<text class="status-badge status-badge-green" wx:if="{{connected}}">已连接</text>
|
||||
<text class="status-badge" style="background:#ccc;" wx:else>未连接</text>
|
||||
<view class="device-battery" wx:if="{{connected}}">
|
||||
<text>🔋</text>
|
||||
<text>电量 {{battery}}%</text>
|
||||
<text class="device-card-battery" wx:if="{{connected}}">🔋 {{battery}}%</text>
|
||||
</view>
|
||||
<button class="btn-reconnect" bindtap="onConnectBle" wx:if="{{!connected}}">重新连接</button>
|
||||
</view>
|
||||
|
||||
<view class="sub-info" bindtap="onViewSubscription">
|
||||
@@ -32,7 +37,9 @@
|
||||
<text class="sub-info-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="action-buttons">
|
||||
<button class="btn-primary" bindtap="onStartTreatment">开始护理</button>
|
||||
<button class="btn-secondary" bindtap="onManageDevice">设备管理</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -19,6 +19,62 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 36rpx 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-card-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.device-card-name {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.device-card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.device-card-battery {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-reconnect {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
padding: 20rpx;
|
||||
background: #f5f5f5;
|
||||
color: #E6508C;
|
||||
border: 2rpx solid #E6508C;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-reconnect::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.sub-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -28,6 +84,7 @@
|
||||
padding: 24rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.sub-info-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -35,10 +92,39 @@
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sub-info-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.sub-info-arrow {
|
||||
color: #999;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 200rpx 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border: 6rpx solid #f0f0f0;
|
||||
border-top-color: #E6508C;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
var api = require('../../utils/api')
|
||||
var config = require('../../config/env')
|
||||
var app = getApp()
|
||||
|
||||
Page({
|
||||
@@ -6,7 +7,12 @@ Page({
|
||||
userInfo: null,
|
||||
subscription: null,
|
||||
deviceCount: 0,
|
||||
subRemaining: 0
|
||||
subRemaining: 0,
|
||||
loading: true,
|
||||
editing: false,
|
||||
editNickname: '',
|
||||
editAvatarUrl: '',
|
||||
saving: false
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
@@ -15,19 +21,29 @@ Page({
|
||||
|
||||
loadProfile: function () {
|
||||
var self = this
|
||||
api.getProfile().then(function (profile) {
|
||||
self.setData({ loading: true })
|
||||
|
||||
var p1 = api.getProfile().then(function (profile) {
|
||||
self.setData({
|
||||
userInfo: profile,
|
||||
deviceCount: profile.device_count || 0
|
||||
})
|
||||
}).catch(function () {})
|
||||
}).catch(function (err) {
|
||||
console.error('getProfile failed', err)
|
||||
})
|
||||
|
||||
api.getSubscription().then(function (sub) {
|
||||
var p2 = api.getSubscription().then(function (sub) {
|
||||
self.setData({
|
||||
subscription: sub,
|
||||
subRemaining: sub.remaining_days || 0
|
||||
})
|
||||
}).catch(function () {})
|
||||
}).catch(function (err) {
|
||||
console.error('getSubscription failed', err)
|
||||
})
|
||||
|
||||
Promise.all([p1, p2]).then(function () {
|
||||
self.setData({ loading: false })
|
||||
})
|
||||
},
|
||||
|
||||
onManageDevice: function () {
|
||||
@@ -75,6 +91,93 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/contact/contact' })
|
||||
},
|
||||
|
||||
onEditProfile: function () {
|
||||
var info = this.data.userInfo || {}
|
||||
this.setData({
|
||||
editing: true,
|
||||
editNickname: info.nickname || '',
|
||||
editAvatarUrl: info.avatar || ''
|
||||
})
|
||||
},
|
||||
|
||||
onCancelEdit: function () {
|
||||
this.setData({ editing: false })
|
||||
},
|
||||
|
||||
onEditNicknameInput: function (e) {
|
||||
this.setData({ editNickname: e.detail.value || '' })
|
||||
},
|
||||
|
||||
onPickAvatar: function () {
|
||||
var self = this
|
||||
wx.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: function (res) {
|
||||
if (res.tempFiles && res.tempFiles[0]) {
|
||||
self.setData({ editAvatarUrl: res.tempFiles[0].tempFilePath })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
uploadAvatar: function (tempPath) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var token = wx.getStorageSync('token')
|
||||
wx.uploadFile({
|
||||
url: config.API_BASE + '/api/v1/user/avatar',
|
||||
filePath: tempPath,
|
||||
name: 'file',
|
||||
header: { Authorization: 'Bearer ' + token },
|
||||
success: function (res) {
|
||||
try {
|
||||
var data = JSON.parse(res.data)
|
||||
if (data.code === 0 && data.data && data.data.avatar) {
|
||||
resolve(data.data.avatar)
|
||||
} else {
|
||||
reject(new Error(data.message || '上传失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('上传失败'))
|
||||
}
|
||||
},
|
||||
fail: function () { reject(new Error('上传失败')) }
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
onSaveProfile: function () {
|
||||
var self = this
|
||||
var nickname = (self.data.editNickname || '').trim()
|
||||
if (!nickname) {
|
||||
wx.showToast({ title: '昵称不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
self.setData({ saving: true })
|
||||
|
||||
var avatarUrl = self.data.editAvatarUrl
|
||||
var isLocalFile = avatarUrl && (avatarUrl.indexOf('wxfile://') === 0 || avatarUrl.indexOf('http://tmp') === 0)
|
||||
var avatarPromise = isLocalFile
|
||||
? self.uploadAvatar(avatarUrl)
|
||||
: Promise.resolve(avatarUrl || '')
|
||||
|
||||
avatarPromise.then(function (permanentUrl) {
|
||||
return api.updateProfile({
|
||||
nickname: nickname,
|
||||
avatar: permanentUrl || ''
|
||||
})
|
||||
}).then(function () {
|
||||
self.setData({ saving: false, editing: false })
|
||||
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||
self.loadProfile()
|
||||
}).catch(function (err) {
|
||||
self.setData({ saving: false })
|
||||
wx.showToast({ title: err.message || '保存失败', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
onLogout: function () {
|
||||
wx.showModal({
|
||||
title: '退出登录',
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<view class="page">
|
||||
<view class="page-content">
|
||||
<view class="loading-container" wx:if="{{loading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="page-content" wx:if="{{!loading}}">
|
||||
<view class="user-row">
|
||||
<view class="user-avatar">👤</view>
|
||||
<image class="user-avatar-img" wx:if="{{userInfo.avatar}}" src="{{userInfo.avatar}}" mode="aspectFill"></image>
|
||||
<view class="user-avatar" wx:else>👤</view>
|
||||
<view class="user-info">
|
||||
<view class="user-name">{{userInfo.nickname || '未登录'}}</view>
|
||||
<view class="user-phone">{{userInfo.phone || ''}}</view>
|
||||
</view>
|
||||
<view class="edit-profile-btn" bindtap="onEditProfile">编辑资料</view>
|
||||
</view>
|
||||
|
||||
<view class="sub-card" wx:if="{{subscription && subscription.status === 'active' && subRemaining > 0}}">
|
||||
@@ -55,4 +62,23 @@
|
||||
|
||||
<view class="logout-btn" bindtap="onLogout">退出登录</view>
|
||||
</view>
|
||||
|
||||
<!-- Edit profile modal -->
|
||||
<view class="edit-mask" wx:if="{{editing}}" bindtap="onCancelEdit"></view>
|
||||
<view class="edit-modal" wx:if="{{editing}}">
|
||||
<view class="edit-modal-title">编辑资料</view>
|
||||
<view class="edit-avatar-row" bindtap="onPickAvatar">
|
||||
<image class="edit-avatar-img" wx:if="{{editAvatarUrl}}" src="{{editAvatarUrl}}" mode="aspectFill"></image>
|
||||
<view class="edit-avatar-placeholder" wx:else>👤</view>
|
||||
<text class="edit-avatar-hint">点击更换头像</text>
|
||||
</view>
|
||||
<view class="edit-field">
|
||||
<text class="edit-label">昵称</text>
|
||||
<input class="edit-input" value="{{editNickname}}" bindinput="onEditNicknameInput" placeholder="请输入昵称" maxlength="20" />
|
||||
</view>
|
||||
<view class="edit-actions">
|
||||
<button class="btn-secondary edit-btn" bindtap="onCancelEdit">取消</button>
|
||||
<button class="btn-primary edit-btn" bindtap="onSaveProfile" disabled="{{saving}}" loading="{{saving}}">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -107,11 +107,144 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 200rpx 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border: 6rpx solid #f0f0f0;
|
||||
border-top-color: #E6508C;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.sub-card-inactive {
|
||||
background: #f5f5f5;
|
||||
border: 1px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.user-avatar-img {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-profile-btn {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 24rpx;
|
||||
padding: 6rpx 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.edit-modal {
|
||||
position: fixed;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 40rpx;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.edit-modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.edit-avatar-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.edit-avatar-img {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.edit-avatar-placeholder {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.edit-avatar-hint {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.edit-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-bottom: 12rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
border: 2rpx solid #e0e0e0;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
flex: 1;
|
||||
margin: 0 !important;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
text-align: center;
|
||||
color: #ff4d4f;
|
||||
|
||||
@@ -8,7 +8,8 @@ Page({
|
||||
selected: 'yearly',
|
||||
purchasing: false,
|
||||
subscription: null,
|
||||
subRemaining: 0
|
||||
subRemaining: 0,
|
||||
loadingPlans: true
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
@@ -31,6 +32,7 @@ Page({
|
||||
|
||||
loadPlans: function () {
|
||||
var self = this
|
||||
self.setData({ loadingPlans: true })
|
||||
api.getPlans().then(function (data) {
|
||||
var serverPlans = data.plans || []
|
||||
var monthlyPrice = 99
|
||||
@@ -54,9 +56,11 @@ Page({
|
||||
}
|
||||
})
|
||||
if (plans.length > 0) self.setData({ plans: plans })
|
||||
self.setData({ loadingPlans: false })
|
||||
self.checkTrialUsed()
|
||||
}).catch(function () {
|
||||
self.setData({
|
||||
loadingPlans: false,
|
||||
plans: [
|
||||
{ key: 'trial', name: '试用', price: 0, priceLabel: '免费', desc: '7天免费体验', disabled: false },
|
||||
{ key: 'monthly', name: '月卡', price: 99, priceLabel: '¥99', desc: '约3.3元/天' },
|
||||
@@ -126,8 +130,8 @@ Page({
|
||||
var planDays = plan.key === 'yearly' ? 365 : 30
|
||||
var title = isRenew ? '确认续费' : '确认支付'
|
||||
var content = isRenew
|
||||
? '在现有订阅基础上延长' + planDays + '天,模拟支付 ¥' + plan.price + '?(测试环境)'
|
||||
: '模拟支付 ¥' + plan.price + '?(测试环境)'
|
||||
? '在现有订阅基础上延长' + planDays + '天,支付 ¥' + plan.price
|
||||
: '支付 ¥' + plan.price
|
||||
|
||||
wx.showModal({
|
||||
title: title,
|
||||
@@ -158,15 +162,72 @@ Page({
|
||||
doPurchase: function (plan) {
|
||||
var self = this
|
||||
self.setData({ purchasing: true })
|
||||
api.mockPurchase(plan.key).then(function (order) {
|
||||
api.purchase(plan.key).then(function (data) {
|
||||
if (data.mock || !data.payment_params) {
|
||||
// Dev fallback: use mock purchase
|
||||
return api.mockPurchase(plan.key).then(function () {
|
||||
self.setData({ purchasing: false })
|
||||
wx.showToast({ title: '支付成功', icon: 'success' })
|
||||
setTimeout(function () {
|
||||
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + plan.key })
|
||||
}, 1000)
|
||||
}).catch(function (err) {
|
||||
self.setData({ purchasing: false })
|
||||
wx.showToast({ title: err.message || '支付失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
// Real payment
|
||||
var params = data.payment_params
|
||||
self._currentOrderId = data.order_id
|
||||
wx.requestPayment({
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign,
|
||||
success: function () {
|
||||
// Payment dialog succeeded, sync order to confirm
|
||||
self.syncAndRedirect(data.order_id, plan.key)
|
||||
},
|
||||
fail: function (err) {
|
||||
self.setData({ purchasing: false })
|
||||
var msg = (err.errMsg || '').indexOf('cancel') > -1 ? '已取消支付' : '支付失败'
|
||||
wx.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({ purchasing: false })
|
||||
wx.showToast({ title: err.message || '创建订单失败', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
syncAndRedirect: function (orderId, planKey) {
|
||||
var self = this
|
||||
var retryCount = 0
|
||||
var maxRetries = 3
|
||||
|
||||
function pollSync() {
|
||||
api.syncPaymentOrder(orderId).then(function (result) {
|
||||
if (result.status === 'paid') {
|
||||
self.setData({ purchasing: false })
|
||||
wx.showToast({ title: '支付成功', icon: 'success' })
|
||||
setTimeout(function () {
|
||||
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + planKey })
|
||||
}, 1000)
|
||||
} else if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(pollSync, 2000)
|
||||
} else {
|
||||
self.setData({ purchasing: false })
|
||||
wx.showToast({ title: '支付处理中,请稍后在订阅页查看', icon: 'none' })
|
||||
}
|
||||
}).catch(function () {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(pollSync, 2000)
|
||||
} else {
|
||||
self.setData({ purchasing: false })
|
||||
wx.showToast({ title: '支付处理中,请稍后在订阅页查看', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
pollSync()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
|
||||
<view class="page-title">选择订阅套餐</view>
|
||||
|
||||
<view class="service-card">
|
||||
<view class="loading-plans" wx:if="{{loadingPlans}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载套餐中...</text>
|
||||
</view>
|
||||
|
||||
<view class="service-card" wx:if="{{!loadingPlans}}">
|
||||
<view class="service-icon">✨</view>
|
||||
<view class="service-info">
|
||||
<view class="service-name">智能模式</view>
|
||||
@@ -28,7 +33,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="plans">
|
||||
<view class="plans" wx:if="{{!loadingPlans}}">
|
||||
<view class="plan {{selected === item.key ? 'selected' : ''}} {{item.disabled ? 'plan-disabled' : ''}}" wx:for="{{plans}}" wx:key="key" bindtap="{{item.disabled ? '' : 'onSelect'}}" data-plan="{{item.key}}">
|
||||
<view class="plan-tag {{item.disabled ? 'plan-tag-disabled' : (selected === item.key ? 'plan-tag-active' : '')}}" wx:if="{{item.tag}}">{{item.tag}}</view>
|
||||
<view class="plan-price">{{item.priceLabel}}</view>
|
||||
@@ -37,9 +42,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<block wx:if="{{!loadingPlans}}">
|
||||
<button class="btn-primary btn-primary-gold" bindtap="onPurchase" disabled="{{purchasing}}" loading="{{purchasing}}">
|
||||
{{subscription && subscription.status === 'active' && subRemaining > 0 ? '续费' : '确认支付'}}
|
||||
</button>
|
||||
<view class="agreement" bindtap="onAgreement">支付即表示同意《订阅协议》</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -128,3 +128,30 @@
|
||||
background: #ccc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loading-plans {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border: 6rpx solid #f0f0f0;
|
||||
border-top-color: #DCB982;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// BLE connection: scan, connect, disconnect, reconnect logic
|
||||
|
||||
var protocol = require('./protocol')
|
||||
var devConfig = require('../../config/env')
|
||||
var SERVICE = protocol.SERVICE
|
||||
var CHAR = protocol.CHAR
|
||||
|
||||
@@ -251,9 +252,13 @@ function startScan(callbacks) {
|
||||
var d = devices[i]
|
||||
var name = (d.name || '').toUpperCase()
|
||||
var localName = (d.localName || '').toUpperCase()
|
||||
if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
|
||||
name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1 ||
|
||||
name.indexOf('SIMPLE PERIPHERAL') !== -1 || localName.indexOf('SIMPLE PERIPHERAL') !== -1) {
|
||||
var matched = name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
|
||||
name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1
|
||||
// Dev board name - only match in dev mode
|
||||
if (!matched && devConfig.__DEV__) {
|
||||
matched = name.indexOf('SIMPLE PERIPHERAL') !== -1 || localName.indexOf('SIMPLE PERIPHERAL') !== -1
|
||||
}
|
||||
if (matched) {
|
||||
wx.stopBluetoothDevicesDiscovery({})
|
||||
if (callbacks.onFound) callbacks.onFound(d)
|
||||
connect(d.deviceId, callbacks)
|
||||
|
||||
@@ -23,6 +23,10 @@ module.exports = {
|
||||
purchase: function (plan) { return http.post('/api/v1/subscription/purchase', { plan: plan }) },
|
||||
mockPurchase: function (plan) { return http.post('/api/v1/subscription/mock-purchase', { plan: plan }) },
|
||||
|
||||
// Payment
|
||||
getPaymentOrder: function (orderId) { return http.get('/api/v1/payment/orders/' + orderId) },
|
||||
syncPaymentOrder: function (orderId) { return http.post('/api/v1/payment/orders/' + orderId + '/sync') },
|
||||
|
||||
// Treatment
|
||||
getRecords: function (params) { return http.get('/api/v1/treatment/history', params) },
|
||||
syncTreatment: function (data) { return http.post('/api/v1/treatment/sync', data) },
|
||||
|
||||
@@ -22,3 +22,17 @@ ADMIN_JWT_SECRET=replace-with-a-different-long-random-secret
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
|
||||
CORS_ORIGIN=https://admin.vsai.net.cn
|
||||
|
||||
# WeChat Pay V3
|
||||
WX_MCH_ID=
|
||||
WX_MCH_API_V3_KEY=
|
||||
WX_MCH_SERIAL_NO=
|
||||
WX_MCH_PRIVATE_KEY=
|
||||
WX_MCH_PRIVATE_KEY_PATH=
|
||||
WX_PAY_NOTIFY_URL=https://api.vsai.net.cn/api/v1/payment/wechat/notify
|
||||
|
||||
DB_POOL_SIZE=10
|
||||
|
||||
COS_CDN_DOMAIN=tx.vsai.net.cn
|
||||
|
||||
@@ -159,6 +159,24 @@ CREATE TABLE IF NOT EXISTS firmware_files (
|
||||
KEY idx_firmware_version (version, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||
order_id VARCHAR(64) NOT NULL COMMENT 'out_trade_no',
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
plan VARCHAR(32) NOT NULL,
|
||||
amount_fen INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'created' COMMENT 'created/paying/paid/closed/failed/refunded',
|
||||
prepay_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
transaction_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
trade_state VARCHAR(32) NOT NULL DEFAULT '',
|
||||
raw_notify_json JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (order_id),
|
||||
KEY idx_payment_orders_user (user_id, status),
|
||||
CONSTRAINT fk_payment_orders_user FOREIGN KEY (user_id) REFERENCES users (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO system_settings (setting_key, setting_value) VALUES
|
||||
('system_name', '"光子美容仪后台"'),
|
||||
('monthly_price', '99'),
|
||||
|
||||
+28
-1
@@ -1,5 +1,6 @@
|
||||
const express = require('express')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
const config = require('./config')
|
||||
const { ok, fail } = require('./lib/response')
|
||||
const { authMiddleware } = require('./middleware/auth')
|
||||
|
||||
@@ -28,19 +29,36 @@ const uploadLimiter = rateLimit({
|
||||
message: { code: 2001, message: 'too_many_attempts' }
|
||||
})
|
||||
|
||||
app.use('/api/v1/payment/wechat/notify', express.raw({ type: 'application/json' }))
|
||||
app.use(express.json())
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
const origin = config.nodeEnv === 'production'
|
||||
? (process.env.CORS_ORIGIN || 'https://admin.vsai.net.cn')
|
||||
: '*'
|
||||
res.header('Access-Control-Allow-Origin', origin)
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform')
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204)
|
||||
next()
|
||||
})
|
||||
|
||||
const paymentLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { code: 2001, message: 'too_many_attempts' }
|
||||
})
|
||||
|
||||
app.use('/api/v1/auth/login', userLoginLimiter)
|
||||
app.use('/api/v1/admin/login', adminLoginLimiter)
|
||||
app.use('/api/v1/user/avatar', uploadLimiter)
|
||||
app.use('/api/v1/user/phone', uploadLimiter)
|
||||
app.use('/api/v1/subscription/purchase', paymentLimiter)
|
||||
app.use('/api/v1/payment/orders', paymentLimiter)
|
||||
|
||||
// WeChat Pay callback — must be before authMiddleware (no JWT)
|
||||
app.post('/api/v1/payment/wechat/notify', require('./routes/payment-notify'))
|
||||
|
||||
app.use(authMiddleware)
|
||||
|
||||
@@ -53,6 +71,7 @@ app.use('/api/v1', require('./routes/subscription'))
|
||||
app.use('/api/v1', require('./routes/treatment'))
|
||||
app.use('/api/v1/admin', require('./routes/admin'))
|
||||
app.use('/api/v1', require('./routes/firmware'))
|
||||
app.use('/api/v1', require('./routes/payment'))
|
||||
|
||||
app.use((req, res) => res.status(404).json(fail(404, 'not_found')))
|
||||
|
||||
@@ -61,4 +80,12 @@ app.use((err, req, res, _next) => {
|
||||
res.status(500).json(fail(3001, 'server_error'))
|
||||
})
|
||||
|
||||
// Clean up stale records on startup (SCF cold start)
|
||||
require('./dao/payment-order.dao').closeStaleOrders().catch(err => {
|
||||
console.error('[STARTUP] closeStaleOrders failed:', err.message)
|
||||
})
|
||||
require('./dao/binding.dao').closeExpiredPending().catch(err => {
|
||||
console.error('[STARTUP] closeExpiredPending failed:', err.message)
|
||||
})
|
||||
|
||||
module.exports = app
|
||||
|
||||
@@ -30,6 +30,15 @@ const config = {
|
||||
admin: {
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || 'admin'
|
||||
},
|
||||
wxpay: {
|
||||
appid: process.env.WECHAT_APPID,
|
||||
mchId: process.env.WX_MCH_ID || '',
|
||||
apiV3Key: process.env.WX_MCH_API_V3_KEY || '',
|
||||
mchSerialNo: process.env.WX_MCH_SERIAL_NO || '',
|
||||
privateKey: process.env.WX_MCH_PRIVATE_KEY || '',
|
||||
privateKeyPath: process.env.WX_MCH_PRIVATE_KEY_PATH || '',
|
||||
notifyUrl: process.env.WX_PAY_NOTIFY_URL || ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +46,10 @@ if (config.nodeEnv === 'production') {
|
||||
if (config.jwt.secret === 'dev-user-secret') throw new Error('JWT_SECRET must be set in production')
|
||||
if (config.jwt.adminSecret === 'dev-admin-secret') throw new Error('ADMIN_JWT_SECRET must be set in production')
|
||||
if (config.admin.username === 'admin' || config.admin.password === 'admin') throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD must be changed from defaults in production')
|
||||
if (!config.wechat.appid) throw new Error('WECHAT_APPID must be set in production')
|
||||
const wp = config.wxpay
|
||||
if (!wp.mchId || !wp.apiV3Key || !wp.mchSerialNo || !wp.notifyUrl) throw new Error('WeChat Pay credentials (WX_MCH_ID, WX_MCH_API_V3_KEY, WX_MCH_SERIAL_NO, WX_PAY_NOTIFY_URL) must be set in production')
|
||||
if (!wp.privateKey && !wp.privateKeyPath) throw new Error('WX_MCH_PRIVATE_KEY or WX_MCH_PRIVATE_KEY_PATH must be set in production')
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
|
||||
@@ -184,11 +184,18 @@ async function countActiveByUser(userId) {
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
async function closeExpiredPending() {
|
||||
return query(
|
||||
'UPDATE bindings SET bind_status = 4 WHERE bind_status = 3 AND bind_expires < NOW()'
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findActiveByUser,
|
||||
findDeviceExists,
|
||||
cancelPending,
|
||||
createPending,
|
||||
closeExpiredPending,
|
||||
confirmBind,
|
||||
mockBind,
|
||||
unbindByUser,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const { query, one, transaction } = require('../lib/db')
|
||||
|
||||
async function createOrder(orderId, userId, plan, amountFen) {
|
||||
return query(
|
||||
'INSERT INTO payment_orders (order_id, user_id, plan, amount_fen, status) VALUES (:order_id, :user_id, :plan, :amount_fen, :status)',
|
||||
{ order_id: orderId, user_id: userId, plan, amount_fen: amountFen, status: 'created' }
|
||||
)
|
||||
}
|
||||
|
||||
async function findByOutTradeNo(orderId) {
|
||||
return one('SELECT * FROM payment_orders WHERE order_id = :order_id', { order_id: orderId })
|
||||
}
|
||||
|
||||
async function markPrepay(orderId, prepayId) {
|
||||
return query(
|
||||
'UPDATE payment_orders SET status = :status, prepay_id = :prepay_id WHERE order_id = :order_id AND status = :old_status',
|
||||
{ order_id: orderId, status: 'paying', prepay_id: prepayId, old_status: 'created' }
|
||||
)
|
||||
}
|
||||
|
||||
// Idempotent: only activates if order is not already paid
|
||||
async function markPaidAndActivateSubscription(orderId, transactionId, rawNotify) {
|
||||
const subscriptionDao = require('./subscription.dao')
|
||||
return transaction(async conn => {
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT * FROM payment_orders WHERE order_id = ? AND status != ? FOR UPDATE',
|
||||
[orderId, 'paid']
|
||||
)
|
||||
if (rows.length === 0) return false // already paid or not found
|
||||
const order = rows[0]
|
||||
if (order.status === 'paid') return false // double check
|
||||
|
||||
await conn.execute(
|
||||
'UPDATE payment_orders SET status = ?, transaction_id = ?, trade_state = ?, raw_notify_json = ?, paid_at = NOW() WHERE order_id = ?',
|
||||
['paid', transactionId, 'SUCCESS', JSON.stringify(rawNotify), orderId]
|
||||
)
|
||||
|
||||
// Activate subscription using the plan from the order
|
||||
const PLAN_DAYS = { monthly: 30, yearly: 365 }
|
||||
const days = PLAN_DAYS[order.plan] || 30
|
||||
// Use raw conn for the subscription activation within the same transaction
|
||||
const [subRows] = await conn.execute(
|
||||
'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1',
|
||||
[order.user_id]
|
||||
)
|
||||
if (subRows.length > 0) {
|
||||
await conn.execute(
|
||||
'UPDATE subscriptions SET expire_time = DATE_ADD(expire_time, INTERVAL ? DAY), plan = ?, amount = amount + ? WHERE subscription_id = ?',
|
||||
[days, order.plan, order.amount_fen / 100, subRows[0].subscription_id]
|
||||
)
|
||||
} else {
|
||||
await conn.execute(
|
||||
'UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1',
|
||||
[order.user_id]
|
||||
)
|
||||
await conn.execute(
|
||||
'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))',
|
||||
[order.user_id, order.plan, order.amount_fen / 100, orderId, days]
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function markClosed(orderId) {
|
||||
return query(
|
||||
'UPDATE payment_orders SET status = :status, trade_state = :trade_state WHERE order_id = :order_id AND status IN (:s1, :s2)',
|
||||
{ order_id: orderId, status: 'closed', trade_state: 'CLOSED', s1: 'created', s2: 'paying' }
|
||||
)
|
||||
}
|
||||
|
||||
async function markFailed(orderId, tradeState) {
|
||||
return query(
|
||||
'UPDATE payment_orders SET status = :status, trade_state = :trade_state WHERE order_id = :order_id AND status IN (:s1, :s2)',
|
||||
{ order_id: orderId, status: 'failed', trade_state: tradeState || 'PAYERROR', s1: 'created', s2: 'paying' }
|
||||
)
|
||||
}
|
||||
|
||||
async function countPendingByUser(userId) {
|
||||
const rows = await query(
|
||||
"SELECT COUNT(*) AS cnt FROM payment_orders WHERE user_id = :user_id AND status IN ('created', 'paying') AND created_at > DATE_SUB(NOW(), INTERVAL 2 HOUR)",
|
||||
{ user_id: userId }
|
||||
)
|
||||
return rows[0].cnt
|
||||
}
|
||||
|
||||
async function closeStaleOrders() {
|
||||
return query(
|
||||
"UPDATE payment_orders SET status = 'closed', trade_state = 'EXPIRED' WHERE status IN ('created', 'paying') AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)"
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { createOrder, findByOutTradeNo, markPrepay, markPaidAndActivateSubscription, markClosed, markFailed, countPendingByUser, closeStaleOrders }
|
||||
+1
-1
@@ -12,7 +12,7 @@ function getPool() {
|
||||
password: config.db.password,
|
||||
database: config.db.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
connectionLimit: parseInt(process.env.DB_POOL_SIZE, 10) || 10,
|
||||
namedPlaceholders: true,
|
||||
timezone: '+08:00'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const config = require('../config')
|
||||
|
||||
function isConfigured() {
|
||||
const c = config.wxpay
|
||||
return !!(c.mchId && c.apiV3Key && c.mchSerialNo && (c.privateKey || c.privateKeyPath))
|
||||
}
|
||||
|
||||
let _cachedPrivateKey = null
|
||||
function getPrivateKey() {
|
||||
if (_cachedPrivateKey) return _cachedPrivateKey
|
||||
let key = ''
|
||||
if (config.wxpay.privateKey) {
|
||||
key = config.wxpay.privateKey.replace(/\\n/g, '\n')
|
||||
} else if (config.wxpay.privateKeyPath) {
|
||||
key = fs.readFileSync(config.wxpay.privateKeyPath, 'utf8')
|
||||
} else {
|
||||
throw new Error('WeChat Pay private key not configured')
|
||||
}
|
||||
if (!key.includes('-----BEGIN')) throw new Error('Invalid private key format (missing PEM header)')
|
||||
_cachedPrivateKey = key
|
||||
return _cachedPrivateKey
|
||||
}
|
||||
|
||||
function generateNonce() {
|
||||
return crypto.randomBytes(16).toString('hex')
|
||||
}
|
||||
|
||||
function buildSignMessage(method, url, timestamp, nonce, body) {
|
||||
return method + '\n' + url + '\n' + timestamp + '\n' + nonce + '\n' + (body || '') + '\n'
|
||||
}
|
||||
|
||||
function signSHA256WithRSA(message) {
|
||||
const sign = crypto.createSign('RSA-SHA256')
|
||||
sign.update(message)
|
||||
return sign.sign(getPrivateKey(), 'base64')
|
||||
}
|
||||
|
||||
function buildAuthHeader(method, url, body) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const nonce = generateNonce()
|
||||
const message = buildSignMessage(method, url, timestamp, nonce, body || '')
|
||||
const signature = signSHA256WithRSA(message)
|
||||
return 'WECHATPAY2-SHA256-RSA2048 mchid="' + config.wxpay.mchId + '",nonce_str="' + nonce + '",signature="' + signature + '",timestamp="' + timestamp + '",serial_no="' + config.wxpay.mchSerialNo + '"'
|
||||
}
|
||||
|
||||
function httpsRequest(method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bodyStr = body ? JSON.stringify(body) : ''
|
||||
const auth = buildAuthHeader(method, path, bodyStr)
|
||||
const options = {
|
||||
hostname: 'api.mch.weixin.qq.com',
|
||||
port: 443,
|
||||
path: path,
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': auth,
|
||||
'User-Agent': 'jw-beauty-server/1.0'
|
||||
}
|
||||
}
|
||||
if (bodyStr) options.headers['Content-Length'] = Buffer.byteLength(bodyStr)
|
||||
|
||||
const req = https.request(options, res => {
|
||||
let raw = ''
|
||||
res.on('data', chunk => { raw += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(data)
|
||||
} else {
|
||||
reject(new Error(data.message || 'wxpay request failed: ' + res.statusCode))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('wxpay response parse error'))
|
||||
}
|
||||
})
|
||||
})
|
||||
req.setTimeout(15000, () => { req.destroy(new Error('wxpay request timeout (15s)')) })
|
||||
req.on('error', reject)
|
||||
if (bodyStr) req.write(bodyStr)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function createPrepayOrder({ openid, orderId, amountFen, description }) {
|
||||
const path = '/v3/pay/transactions/jsapi'
|
||||
const body = {
|
||||
appid: config.wxpay.appid || config.wechat.appid,
|
||||
mchid: config.wxpay.mchId,
|
||||
description: description || '光子美容仪订阅',
|
||||
out_trade_no: orderId,
|
||||
notify_url: config.wxpay.notifyUrl,
|
||||
amount: { total: amountFen, currency: 'CNY' },
|
||||
payer: { openid: openid }
|
||||
}
|
||||
const result = await httpsRequest('POST', path, body)
|
||||
if (!result.prepay_id) throw new Error('prepay_id not returned')
|
||||
return result.prepay_id
|
||||
}
|
||||
|
||||
function generatePaymentParams(prepayId) {
|
||||
const appId = config.wxpay.appid || config.wechat.appid
|
||||
const timeStamp = Math.floor(Date.now() / 1000).toString()
|
||||
const nonceStr = generateNonce()
|
||||
const pkg = 'prepay_id=' + prepayId
|
||||
const message = appId + '\n' + timeStamp + '\n' + nonceStr + '\n' + pkg + '\n'
|
||||
const paySign = signSHA256WithRSA(message)
|
||||
return { timeStamp, nonceStr, package: pkg, signType: 'RSA', paySign }
|
||||
}
|
||||
|
||||
let _platformCerts = {}
|
||||
let _platformCertsExpiry = 0
|
||||
|
||||
async function fetchPlatformCertificates() {
|
||||
if (_platformCertsExpiry > Date.now()) return _platformCerts
|
||||
try {
|
||||
const path = '/v3/certificates'
|
||||
const result = await httpsRequest('GET', path)
|
||||
const certs = {}
|
||||
for (const item of (result.data || [])) {
|
||||
const resource = item.encrypt_certificate
|
||||
if (!resource) continue
|
||||
const certPem = decryptResource(resource)
|
||||
certs[item.serial_no] = certPem
|
||||
}
|
||||
if (Object.keys(certs).length > 0) {
|
||||
_platformCerts = certs
|
||||
_platformCertsExpiry = Date.now() + 12 * 3600 * 1000
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WXPAY] cert refresh failed, keeping old certs:', err.message)
|
||||
if (Object.keys(_platformCerts).length === 0) throw err
|
||||
}
|
||||
return _platformCerts
|
||||
}
|
||||
|
||||
async function verifyNotifySignature(headers, rawBody) {
|
||||
const timestamp = headers['wechatpay-timestamp']
|
||||
const nonce = headers['wechatpay-nonce']
|
||||
const signature = headers['wechatpay-signature']
|
||||
const serial = headers['wechatpay-serial']
|
||||
if (!timestamp || !nonce || !signature || !serial) throw new Error('missing wechatpay headers')
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (Math.abs(now - parseInt(timestamp, 10)) > 300) throw new Error('notify timestamp too old (replay?)')
|
||||
|
||||
try {
|
||||
const certs = await fetchPlatformCertificates()
|
||||
const publicKey = certs[serial]
|
||||
if (!publicKey) throw new Error('unknown platform certificate serial: ' + serial)
|
||||
const message = timestamp + '\n' + nonce + '\n' + rawBody + '\n'
|
||||
const verify = crypto.createVerify('RSA-SHA256')
|
||||
verify.update(message)
|
||||
if (!verify.verify(publicKey, signature, 'base64')) {
|
||||
throw new Error('notify signature verification failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WXPAY] signature verification failed:', err.message)
|
||||
throw err
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function decryptResource(resource) {
|
||||
if (!resource || !resource.ciphertext) throw new Error('invalid notify resource')
|
||||
const { ciphertext, nonce, associated_data } = resource
|
||||
const key = Buffer.from(config.wxpay.apiV3Key, 'utf8')
|
||||
const iv = Buffer.from(nonce, 'utf8')
|
||||
const aad = Buffer.from(associated_data || '', 'utf8')
|
||||
const data = Buffer.from(ciphertext, 'base64')
|
||||
const authTag = data.slice(data.length - 16)
|
||||
const encrypted = data.slice(0, data.length - 16)
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
decipher.setAAD(aad)
|
||||
let decrypted = decipher.update(encrypted, null, 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
function decryptNotifyResource(resource) {
|
||||
return JSON.parse(decryptResource(resource))
|
||||
}
|
||||
|
||||
async function queryOrder(orderId) {
|
||||
const path = '/v3/pay/transactions/out-trade-no/' + orderId + '?mchid=' + config.wxpay.mchId
|
||||
return httpsRequest('GET', path)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
createPrepayOrder,
|
||||
generatePaymentParams,
|
||||
verifyNotifySignature,
|
||||
decryptNotifyResource,
|
||||
queryOrder
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const { ok } = require('../lib/response')
|
||||
const config = require('../config')
|
||||
const wxpay = require('../lib/wxpay')
|
||||
const paymentOrderDao = require('../dao/payment-order.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
async function handleNotify(req, res) {
|
||||
try {
|
||||
if (!Buffer.isBuffer(req.body)) throw new Error('invalid body format')
|
||||
const rawBody = req.body.toString('utf8')
|
||||
const parsed = JSON.parse(rawBody)
|
||||
|
||||
await wxpay.verifyNotifySignature(req.headers, rawBody)
|
||||
|
||||
const result = wxpay.decryptNotifyResource(parsed.resource)
|
||||
|
||||
// Validate appid and mchid
|
||||
const expectedAppid = config.wxpay.appid || config.wechat.appid
|
||||
if (result.appid !== expectedAppid) throw new Error('appid mismatch')
|
||||
if (result.mchid !== config.wxpay.mchId) throw new Error('mchid mismatch')
|
||||
|
||||
const orderId = result.out_trade_no
|
||||
const order = await paymentOrderDao.findByOutTradeNo(orderId)
|
||||
if (!order) throw new Error('order not found: ' + orderId)
|
||||
|
||||
if (!result.amount || result.amount.total !== order.amount_fen) throw new Error('amount mismatch')
|
||||
|
||||
if (result.trade_state === 'SUCCESS') {
|
||||
const activated = await paymentOrderDao.markPaidAndActivateSubscription(orderId, result.transaction_id, result)
|
||||
if (activated) {
|
||||
await logDao.write({ user_id: order.user_id, action: 'payment_notify_success', detail: 'order: ' + orderId + ' tx: ' + result.transaction_id, ip: req.ip })
|
||||
}
|
||||
}
|
||||
|
||||
// WeChat expects this exact response format
|
||||
res.status(200).json({ code: 'SUCCESS', message: '' })
|
||||
} catch (err) {
|
||||
console.error('[WXPAY NOTIFY ERROR]', err.message)
|
||||
res.status(400).json({ code: 'FAIL', message: 'processing error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = handleNotify
|
||||
@@ -0,0 +1,50 @@
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
const wxpay = require('../lib/wxpay')
|
||||
const paymentOrderDao = require('../dao/payment-order.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
// Query order status (user)
|
||||
router.get('/payment/orders/:order_id', requireUser, wrap(async (req, res) => {
|
||||
const order = await paymentOrderDao.findByOutTradeNo(req.params.order_id)
|
||||
if (!order || order.user_id !== req.user.user_id) return res.json(fail(1005, 'order_not_found'))
|
||||
res.json(ok({
|
||||
order_id: order.order_id,
|
||||
plan: order.plan,
|
||||
amount_fen: order.amount_fen,
|
||||
status: order.status,
|
||||
paid_at: order.paid_at
|
||||
}))
|
||||
}))
|
||||
|
||||
// Sync order — query WeChat and activate if paid (user)
|
||||
router.post('/payment/orders/:order_id/sync', requireUser, wrap(async (req, res) => {
|
||||
const order = await paymentOrderDao.findByOutTradeNo(req.params.order_id)
|
||||
if (!order || order.user_id !== req.user.user_id) return res.json(fail(1005, 'order_not_found'))
|
||||
if (order.status === 'paid') return res.json(ok({ status: 'paid', message: 'already activated' }))
|
||||
|
||||
if (!wxpay.isConfigured()) return res.json(fail(2001, 'payment not configured'))
|
||||
|
||||
const wxOrder = await wxpay.queryOrder(order.order_id)
|
||||
if (wxOrder.trade_state === 'SUCCESS') {
|
||||
if (!wxOrder.amount || wxOrder.amount.payer_total !== order.amount_fen) {
|
||||
return res.json(fail(2001, 'amount mismatch'))
|
||||
}
|
||||
await paymentOrderDao.markPaidAndActivateSubscription(order.order_id, wxOrder.transaction_id, wxOrder)
|
||||
await logDao.write({ user_id: order.user_id, action: 'payment_sync_success', detail: 'order: ' + order.order_id, ip: req.ip })
|
||||
return res.json(ok({ status: 'paid', message: 'subscription activated' }))
|
||||
}
|
||||
|
||||
if (wxOrder.trade_state === 'CLOSED' || wxOrder.trade_state === 'REVOKED') {
|
||||
await paymentOrderDao.markClosed(order.order_id)
|
||||
} else if (wxOrder.trade_state === 'PAYERROR') {
|
||||
await paymentOrderDao.markFailed(order.order_id, wxOrder.trade_state)
|
||||
}
|
||||
|
||||
res.json(ok({ status: order.status, trade_state: wxOrder.trade_state }))
|
||||
}))
|
||||
|
||||
module.exports = router
|
||||
@@ -1,3 +1,4 @@
|
||||
const crypto = require('crypto')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
@@ -42,9 +43,43 @@ router.get('/subscription', requireUser, wrap(async (req, res) => {
|
||||
|
||||
router.post('/subscription/purchase', requireUser, wrap(async (req, res) => {
|
||||
const plan = req.body.plan || req.body.plan_type
|
||||
if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan'))
|
||||
if (!PLANS[plan] || plan === 'trial') return res.json(fail(2001, 'invalid plan'))
|
||||
|
||||
const wxpay = require('../lib/wxpay')
|
||||
const config = require('../config')
|
||||
if (!wxpay.isConfigured()) {
|
||||
if (config.nodeEnv === 'production') return res.json(fail(2001, 'payment not configured'))
|
||||
const orderId = 'ORD' + Date.now()
|
||||
res.json(ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount }))
|
||||
return res.json(ok({ order_id: orderId, payment_params: null, mock: true, plan, amount: PLANS[plan].amount }))
|
||||
}
|
||||
|
||||
const paymentOrderDao = require('../dao/payment-order.dao')
|
||||
const pendingCount = await paymentOrderDao.countPendingByUser(req.user.user_id)
|
||||
if (pendingCount >= 5) return res.json(fail(2001, '待支付订单过多,请先完成或取消现有订单'))
|
||||
|
||||
const settingsDao = require('../dao/settings.dao')
|
||||
const settings = await settingsDao.getAll()
|
||||
const priceYuan = plan === 'yearly'
|
||||
? (Number(settings.yearly_price) || PLANS[plan].amount)
|
||||
: (Number(settings.monthly_price) || PLANS[plan].amount)
|
||||
const amountFen = Math.round(priceYuan * 100)
|
||||
|
||||
const orderId = 'ORD' + Date.now() + crypto.randomBytes(6).toString('hex')
|
||||
await paymentOrderDao.createOrder(orderId, req.user.user_id, plan, amountFen)
|
||||
|
||||
const prepayId = await wxpay.createPrepayOrder({
|
||||
openid: req.user.openid,
|
||||
orderId,
|
||||
amountFen,
|
||||
description: '光子美容仪-' + (plan === 'yearly' ? '年卡' : '月卡')
|
||||
})
|
||||
|
||||
await paymentOrderDao.markPrepay(orderId, prepayId)
|
||||
const paymentParams = wxpay.generatePaymentParams(prepayId)
|
||||
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'payment_create', detail: 'order: ' + orderId + ' plan: ' + plan, ip: req.ip })
|
||||
|
||||
res.json(ok({ order_id: orderId, payment_params: paymentParams, plan, amount: priceYuan }))
|
||||
}))
|
||||
|
||||
router.post('/subscription/mock-purchase', requireUser, wrap(async (req, res) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户