fix: 修复支付页测试/生产 Product 映射串用与无法保存

切换环境时先暂存再切换;后端 Profile 不再串用另一侧商品;支持一键填充 OpsXshell 与跨环境复制,调试使用当前页映射。
This commit is contained in:
Logan.Li committed 2026-08-12 23:35:27 +08:00
1 parent 9b79eabd34
commit 7c146cb639
5 files changed
+317 -71

No files matched your search

+129 -51
View File
@@ -8,6 +8,10 @@
<el-tag size="small" type="info" style="margin-left: 8px">后台配置优先于 .env</el-tag>
</div>
<div class="header-actions">
<el-select v-model="debugPlan" size="default" style="width: 120px">
<el-option label="测包年" value="yearly" />
<el-option label="测永久" value="perpetual" />
</el-select>
<el-button :loading="debugging" @click="testConnection">测试连接 / 调试</el-button>
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
@@ -18,7 +22,7 @@
type="info"
:closable="false"
show-icon
title="测试 / 生产环境的私钥与 Product ID 映射相互隔离。切换到生产后需单独填写生产密钥;保存后才会生效。"
title="测试 / 生产的私钥与 Product 映射相互隔离。改映射后需点「保存」;调试不会自动写库。生产 create-session 需 Waffo 门店已 Go-Live。"
style="margin-bottom: 16px"
/>
@@ -30,7 +34,7 @@
<div class="env-row">
<span :class="{ muted: isLive }">测试</span>
<el-switch
v-model="isLive"
:model-value="isLive"
inline-prompt
active-text="生产"
inactive-text="测试"
@@ -42,7 +46,7 @@
{{ isLive ? 'live' : 'test' }}
</el-tag>
</div>
<div class="hint">切换环境会载入该环境的 Product 映射;若该环境尚未配置私钥,需重新粘贴保存。</div>
<div class="hint">切换前会暂存当前映射到对应环境;未配置私钥的环境需单独粘贴保存。</div>
</el-form-item>
<el-form-item label="商户 ID">
<el-input v-model="waffo.merchant_id" placeholder="MER_…" />
@@ -72,8 +76,9 @@
style="margin-top: 8px"
/>
</el-form-item>
<el-form-item label="货币">
<el-input v-model="waffo.currency" style="width: 120px" />
<el-form-item label="默认货币">
<el-input v-model="waffo.currency" style="width: 120px" placeholder="CNY" />
<div class="hint">映射表可按计划覆盖(包年 CNY / 永久 USD)</div>
</el-form-item>
<el-form-item label="SUCCESS_URL">
<el-input v-model="waffo.success_url" placeholder="支付成功回跳" />
@@ -93,41 +98,55 @@
<template #header>
<div class="card-header">
<span class="title">Product ID 映射({{ isLive ? '生产' : '测试' }})</span>
<el-button size="small" type="primary" @click="addMapping">新增行</el-button>
<div class="header-actions">
<el-button size="small" @click="fillOpsxshellProducts">填充 OpsXshell 商品</el-button>
<el-button size="small" @click="copyMappingsFromOther">
{{ isLive ? '从测试复制' : '从生产复制' }}
</el-button>
<el-button size="small" type="primary" @click="addMapping">新增行</el-button>
</div>
</div>
</template>
<el-alert
type="warning"
:closable="false"
show-icon
title="测试与生产的 Product ID 通常不同,已按环境分别保存。切换开关即可编辑对应环境映射。"
title="调试按钮不会写库。改完映射请点右上角「保存配置」。生产与测试可点「填充 OpsXshell 商品」一键写入包年/永久 Product ID。"
style="margin-bottom: 12px"
/>
<el-table :data="productMappings" border stripe>
<el-table-column label="计划类型 / Key" min-width="180">
<el-table-column label="计划类型 / Key" min-width="160">
<template #default="{ row }">
<el-select
v-model="row.plan_key"
filterable
allow-create
default-first-option
placeholder="如 yearly / perpetual / trial"
placeholder="yearly / perpetual"
style="width: 100%"
>
<el-option v-for="opt in planKeyOptions" :key="opt" :label="opt" :value="opt" />
</el-select>
</template>
</el-table-column>
<el-table-column label="说明" min-width="140">
<el-table-column label="说明" min-width="120">
<template #default="{ row }">
<el-input v-model="row.label" placeholder="可选说明" />
<el-input v-model="row.label" placeholder="可选" />
</template>
</el-table-column>
<el-table-column label="Waffo Product ID" min-width="220">
<el-table-column label="Waffo Product ID" min-width="240">
<template #default="{ row }">
<el-input v-model="row.product_id" placeholder="PROD_…" />
</template>
</el-table-column>
<el-table-column label="货币" width="110">
<template #default="{ row }">
<el-select v-model="row.currency" allow-create filterable placeholder="CNY" style="width: 100%">
<el-option label="CNY" value="CNY" />
<el-option label="USD" value="USD" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ $index }">
<el-button size="small" type="danger" link @click="removeMapping($index)">删除</el-button>
@@ -150,9 +169,26 @@ import { getWaffoSettings, updateWaffoSettings, testWaffoConnection } from '@/ap
const planKeyOptions = ['trial', 'yearly', 'perpetual', 'monthly', 'lifetime']
// OpsXshell 线上商品(pancake 购买链接对应) // Logan.Li
const OPSXSHELL_PRODUCTS = [
{
plan_key: 'yearly',
label: 'OpsXshell Yearly',
product_id: 'PROD_5PqVJyEiCM4nDgVoA0RKjN',
currency: 'CNY',
},
{
plan_key: 'perpetual',
label: 'OpsXshell Perpetual',
product_id: 'PROD_3BSJqDRkVBoYNgO78NsTAU',
currency: 'USD',
},
]
const loading = ref(false)
const saving = ref(false)
const debugging = ref(false)
const debugPlan = ref('yearly')
const showPrivateKey = ref(false)
const privateKeyInput = ref('')
const clearKeyFlag = ref(false)
@@ -177,17 +213,12 @@ const waffo = reactive({
private_key_configured: false,
})
const isLive = computed({
get: () => waffo.environment === 'live',
set: (v) => {
waffo.environment = v ? 'live' : 'test'
},
})
const isLive = computed(() => waffo.environment === 'live')
function defaultMappings() {
return [
{ plan_key: 'yearly', label: '包年', product_id: '' },
{ plan_key: 'perpetual', label: '永久', product_id: '' },
{ plan_key: 'yearly', label: '包年', product_id: '', currency: 'CNY' },
{ plan_key: 'perpetual', label: '永久', product_id: '', currency: 'USD' },
]
}
@@ -197,28 +228,43 @@ function normalizeMappings(data = {}) {
plan_key: m.plan_key || m.key || m.plan_type || '',
label: m.label || '',
product_id: m.product_id || '',
currency: (m.currency || '').toUpperCase() || guessCurrency(m.plan_key),
}))
}
const rows = []
if (data.product_id_yearly) {
rows.push({ plan_key: 'yearly', label: '包年', product_id: data.product_id_yearly })
rows.push({
plan_key: 'yearly',
label: '包年',
product_id: data.product_id_yearly,
currency: 'CNY',
})
}
if (data.product_id_perpetual || data.product_id) {
rows.push({
plan_key: 'perpetual',
label: '永久',
product_id: data.product_id_perpetual || data.product_id,
currency: 'USD',
})
}
return rows.length ? rows : defaultMappings()
}
function guessCurrency(planKey) {
const k = (planKey || '').toLowerCase()
if (k === 'perpetual' || k === 'lifetime') return 'USD'
return 'CNY'
}
function mappingsToCompatFields(rows) {
const map = {}
const currencyMap = {}
for (const row of rows) {
const key = (row.plan_key || '').trim()
if (!key) continue
map[key] = (row.product_id || '').trim()
currencyMap[key] = (row.currency || '').trim().toUpperCase()
}
return {
product_mappings: rows
@@ -227,18 +273,21 @@ function mappingsToCompatFields(rows) {
plan_key: r.plan_key.trim(),
label: r.label || '',
product_id: (r.product_id || '').trim(),
currency: (r.currency || '').trim().toUpperCase(),
})),
product_id_yearly: map.yearly || '',
product_id_perpetual: map.perpetual || map.lifetime || '',
product_id: map.perpetual || map.lifetime || map.yearly || '',
currency_yearly: currencyMap.yearly || '',
currency_perpetual: currencyMap.perpetual || currencyMap.lifetime || '',
}
}
function stashCurrentEnv() {
const env = waffo.environment === 'live' ? 'live' : 'test'
profiles.value[env] = {
function stashEnv(env) {
const target = env === 'live' ? 'live' : 'test'
profiles.value[target] = {
product_mappings: productMappings.value.map((r) => ({ ...r })),
private_key_configured: waffo.private_key_configured,
private_key_configured: !!(keyByEnv.value[target] || waffo.private_key_configured),
}
}
@@ -251,18 +300,21 @@ function applyEnv(env) {
waffo.private_key_configured = configured
privateKeyInput.value = ''
clearKeyFlag.value = false
// 未配置私钥时强制展开输入框,避免仍显示另一环境密钥状态 // Logan.Li
showPrivateKey.value = !configured
}
function onEnvSwitch(live) {
stashCurrentEnv()
applyEnv(live ? 'live' : 'test')
ElMessage.info(live ? '已切换到生产环境视图(保存后生效)' : '已切换到测试环境视图(保存后生效)')
const next = live ? 'live' : 'test'
const prev = waffo.environment === 'live' ? 'live' : 'test'
if (next === prev) return
// 必须先把当前表写入「切换前」环境,再加载目标环境(勿用会提前改 environment 的 v-model) // Logan.Li
stashEnv(prev)
applyEnv(next)
ElMessage.info(live ? '已切换到生产映射(记得保存)' : '已切换到测试映射(记得保存)')
}
function addMapping() {
productMappings.value.push({ plan_key: '', label: '', product_id: '' })
productMappings.value.push({ plan_key: '', label: '', product_id: '', currency: 'CNY' })
}
function removeMapping(index) {
@@ -270,6 +322,46 @@ function removeMapping(index) {
if (!productMappings.value.length) addMapping()
}
function fillOpsxshellProducts() {
const byKey = Object.fromEntries(productMappings.value.map((r) => [r.plan_key, r]))
for (const item of OPSXSHELL_PRODUCTS) {
if (byKey[item.plan_key]) {
byKey[item.plan_key].product_id = item.product_id
byKey[item.plan_key].label = item.label
byKey[item.plan_key].currency = item.currency
} else {
productMappings.value.push({ ...item })
}
}
// 保证 yearly / perpetual 在前 // Logan.Li
const order = ['yearly', 'perpetual']
productMappings.value.sort((a, b) => {
const ia = order.indexOf(a.plan_key)
const ib = order.indexOf(b.plan_key)
if (ia === -1 && ib === -1) return 0
if (ia === -1) return 1
if (ib === -1) return -1
return ia - ib
})
stashEnv(waffo.environment)
ElMessage.success(`已填入当前「${isLive.value ? '生产' : '测试'}」映射,请点保存`)
}
function copyMappingsFromOther() {
const other = isLive.value ? 'test' : 'live'
stashEnv(waffo.environment)
const src = profiles.value[other]
const rows = normalizeMappings(src || {})
const hasId = rows.some((r) => (r.product_id || '').trim())
if (!hasId) {
ElMessage.warning(`${other === 'live' ? '生产' : '测试'}环境尚无 Product ID,无法复制`)
return
}
productMappings.value = rows.map((r) => ({ ...r }))
stashEnv(waffo.environment)
ElMessage.success(`已从${other === 'live' ? '生产' : '测试'}复制到当前,请点保存`)
}
async function load() {
loading.value = true
try {
@@ -287,14 +379,6 @@ async function load() {
test: data.profiles?.test || { product_mappings: [] },
live: data.profiles?.live || { product_mappings: [] },
}
// 当前环境优先用顶层字段(已 ApplyEnvProfile) // Logan.Li
profiles.value[waffo.environment] = {
product_mappings: data.product_mappings || [],
product_id: data.product_id,
product_id_yearly: data.product_id_yearly,
product_id_perpetual: data.product_id_perpetual,
private_key_configured: data.private_key_configured,
}
applyEnv(waffo.environment)
debugResult.value = ''
} finally {
@@ -305,7 +389,7 @@ async function load() {
async function save() {
saving.value = true
try {
stashCurrentEnv()
stashEnv(waffo.environment)
const compat = mappingsToCompatFields(productMappings.value)
const payload = {
enabled: waffo.enabled,
@@ -321,9 +405,6 @@ async function save() {
if (privateKeyInput.value.trim()) {
payload.private_key = privateKeyInput.value.trim()
payload.clear_private_key = false
} else if (!waffo.private_key_configured) {
// 当前环境无密钥且未填写:明确清除该环境密钥位 // Logan.Li
payload.clear_private_key = true
}
const res = await updateWaffoSettings(payload)
Object.assign(waffo, res.data || {})
@@ -333,12 +414,8 @@ async function save() {
live: !!(data.private_key_configured_by_env?.live),
}
profiles.value = {
test: data.profiles?.test || profiles.value.test,
live: data.profiles?.live || profiles.value.live,
}
profiles.value[waffo.environment] = {
product_mappings: data.product_mappings || [],
private_key_configured: data.private_key_configured,
test: data.profiles?.test || { product_mappings: [] },
live: data.profiles?.live || { product_mappings: [] },
}
applyEnv(waffo.environment)
ElMessage.success('支付配置已保存')
@@ -375,10 +452,11 @@ async function testConnection() {
store_id: waffo.store_id,
store_slug: waffo.store_slug,
environment: waffo.environment,
plan_type: debugPlan.value,
...compat,
})
debugResult.value = JSON.stringify(res.data ?? res, null, 2)
ElMessage.success('调试请求已完成')
ElMessage.success('调试请求已完成(未写库;映射请手动保存)')
} catch (err) {
debugResult.value = JSON.stringify({
error: err?.response?.data || err?.message || String(err),
@@ -392,9 +470,9 @@ onMounted(load)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.title { font-weight: 600; }
.header-actions { display: flex; gap: 8px; }
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.secret-row { display: flex; align-items: center; gap: 10px; }
.env-row { display: flex; align-items: center; }
.muted { color: #c0c4cc; }