55 changed files with 4098 additions and 1108 deletions
@ -1,116 +0,0 @@ |
|||
<template> |
|||
<!-- 仪表盘顶部第三方服务异常告警条。没有异常时整块不渲染,不占首屏。 --> |
|||
<div v-if="errorCount + warnCount > 0" class="alert-banner" :class="errorCount > 0 ? 'lv-error' : 'lv-warn'"> |
|||
<div class="ab-head"> |
|||
<span class="ab-ico">{{ errorCount > 0 ? '🚨' : '⚠️' }}</span> |
|||
<div class="ab-title"> |
|||
<strong> |
|||
{{ errorCount > 0 ? `${errorCount} 项第三方服务不可用` : `${warnCount} 项第三方服务存在隐患` }} |
|||
</strong> |
|||
<span v-if="errorCount > 0 && warnCount > 0" class="ab-sub">,另有 {{ warnCount }} 项隐患</span> |
|||
<span class="ab-sub"> · 最近巡检 {{ lastRunText }}</span> |
|||
</div> |
|||
<button class="ab-btn" @click="expanded = !expanded">{{ expanded ? '收起' : '展开详情' }}</button> |
|||
<NuxtLink to="/svcinspect" class="ab-btn ab-btn-primary">前往巡检</NuxtLink> |
|||
</div> |
|||
|
|||
<div v-if="expanded" class="ab-list"> |
|||
<div v-for="a in alerts" :key="`${a.app_name}/${a.svc_id}/${a.region}`" class="ab-item"> |
|||
<span class="ab-dot" :class="a.status === 'error' ? 'd-error' : 'd-warn'"></span> |
|||
<span class="ab-name">{{ a.name || a.svc_id }}</span> |
|||
<span v-if="a.provider" class="ab-chip">{{ a.provider }}</span> |
|||
<span class="ab-chip ab-chip-scope">{{ scopeText(a) }}</span> |
|||
<span class="ab-msg">{{ reasonOf(a) }}</span> |
|||
<span v-if="a.fail_streak > 1" class="ab-streak">连续失败 {{ a.fail_streak }} 次</span> |
|||
</div> |
|||
<div v-if="truncated" class="ab-more"> |
|||
仅显示前 {{ alerts.length }} 条,其余请到<NuxtLink to="/svcinspect" class="ab-link">服务巡检</NuxtLink>查看 |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { REGION_LABEL, healthReason, healthLastRunText, type SvcHealth } from '~/utils/svchealth' |
|||
|
|||
const { webApi } = useApi() |
|||
|
|||
const alerts = ref<SvcHealth[]>([]) |
|||
const errorCount = ref(0) |
|||
const warnCount = ref(0) |
|||
const lastRun = ref(0) |
|||
const expanded = ref(false) |
|||
const truncated = ref(false) |
|||
|
|||
const LIMIT = 12 |
|||
|
|||
const lastRunText = computed(() => healthLastRunText(lastRun.value)) |
|||
|
|||
function scopeText(a: SvcHealth): string { |
|||
const app = a.app_name ? a.app_name : '全局' |
|||
return a.region ? `${app} · ${REGION_LABEL[a.region] || '区域' + a.region}` : app |
|||
} |
|||
|
|||
function reasonOf(a: SvcHealth): string { |
|||
return healthReason(a) |
|||
} |
|||
|
|||
async function load() { |
|||
try { |
|||
const res = await webApi<{ |
|||
alerts: SvcHealth[] |
|||
error_count: number |
|||
warn_count: number |
|||
last_run: number |
|||
}>('getsvcalerts', { limit: LIMIT }) |
|||
alerts.value = res.alerts || [] |
|||
errorCount.value = res.error_count || 0 |
|||
warnCount.value = res.warn_count || 0 |
|||
lastRun.value = res.last_run || 0 |
|||
truncated.value = errorCount.value + warnCount.value > alerts.value.length |
|||
// 有明确不可用的服务时默认展开——这类问题需要马上看见是哪几个,而不是再点一次。 |
|||
expanded.value = errorCount.value > 0 |
|||
} catch { |
|||
// 巡检数据拿不到不应该拖垮整个仪表盘,静默降级为「无告警」。 |
|||
alerts.value = [] |
|||
errorCount.value = 0 |
|||
warnCount.value = 0 |
|||
} |
|||
} |
|||
|
|||
onMounted(load) |
|||
defineExpose({ load }) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.alert-banner { border-radius:14px; padding:12px 16px; margin-bottom:18px; border:1px solid; } |
|||
.lv-error { background:linear-gradient(180deg,#fff1f2 0%,#fff7f7 100%); border-color:#fecdd3; } |
|||
.lv-warn { background:linear-gradient(180deg,#fffbeb 0%,#fffdf5 100%); border-color:#fde68a; } |
|||
|
|||
.ab-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } |
|||
.ab-ico { font-size:17px; line-height:1; } |
|||
.ab-title { font-size:13px; color:#334155; flex:1; min-width:200px; } |
|||
.lv-error .ab-title strong { color:#be123c; } |
|||
.lv-warn .ab-title strong { color:#b45309; } |
|||
.ab-sub { color:#94a3b8; font-size:12px; } |
|||
|
|||
.ab-btn { font-size:12px; padding:4px 11px; border-radius:8px; border:1px solid #e2e8f0; background:#fff; color:#475569; white-space:nowrap; } |
|||
.ab-btn:hover { background:#f8fafc; } |
|||
.ab-btn-primary { background:#0f172a; border-color:#0f172a; color:#fff; } |
|||
.ab-btn-primary:hover { background:#1e293b; } |
|||
|
|||
.ab-list { margin-top:10px; padding-top:10px; border-top:1px dashed rgba(148,163,184,.4); display:flex; flex-direction:column; gap:6px; } |
|||
.ab-item { display:flex; align-items:center; gap:8px; font-size:12.5px; color:#475569; flex-wrap:wrap; } |
|||
.ab-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; } |
|||
.d-error { background:#f43f5e; } |
|||
.d-warn { background:#f59e0b; } |
|||
.ab-name { font-weight:600; color:#1e293b; } |
|||
.ab-chip { font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; } |
|||
.ab-chip-scope { background:#e0e7ff; color:#4338ca; } |
|||
.ab-msg { color:#64748b; flex:1; min-width:160px; } |
|||
.ab-streak { font-size:11px; color:#be123c; font-weight:600; } |
|||
.ab-more { font-size:12px; color:#94a3b8; padding-top:2px; } |
|||
.ab-link { color:#4338ca; text-decoration:underline; margin:0 2px; } |
|||
</style> |
|||
@ -0,0 +1,142 @@ |
|||
<template> |
|||
<!-- 顶栏通知铃铛:第三方服务巡检的异常提醒。 |
|||
只对有「服务巡检」页面权限的账号(超管/管理员/运营)拉取与展示, |
|||
品牌商/渠道商这类外部角色既看不到菜单,也不该在这里看到平台的服务健康。 --> |
|||
<div class="dropdown dropdown-end"> |
|||
<div tabindex="0" class="btn btn-ghost btn-sm btn-circle relative" @click="onOpen"> |
|||
<svg class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> |
|||
</svg> |
|||
<span v-if="total > 0" class="bell-badge" :class="errorCount > 0 ? 'lv-error' : 'lv-warn'"> |
|||
{{ total > 99 ? '99+' : total }} |
|||
</span> |
|||
</div> |
|||
|
|||
<div tabindex="0" class="dropdown-content z-[2] shadow-xl bg-white rounded-xl w-96 border overflow-hidden"> |
|||
<div class="flex items-center justify-between px-4 py-3 border-b"> |
|||
<span class="text-sm font-semibold text-slate-700">系统通知</span> |
|||
<span class="text-[11px] text-slate-400">巡检 {{ lastRunText }}</span> |
|||
</div> |
|||
|
|||
<div v-if="!canSee || total === 0" class="px-3 py-6 text-center text-slate-400 text-xs">暂无新通知</div> |
|||
|
|||
<template v-else> |
|||
<div class="px-4 py-2.5 text-xs" :class="errorCount > 0 ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'"> |
|||
<span class="font-semibold"> |
|||
{{ errorCount > 0 ? `${errorCount} 项第三方服务不可用` : `${warnCount} 项第三方服务存在隐患` }} |
|||
</span> |
|||
<span v-if="errorCount > 0 && warnCount > 0" class="opacity-70">,另有 {{ warnCount }} 项隐患</span> |
|||
</div> |
|||
|
|||
<div class="bell-list"> |
|||
<NuxtLink v-for="a in alerts" :key="rowKey(a)" to="/svcinspect" class="bell-item"> |
|||
<span class="bell-dot" :class="a.status === 'error' ? 'd-error' : 'd-warn'"></span> |
|||
<div class="min-w-0 flex-1"> |
|||
<div class="flex items-center gap-1.5"> |
|||
<span class="bell-name">{{ a.name || a.svc_id }}</span> |
|||
<span class="bell-chip">{{ scopeText(a) }}</span> |
|||
<span v-if="a.fail_streak > 1" class="bell-streak">连续 {{ a.fail_streak }} 次</span> |
|||
</div> |
|||
<div class="bell-msg">{{ reasonOf(a) || '巡检异常' }}</div> |
|||
</div> |
|||
</NuxtLink> |
|||
</div> |
|||
|
|||
<NuxtLink to="/svcinspect" class="bell-foot"> |
|||
{{ truncated ? `仅显示前 ${alerts.length} 条 · 前往服务巡检` : '前往服务巡检' }} |
|||
</NuxtLink> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useAuthStore } from '~/stores/auth' |
|||
import { REGION_LABEL, healthReason, healthLastRunText, type SvcHealth } from '~/utils/svchealth' |
|||
|
|||
const { webApi } = useApi() |
|||
const auth = useAuthStore() |
|||
|
|||
const alerts = ref<SvcHealth[]>([]) |
|||
const errorCount = ref(0) |
|||
const warnCount = ref(0) |
|||
const lastRun = ref(0) |
|||
const truncated = ref(false) |
|||
|
|||
const LIMIT = 8 |
|||
const POLL_MS = 5 * 60 * 1000 |
|||
|
|||
// 只有能进「服务巡检」页的账号才拉这份数据(品牌商/渠道商无此菜单)。 |
|||
const canSee = computed(() => auth.hasAccess('svcinspect')) |
|||
const total = computed(() => errorCount.value + warnCount.value) |
|||
const lastRunText = computed(() => healthLastRunText(lastRun.value)) |
|||
|
|||
function rowKey(a: SvcHealth) { return `${a.app_name}/${a.svc_id}/${a.region}` } |
|||
|
|||
function scopeText(a: SvcHealth): string { |
|||
const app = a.app_name ? a.app_name : '全局' |
|||
return a.region ? `${app} · ${REGION_LABEL[a.region] || '区域' + a.region}` : app |
|||
} |
|||
|
|||
function reasonOf(a: SvcHealth): string { |
|||
return healthReason(a) |
|||
} |
|||
|
|||
async function load() { |
|||
if (!canSee.value || !auth.token) return |
|||
try { |
|||
const res = await webApi<{ |
|||
alerts: SvcHealth[] |
|||
error_count: number |
|||
warn_count: number |
|||
last_run: number |
|||
}>('getsvcalerts', { limit: LIMIT }) |
|||
alerts.value = res.alerts || [] |
|||
errorCount.value = res.error_count || 0 |
|||
warnCount.value = res.warn_count || 0 |
|||
lastRun.value = res.last_run || 0 |
|||
truncated.value = total.value > alerts.value.length |
|||
} catch { |
|||
// 巡检数据拿不到不该影响顶栏,静默降级为「无通知」。 |
|||
alerts.value = [] |
|||
errorCount.value = 0 |
|||
warnCount.value = 0 |
|||
truncated.value = false |
|||
} |
|||
} |
|||
|
|||
// 点开时刷一次,避免看到几分钟前的旧状态。 |
|||
function onOpen() { load() } |
|||
|
|||
let timer: ReturnType<typeof setInterval> | null = null |
|||
onMounted(() => { |
|||
load() |
|||
timer = setInterval(load, POLL_MS) |
|||
}) |
|||
onBeforeUnmount(() => { if (timer) clearInterval(timer) }) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.bell-badge { |
|||
position:absolute; top:-1px; right:-1px; min-width:16px; height:16px; padding:0 4px; |
|||
border-radius:999px; color:#fff; font-size:10px; font-weight:700; line-height:16px; |
|||
display:flex; align-items:center; justify-content:center; border:1.5px solid #fff; |
|||
} |
|||
.lv-error { background:#f43f5e; } |
|||
.lv-warn { background:#f59e0b; } |
|||
|
|||
.bell-list { max-height:320px; overflow-y:auto; } |
|||
.bell-item { display:flex; align-items:flex-start; gap:8px; padding:9px 14px; border-bottom:1px solid #f8fafc; } |
|||
.bell-item:hover { background:#f8fafc; } |
|||
.bell-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; margin-top:6px; } |
|||
.d-error { background:#f43f5e; } |
|||
.d-warn { background:#f59e0b; } |
|||
.bell-name { font-size:12.5px; font-weight:600; color:#1e293b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } |
|||
.bell-chip { font-size:10.5px; padding:0 6px; border-radius:999px; background:#e0e7ff; color:#4338ca; white-space:nowrap; } |
|||
.bell-streak { font-size:10.5px; color:#be123c; font-weight:600; white-space:nowrap; } |
|||
.bell-msg { font-size:11.5px; color:#64748b; margin-top:2px; line-height:1.45; } |
|||
.bell-foot { display:block; text-align:center; font-size:12px; color:#4338ca; padding:9px; background:#f8fafc; } |
|||
.bell-foot:hover { background:#f1f5f9; } |
|||
</style> |
|||
@ -0,0 +1,44 @@ |
|||
import { ref } from 'vue' |
|||
|
|||
// 列表表头按字段排序。各页传一组「升序语义」的比较器,降序由这里统一取反;
|
|||
// 比较器里用 `||` 串一个兜底字段(一般是 id),值相等时顺序才稳定、不会每次渲染乱跳。
|
|||
//
|
|||
// 用法:
|
|||
// const { toggleSort, sortMark, applySort } = useTableSort<Row>({
|
|||
// id: (a, b) => a.id - b.id,
|
|||
// name: (a, b) => a.name.localeCompare(b.name, 'zh') || a.id - b.id,
|
|||
// }, 'id')
|
|||
// const viewRows = computed(() => applySort(rows.value.filter(...)))
|
|||
// <th class="cursor-pointer select-none" @click="toggleSort('name')">名称{{ sortMark('name') }}</th>
|
|||
export type SortDir = 'asc' | 'desc' |
|||
export type Comparator<T> = (a: T, b: T) => number |
|||
|
|||
export function useTableSort<T>( |
|||
comparators: Record<string, Comparator<T>>, |
|||
defaultKey: string, |
|||
defaultDir: SortDir = 'asc' |
|||
) { |
|||
const sortKey = ref(defaultKey) |
|||
const sortDir = ref<SortDir>(defaultDir) |
|||
|
|||
function toggleSort(key: string) { |
|||
if (!comparators[key]) return |
|||
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc' |
|||
else { sortKey.value = key; sortDir.value = 'asc' } |
|||
} |
|||
|
|||
// 表头后缀的排序箭头;非当前排序列返回空串
|
|||
function sortMark(key: string) { |
|||
return sortKey.value === key ? (sortDir.value === 'asc' ? ' ↑' : ' ↓') : '' |
|||
} |
|||
|
|||
// 返回新数组,不改原列表(原列表常是 ref 里的接口数据)
|
|||
function applySort(list: T[]): T[] { |
|||
const cmp = comparators[sortKey.value] |
|||
if (!cmp) return list |
|||
const dir = sortDir.value === 'asc' ? 1 : -1 |
|||
return [...list].sort((a, b) => cmp(a, b) * dir) |
|||
} |
|||
|
|||
return { sortKey, sortDir, toggleSort, sortMark, applySort } |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,416 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 列表视图 --> |
|||
<div v-show="view === 'list'"> |
|||
<div class="flex items-center justify-between mb-5"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">应用商品配置</h2> |
|||
<p class="text-xs text-slate-400 mt-1">应用的内购商品(VIP 时长、AI/会议/翻译资源点包等),支持按语言配置名称、货币与售价。统一存后台公共库,按应用隔离,同一应用的各区域部署共用一份。</p> |
|||
</div> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="!curAppName" @click="showForm()">+ 新增商品</button> |
|||
</div> |
|||
<div class="flex items-center gap-3 mb-4 flex-wrap"> |
|||
<span class="text-sm text-slate-500">应用</span> |
|||
<select v-model="curAppName" class="select select-bordered select-sm min-w-48" @change="loadData"> |
|||
<option value="" disabled>请选择应用</option> |
|||
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option> |
|||
</select> |
|||
<input v-model="keyword" class="input input-bordered input-sm w-56" placeholder="按商品ID/名称搜索" /> |
|||
<label class="label cursor-pointer gap-2"> |
|||
<input v-model="onlyEnabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
|||
<span class="label-text text-sm text-slate-500">只看已上架</span> |
|||
</label> |
|||
</div> |
|||
<div v-if="!curAppName" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16"> |
|||
<div class="text-4xl mb-2">🛒</div> |
|||
<p class="text-sm">请先在上方选择应用</p> |
|||
</div> |
|||
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100"> |
|||
<div class="overflow-x-auto"> |
|||
<table class="table"> |
|||
<thead> |
|||
<tr> |
|||
<th>商品ID</th><th>名称</th><th>类型</th><th>默认价格</th><th>发放内容</th><th>多语言</th><th>状态</th><th class="text-right">操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="loading"> |
|||
<td colspan="8" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td> |
|||
</tr> |
|||
<tr v-else-if="!filteredRows.length"> |
|||
<td colspan="8" class="text-center text-slate-400 py-8">暂无商品</td> |
|||
</tr> |
|||
<tr v-for="g in filteredRows" :key="g.id" class="hover"> |
|||
<td class="font-mono text-sm font-medium">{{ g.goods_id }}</td> |
|||
<td class="text-sm">{{ g.name || '-' }}</td> |
|||
<td class="text-sm text-slate-500">{{ usageLabel(g.usagetype) }}</td> |
|||
<td class="text-sm">{{ g.price }}</td> |
|||
<td class="text-xs text-slate-500">{{ rewardText(g) || '-' }}</td> |
|||
<td> |
|||
<div v-if="localeCodes(g).length" class="flex flex-wrap gap-1"> |
|||
<span v-for="code in localeCodes(g)" :key="code" class="badge badge-ghost badge-sm font-mono">{{ code }}</span> |
|||
</div> |
|||
<span v-else class="text-xs text-slate-400">未配置</span> |
|||
</td> |
|||
<td> |
|||
<span class="badge badge-sm" :class="g.enable ? 'badge-success text-white' : 'badge-ghost'">{{ g.enable ? '上架' : '下架' }}</span> |
|||
</td> |
|||
<td class="text-right"> |
|||
<div class="inline-flex gap-1"> |
|||
<button class="btn btn-ghost btn-xs" @click="showForm(g)">编辑</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="delGoods(g)">删除</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 表单视图 --> |
|||
<div v-show="view === 'form'"> |
|||
<div class="flex items-center gap-3 mb-5"> |
|||
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button> |
|||
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑商品' : '新增商品' }}</h2> |
|||
<span class="text-xs text-slate-400">所属应用:{{ curAppName || '-' }}</span> |
|||
</div> |
|||
<form class="space-y-5 max-w-4xl" @submit.prevent="saveGoods"> |
|||
<!-- 基本信息 --> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> |
|||
<h3 class="text-sm font-semibold text-slate-600 mb-4">基本信息</h3> |
|||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">商品ID (goods_id) *</span></label> |
|||
<input v-model="form.goods_id" type="text" class="input input-bordered w-full font-mono" required placeholder="如 vip_month_1(与苹果/谷歌后台的内购商品ID一致)" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">默认名称</span></label> |
|||
<input v-model="form.name" type="text" class="input input-bordered w-full" placeholder="未匹配到语言时展示的兜底名称" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">商品类型</span></label> |
|||
<select v-model.number="form.usagetype" class="select select-bordered w-full"> |
|||
<option v-for="o in USAGE_TYPES" :key="o.value" :value="o.value">{{ o.label }}</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">默认价格</span></label> |
|||
<input v-model.number="form.price" type="number" class="input input-bordered w-full" /> |
|||
<span class="text-xs text-slate-400 mt-1">未配置该语言时的兜底价;各语言售价在下方按语言单独配。</span> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">排序</span></label> |
|||
<input v-model.number="form.sort" type="number" class="input input-bordered w-full" /> |
|||
<span class="text-xs text-slate-400 mt-1">值小的排前面。</span> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">状态</span></label> |
|||
<label class="label cursor-pointer justify-start gap-3"> |
|||
<input v-model="form.enable" type="checkbox" class="toggle toggle-primary toggle-sm" /> |
|||
<span class="label-text">{{ form.enable ? '上架(客户端可见)' : '下架' }}</span> |
|||
</label> |
|||
</div> |
|||
<div class="form-control md:col-span-2"> |
|||
<label class="label"><span class="label-text font-medium">备注</span></label> |
|||
<input v-model="form.remark" type="text" class="input input-bordered w-full" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 发放内容 --> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> |
|||
<h3 class="text-sm font-semibold text-slate-600 mb-1">发放内容</h3> |
|||
<p class="text-xs text-slate-400 mb-4">购买成功后给用户加的资源,按需填写,不发的留 0。</p> |
|||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">会员时长 (viptime)</span></label> |
|||
<input v-model.number="form.viptime" type="number" class="input input-bordered w-full" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">AI 资源点 (ainum)</span></label> |
|||
<input v-model.number="form.ainum" type="number" class="input input-bordered w-full" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">会议资源点 (meetnum)</span></label> |
|||
<input v-model.number="form.meetnum" type="number" class="input input-bordered w-full" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">翻译资源点 (tradenum)</span></label> |
|||
<input v-model.number="form.tradenum" type="number" class="input input-bordered w-full" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 多语言 --> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> |
|||
<div class="flex items-center justify-between mb-1"> |
|||
<h3 class="text-sm font-semibold text-slate-600">多语言配置</h3> |
|||
<div class="flex items-center gap-2"> |
|||
<select v-model="langToAdd" class="select select-bordered select-sm min-w-52"> |
|||
<option value="" disabled>选择语言</option> |
|||
<option v-for="o in addableLangs" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option> |
|||
</select> |
|||
<button type="button" class="btn btn-outline btn-sm" :disabled="!langToAdd" @click="addLocale()">+ 添加语言</button> |
|||
</div> |
|||
</div> |
|||
<p class="text-xs text-slate-400 mb-4">客户端按自身语言取名称与售价;没配的语言回落到上方的默认名称与默认价格。</p> |
|||
<div v-if="!form.locales.length" class="text-center text-slate-400 text-sm py-8 border border-dashed border-slate-200 rounded-lg"> |
|||
还没有语言配置,选一个语言添加 |
|||
</div> |
|||
<div v-else class="overflow-x-auto"> |
|||
<table class="table table-sm"> |
|||
<thead> |
|||
<tr> |
|||
<th class="w-52">语言</th><th>名称</th><th class="w-28">货币符号</th><th class="w-36">价格</th><th class="w-20 text-right">操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="(l, i) in form.locales" :key="l.lang"> |
|||
<td> |
|||
<div class="font-mono text-sm">{{ l.lang }}</div> |
|||
<div class="text-xs text-slate-400">{{ langName(l.lang) }}</div> |
|||
</td> |
|||
<td><input v-model="l.name" type="text" class="input input-bordered input-sm w-full" placeholder="该语言下的商品名" /></td> |
|||
<td><input v-model="l.currency" type="text" class="input input-bordered input-sm w-full" placeholder="¥ / $" /></td> |
|||
<td><input v-model.number="l.price" type="number" step="0.01" class="input input-bordered input-sm w-full" /></td> |
|||
<td class="text-right"> |
|||
<button type="button" class="btn btn-ghost btn-xs text-red-500" @click="form.locales.splice(i, 1)">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="flex gap-3"> |
|||
<button type="button" class="btn btn-ghost" @click="showList()">取消</button> |
|||
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import { LANG_OPTIONS, langName } from '~/composables/useLangOptions' |
|||
|
|||
definePageMeta({ title: '应用商品配置' }) |
|||
|
|||
// 商品按「应用名」隔离(同一应用的多区域部署共用一份),故这里只选应用、不选部署环境—— |
|||
// 与「应用环境配置」页按应用×部署环境读写业务库不同,本页数据统一落 console 公共库 app_goods。 |
|||
interface GoodsLocale { name: string; currency: string; price: number } |
|||
interface Goods { |
|||
id: number |
|||
app_name: string |
|||
goods_id: string |
|||
enable: boolean |
|||
name: string |
|||
price: number |
|||
usagetype: number |
|||
ainum: number |
|||
meetnum: number |
|||
tradenum: number |
|||
viptime: number |
|||
sort: number |
|||
remark: string |
|||
locales: Record<string, GoodsLocale> |
|||
} |
|||
|
|||
// pb.UsageType:1翻译 2会议 3智能体 4购买VIP时长 |
|||
const USAGE_TYPES = [ |
|||
{ value: 0, label: '未指定' }, |
|||
{ value: 1, label: '翻译' }, |
|||
{ value: 2, label: '会议' }, |
|||
{ value: 3, label: '智能体' }, |
|||
{ value: 4, label: 'VIP 时长' }, |
|||
] |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
const view = ref<'list' | 'form'>('list') |
|||
const appNames = ref<string[]>([]) |
|||
const curAppName = ref('') |
|||
const items = ref<Goods[]>([]) |
|||
const loading = ref(false) |
|||
const submitting = ref(false) |
|||
const isEditing = ref(false) |
|||
const keyword = ref('') |
|||
const onlyEnabled = ref(false) |
|||
const langToAdd = ref('') |
|||
|
|||
const form = reactive({ |
|||
id: 0, |
|||
goods_id: '', |
|||
name: '', |
|||
enable: true, |
|||
price: 0, |
|||
usagetype: 4, |
|||
ainum: 0, |
|||
meetnum: 0, |
|||
tradenum: 0, |
|||
viptime: 0, |
|||
sort: 0, |
|||
remark: '', |
|||
locales: [] as Array<{ lang: string } & GoodsLocale>, |
|||
}) |
|||
|
|||
function usageLabel(v: number) { |
|||
return USAGE_TYPES.find((o) => o.value === v)?.label ?? String(v) |
|||
} |
|||
|
|||
function localeCodes(g: Goods) { |
|||
return Object.keys(g.locales || {}).sort() |
|||
} |
|||
|
|||
// 发放内容摘要(列表列,只显示非 0 项) |
|||
function rewardText(g: Goods) { |
|||
const parts: string[] = [] |
|||
if (g.viptime) parts.push(`VIP ${g.viptime}`) |
|||
if (g.ainum) parts.push(`AI ${g.ainum}`) |
|||
if (g.meetnum) parts.push(`会议 ${g.meetnum}`) |
|||
if (g.tradenum) parts.push(`翻译 ${g.tradenum}`) |
|||
return parts.join(' · ') |
|||
} |
|||
|
|||
const filteredRows = computed(() => { |
|||
const kw = keyword.value.trim().toLowerCase() |
|||
return items.value.filter((g) => { |
|||
if (onlyEnabled.value && !g.enable) return false |
|||
if (!kw) return true |
|||
return ((g.goods_id || '') + (g.name || '')).toLowerCase().includes(kw) |
|||
}) |
|||
}) |
|||
|
|||
// 还没配过的语言(已配的不重复出现在「添加语言」下拉里) |
|||
const addableLangs = computed(() => { |
|||
const used = new Set(form.locales.map((l) => l.lang)) |
|||
return LANG_OPTIONS.filter((o) => !used.has(o.code)) |
|||
}) |
|||
|
|||
// 应用名来源:应用中心的应用表为主,再并上已有部署记录里的应用名(兜底老数据) |
|||
async function loadApps() { |
|||
try { |
|||
const [products, deploys] = await Promise.all([ |
|||
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => []), |
|||
consoleApi<Array<{ app_name: string; name: string }>>('apps/list', {}).catch(() => []), |
|||
]) |
|||
const names = new Set<string>() |
|||
;(products ?? []).forEach((p) => p.name && names.add(p.name)) |
|||
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name)) |
|||
appNames.value = [...names].sort() |
|||
if (!curAppName.value && appNames.value.length) { |
|||
curAppName.value = appNames.value[0]! |
|||
await loadData() |
|||
} |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取应用列表失败') |
|||
appNames.value = [] |
|||
} |
|||
} |
|||
|
|||
async function loadData() { |
|||
if (!curAppName.value) { |
|||
items.value = [] |
|||
return |
|||
} |
|||
loading.value = true |
|||
try { |
|||
const data = await consoleApi<{ items: Goods[] }>('appgoods/list', { app_name: curAppName.value }) |
|||
items.value = data?.items ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取商品失败') |
|||
items.value = [] |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
function showList() { |
|||
view.value = 'list' |
|||
loadData() |
|||
} |
|||
|
|||
function showForm(g?: Goods) { |
|||
isEditing.value = !!g |
|||
form.id = g?.id ?? 0 |
|||
form.goods_id = g?.goods_id ?? '' |
|||
form.name = g?.name ?? '' |
|||
form.enable = g ? !!g.enable : true |
|||
form.price = g?.price ?? 0 |
|||
form.usagetype = g?.usagetype ?? 4 |
|||
form.ainum = g?.ainum ?? 0 |
|||
form.meetnum = g?.meetnum ?? 0 |
|||
form.tradenum = g?.tradenum ?? 0 |
|||
form.viptime = g?.viptime ?? 0 |
|||
form.sort = g?.sort ?? 0 |
|||
form.remark = g?.remark ?? '' |
|||
form.locales = Object.entries(g?.locales ?? {}) |
|||
.map(([lang, v]) => ({ lang, name: v?.name ?? '', currency: v?.currency ?? '', price: Number(v?.price ?? 0) })) |
|||
.sort((a, b) => (a.lang < b.lang ? -1 : 1)) |
|||
langToAdd.value = '' |
|||
view.value = 'form' |
|||
} |
|||
|
|||
// 新增语言行:默认名称/货币/价格从上方基础信息带一份,改起来快 |
|||
function addLocale() { |
|||
const lang = langToAdd.value |
|||
if (!lang || form.locales.some((l) => l.lang === lang)) return |
|||
form.locales.push({ lang, name: form.name, currency: '', price: form.price }) |
|||
langToAdd.value = '' |
|||
} |
|||
|
|||
async function saveGoods() { |
|||
if (!curAppName.value) { |
|||
error('请先选择应用') |
|||
return |
|||
} |
|||
const locales: Record<string, GoodsLocale> = {} |
|||
for (const l of form.locales) { |
|||
locales[l.lang] = { name: l.name.trim(), currency: l.currency.trim(), price: Number(l.price) || 0 } |
|||
} |
|||
submitting.value = true |
|||
try { |
|||
const data: Record<string, unknown> = { |
|||
app_name: curAppName.value, |
|||
goods_id: form.goods_id.trim(), |
|||
name: form.name.trim(), |
|||
enable: form.enable, |
|||
price: Number(form.price) || 0, |
|||
usagetype: Number(form.usagetype) || 0, |
|||
ainum: Number(form.ainum) || 0, |
|||
meetnum: Number(form.meetnum) || 0, |
|||
tradenum: Number(form.tradenum) || 0, |
|||
viptime: Number(form.viptime) || 0, |
|||
sort: Number(form.sort) || 0, |
|||
remark: form.remark.trim(), |
|||
locales, |
|||
} |
|||
if (isEditing.value) { |
|||
data.id = Number(form.id) |
|||
await consoleApi('appgoods/update', data) |
|||
} else { |
|||
await consoleApi('appgoods/add', data) |
|||
} |
|||
success('保存成功') |
|||
showList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
function delGoods(g: Goods) { |
|||
if (!window.confirm(`确定删除商品「${g.goods_id}」吗?`)) return |
|||
consoleApi('appgoods/del', { app_name: curAppName.value, ids: [g.id] }) |
|||
.then(() => { success('删除成功'); loadData() }) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
onMounted(loadApps) |
|||
</script> |
|||
@ -0,0 +1,23 @@ |
|||
// 芯片方案厂商分组,对应后端 DBSolutionProvider.vendor(proto 的 SolutionVendor 枚举)。
|
|||
// 一个芯片厂商下可能有多套方案,每套方案是一条方案商记录,靠这个分组归拢。
|
|||
// 新增芯片厂商要三处同步补同一个数值:
|
|||
// apps/proto/db.proto 的 SolutionVendor
|
|||
// apps/services/modules/console/api_device.go 的 chipVendorNames
|
|||
// 本文件的 CHIP_VENDORS
|
|||
export interface ChipVendor { |
|||
value: number |
|||
label: string |
|||
} |
|||
|
|||
export const CHIP_VENDORS: ChipVendor[] = [ |
|||
{ value: 1, label: '杰里' }, |
|||
{ value: 2, label: '蓝汛' }, |
|||
{ value: 3, label: '恒玄' }, |
|||
{ value: 4, label: '博通' }, |
|||
] |
|||
|
|||
export function chipVendorName(v?: number | null): string { |
|||
const n = Number(v || 0) |
|||
if (!n) return '未分组' |
|||
return CHIP_VENDORS.find((x) => x.value === n)?.label ?? ('厂商#' + n) |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
package comm |
|||
|
|||
// AppGoods 应用内购商品配置:一行 = 某应用的一个内购商品(如 vip_month_1 / ai_点数包)。
|
|||
// 存 console 主库(与 app_registry / app_product 同库),**按应用名 app_name 隔离**——
|
|||
// 同一应用在上海/日本/新加坡等多个部署环境共用同一份商品配置,不随部署走。
|
|||
//
|
|||
// 放在 comm 的原因:这张表由 console 后台维护、业务侧(pay 发货)读取,两边共用同一结构,
|
|||
// 与 AgentConfig / ThirdSvcConfig 等跨模块公共库表同一套路。业务侧取自己那份用 AppName()。
|
|||
//
|
|||
// 字段与 pb.DBGoods 一一对应(多出 AppName/Sort/Remark/时间戳),多语言沿用 DBGoods.localname 的 JSON 形态:
|
|||
//
|
|||
// {"zh-CN":{"name":"月度会员","currency":"¥","price":30},"en-US":{...}}
|
|||
type AppGoods struct { |
|||
Id uint32 `gorm:"primaryKey;autoIncrement;column:id" json:"id"` |
|||
AppName string `gorm:"column:app_name;size:64;index" json:"app_name"` // 所属应用名(app_product.name,如 Voitrans)
|
|||
GoodsId string `gorm:"column:goods_id;size:128;index" json:"goods_id"` // 商品标识(= DBGoods.id / DBPayOrder.product_id,苹果/谷歌后台的内购商品 ID),应用内唯一
|
|||
Enable bool `gorm:"column:enable" json:"enable"` // 是否上架
|
|||
Name string `gorm:"column:name;size:128" json:"name"` // 默认名称(无对应语言时的兜底)
|
|||
|
|||
// ── 售价与发放内容(语义同 pb.DBGoods)──
|
|||
Price int32 `gorm:"column:price" json:"price"` // 默认价格(未配该语言时的兜底价)
|
|||
Usagetype int32 `gorm:"column:usagetype" json:"usagetype"` // 商品类型 pb.UsageType:1翻译 2会议 3智能体 4VIP时长
|
|||
Ainum int64 `gorm:"column:ainum" json:"ainum"` // 发放的 AI 资源点
|
|||
Meetnum int64 `gorm:"column:meetnum" json:"meetnum"` // 发放的会议资源点
|
|||
Tradenum int64 `gorm:"column:tradenum" json:"tradenum"` // 发放的翻译资源点
|
|||
Viptime int32 `gorm:"column:viptime" json:"viptime"` // 发放的会员时长
|
|||
|
|||
// 多语言名称/货币/价格,JSON 同 pb.DBGoods.localname;后台读写经 console 的 api_goods.go 结构化收发。
|
|||
Localname string `gorm:"column:localname;type:text" json:"localname"` |
|||
|
|||
Sort int32 `gorm:"column:sort" json:"sort"` // 排序权重(小的在前,同值按 id)
|
|||
Remark string `gorm:"column:remark;size:255" json:"remark"` |
|||
Createtime int64 `gorm:"column:createtime" json:"createtime"` |
|||
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` |
|||
} |
|||
|
|||
func (AppGoods) TableName() string { return TableAppGoods } |
|||
@ -0,0 +1,262 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/lego/sys/postgres" |
|||
"yunyan/pb" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// ============================ 应用商品配置 (appgoods) ============================
|
|||
//
|
|||
// 「应用商品配置」页(admin appgoods.vue)的后端:读写 console 主库的 app_goods 表——
|
|||
// 即各应用的**内购商品**(VIP 时长包、AI/会议/翻译资源点包等)及其多语言售卖信息。
|
|||
//
|
|||
// 与「应用环境配置」(appcfg)的差别:
|
|||
// - appcfg 按「应用 × 部署环境」写各应用**业务库**;
|
|||
// - 商品是应用级的售卖定义,与部署在哪个区域无关,故按 **app_name** 隔离、统一落 console 公共库,
|
|||
// 同一应用的多个区域部署共用一份。
|
|||
//
|
|||
// 多语言:前端按语言逐条编辑(语言 / 名称 / 货币符号 / 价格),后端序列化成
|
|||
// pb.DBGoods.localname 同构的 JSON 存 Localname 列,列表/详情再解析回 locales 下发,
|
|||
// 前端不必碰 JSON 字符串。
|
|||
|
|||
// GoodsLocale 单语言售卖信息,字段与 pb.DBGoodsLocal 的 json 名一致。
|
|||
type GoodsLocale struct { |
|||
Name string `json:"name"` // 该语言下的商品名
|
|||
Currency string `json:"currency"` // 货币符号,如 ¥ / $
|
|||
Price float64 `json:"price"` // 该语言(地区)售价
|
|||
} |
|||
|
|||
// appGoodsReq 新增/更新商品的请求体(locales 为结构化多语言,落库时序列化进 localname)。
|
|||
type appGoodsReq struct { |
|||
Id uint32 `json:"id"` |
|||
AppName string `json:"app_name"` |
|||
GoodsId string `json:"goods_id"` |
|||
Enable bool `json:"enable"` |
|||
Name string `json:"name"` |
|||
Price int32 `json:"price"` |
|||
Usagetype int32 `json:"usagetype"` |
|||
Ainum int64 `json:"ainum"` |
|||
Meetnum int64 `json:"meetnum"` |
|||
Tradenum int64 `json:"tradenum"` |
|||
Viptime int32 `json:"viptime"` |
|||
Locales map[string]*GoodsLocale `json:"locales"` |
|||
Sort int32 `json:"sort"` |
|||
Remark string `json:"remark"` |
|||
} |
|||
|
|||
// appGoodsView 下发给前端的商品:库行 + 解析好的多语言(localname 原串一并保留,便于排查)。
|
|||
type appGoodsView struct { |
|||
*AppGoods |
|||
Locales map[string]*GoodsLocale `json:"locales"` |
|||
} |
|||
|
|||
// parseGoodsLocales 解析 localname JSON;空串或坏数据都返回空 map(坏数据不该让整页打不开)。
|
|||
func parseGoodsLocales(s string) map[string]*GoodsLocale { |
|||
out := make(map[string]*GoodsLocale) |
|||
if strings.TrimSpace(s) == "" { |
|||
return out |
|||
} |
|||
if err := json.Unmarshal([]byte(s), &out); err != nil { |
|||
return make(map[string]*GoodsLocale) |
|||
} |
|||
for k, v := range out { |
|||
if v == nil { |
|||
delete(out, k) |
|||
} |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// marshalGoodsLocales 序列化多语言为 localname JSON:语言码去空白、丢弃空码与空项;
|
|||
// 一项都没有时存空串(而不是 "{}"),与历史数据保持一致。
|
|||
func marshalGoodsLocales(in map[string]*GoodsLocale) (string, error) { |
|||
clean := make(map[string]*GoodsLocale, len(in)) |
|||
for k, v := range in { |
|||
lang := strings.TrimSpace(k) |
|||
if lang == "" || v == nil { |
|||
continue |
|||
} |
|||
clean[lang] = &GoodsLocale{ |
|||
Name: strings.TrimSpace(v.Name), |
|||
Currency: strings.TrimSpace(v.Currency), |
|||
Price: v.Price, |
|||
} |
|||
} |
|||
if len(clean) == 0 { |
|||
return "", nil |
|||
} |
|||
b, err := json.Marshal(clean) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
return string(b), nil |
|||
} |
|||
|
|||
// applyGoodsReq 把请求体写进模型(不含 Id/AppName/时间戳,由调用方按新增/更新分别处理)。
|
|||
func applyGoodsReq(m *AppGoods, req *appGoodsReq, localname string) { |
|||
m.GoodsId = strings.TrimSpace(req.GoodsId) |
|||
m.Enable = req.Enable |
|||
m.Name = strings.TrimSpace(req.Name) |
|||
m.Price = req.Price |
|||
m.Usagetype = req.Usagetype |
|||
m.Ainum = req.Ainum |
|||
m.Meetnum = req.Meetnum |
|||
m.Tradenum = req.Tradenum |
|||
m.Viptime = req.Viptime |
|||
m.Localname = localname |
|||
m.Sort = req.Sort |
|||
m.Remark = strings.TrimSpace(req.Remark) |
|||
} |
|||
|
|||
// checkGoodsReq 校验必填并归一,返回错误说明(空串=通过)。
|
|||
func checkGoodsReq(req *appGoodsReq) string { |
|||
req.AppName = strings.TrimSpace(req.AppName) |
|||
req.GoodsId = strings.TrimSpace(req.GoodsId) |
|||
if req.AppName == "" { |
|||
return "app_name 必填" |
|||
} |
|||
if req.GoodsId == "" { |
|||
return "商品ID(goods_id) 必填" |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// appGoodsList 列出某应用的全部商品(含多语言)。
|
|||
func (this *serverComp) appGoodsList(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
req.AppName = strings.TrimSpace(req.AppName) |
|||
if req.AppName == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填") |
|||
return |
|||
} |
|||
models, err := this.module.model.listGoods(req.AppName) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
items := make([]*appGoodsView, 0, len(models)) |
|||
for _, m := range models { |
|||
items = append(items, &appGoodsView{AppGoods: m, Locales: parseGoodsLocales(m.Localname)}) |
|||
} |
|||
writeOK(c, gin.H{"items": items}) |
|||
} |
|||
|
|||
// appGoodsAdd 新增商品:同一应用内 goods_id 不可重复。
|
|||
func (this *serverComp) appGoodsAdd(c *gin.Context) { |
|||
var req appGoodsReq |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if msg := checkGoodsReq(&req); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
dup, err := this.module.model.getGoodsByGid(req.AppName, req.GoodsId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
if dup != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "该应用下商品ID已存在: "+req.GoodsId) |
|||
return |
|||
} |
|||
localname, err := marshalGoodsLocales(req.Locales) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "多语言配置格式错误: "+err.Error()) |
|||
return |
|||
} |
|||
now := time.Now().Unix() |
|||
m := &AppGoods{AppName: req.AppName, Createtime: now, Updatetime: now} |
|||
applyGoodsReq(m, &req, localname) |
|||
if err := this.module.model.addGoods(m); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
writeOK(c, &appGoodsView{AppGoods: m, Locales: parseGoodsLocales(m.Localname)}) |
|||
} |
|||
|
|||
// appGoodsUpdate 更新商品:应用归属不可改;改了 goods_id 同样要在应用内查重。
|
|||
func (this *serverComp) appGoodsUpdate(c *gin.Context) { |
|||
var req appGoodsReq |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if msg := checkGoodsReq(&req); msg != "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
if req.Id == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") |
|||
return |
|||
} |
|||
old, err := this.module.model.getGoods(req.Id) |
|||
if err == postgres.ErrNoDocuments || (err == nil && old.Id == 0) { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "商品不存在") |
|||
return |
|||
} |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
if old.AppName != req.AppName { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "商品归属应用不可修改") |
|||
return |
|||
} |
|||
if old.GoodsId != strings.TrimSpace(req.GoodsId) { |
|||
dup, err := this.module.model.getGoodsByGid(req.AppName, req.GoodsId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
if dup != nil && dup.Id != req.Id { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "该应用下商品ID已存在: "+req.GoodsId) |
|||
return |
|||
} |
|||
} |
|||
localname, err := marshalGoodsLocales(req.Locales) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "多语言配置格式错误: "+err.Error()) |
|||
return |
|||
} |
|||
applyGoodsReq(old, &req, localname) |
|||
old.Updatetime = time.Now().Unix() |
|||
if err := this.module.model.saveGoods(old); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
writeOK(c, &appGoodsView{AppGoods: old, Locales: parseGoodsLocales(old.Localname)}) |
|||
} |
|||
|
|||
// appGoodsDel 按 id 批量删除某应用的商品。
|
|||
func (this *serverComp) appGoodsDel(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
Ids []uint32 `json:"ids"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
req.AppName = strings.TrimSpace(req.AppName) |
|||
if req.AppName == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填") |
|||
return |
|||
} |
|||
if len(req.Ids) == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填") |
|||
return |
|||
} |
|||
if err := this.module.model.delGoods(req.AppName, req.Ids); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
writeOK(c, gin.H{"deleted": len(req.Ids)}) |
|||
} |
|||
@ -0,0 +1,67 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/postgres" |
|||
) |
|||
|
|||
// AppGoods 应用内购商品配置。结构定义在 comm(见 comm/appgoods.go)——这张表由本后台维护、
|
|||
// 业务侧 pay 模块发货时按应用名读取,两边共用同一份结构,故上移到 comm,这里保留别名不动调用方。
|
|||
//
|
|||
// 与旧方案的关系:商品原本落在**各应用业务库**的 goods 表(pb.DBGoods,由 api 模块的
|
|||
// api_getgoodss/api_addgoods 等接口维护)。本版把配置口径统一收到后台公共库,
|
|||
// 字段与 pb.DBGoods 一一对应(多出 AppName/Sort/Remark/时间戳)。
|
|||
// 业务侧读取现状:PayPal 通知 v2(pay_paypalnotifyv2)已改读本表发货;
|
|||
// 其余支付渠道与 api_getgoodss 仍读各自业务库的 goods 表,逐步迁移。
|
|||
type AppGoods = comm.AppGoods |
|||
|
|||
// ---- 商品表 CRUD(console 主库 postgres,与 app_product 同库同套 helper)----
|
|||
|
|||
// listGoods 列出某应用的全部商品(sort 升序,其次 id)。appName 为空返回空列表——
|
|||
// 商品必须挂在具体应用下,没有「全局商品」这一档。
|
|||
func (this *modelComp) listGoods(appName string) (models []*AppGoods, err error) { |
|||
models = make([]*AppGoods, 0) |
|||
if appName == "" { |
|||
return |
|||
} |
|||
err = postgres.Table(comm.TableAppGoods).Where("app_name=?", appName).Order("sort ASC, id ASC").Find(&models).Error |
|||
return |
|||
} |
|||
|
|||
// getGoods 按主键取商品。
|
|||
func (this *modelComp) getGoods(id uint32) (model *AppGoods, err error) { |
|||
model = &AppGoods{} |
|||
err = postgres.FindOne(comm.TableAppGoods, model, "id=?", id) |
|||
return |
|||
} |
|||
|
|||
// getGoodsByGid 按 (应用, 商品标识) 取商品,用于查重。不存在返回 (nil,nil)。
|
|||
func (this *modelComp) getGoodsByGid(appName, goodsId string) (*AppGoods, error) { |
|||
model := &AppGoods{} |
|||
err := postgres.FindOne(comm.TableAppGoods, model, "app_name=? AND goods_id=?", appName, goodsId) |
|||
if err != nil { |
|||
if err == postgres.ErrNoDocuments { |
|||
return nil, nil |
|||
} |
|||
return nil, err |
|||
} |
|||
return model, nil |
|||
} |
|||
|
|||
// addGoods 新增商品(Insert 后 model.Id 被回填)。
|
|||
func (this *modelComp) addGoods(model *AppGoods) (err error) { |
|||
err = postgres.Insert(comm.TableAppGoods, model) |
|||
return |
|||
} |
|||
|
|||
// saveGoods 更新商品。
|
|||
func (this *modelComp) saveGoods(model *AppGoods) (err error) { |
|||
err = postgres.Save(comm.TableAppGoods, model) |
|||
return |
|||
} |
|||
|
|||
// delGoods 按 id 批量删除某应用的商品(带 app_name 条件,防跨应用误删)。
|
|||
func (this *modelComp) delGoods(appName string, ids []uint32) (err error) { |
|||
err = postgres.Delete(comm.TableAppGoods, "app_name=? AND id IN ?", appName, ids) |
|||
return |
|||
} |
|||
@ -0,0 +1,196 @@ |
|||
package pay |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"math" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
"yunyan/sys/pay/paypal" |
|||
"yunyan/utils" |
|||
) |
|||
|
|||
// @Summary 处理PayPal支付异步通知 v2 [内部RPC]
|
|||
// @Description 接收由HTTP回调网关转发来的支付通知,验签后按管理后台的商品配置发货
|
|||
// @Tags Pay
|
|||
// @Accept json
|
|||
// @Produce json
|
|||
// @Security BearerAuth
|
|||
// @Param notify body pb.PayPayPalNotifyReq true "PayPal 异步通知参数"
|
|||
// @Success 200 {object} comm.HttpResult "处理结果"
|
|||
// @Router /api/home/pay_paypalnotifyv2 [post]
|
|||
//
|
|||
// PayPalNotifyV2 与 v1(PayPalNotify)流程一致,差别只有一处、但是关键的一处:
|
|||
// **发货口径改成读管理后台的商品配置** —— console 公共库的 app_goods 表,
|
|||
// 按本部署的应用名(comm.AppName() / 环境变量 ANALYZE_APP_NAME)隔离,
|
|||
// 而不是各业务库自己维护的 goods 表。这样商品(VIP 时长、AI/会议/翻译点数)在后台
|
|||
// 「应用商品配置」页改完即生效,多区域部署共用一份,不必再逐个业务库同步。
|
|||
//
|
|||
// 幂等:订单已不是「待支付」就直接回 SUCCESS,PayPal 重推不会重复发货。
|
|||
// 后台没配该商品时**不发货、也不改订单状态**,回 Fail 让 PayPal 重试——
|
|||
// 补好配置后自然会补发,比发一笔空货再人工补要好。
|
|||
func (this *apiComp) PayPalNotifyV2(session comm.IUserSession, req *pb.PayPayPalNotifyReq) (resp []byte, errdata *pb.ErrorData) { |
|||
var ( |
|||
order *pb.DBPayOrder |
|||
goods *comm.AppGoods |
|||
user *pb.DBUser |
|||
ok bool |
|||
err error |
|||
) |
|||
|
|||
// 1. 验签:webhook 的可信来源判断全靠它,失败一律不处理
|
|||
headers := map[string]string{ |
|||
"PayPal-Transmission-Id": req.TransmissionId, |
|||
"PayPal-Transmission-Time": req.TransmissionTime, |
|||
"PayPal-Cert-Url": req.CertUrl, |
|||
"PayPal-Auth-Algo": req.AuthAlgo, |
|||
"PayPal-Transmission-Sig": req.TransmissionSig, |
|||
"Webhook-Id": req.WebhookId, |
|||
} |
|||
if ok, err = paypal.VerifyWebhook(headers, []byte(req.Body)); !ok || err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_PayNotifyDecryptFail, |
|||
Message: "验证PayPal通知失败:" + fmt.Sprintf("%v", err), |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 2. 解析通知体,只认支付完成事件;其余事件回 SUCCESS 让 PayPal 别再推
|
|||
var body map[string]any |
|||
if err = json.Unmarshal([]byte(req.Body), &body); err != nil { |
|||
resp = payNotifyResp("Fail", "通知体解析失败") |
|||
return |
|||
} |
|||
if eventType, _ := body["event_type"].(string); eventType != "PAYMENT.CAPTURE.COMPLETED" { |
|||
resp = payNotifyResp("SUCCESS", "忽略非支付完成事件") |
|||
return |
|||
} |
|||
resource, _ := body["resource"].(map[string]any) |
|||
outTradeNo := paypalOutTradeNo(resource) |
|||
if outTradeNo == "" { |
|||
resp = payNotifyResp("Fail", "无法识别订单号") |
|||
return |
|||
} |
|||
|
|||
// 3. 订单:不存在回 Fail(等 PayPal 重试),已处理过直接回 SUCCESS(幂等)
|
|||
if order, err = this.module.model.getPayOrder(outTradeNo); err != nil { |
|||
resp = payNotifyResp("Fail", fmt.Sprintf("订单不存在: %s", outTradeNo)) |
|||
return |
|||
} |
|||
if order.Status != pb.PayOrderStatus_PAY_ORDER_PENDING { |
|||
resp = payNotifyResp("SUCCESS", "成功") |
|||
return |
|||
} |
|||
// 金额对账:验签已保证通知来自 PayPal,这里对不上多半是配置/口径问题,
|
|||
// 记日志留痕但不拦截发货(用户确实付了钱);要改成严格拒绝,把下面的日志换成 return 即可。
|
|||
if cents, ok2 := paypalAmountCents(resource); ok2 && cents != order.Amount { |
|||
this.module.Error("PayPalNotifyV2 金额与订单不一致", |
|||
log.Field{Key: "order", Value: order.OrderId}, |
|||
log.Field{Key: "order_amount", Value: order.Amount}, |
|||
log.Field{Key: "notify_amount", Value: cents}) |
|||
} |
|||
|
|||
// 4. 商品:读后台配置(按应用隔离)。没配就不发货、不改状态,等补好配置后 PayPal 重推
|
|||
if goods, err = this.module.model.appGoods(order.ProductId); err != nil { |
|||
resp = payNotifyResp("Fail", "查询商品配置失败: "+err.Error()) |
|||
return |
|||
} |
|||
if goods == nil { |
|||
this.module.Error("PayPalNotifyV2 后台未配置该商品,暂不发货", |
|||
log.Field{Key: "app", Value: comm.AppName()}, |
|||
log.Field{Key: "order", Value: order.OrderId}, |
|||
log.Field{Key: "goods_id", Value: order.ProductId}) |
|||
resp = payNotifyResp("Fail", fmt.Sprintf("应用 %s 未配置商品 %s", comm.AppName(), order.ProductId)) |
|||
return |
|||
} |
|||
if !goods.Enable { |
|||
// 已下架的商品仍然照常发货:钱已经收了,下架只影响后续售卖
|
|||
this.module.Warnf("PayPalNotifyV2 商品已下架仍按订单发货: app=%s order=%s goods=%s", |
|||
comm.AppName(), order.OrderId, order.ProductId) |
|||
} |
|||
|
|||
// 5. 用户 → 发货 → 落订单状态
|
|||
if user, err = this.module.model.getUser(order.Uid); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_DBError, |
|||
Message: "查询用户信息失败:" + err.Error(), |
|||
} |
|||
return |
|||
} |
|||
order.Status = pb.PayOrderStatus_PAY_ORDER_PAID |
|||
order.PayTime = time.Now().Unix() |
|||
if order.ProductName == "" { |
|||
order.ProductName = goods.Name // 老订单没存商品名时,用后台配置补上,便于对账
|
|||
} |
|||
if id, _ := resource["id"].(string); id != "" { |
|||
order.TransactionId = id |
|||
} |
|||
if err = this.deliverGrant(user, order, grantOfAppGoods(goods)); err != nil { |
|||
resp = payNotifyResp("Fail", fmt.Sprintf("发货资源失败: %v", err)) |
|||
return |
|||
} |
|||
if err = this.module.model.updatePayOrder(order); err != nil { |
|||
resp = payNotifyResp("Fail", fmt.Sprintf("更新订单状态失败: %v", err)) |
|||
return |
|||
} |
|||
this.module.Debug("PayPalNotifyV2 发货完成", |
|||
log.Field{Key: "app", Value: comm.AppName()}, |
|||
log.Field{Key: "order", Value: order.OrderId}, |
|||
log.Field{Key: "uid", Value: order.Uid}, |
|||
log.Field{Key: "goods_id", Value: goods.GoodsId}) |
|||
resp = payNotifyResp("SUCCESS", "成功") |
|||
return |
|||
} |
|||
|
|||
// payNotifyResp PayPal 回调的应答体(与 v1 保持同样的形状)。
|
|||
func payNotifyResp(code, message string) []byte { |
|||
return []byte(utils.ToString(map[string]string{"code": code, "message": message})) |
|||
} |
|||
|
|||
// paypalOutTradeNo 从通知的 resource 里取我们自己的商户订单号:
|
|||
// 下单时写在 custom_id;取不到再退而求其次找 purchase_units[0].reference_id / invoice_id。
|
|||
func paypalOutTradeNo(resource map[string]any) string { |
|||
if resource == nil { |
|||
return "" |
|||
} |
|||
if v, _ := resource["custom_id"].(string); strings.TrimSpace(v) != "" { |
|||
return strings.TrimSpace(v) |
|||
} |
|||
if pus, _ := resource["purchase_units"].([]any); len(pus) > 0 { |
|||
if unit, _ := pus[0].(map[string]any); unit != nil { |
|||
if v, _ := unit["reference_id"].(string); strings.TrimSpace(v) != "" { |
|||
return strings.TrimSpace(v) |
|||
} |
|||
} |
|||
} |
|||
if v, _ := resource["invoice_id"].(string); strings.TrimSpace(v) != "" { |
|||
return strings.TrimSpace(v) |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// paypalAmountCents 取通知里的实付金额并换算成「分」(订单 Amount 的单位)。
|
|||
// PayPal 的 amount.value 是字符串金额(如 "9.99");解析不出来返回 ok=false,此时不做对账。
|
|||
func paypalAmountCents(resource map[string]any) (int64, bool) { |
|||
if resource == nil { |
|||
return 0, false |
|||
} |
|||
amount, _ := resource["amount"].(map[string]any) |
|||
if amount == nil { |
|||
return 0, false |
|||
} |
|||
value, _ := amount["value"].(string) |
|||
if strings.TrimSpace(value) == "" { |
|||
return 0, false |
|||
} |
|||
f, err := strconv.ParseFloat(strings.TrimSpace(value), 64) |
|||
if err != nil { |
|||
return 0, false |
|||
} |
|||
return int64(math.Round(f * 100)), true |
|||
} |
|||
@ -1,57 +0,0 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
"yunyan/comm" |
|||
) |
|||
|
|||
// 客户端拿到的就是这三个布尔值,各端据此决定「提示更新 / 阻断使用 / 隐藏未过审功能」,
|
|||
// 判定错了直接表现为线上事故,故逐个组合固化。
|
|||
func TestEvalChannelUpgrade(t *testing.T) { |
|||
cfg := &comm.ChannelApp{Version: "1.10.0", MinVersion: "1.5.0", ForceUpdate: true, ReviewVersion: "1.11.0"} |
|||
|
|||
cases := []struct { |
|||
name string |
|||
cfg *comm.ChannelApp |
|||
client string |
|||
need, force, reviewing bool |
|||
}{ |
|||
{"低于最低支持版本:强更", cfg, "1.4.9", true, true, false}, |
|||
{"高于最低支持、低于线上:只提示更新", cfg, "1.9.0", true, false, false}, |
|||
{"已是线上最新:什么都不提示", cfg, "1.10.0", false, false, false}, |
|||
{"高于线上版本(内测包):不提示", cfg, "1.11.0", false, false, true}, |
|||
{"送审版本:标记审核期", cfg, "1.11.0", false, false, true}, |
|||
{"客户端未上报版本:一律 false,不能把老客户端全挡住", cfg, "", false, false, false}, |
|||
{"该渠道未配置:一律 false", nil, "1.0.0", false, false, false}, |
|||
{"字符串比较陷阱:1.9 < 1.10 应判定为需更新", cfg, "1.9.9", true, false, false}, |
|||
} |
|||
for _, c := range cases { |
|||
t.Run(c.name, func(t *testing.T) { |
|||
need, force, reviewing := evalChannelUpgrade(c.cfg, c.client) |
|||
if need != c.need || force != c.force || reviewing != c.reviewing { |
|||
t.Fatalf("client=%q 得到 need=%v force=%v reviewing=%v,期望 need=%v force=%v reviewing=%v", |
|||
c.client, need, force, reviewing, c.need, c.force, c.reviewing) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// 运营把最低支持版本填得比线上版本还高时,不能出现「必须更新但没有更新可用」这种客户端无解的组合。
|
|||
func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) { |
|||
cfg := &comm.ChannelApp{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true} |
|||
need, force, _ := evalChannelUpgrade(cfg, "1.5.0") |
|||
if !force { |
|||
t.Fatal("低于最低支持版本应强更") |
|||
} |
|||
if !need { |
|||
t.Fatal("强更时 needupdate 必须同时为 true,否则客户端会收到「必须更新但无更新」") |
|||
} |
|||
} |
|||
|
|||
// 停用/草稿的渠道由 model 层过滤成 nil,这里确认 nil 不会 panic 也不会误判。
|
|||
func TestEvalChannelUpgradeNilSafe(t *testing.T) { |
|||
if need, force, reviewing := evalChannelUpgrade(nil, "1.0.0"); need || force || reviewing { |
|||
t.Fatal("nil 配置必须全 false") |
|||
} |
|||
} |
|||
@ -1,88 +0,0 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"io" |
|||
"net/http" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"yunyan/lego/utils/crypto/aes" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 联调用:打一次线上接口,解密后反序列化成对象,方便打断点看数据。改下面三个常量即可。
|
|||
//
|
|||
// go test ./modules/user/ -run TestApiProbe -v
|
|||
//
|
|||
// probeKey 留空 = 不解密(v1 之类不加密的接口)。⚠️ 数据含明文凭据,别外传。
|
|||
const ( |
|||
probeURL = "https://echomeet.voitrans.net/api/home/user_getappconfig_v2" |
|||
probeToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJFYXJwaG9uZSIsInN1YiI6IlNnaW4iLCJuYmYiOjE3ODY3NjEzNTYsImlhdCI6MTc4Njc2MTM1NiwianRpIjoiMjA4ODQ1NDYxNjI2MDgwNDYwOCJ9.tAkiUVNNEJMF4_Gfl1qYseUmh73CWcU5Ls9govEOtEQ" |
|||
probeKey = "83KNTeWwQ4GiQHhlNJH1H8RyN88HNUOf" // 该环境 .env 里的 GATEWAY_ENCRYPT_KEY
|
|||
) |
|||
|
|||
// probeResult 换接口时把 Data 的类型改掉即可:
|
|||
//
|
|||
// user_getappconfig / _v2 → pb.UserGetAppConfigResp
|
|||
// user_getappconfig_v3 → pb.UserGetAppConfigV3Resp
|
|||
// user_getthirdsvcs_v2 → pb.UserGetThirdSvcsResp
|
|||
// user_getagents_v2 / _v3 → pb.UserGetAgentsResp / pb.UserGetAgentsV3Resp
|
|||
type probeResult struct { |
|||
Code int `json:"code"` |
|||
Msg string `json:"msg"` |
|||
Data pb.UserGetAppConfigResp `json:"data"` |
|||
} |
|||
|
|||
func TestApiProbe(t *testing.T) { |
|||
req, _ := http.NewRequest(http.MethodPost, probeURL, bytes.NewBufferString("{}")) |
|||
req.Header.Set("Content-Type", "application/json") |
|||
req.Header.Set("Authorization", probeToken) |
|||
|
|||
resp, err := http.DefaultClient.Do(req) |
|||
if err != nil { |
|||
t.Fatalf("请求失败: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
raw, _ := io.ReadAll(resp.Body) |
|||
|
|||
body := string(raw) |
|||
if resp.Header.Get("X-Encrypted") == "1" { |
|||
if probeKey == "" { |
|||
t.Fatal("响应是加密的,请先填 probeKey(该环境的 GATEWAY_ENCRYPT_KEY)") |
|||
} |
|||
body = aes.AesDecryptCBC(strings.TrimSpace(body), probeKey) |
|||
if !strings.HasPrefix(strings.TrimSpace(body), "{") { |
|||
t.Fatalf("解密失败:probeKey 与该环境的 GATEWAY_ENCRYPT_KEY 不一致(长度须 16/24/32,当前 %d)", len(probeKey)) |
|||
} |
|||
} |
|||
|
|||
// ① 强类型对象——断点看它,字段能直接展开
|
|||
var result probeResult |
|||
if err := json.Unmarshal([]byte(body), &result); err != nil { |
|||
t.Fatalf("反序列化失败: %v", err) |
|||
} |
|||
env := result.Data.Env |
|||
agents := result.Data.Agents |
|||
mcps := result.Data.Mcps |
|||
products := result.Data.Products |
|||
|
|||
// ② 通用对象——换了接口懒得改 Data 类型时,看这个
|
|||
var generic map[string]interface{} |
|||
_ = json.Unmarshal([]byte(body), &generic) |
|||
|
|||
// 断点打在这一行,watch: result / env / mcps / generic
|
|||
t.Logf("code=%d msg=%s | env=%d agents=%d mcps=%d products=%d", |
|||
result.Code, result.Msg, len(env), len(agents), len(mcps), len(products)) |
|||
|
|||
for k, v := range env { |
|||
t.Logf(" env[%s] = %s", k, v) |
|||
} |
|||
for id, m := range mcps { |
|||
t.Logf(" mcp[%s] name=%s url=%s type=%v tools=%s", id, m.Servername, m.Url, m.Type, m.Tools) |
|||
} |
|||
for _, a := range agents { |
|||
t.Logf(" agent id=%s name=%s", a.Id, a.Name) |
|||
} |
|||
} |
|||
@ -1,71 +0,0 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
"yunyan/comm" |
|||
) |
|||
|
|||
// v3 下发的 voice_map 是「补全后」的:客户端不带音色表,切语言时得能直接查到。
|
|||
func TestResolveAgentVoices(t *testing.T) { |
|||
a := &comm.AgentConfig{ |
|||
Type: comm.AgentTypeChat, |
|||
Gender: comm.GenderFemale, |
|||
SupportLangs: "zh-CN, en-US ,ja-JP", |
|||
DefaultLang: "zh-CN", // 与 support_langs 重复,不该出两条
|
|||
VoiceMap: []comm.AgentVoice{ |
|||
{Language: "zh-CN", Gender: comm.GenderFemale, Voice: "zh-CN-XiaoyiNeural"}, // 运营选过,优先
|
|||
}, |
|||
} |
|||
got := resolveAgentVoices(a, comm.TTSProviderAzure) |
|||
if len(got) != 3 { |
|||
t.Fatalf("三种支持语言应各一条,实际 %d 条: %+v", len(got), got) |
|||
} |
|||
byLang := map[string]string{} |
|||
for _, v := range got { |
|||
if _, dup := byLang[v.Language]; dup { |
|||
t.Fatalf("语言 %s 出现重复", v.Language) |
|||
} |
|||
byLang[v.Language] = v.Voice |
|||
} |
|||
if byLang["zh-CN"] != "zh-CN-XiaoyiNeural" { |
|||
t.Fatalf("运营在后台选过的音色应优先,实际 %s", byLang["zh-CN"]) |
|||
} |
|||
if byLang["en-US"] != "en-US-JennyNeural" || byLang["ja-JP"] != "ja-JP-NanamiNeural" { |
|||
t.Fatalf("未配的语言应按音色表取第一个女声,实际 %+v", byLang) |
|||
} |
|||
} |
|||
|
|||
// 音色表覆盖不到的:不能瞎发一个别的语言的音色,宁可不发让客户端走服务商默认。
|
|||
func TestResolveAgentVoicesSkipsUnknown(t *testing.T) { |
|||
a := &comm.AgentConfig{Type: comm.AgentTypeChat, Gender: comm.GenderMale, SupportLangs: "zh-CN,xx-YY"} |
|||
got := resolveAgentVoices(a, comm.TTSProviderAzure) |
|||
if len(got) != 1 || got[0].Language != "zh-CN" { |
|||
t.Fatalf("未知语言不该下发,实际 %+v", got) |
|||
} |
|||
// 服务商不在音色表里(火山等)且运营没手配 → 一条都不发
|
|||
if n := len(resolveAgentVoices(a, "volcengine")); n != 0 { |
|||
t.Fatalf("音色表未覆盖的服务商应不下发音色,实际 %d 条", n) |
|||
} |
|||
// 但运营手填过的照发
|
|||
a2 := &comm.AgentConfig{ |
|||
Type: comm.AgentTypeChat, SupportLangs: "zh-CN", |
|||
VoiceMap: []comm.AgentVoice{{Language: "zh-CN", Voice: "BV700_streaming"}}, |
|||
} |
|||
got2 := resolveAgentVoices(a2, "volcengine") |
|||
if len(got2) != 1 || got2[0].Voice != "BV700_streaming" { |
|||
t.Fatalf("手填音色应照常下发,实际 %+v", got2) |
|||
} |
|||
} |
|||
|
|||
// 出声的是哪个服务:对话类看 TTS,端到端对话看 STS(音色候选按服务商区分,取错就全错)。
|
|||
func TestVoiceSvcIdOf(t *testing.T) { |
|||
chat := &comm.AgentConfig{Type: comm.AgentTypeChat, TtsSvcId: "tts_azure", StsSvcId: "sts_x"} |
|||
if got := voiceSvcIdOf(chat); got != "tts_azure" { |
|||
t.Fatalf("chat 应取 tts_svc_id,实际 %s", got) |
|||
} |
|||
sts := &comm.AgentConfig{Type: comm.AgentTypeStsChat, TtsSvcId: "tts_azure", StsSvcId: "sts_x"} |
|||
if got := voiceSvcIdOf(sts); got != "sts_x" { |
|||
t.Fatalf("sts-chat 应取 sts_svc_id,实际 %s", got) |
|||
} |
|||
} |
|||
@ -1,67 +0,0 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"sync" |
|||
"testing" |
|||
|
|||
"yunyan/pb" |
|||
|
|||
"gorm.io/gorm/schema" |
|||
) |
|||
|
|||
// 老包按「非空 int」解析 factoryid,缺字段整个响应就解析不了。这里固化两件事:
|
|||
// 值等于 brandid,且 brandid=0 时也要输出 0(不能被 omitempty 吃掉变成 null)。
|
|||
func TestCompatProductFillsFactoryid(t *testing.T) { |
|||
cases := []struct { |
|||
name string |
|||
brandid uint32 |
|||
}{ |
|||
{"正常品牌商", 40981}, |
|||
{"无品牌归属:仍要输出 0,不能缺字段", 0}, |
|||
} |
|||
for _, c := range cases { |
|||
t.Run(c.name, func(t *testing.T) { |
|||
p := compatProduct(&pb.DBProduct{Id: 45107, Brandid: c.brandid}) |
|||
if p.Factoryid != c.brandid { |
|||
t.Fatalf("factoryid=%d,期望等于 brandid=%d", p.Factoryid, c.brandid) |
|||
} |
|||
buf, err := json.Marshal(p) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
var got map[string]any |
|||
if err = json.Unmarshal(buf, &got); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
v, ok := got["factoryid"] |
|||
if !ok || v == nil { |
|||
t.Fatalf("下发 JSON 缺 factoryid(老客户端会 TypeError):%s", buf) |
|||
} |
|||
if v.(float64) != float64(c.brandid) { |
|||
t.Fatalf("下发 factoryid=%v,期望 %d", v, c.brandid) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestCompatProductsNilSafe(t *testing.T) { |
|||
if compatProduct(nil) != nil { |
|||
t.Fatal("nil 入参不应造出对象") |
|||
} |
|||
compatProducts([]*pb.DBProduct{nil, {Brandid: 7}}) // 不能 panic
|
|||
} |
|||
|
|||
// factoryid 是纯下发字段:gorm 必须完全忽略它,否则 AutoMigrate 会给产品表建一个恒为 0 的多余列。
|
|||
func TestFactoryidNotPersisted(t *testing.T) { |
|||
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{}) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if f := s.LookUpField("factoryid"); f != nil { |
|||
t.Fatalf("factoryid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName) |
|||
} |
|||
if s.LookUpField("brandid") == nil { |
|||
t.Fatal("brandid 应该是库字段,测试基准不成立") |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
-- 方案商归属从「品牌商」下移到「产品」。 |
|||
-- 原因:方案商 = 这套产品用的芯片方案提供方,同一个品牌商的不同产品可能用不同方案商的方案, |
|||
-- 挂在品牌商上会串号——生产设备码的服务ID(CID)取的就是它。 |
|||
-- brand.providerid 保留列但停止读写(后端已不再写入),待确认无回滚需求后再删列。 |
|||
\set ON_ERROR_STOP on |
|||
BEGIN; |
|||
|
|||
-- 1. 产品级方案商归属 |
|||
ALTER TABLE product ADD COLUMN IF NOT EXISTS providerid integer DEFAULT 0; |
|||
CREATE INDEX IF NOT EXISTS idx_product_providerid ON product (providerid); |
|||
|
|||
-- 2. 历史产品按原品牌商归属回填(品牌商没归属或方案商已不存在的,留 0 由后台补录) |
|||
UPDATE product p |
|||
SET providerid = b.providerid |
|||
FROM brand b |
|||
WHERE b.id = p.brandid |
|||
AND COALESCE(p.providerid, 0) = 0 |
|||
AND COALESCE(b.providerid, 0) <> 0 |
|||
AND EXISTS (SELECT 1 FROM solution_provider s WHERE s.id = b.providerid); |
|||
|
|||
COMMIT; |
|||
|
|||
-- 回填后核对:仍未绑定方案商的产品(后台产品管理里逐个补,补完才能正常生产设备码) |
|||
-- SELECT id, devicename, brandid FROM product WHERE COALESCE(providerid,0) = 0 ORDER BY id; |
|||
|
|||
-- ── 同批:产品创建时间 + 产品说明 ────────────────────────────────────────── |
|||
-- createtime 只在新增时写一次(历史产品无从还原,留 0,后台显示为 "-"); |
|||
-- description 是「这款产品是什么、有什么特性」,与版本更新说明 updatedescription 无关。 |
|||
BEGIN; |
|||
ALTER TABLE product ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0; |
|||
ALTER TABLE product ADD COLUMN IF NOT EXISTS description text DEFAULT ''; |
|||
COMMIT; |
|||
|
|||
-- ── 同批:品牌商创建时间 ─────────────────────────────────────────────────── |
|||
-- 与产品一致,只在新增时写一次;历史品牌商无从还原,留 0,后台显示为 "-"。 |
|||
BEGIN; |
|||
ALTER TABLE brand ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0; |
|||
COMMIT; |
|||
Loading…
Reference in new issue