feat: add password change, trial subscription, batch import, UX improvements
Server: - POST /api/v1/admin/password: admin password change with bcrypt migration - POST /api/v1/subscription/trial: user trial activation, one per user - POST /api/v1/admin/devices/batch: bulk device import (up to 500) - Add trial plan (7 days, free) to PLANS constant Admin console: - Settings page: add password change form with validation - Device page: add batch import modal with textarea input Miniprogram: - Treating page: add back button with stop-treatment confirmation - Index page: add mock device bind button (dev mode only)
这个提交包含在:
@@ -11,6 +11,7 @@
|
|||||||
/>
|
/>
|
||||||
<button class="btn-primary btn-sm" @click="onSearch">搜索</button>
|
<button class="btn-primary btn-sm" @click="onSearch">搜索</button>
|
||||||
<button class="btn-primary btn-sm" @click="onCreateDevice">预生成产品码</button>
|
<button class="btn-primary btn-sm" @click="onCreateDevice">预生成产品码</button>
|
||||||
|
<button class="btn-default btn-sm" @click="showBatchImport = true">批量导入</button>
|
||||||
<button class="btn-default btn-sm" @click="onExport">导出</button>
|
<button class="btn-default btn-sm" @click="onExport">导出</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -55,6 +56,21 @@
|
|||||||
<text class="page-info">共 {{ totalPages }} 页</text>
|
<text class="page-info">共 {{ totalPages }} 页</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-mask" v-if="showBatchImport" @click="showBatchImport = false">
|
||||||
|
<view class="modal-content" @click.stop>
|
||||||
|
<view class="modal-title">批量导入设备</view>
|
||||||
|
<view class="form-group">
|
||||||
|
<text class="form-label">设备编号(每行一个)</text>
|
||||||
|
<textarea class="form-textarea" v-model="batchDeviceIds" placeholder="输入设备编号,每行一个 例如: HOX001 HOX002 HOX003" :maxlength="-1"></textarea>
|
||||||
|
</view>
|
||||||
|
<view class="batch-hint">最多 500 个设备</view>
|
||||||
|
<view class="modal-actions">
|
||||||
|
<button class="btn-default" @click="showBatchImport = false">取消</button>
|
||||||
|
<button class="btn-primary" @click="onBatchImport" :loading="batchImporting">导入</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -73,7 +89,10 @@ export default {
|
|||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
|
statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' },
|
||||||
|
showBatchImport: false,
|
||||||
|
batchDeviceIds: '',
|
||||||
|
batchImporting: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -146,6 +165,29 @@ export default {
|
|||||||
return map[status] || 'badge badge-default'
|
return map[status] || 'badge badge-default'
|
||||||
},
|
},
|
||||||
formatDate: formatDateShort,
|
formatDate: formatDateShort,
|
||||||
|
async onBatchImport() {
|
||||||
|
const ids = this.batchDeviceIds.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (ids.length === 0) {
|
||||||
|
uni.showToast({ title: '请输入设备编号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ids.length > 500) {
|
||||||
|
uni.showToast({ title: '最多 500 个', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.batchImporting = true
|
||||||
|
try {
|
||||||
|
const result = await post('/api/v1/admin/devices/batch', { device_ids: ids })
|
||||||
|
uni.showToast({ title: '导入 ' + result.created + ' 个设备', icon: 'success' })
|
||||||
|
this.showBatchImport = false
|
||||||
|
this.batchDeviceIds = ''
|
||||||
|
this.loadDevices()
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '导入失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.batchImporting = false
|
||||||
|
}
|
||||||
|
},
|
||||||
async onExport() {
|
async onExport() {
|
||||||
try {
|
try {
|
||||||
const data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
|
const data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
|
||||||
@@ -171,4 +213,61 @@ export default {
|
|||||||
.pagination {
|
.pagination {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 480px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminLayout currentPage="/pages/settings/index">
|
<AdminLayout currentPage="/pages/settings/index">
|
||||||
<view class="page-card">
|
<view class="page-card">
|
||||||
|
<view class="section-card">
|
||||||
|
<view class="section-title">修改密码</view>
|
||||||
|
<view class="form-grid">
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">当前密码</text>
|
||||||
|
<input class="form-input" v-model="passwordForm.old_password" type="password" placeholder="请输入当前密码" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">新密码</text>
|
||||||
|
<input class="form-input" v-model="passwordForm.new_password" type="password" placeholder="请输入新密码(≥6位)" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">确认新密码</text>
|
||||||
|
<input class="form-input" v-model="passwordForm.confirm_password" type="password" placeholder="请再次输入新密码" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label"></text>
|
||||||
|
<button class="btn-primary btn-sm" @click="onChangePassword">修改密码</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="section-card">
|
<view class="section-card">
|
||||||
<view class="section-title">基础配置</view>
|
<view class="section-title">基础配置</view>
|
||||||
<view class="form-grid">
|
<view class="form-grid">
|
||||||
@@ -123,7 +145,8 @@ export default {
|
|||||||
enable_free_mode: true,
|
enable_free_mode: true,
|
||||||
maintenance_mode: false
|
maintenance_mode: false
|
||||||
},
|
},
|
||||||
timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London']
|
timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London'],
|
||||||
|
passwordForm: { old_password: '', new_password: '', confirm_password: '' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
@@ -153,6 +176,30 @@ export default {
|
|||||||
},
|
},
|
||||||
onTimezoneChange(e) {
|
onTimezoneChange(e) {
|
||||||
this.settings.timezone = this.timezoneOptions[e.detail.value]
|
this.settings.timezone = this.timezoneOptions[e.detail.value]
|
||||||
|
},
|
||||||
|
async onChangePassword() {
|
||||||
|
if (!this.passwordForm.old_password || !this.passwordForm.new_password) {
|
||||||
|
uni.showToast({ title: '请填写完整', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.passwordForm.new_password.length < 6) {
|
||||||
|
uni.showToast({ title: '新密码至少6位', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||||
|
uni.showToast({ title: '两次密码不一致', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await post('/api/v1/admin/password', {
|
||||||
|
old_password: this.passwordForm.old_password,
|
||||||
|
new_password: this.passwordForm.new_password
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '密码修改成功', icon: 'success' })
|
||||||
|
this.passwordForm = { old_password: '', new_password: '', confirm_password: '' }
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '修改失败', icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,4 +385,11 @@ export default {
|
|||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -246,6 +246,10 @@ page {
|
|||||||
margin-bottom: 8rpx;
|
margin-bottom: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-back-light {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
.debug-actions {
|
.debug-actions {
|
||||||
margin-top: 40rpx;
|
margin-top: 40rpx;
|
||||||
padding: 20rpx;
|
padding: 20rpx;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
var ble = require('../../services/ble')
|
var ble = require('../../services/ble')
|
||||||
var http = require('../../utils/request')
|
var http = require('../../utils/request')
|
||||||
|
var config = require('../../config/env')
|
||||||
var app = getApp()
|
var app = getApp()
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
@@ -12,10 +13,12 @@ Page({
|
|||||||
modeState: '空闲',
|
modeState: '空闲',
|
||||||
bleState: 'disconnected',
|
bleState: 'disconnected',
|
||||||
hasDevice: false,
|
hasDevice: false,
|
||||||
deviceInfo: null
|
deviceInfo: null,
|
||||||
|
devMode: false
|
||||||
},
|
},
|
||||||
|
|
||||||
onShow: function () {
|
onShow: function () {
|
||||||
|
this.setData({ devMode: config.__DEV__ || false })
|
||||||
this.checkState()
|
this.checkState()
|
||||||
this._onStatus = this.onBleStatus.bind(this)
|
this._onStatus = this.onBleStatus.bind(this)
|
||||||
ble.on('status', this._onStatus)
|
ble.on('status', this._onStatus)
|
||||||
@@ -145,6 +148,30 @@ Page({
|
|||||||
wx.navigateTo({ url: '/pages/wear-check/wear-check' })
|
wx.navigateTo({ url: '/pages/wear-check/wear-check' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onMockBind: function () {
|
||||||
|
var self = this
|
||||||
|
var mockDeviceId = 'MOCK_' + Date.now().toString(36).toUpperCase()
|
||||||
|
wx.showModal({
|
||||||
|
title: '模拟绑定',
|
||||||
|
content: '将创建一个模拟设备 ' + mockDeviceId + ' 并绑定到当前账号',
|
||||||
|
success: function (res) {
|
||||||
|
if (res.confirm) {
|
||||||
|
http.post('/api/v1/device/bind', { device_id: mockDeviceId }).then(function (data) {
|
||||||
|
return http.post('/api/v1/device/bind/confirm', {
|
||||||
|
device_id: data.device_id,
|
||||||
|
bind_token: data.bind_token
|
||||||
|
})
|
||||||
|
}).then(function () {
|
||||||
|
wx.showToast({ title: '模拟绑定成功', icon: 'success' })
|
||||||
|
self.checkState()
|
||||||
|
}).catch(function (err) {
|
||||||
|
wx.showToast({ title: err.message || '绑定失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
onViewSubscription: function () {
|
onViewSubscription: function () {
|
||||||
if (!this.data.subscription || this.data.subscription.status !== 'active') {
|
if (!this.data.subscription || this.data.subscription.status !== 'active') {
|
||||||
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
|
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<text class="empty-icon">📱</text>
|
<text class="empty-icon">📱</text>
|
||||||
<view class="empty-text">还没有绑定设备</view>
|
<view class="empty-text">还没有绑定设备</view>
|
||||||
<button class="btn-primary mt-30" bindtap="onAddDevice">扫码添加设备</button>
|
<button class="btn-primary mt-30" bindtap="onAddDevice">扫码添加设备</button>
|
||||||
|
<view class="debug-actions" wx:if="{{devMode}}">
|
||||||
|
<view class="debug-label">🔧 调试工具</view>
|
||||||
|
<button class="btn-secondary btn-sm" bindtap="onMockBind">模拟绑定设备</button>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,10 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onBack: function () {
|
||||||
|
this.onStop()
|
||||||
|
},
|
||||||
|
|
||||||
onStop: function () {
|
onStop: function () {
|
||||||
var self = this
|
var self = this
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<view class="page">
|
<view class="page">
|
||||||
<view class="page-header page-header-blue" style="padding-top: {{statusBarHeight + 24}}px;">
|
<view class="page-header page-header-blue" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||||
|
<view class="nav-back nav-back-light" bindtap="onBack">‹ 返回</view>
|
||||||
<view class="page-header-title">护理中</view>
|
<view class="page-header-title">护理中</view>
|
||||||
<view class="page-header-subtitle">正在护理...</view>
|
<view class="page-header-subtitle">正在护理...</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ function register(router) {
|
|||||||
return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })
|
return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/api/v1/admin/password', async ctx => {
|
||||||
|
const admin = await requireAdmin(ctx)
|
||||||
|
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||||
|
const oldPassword = ctx.body.old_password || ''
|
||||||
|
const newPassword = ctx.body.new_password || ''
|
||||||
|
if (newPassword.length < 6) return fail(2001, 'password too short')
|
||||||
|
const current = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: admin.admin_id })
|
||||||
|
if (!current) return fail(1002, '未授权,请重新登录')
|
||||||
|
let matched = verifyPassword(oldPassword, current.password_hash)
|
||||||
|
if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) {
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
if (!matched) return fail(1001, '原密码错误')
|
||||||
|
const newHash = hashPassword(newPassword)
|
||||||
|
await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id })
|
||||||
|
await writeLog({ admin_id: admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: ctx.ip })
|
||||||
|
return ok({ message: 'success' })
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/api/v1/admin/dashboard', async ctx => {
|
router.get('/api/v1/admin/dashboard', async ctx => {
|
||||||
const admin = await requireAdmin(ctx)
|
const admin = await requireAdmin(ctx)
|
||||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||||
@@ -71,6 +90,36 @@ function register(router) {
|
|||||||
return ok({ device_id: deviceId })
|
return ok({ device_id: deviceId })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/api/v1/admin/devices/batch', async ctx => {
|
||||||
|
const admin = await requireAdmin(ctx)
|
||||||
|
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||||
|
const deviceIds = ctx.body.device_ids
|
||||||
|
if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) return fail(2001, 'device_ids must be an array with 1-500 items')
|
||||||
|
let successCount = 0
|
||||||
|
const failedIds = []
|
||||||
|
for (const id of deviceIds) {
|
||||||
|
const deviceId = String(id || '').trim()
|
||||||
|
if (!deviceId) { failedIds.push(id); continue }
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
|
||||||
|
{
|
||||||
|
device_id: deviceId,
|
||||||
|
product_id: 'HOX_LIGHT_MASK',
|
||||||
|
device_secret: '',
|
||||||
|
device_name: '光子美容仪',
|
||||||
|
firmware_version: '1.0.0'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
successCount++
|
||||||
|
} catch (err) {
|
||||||
|
failedIds.push(deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + successCount + '/' + deviceIds.length, ip: ctx.ip })
|
||||||
|
return ok({ created: successCount, failed: failedIds })
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/api/v1/admin/devices/:device_id', async ctx => {
|
router.get('/api/v1/admin/devices/:device_id', async ctx => {
|
||||||
const admin = await requireAdmin(ctx)
|
const admin = await requireAdmin(ctx)
|
||||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { requireUser, requireAdmin } = require('../lib/auth')
|
|||||||
const { writeLog } = require('../lib/log')
|
const { writeLog } = require('../lib/log')
|
||||||
|
|
||||||
const PLANS = {
|
const PLANS = {
|
||||||
|
trial: { amount: 0, days: 7 },
|
||||||
monthly: { amount: 99, days: 30 },
|
monthly: { amount: 99, days: 30 },
|
||||||
yearly: { amount: 899, days: 365 }
|
yearly: { amount: 899, days: 365 }
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,18 @@ function register(router) {
|
|||||||
return ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount })
|
return ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/api/v1/subscription/trial', async ctx => {
|
||||||
|
const user = await requireUser(ctx)
|
||||||
|
if (!user) return fail(1001, 'invalid_token')
|
||||||
|
const usedTrial = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = \'trial\' LIMIT 1', { user_id: user.user_id })
|
||||||
|
if (usedTrial) return fail(2001, '已使用过试用')
|
||||||
|
const activeSub = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1', { user_id: user.user_id })
|
||||||
|
if (activeSub) return fail(2001, '已有有效订阅')
|
||||||
|
const orderId = 'TRIAL' + Date.now()
|
||||||
|
await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, \'trial\', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', { user_id: user.user_id, order_id: orderId })
|
||||||
|
return ok({ status: 'active', plan: 'trial', remaining_days: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
// Temporary: admin-only until payment integration
|
// Temporary: admin-only until payment integration
|
||||||
router.post('/api/v1/subscription/verify', async ctx => {
|
router.post('/api/v1/subscription/verify', async ctx => {
|
||||||
const admin = await requireAdmin(ctx)
|
const admin = await requireAdmin(ctx)
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户