Compare commits

...

3 Commits

Author SHA1 Message Date
Rodger-Wang d6892eb3d6 后台管理下载落地页;实名认证显隐与游客登录同一个口子 3 weeks ago
Rodger-Wang d50edc6635 deploy/dl: 下载页改名 download.html;client(ios): 构建号 20 3 weeks ago
Rodger-Wang 3dc66fb396 服务端:微信登录支持热更新,AppSecret 为空时启动如实报未配置 3 weeks ago
  1. 35
      CLAUDE.md
  2. 3
      apps/admin/app/components/AppReleasePanel.vue
  3. 347
      apps/admin/app/components/DistPageManager.vue
  4. 21
      apps/admin/app/pages/guestaccess.vue
  5. 4
      apps/admin/package.json
  6. 197
      apps/admin/pnpm-lock.yaml
  7. 22
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  8. 13
      apps/client/lib/core/utils/id_verify_guard.dart
  9. 12
      apps/client/lib/data/models/channel_app_model.dart
  10. 2
      apps/client/lib/data/models/channel_app_model.g.dart
  11. 9
      apps/client/lib/data/services/version_update_service.dart
  12. 19
      apps/client/lib/modules/profile/views/profile_view.dart
  13. 49
      apps/client/test/id_verify_visibility_test.dart
  14. 1
      apps/proto/db.proto
  15. 18
      apps/services/comm/apprelease.go
  16. 17
      apps/services/comm/apprelease_test.go
  17. 2
      apps/services/modules/console/api_config.go
  18. 229
      apps/services/modules/console/api_distpage.go
  19. 39
      apps/services/modules/console/api_distpage_test.go
  20. 7
      apps/services/modules/console/server.go
  21. 1
      apps/services/modules/user/model_user.go
  22. 11
      apps/services/modules/user/module_test.go
  23. 13
      apps/services/pb/db.pb.go
  24. 6
      apps/services/services/home/configreload.go
  25. 50
      apps/services/sys/auth/wechat/core.go
  26. 19
      apps/services/sys/auth/wechat/options.go
  27. 44
      apps/services/sys/auth/wechat/reload_test.go
  28. 4
      deploy/dl/README.md
  29. 14
      deploy/dl/download.html

35
CLAUDE.md

@ -569,6 +569,20 @@ EAIMAR 客户端不再从 `env` 兼容层拿第三方凭据,改走**自己的
请求失败后缓存时间戳已落下,而缓存命中分支**不重算** `_isShowTourists`。
splash 那次失败之后,登录页再怎么重进都是隐藏的。
#### 实名认证与游客登录同一个口子(2026-09-23)
实名认证**不再恒定生效**:能看到游客登录的那一版(送审版本)才显示实名认证并按实名限制功能,
其余版本实名整个隐藏(「我的」页那一行、卡片「未认证」角标都不显示),**所有功能不受实名限制**。
不分 Android / iOS,后台只有「游客显隐控制」一个开关。
- 服务端 `comm.AppRelease.ShowIdVerify`(目前 = `ShowTourists`)→ `DBChannelApp.idverify` 随 `user_getchannelapp` 下发。
单独一个字段是为了将来拆开控制时不用再发 App。
- 客户端 `VersionUpdateService.showIdVerify`(RxBool)→ `IdVerifyGuard.entryShown`;`required` 第一条就是
「隐藏即放行」,所有 `blocked()` 调用点不用改。老服务端没有 `idverify` 时 `AppInfo` 退回读 `tourists`。
- 拿不到配置 = 隐藏 = 放行(用户定的失败方向)。服务端本来就没有按实名拦截的接口,实名状态接口照常可用。
- ⚠️ 本规则之前发布的老包仍恒定要求实名,用户决定不处理。
- 性别锁定(已实名则性别以身份证为准)与入口显隐无关,保留。
### EMAI 助手的端侧指令(2026-08-29 接入)
百炼多模态会在 `RespondingContent` 这一帧里下发端侧函数调用,客户端原先**整个丢掉**了
@ -1718,6 +1732,22 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
[auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容放行——同
「应用环境配置 → 服务与环境配置」那次的处理。
### 下载落地页在后台编辑发布(2026-09-23)
「版本与分发」页「下载与说明」区顶部的「下载页」一行管理分发桶上的静态页
(外包装二维码指向的 `https://dl.ymaikj.com/<应用>/download.html`)。组件
[DistPageManager.vue](apps/admin/app/components/DistPageManager.vue),后端
[api_distpage.go](apps/services/modules/console/api_distpage.go)(`api_getdistpage` / `api_savedistpage`)。
- **编辑只存草稿**(浏览器 localStorage,按 应用+地址 分开),**「保存」才发布**:服务端 PutObject 覆盖,
强制 `text/html; charset=utf-8` + `Cache-Control: no-cache`,地址写进 `app_release.download_page`。
读写都走服务端,不走浏览器直传;不做备份(用户定的)。
- 地址只能改文件名:域名必须是分发域名、路径恰好 `/<应用>/<名>.html`(`parseDistPageURL`,`TestParseDistPageURL` 守着)。
放宽等于让人在公共桶上挂任意页面、或覆盖别的应用的页面 / `android/` 下的安装包。
- ⚠️ 从此**后台是这份页面的唯一来源**,`deploy/dl/` 里的文件只是初始模板。改了仓库不会生效,
反过来用仓库文件手传 OSS 会覆盖掉后台改过的版本。
- `download_page` 与版本字段同一行,保存仍是「读整行 → 只改这一列 → 写回」,同游客显隐那条规矩。
### 配置分家:channel_app 与 app_release(2026-08-31)
后台的发版类配置拆成两张表,边界是「**这一项会不会因渠道而异**」:
@ -2424,6 +2454,11 @@ Google 语音、Google 登录、微信支付、支付宝、微软翻译——都
`Insert` 一次默认超管、返回值还没接。改成**只在账号不存在时**插入。
⚠️ 别图省事改用 `Save`/Upsert:那会把运营改过的密码在每次重启时刷回配置文件里的初始值。
⚠️ **微信登录 2026-09-23 起支持热更**(`wechat_auth.Reload`,登记在 `services/home/configreload.go`)。
此前它不在热更清单里、且 `OnInit` 对空 AppSecret 也报 `success`:正式服在后台补填 AppSecret 后仍持续
`41004 appsecret missing`,直到重启。现在空 AppID/AppSecret 启动即 WARN 降级,后台保存后即时生效,
填错则本次重载失败、保留旧配置。**其余 auth(google_auth 等)仍需重启**,要扩展照这个 Reload 补。
顺带两处:`sys/auth/*` 五个包的 logger 名全被复制成了 `sys.tavily`(所以 firebase 的错误显示成
`[sys.tavily]`,查的时候会被带偏),已各改各名;`google_auth` 原先 `newSys` 无条件返回成功、日志打
`init sys.google_auth success!`,而配置指的 json 根本不存在,直到有人点 Google 登录才失败——

3
apps/admin/app/components/AppReleasePanel.vue

@ -57,6 +57,8 @@
<section class="ed-section">
<div class="ed-section-title">下载与说明</div>
<!-- 下载落地页:编辑/发布分发桶上那份静态页。独立发布,不随本表单的「保存」提交 -->
<DistPageManager :app-name="scopeApp" class="mb-4" />
<span class="ed-label">默认下载地址(网页下载页)</span>
<div class="flex gap-2">
<input v-model="form.address" class="input input-bordered input-sm w-full" :placeholder="DIST_DOMAIN" />
@ -128,6 +130,7 @@
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import DistPageManager from '~/components/DistPageManager.vue'
interface AppRelease {
version: string

347
apps/admin/app/components/DistPageManager.vue

@ -0,0 +1,347 @@
<template>
<div>
<span class="ed-label">下载落地页(外包装二维码指向它)</span>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm" :disabled="busy" @click="openDrawer('code')">编辑</button>
<button class="btn btn-primary btn-sm text-white" :disabled="busy" @click="publish">保存</button>
<button class="btn btn-outline btn-sm" :disabled="busy" @click="openDrawer('preview')">查看</button>
<input v-model="url" class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="defaultUrl || 'https://dl.ymaikj.com/<应用名>/download.html'" />
</div>
<p class="ed-hint">
<template v-if="hasDraft">
<span class="text-amber-600">● 有未发布的草稿</span>({{ draftAtText }} 自动存于本浏览器),点「保存」才会发布到线上。
</template>
<template v-else-if="loadedFor && !exists">这个地址上还没有页面,点「编辑」写好后「保存」即可发布。</template>
<template v-else>「编辑」改源码会自动存草稿(只在本浏览器);<b>「保存」才会发布到 OSS</b>,扫码的人立刻看到新页面。</template>
地址只能改最后的文件名,目录固定为本应用 <code>/{{ appName || '应用名' }}/</code>。
</p>
<!-- 右侧抽屉:盖在配置区上面,不挤布局 -->
<Teleport to="body">
<div v-if="drawerOpen" class="dp-mask" @click.self="drawerOpen = false">
<aside class="dp-drawer">
<header class="dp-head">
<div class="flex items-center gap-2 min-w-0">
<div class="join">
<button class="btn btn-sm join-item" :class="tab === 'code' ? 'btn-primary text-white' : 'btn-ghost'" @click="tab = 'code'">编辑</button>
<button class="btn btn-sm join-item" :class="tab === 'preview' ? 'btn-primary text-white' : 'btn-ghost'" @click="tab = 'preview'">查看</button>
</div>
<span class="text-xs text-slate-500 truncate font-mono">{{ loadedFor }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs" :class="hasDraft ? 'text-amber-600' : 'text-slate-400'">
{{ hasDraft ? `草稿已自动保存 ${draftAtText}` : '与线上一致' }}
</span>
<button v-if="hasDraft" class="btn btn-ghost btn-sm" :disabled="busy" @click="discardDraft">丢弃草稿</button>
<button class="btn btn-primary btn-sm text-white" :disabled="busy" @click="publish">保存并发布</button>
<button class="btn btn-ghost btn-sm" @click="drawerOpen = false">✕</button>
</div>
</header>
<div v-show="tab === 'code'" class="dp-body">
<div ref="editorHost" class="dp-editor" />
</div>
<div v-if="tab === 'preview'" class="dp-body flex flex-col">
<div class="dp-subbar">
<div class="join">
<button class="btn btn-xs join-item" :class="previewSrc === 'draft' ? 'btn-neutral' : 'btn-ghost'" @click="previewSrc = 'draft'">当前编辑内容</button>
<button class="btn btn-xs join-item" :class="previewSrc === 'online' ? 'btn-neutral' : 'btn-ghost'" @click="previewSrc = 'online'">线上版本</button>
</div>
<div class="join">
<button class="btn btn-xs join-item" :class="device === 'mobile' ? 'btn-neutral' : 'btn-ghost'" @click="device = 'mobile'">手机</button>
<button class="btn btn-xs join-item" :class="device === 'desktop' ? 'btn-neutral' : 'btn-ghost'" @click="device = 'desktop'">电脑</button>
</div>
<a v-if="exists" :href="loadedFor" target="_blank" rel="noopener" class="link text-xs">新窗口打开线上页面</a>
<span class="text-xs text-slate-400">
预览里「版本号 / 更新日志」等接口数据可能因跨域取不到,以线上页面为准。
</span>
</div>
<div class="dp-stage">
<iframe
v-if="previewHtml"
:key="previewSrc + device"
class="dp-frame"
:class="device"
:srcdoc="previewHtml"
sandbox="allow-scripts allow-popups"
/>
<div v-else class="text-sm text-slate-400 p-8">没有内容可预览</div>
</div>
</div>
</aside>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
/*
下载落地页管理:分发桶上那份静态 download.html(如 https://dl.ymaikj.com/EAIMAR/download.html)。
- 编辑:从 OSS 读回线上源码 → CodeMirror 编辑 → 改动防抖写进 localStorage 作为**草稿**,不碰线上。
- 保存:把「草稿,没有草稿就用线上内容」发布到输入框里的地址(服务端 PutObject),地址落 app_release.download_page。
- 查看:右侧抽屉里用 iframe srcdoc 渲染,可切「当前编辑内容 / 线上版本」。
⚠️ 草稿按 (应用, 地址) 分开存:换了地址再点编辑,读的是新地址上的页面;
新地址上还没有页面时,用当前内容起一份草稿(等于「另存为」)。
*/
const props = defineProps<{ appName: string }>()
const { webApi } = useApi()
const { success, error } = useToast()
interface DistPageResp {
url: string
saved_url: string
default_url: string
content: string
exists: boolean
}
const url = ref('')
const defaultUrl = ref('')
const loadedFor = ref('') // online 对应的地址(服务端规范化过的)
const online = ref('') // 线上内容
const exists = ref(false)
const draft = ref<string | null>(null)
const draftAt = ref(0)
const busy = ref(false)
const drawerOpen = ref(false)
const tab = ref<'code' | 'preview'>('code')
const previewSrc = ref<'draft' | 'online'>('draft')
const device = ref<'mobile' | 'desktop'>('mobile')
const hasDraft = computed(() => draft.value !== null && draft.value !== online.value)
const current = computed(() => draft.value ?? online.value)
const previewHtml = computed(() => (previewSrc.value === 'online' ? online.value : current.value))
const draftAtText = computed(() => {
if (!draftAt.value) return ''
const d = new Date(draftAt.value)
const p = (n: number) => String(n).padStart(2, '0')
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
})
// ── 草稿(localStorage,任何读写都可能抛:隐私模式 / 配额满)──
const draftKey = (u: string) => `distpage-draft:${props.appName}:${u}`
function readDraft(u: string) {
try {
const raw = localStorage.getItem(draftKey(u))
if (!raw) return null
const v = JSON.parse(raw)
return typeof v?.content === 'string' ? (v as { content: string; at: number }) : null
} catch {
return null
}
}
function writeDraft(u: string, content: string | null) {
try {
if (content === null) localStorage.removeItem(draftKey(u))
else localStorage.setItem(draftKey(u), JSON.stringify({ content, at: Date.now() }))
} catch {
// 存不进去只是没有草稿,不阻断编辑
}
}
function adoptDraftFor(u: string) {
const d = readDraft(u)
draft.value = d?.content ?? null
draftAt.value = d?.at ?? 0
}
// ── 读线上 ──
async function fetchPage(target?: string): Promise<boolean> {
if (!props.appName) return false
try {
const d = await webApi<DistPageResp>('getdistpage', { app_name: props.appName, url: target ?? '' })
defaultUrl.value = d.default_url
loadedFor.value = d.url
url.value = d.url
online.value = d.content ?? ''
exists.value = !!d.exists
adoptDraftFor(d.url)
return true
} catch (e: any) {
error(e?.message ?? '读取下载页失败')
return false
}
}
// 输入框里的地址与已加载的不一致时,先按新地址读一次。
async function syncToInput(): Promise<boolean> {
const want = url.value.trim()
if (loadedFor.value && want === loadedFor.value) return true
const carry = current.value // 旧地址上正在编辑的内容
if (!(await fetchPage(want))) return false
if (!exists.value && draft.value === null && carry) {
draft.value = carry
draftAt.value = Date.now()
writeDraft(loadedFor.value, carry)
success('这个地址上还没有页面,已用当前内容起了一份草稿')
}
return true
}
async function openDrawer(which: 'code' | 'preview') {
if (!props.appName) { error('请先在页面顶部选择应用'); return }
busy.value = true
try {
if (!(await syncToInput())) return
} finally {
busy.value = false
}
tab.value = which
previewSrc.value = 'draft'
drawerOpen.value = true
}
// ── 发布 ──
async function publish() {
if (!props.appName) { error('请先在页面顶部选择应用'); return }
busy.value = true
try {
if (!(await syncToInput())) return
const content = current.value
if (!content.trim()) { error('页面内容为空,先点「编辑」写好再保存'); return }
if (!window.confirm(`确定发布到\n${loadedFor.value}\n?发布后扫码的人会立刻看到新页面。`)) return
await webApi('savedistpage', { app_name: props.appName, url: loadedFor.value, content })
online.value = content
exists.value = true
draft.value = null
draftAt.value = 0
writeDraft(loadedFor.value, null)
success('下载页已发布')
} catch (e: any) {
error(e?.message ?? '发布失败')
} finally {
busy.value = false
}
}
function discardDraft() {
if (!window.confirm('丢弃草稿,恢复成线上版本?')) return
draft.value = null
draftAt.value = 0
writeDraft(loadedFor.value, null)
setEditorDoc(online.value)
}
// ── CodeMirror(只在浏览器里按需加载,SSR 阶段碰不到 DOM)──
const editorHost = ref<HTMLElement | null>(null)
const view = shallowRef<any>(null)
let applyingExternal = false
let saveTimer: ReturnType<typeof setTimeout> | null = null
function onEditorChange(text: string) {
if (applyingExternal) return
draft.value = text
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
const u = loadedFor.value
if (draft.value === online.value) {
writeDraft(u, null) // 改回和线上一样就不算草稿
draftAt.value = 0
} else {
writeDraft(u, draft.value)
draftAt.value = Date.now()
}
}, 600)
}
async function ensureEditor() {
if (!editorHost.value) return
if (view.value) {
if (view.value.state.doc.toString() !== current.value) setEditorDoc(current.value)
return
}
const [{ EditorView, basicSetup }, { html }] = await Promise.all([
import('codemirror'),
import('@codemirror/lang-html'),
])
view.value = new EditorView({
doc: current.value,
parent: editorHost.value,
extensions: [
basicSetup,
html(),
EditorView.lineWrapping,
EditorView.updateListener.of((u: any) => { if (u.docChanged) onEditorChange(u.state.doc.toString()) }),
],
})
}
function setEditorDoc(text: string) {
const v = view.value
if (!v) return
applyingExternal = true
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: text } })
applyingExternal = false
}
function destroyEditor() {
view.value?.destroy()
view.value = null
}
watch([drawerOpen, tab], async ([open, t]) => {
if (!open) { destroyEditor(); return }
if (t === 'code') { await nextTick(); await ensureEditor() }
})
function flushDraft() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
if (hasDraft.value && draft.value !== null) writeDraft(loadedFor.value, draft.value)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && drawerOpen.value) drawerOpen.value = false
}
// ⚠️ 初次加载放 onMounted:webApi 要登录态,放 watch immediate 会在 SSR 阶段执行而整页 500。
onMounted(() => {
window.addEventListener('keydown', onKey)
window.addEventListener('beforeunload', flushDraft)
if (props.appName) fetchPage()
})
onBeforeUnmount(() => {
flushDraft()
destroyEditor()
window.removeEventListener('keydown', onKey)
window.removeEventListener('beforeunload', flushDraft)
})
watch(() => props.appName, (n) => {
flushDraft()
drawerOpen.value = false
url.value = ''
loadedFor.value = ''
online.value = ''
exists.value = false
draft.value = null
if (n) fetchPage()
})
watch(drawerOpen, (open) => { if (!open) flushDraft() })
</script>
<style scoped>
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; }
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; }
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; }
.link { color: #4f46e5; text-decoration: underline; }
.dp-mask { position: fixed; inset: 0; z-index: 60; background: rgba(15, 23, 42, 0.35); display: flex; justify-content: flex-end; }
.dp-drawer { width: min(1100px, 94vw); height: 100%; background: #fff; display: flex; flex-direction: column; box-shadow: -8px 0 24px rgba(15, 23, 42, 0.15); }
.dp-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #e2e8f0; }
.dp-body { flex: 1; min-height: 0; }
.dp-editor { height: 100%; overflow: hidden; }
.dp-editor :deep(.cm-editor) { height: 100%; font-size: 12px; }
.dp-editor :deep(.cm-scroller) { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dp-subbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid #f1f5f9; }
.dp-stage { flex: 1; min-height: 0; background: #f1f5f9; display: flex; justify-content: center; align-items: flex-start; overflow: auto; padding: 16px; }
.dp-frame { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; }
.dp-frame.mobile { width: 390px; height: 780px; max-height: 100%; }
.dp-frame.desktop { width: 100%; height: 100%; }
</style>

21
apps/admin/app/pages/guestaccess.vue

@ -5,7 +5,8 @@
<div>
<h2 class="text-lg font-semibold text-slate-800">游客显隐控制</h2>
<p class="text-xs text-slate-400 mt-1">
控制登录页的「游客登录」入口对哪一版客户端可见。<b>整个应用一套,不分渠道。</b>
控制「游客登录」入口与「实名认证」对哪一版客户端可见——<b>两者同一个开关</b>,都是为应对审核。
<b>整个应用一套,不分渠道、不分 Android / iOS。</b>
</p>
</div>
<div class="flex items-center gap-3">
@ -27,7 +28,7 @@
<div class="ed-section-title">游客登录入口</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.tourists" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>允许游客登录</span>
<span>允许游客登录(同时开启实名认证)</span>
</label>
<div v-if="form.tourists" class="pl-7 mt-3">
<span class="ed-label">生效版本(必填)</span>
@ -64,7 +65,7 @@
</p>
<table class="note-table">
<thead>
<tr><th>开关</th><th>生效版本</th><th>效果</th></tr>
<tr><th>开关</th><th>生效版本</th><th>游客登录 + 实名认证</th></tr>
</thead>
<tbody>
<tr><td>关</td><td>任意</td><td>全部版本隐藏</td></tr>
@ -75,13 +76,19 @@
<p class="note-p">
典型用法是送审:只给正在过审的那一版开游客登录,线上其它版本不受影响。
</p>
<p class="note-p">
<b>实名认证跟着这个开关走</b>:能看到游客登录的那一版,同时显示「实名认证」并<b>要求实名才能用功能</b>;
其余版本实名认证整个隐藏,<b>所有功能不受实名限制</b>。已有的实名记录不受影响。
本规则之前发布的老版本 App 不认识它,仍一律要求实名。
</p>
<p class="note-p muted">
判定在服务端做(客户端只拿结论,不拿规则)。留空按「全隐藏」而非「全放行」处理是刻意选的失败方向——
判宽会让不该露入口的版本露出去(过审风险、白嫖开户礼),判严只是入口少显示。
<b>要全部隐藏请直接取消勾选</b>,不要靠留空版本号表达。
</p>
<p class="note-p muted">
客户端拿不到配置(无网/接口失败)时同样按隐藏处理,所以断网启动后短时间内入口不出现是预期行为。
客户端拿不到配置(无网/接口失败)时同样按隐藏处理,所以断网启动后短时间内入口不出现是预期行为;
实名认证此时也隐藏,功能放行。
</p>
</div>
</div>
@ -140,13 +147,13 @@ const warn = computed(() => {
const effect = computed(() => {
if (!form.tourists) {
return { tone: 'off', icon: '🚫', title: '全部版本隐藏', desc: '所有客户端的登录页都不显示游客登录入口。' }
return { tone: 'off', icon: '🚫', title: '全部版本隐藏', desc: '所有客户端都不显示游客登录入口与实名认证,功能不受实名限制。' }
}
const v = form.touristsversion.trim()
if (!v) {
return { tone: 'warn', icon: '⚠️', title: '等于没开', desc: '勾了开关却没点名版本,所有版本仍然看不到入口。保存时会被拦下。' }
return { tone: 'warn', icon: '⚠️', title: '等于没开', desc: '勾了开关却没点名版本,所有版本仍然看不到入口(实名同样隐藏)。保存时会被拦下。' }
}
return { tone: 'on', icon: '✅', title: `只有 ${v} 可见`, desc: `上报版本号等于 ${v} 的客户端会显示游客登录入口,其余版本一律隐藏。` }
return { tone: 'on', icon: '✅', title: `只有 ${v} 可见`, desc: `上报版本号等于 ${v} 的客户端显示游客登录入口与实名认证、并要求实名;其余版本两者都隐藏,功能不受实名限制。` }
})
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 api_getmyapps。

4
apps/admin/package.json

@ -10,7 +10,11 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.12",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@pinia/nuxt": "^0.11.2",
"codemirror": "^6.0.2",
"nuxt": "^4.1.2",
"pinia": "^3.0.2",
"vue": "^3.5.21"

197
apps/admin/pnpm-lock.yaml

@ -8,9 +8,21 @@ importers:
.:
dependencies:
'@codemirror/lang-html':
specifier: ^6.4.12
version: 6.4.12
'@codemirror/state':
specifier: ^6.7.6
version: 6.7.6
'@codemirror/view':
specifier: ^6.43.13
version: 6.43.13
'@pinia/nuxt':
specifier: ^0.11.2
version: 0.11.3(magicast@0.5.3)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)))
codemirror:
specifier: ^6.0.2
version: 6.0.2
nuxt:
specifier: ^4.1.2
version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(ioredis@5.11.1)(magicast@0.5.3)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.17)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0)
@ -202,6 +214,36 @@ packages:
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
'@codemirror/commands@6.11.1':
resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-html@6.4.12':
resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
'@codemirror/language@6.12.4':
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
'@codemirror/state@6.7.6':
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/view@6.43.13':
resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@colordx/core@5.4.3':
resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==}
@ -590,11 +632,32 @@ packages:
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
'@lezer/css@1.3.8':
resolution: {integrity: sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/javascript@1.5.5':
resolution: {integrity: sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==}
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
'@mapbox/node-pre-gyp@2.0.3':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
hasBin: true
'@marijn/find-cluster-break@1.0.4':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@napi-rs/wasm-runtime@1.1.5':
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
peerDependencies:
@ -1776,6 +1839,9 @@ packages:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1861,6 +1927,9 @@ packages:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
croner@10.0.1:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
@ -3474,6 +3543,9 @@ packages:
structured-clone-es@2.0.0:
resolution: {integrity: sha512-5UuAHmBLXYPCl22xWJrFuGmIhBKQzxISPVz6E7nmTmTcAOpUzlbjKJsRrCE4vADmMQ0dzeCnlWn9XufnAGf76Q==}
style-mod@4.1.4:
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
stylehacks@8.0.1:
resolution: {integrity: sha512-Gv095oTD0N+BdJALNFDsxZpETHZLTxbOl5RyIO7y6VAE6sR3z0MnV3Nix7N0IATNldNTrkvSASp2KR1Yt526HA==}
engines: {node: ^22.11.0 || ^24.11.0 || >=26.0}
@ -3912,6 +3984,9 @@ packages:
typescript:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@ -4220,6 +4295,82 @@ snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {}
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/commands@6.11.1':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.8
'@codemirror/lang-html@6.4.12':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/css': 1.3.8
'@lezer/html': 1.3.13
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
style-mod: 4.1.4
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/search@6.7.2':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/state@6.7.6':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/view@6.43.13':
dependencies:
'@codemirror/state': 6.7.6
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
'@colordx/core@5.4.3': {}
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.4)':
@ -4473,6 +4624,34 @@ snapshots:
'@kwsites/promise-deferred@1.1.1': {}
'@lezer/common@1.5.2': {}
'@lezer/css@1.3.8':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/javascript@1.5.5':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/lr@1.4.10':
dependencies:
'@lezer/common': 1.5.2
'@mapbox/node-pre-gyp@2.0.3':
dependencies:
consola: 3.4.2
@ -4486,6 +4665,8 @@ snapshots:
- encoding
- supports-color
'@marijn/find-cluster-break@1.0.4': {}
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@ -5723,6 +5904,16 @@ snapshots:
co@4.6.0: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -5789,6 +5980,8 @@ snapshots:
crc-32: 1.2.2
readable-stream: 4.7.0
crelt@1.0.7: {}
croner@10.0.1: {}
cross-spawn@7.0.6:
@ -7693,6 +7886,8 @@ snapshots:
structured-clone-es@2.0.0: {}
style-mod@4.1.4: {}
stylehacks@8.0.1(postcss@8.5.15):
dependencies:
browserslist: 4.28.4
@ -8126,6 +8321,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
w3c-keyname@2.2.8: {}
webidl-conversions@3.0.1: {}
webpack-virtual-modules@0.6.2: {}

22
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
@ -338,7 +338,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@ -389,10 +389,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -437,10 +441,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@ -641,7 +649,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -835,7 +843,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -869,7 +877,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -988,7 +996,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
@ -997,7 +1005,7 @@
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */

13
apps/client/lib/core/utils/id_verify_guard.dart

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import '../../data/models/user_Info.dart';
import '../../data/services/id_verify_service.dart';
import '../../data/services/version_update_service.dart';
import '../../modules/common/widgets/id_verify_dialog.dart';
/// 功能闸门:所有 agent 功能都要求先实名。
@ -11,14 +12,23 @@ import '../../modules/common/widgets/id_verify_dialog.dart';
/// 与正式账号同一套判定:游客点功能同样弹实名窗,认证通过就能用。
/// 游客登录已改走服务端(拿真实账号与 JWT),实名信息有账号可落,
/// 服务端又按设备找回同一个游客账号,所以认证一次就一直有效。
///
/// **整个闸门受「实名认证显隐」控制**(2026-09-23):与游客登录同一个口子,由服务端按本机版本
/// 算好下发([VersionUpdateService.showIdVerify])。隐藏时实名入口、「未认证」角标全部不显示,
/// 所有功能不受实名限制;只有能看到游客登录的那一版(送审版本)才显示并拦截。
/// 拿不到配置时按隐藏处理,即放行。
class IdVerifyGuard {
IdVerifyGuard._();
/// 这一版是否显示实名认证(隐藏 = 闸门整个失效)。在 Obx 里读会被收集。
static bool get entryShown => VersionUpdateService.showIdVerify.value;
/// 是否需要先去实名。
///
/// 状态还没从服务端拉到(`loaded==false`)时一律放行 —— 冷启动、断网、
/// 接口超时都属于这种情况,宁可漏拦也不能把已实名的用户挡在功能外面。
static bool get required {
if (!entryShown) return false; // 实名认证被隐藏:所有功能不受实名限制
if (!User.isLoggedIn()) return false; // 没登录轮不到实名闸门管
if (!Get.isRegistered<IdVerifyService>()) return false;
final svc = IdVerifyService.to;
@ -36,10 +46,11 @@ class IdVerifyGuard {
static Widget builder(Widget Function(bool needVerify) build) {
if (!Get.isRegistered<IdVerifyService>()) return build(false);
return Obx(() {
final shown = entryShown;
final svc = IdVerifyService.to;
final loaded = svc.loaded.value;
final verified = svc.verified.value;
return build(User.isLoggedIn() && loaded && !verified);
return build(shown && User.isLoggedIn() && loaded && !verified);
});
}

12
apps/client/lib/data/models/channel_app_model.dart

@ -77,6 +77,14 @@ class AppInfo {
@JsonKey(name: 'touristsversion')
final String? touristsversion;
/// 这一版是否显示「实名认证」并按实名限制功能(服务端算好的结论)。
///
/// 与游客登录同一个口子(后台「游客显隐控制」):能看到游客登录的那一版才要求实名,
/// 其余版本实名整个隐藏、功能不受限制。见服务端 comm.AppRelease.ShowIdVerify。
/// 老服务端不下发这个字段时退回 [tourists]——两者本来就同值。
@JsonKey(name: 'idverify', readValue: _readIdverify)
final bool idverify;
/// 是否允许跳过设备绑定
@JsonKey(name: 'allowskipdevicebinding')
final bool allowskipdevicebinding;
@ -107,6 +115,7 @@ class AppInfo {
required this.version,
this.tourists = false,
this.touristsversion,
this.idverify = false,
this.allowskipdevicebinding = false,
required this.paychannels,
this.minversion,
@ -126,6 +135,9 @@ int _channelToJson(UserChannel channel) => channel.value;
Object? _readPaychannels(Map json, String key) =>
json[key] ?? json['Paychannels'];
// 老服务端没有 idverify 字段:退回 tourists(两者同一个口子,本来就同值)。
Object? _readIdverify(Map json, String key) => json[key] ?? json['tourists'];
/// 版本更新信息模型
class VersionUpdateInfo {
/// 是否有新版本

2
apps/client/lib/data/models/channel_app_model.g.dart

@ -39,6 +39,7 @@ AppInfo _$AppInfoFromJson(Map<String, dynamic> json) => AppInfo(
version: json['version'] as String,
tourists: json['tourists'] as bool? ?? false,
touristsversion: json['touristsversion'] as String?,
idverify: _readIdverify(json, 'idverify') as bool? ?? false,
allowskipdevicebinding: json['allowskipdevicebinding'] as bool? ?? false,
paychannels: _readPaychannels(json, 'paychannels') as String?,
minversion: json['minversion'] as String?,
@ -52,6 +53,7 @@ Map<String, dynamic> _$AppInfoToJson(AppInfo instance) => <String, dynamic>{
'version': instance.version,
'tourists': instance.tourists,
'touristsversion': instance.touristsversion,
'idverify': instance.idverify,
'allowskipdevicebinding': instance.allowskipdevicebinding,
'paychannels': instance.paychannels,
'minversion': instance.minversion,

9
apps/client/lib/data/services/version_update_service.dart

@ -27,6 +27,12 @@ class VersionUpdateService extends GetxService {
static String? _updateUrl = ""; //更新链接
static AppInfo? _appInfo; //应用信息
static bool? _isShowTourists = false; //是否显示游客登录
/// 这一版是否显示实名认证并按实名限制功能(服务端结论,与游客登录同一个口子)。
///
/// 做成 Rx:「未认证」角标、「我的」页实名行在 Obx 里读它,结论到达/变化时要能跟着显隐。
/// 拿不到配置(断网、接口失败、渠道没配)一律 false = 隐藏实名、放行功能(2026-09-23 定的失败方向)。
static final RxBool showIdVerify = false.obs;
static bool? _allowSkipDeviceBinding = false; //是否允许跳过设备绑定
static DateTime? _lastCheckTime; //上次检查时间
static const int _cacheIntervalMinutes = 10; // 可配置化
@ -199,6 +205,7 @@ class VersionUpdateService extends GetxService {
_logger.w('lxm--该渠道未配置,按无更新处理');
_appInfo = null;
_isShowTourists = false;
showIdVerify.value = false;
_allowSkipDeviceBinding = false;
_hasUpdate = false;
_isForceUpdate = false;
@ -217,6 +224,7 @@ class VersionUpdateService extends GetxService {
// 这里的 tourists 已是服务端按本机版本算好的结论,直接用,不要再做版本比较。
_isShowTourists = app.tourists;
showIdVerify.value = app.idverify;
_allowSkipDeviceBinding = app.allowskipdevicebinding;
_logger.d('lxm--从服务器获取游客登录配置(最终): $_isShowTourists');
@ -242,6 +250,7 @@ class VersionUpdateService extends GetxService {
// 失败方向偏向「不显示游客入口 / 不提示更新」。注意**不写** _lastCheckTime:
// 失败不该占用 10 分钟缓存窗口,下一次调用要能真的重试。
_isShowTourists = false;
showIdVerify.value = false;
_allowSkipDeviceBinding = false;
_hasUpdate = false;
_isForceUpdate = false;

19
apps/client/lib/modules/profile/views/profile_view.dart

@ -7,6 +7,7 @@ import 'dart:io';
import 'dart:ui';
import '../../common/webview_page.dart';
import '../../../core/utils/doc_url_helper.dart';
import '../../../core/utils/id_verify_guard.dart';
import 'unified_avatar.dart';
import '../../../data/services/id_verify_service.dart';
import '../../common/widgets/id_verify_dialog.dart';
@ -295,13 +296,17 @@ class EditProfileView extends GetView<ProfileController> {
: Colors.grey[200]),
// 性别设置项
_buildGenderSetting(context, isDarkMode),
Divider(
height: 1,
color: isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
// 实名认证设置项
_buildIdVerifySetting(isDarkMode),
// 实名认证设置项:与游客登录同一个口子,隐藏时连同上面的分隔线一起不显示
Obx(() => IdVerifyGuard.entryShown
? Column(children: [
Divider(
height: 1,
color: isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
_buildIdVerifySetting(isDarkMode),
])
: const SizedBox.shrink()),
],
),
),

49
apps/client/test/id_verify_visibility_test.dart

@ -0,0 +1,49 @@
import 'package:eaimar/core/utils/id_verify_guard.dart';
import 'package:eaimar/data/models/channel_app_model.dart';
import 'package:eaimar/data/services/version_update_service.dart';
import 'package:flutter_test/flutter_test.dart';
/// 实名认证的显隐与游客登录同一个口子(2026-09-23):服务端下发 idverify,
/// 隐藏时 IdVerifyGuard 整个失效、所有功能不受实名限制。
void main() {
Map<String, dynamic> base() => {
'channel': 100,
'description': '',
'version': '1.0.3',
'paychannels': '',
};
group('AppInfo.idverify', () {
test('读服务端下发的 idverify', () {
expect(AppInfo.fromJson({...base(), 'tourists': false, 'idverify': true}).idverify, isTrue);
expect(AppInfo.fromJson({...base(), 'tourists': true, 'idverify': false}).idverify, isFalse);
});
test('老服务端没有 idverify 字段时退回 tourists', () {
expect(AppInfo.fromJson({...base(), 'tourists': true}).idverify, isTrue);
expect(AppInfo.fromJson({...base(), 'tourists': false}).idverify, isFalse);
});
test('两个字段都没有 = 隐藏', () {
expect(AppInfo.fromJson(base()).idverify, isFalse);
});
});
group('IdVerifyGuard', () {
tearDown(() => VersionUpdateService.showIdVerify.value = false);
test('实名隐藏时闸门放行、不弹窗', () {
VersionUpdateService.showIdVerify.value = false;
expect(IdVerifyGuard.entryShown, isFalse);
expect(IdVerifyGuard.required, isFalse);
expect(IdVerifyGuard.blocked(), isFalse);
});
test('默认值是隐藏(拿不到配置时放行)', () {
expect(VersionUpdateService.showIdVerify.value, isFalse);
});
test('实名显示时 entryShown 跟着变', () {
VersionUpdateService.showIdVerify.value = true;
expect(IdVerifyGuard.entryShown, isTrue);
});
});
}

1
apps/proto/db.proto

@ -413,6 +413,7 @@ message DBChannelApp{
string reviewversion = 11; //@go_tags(`gorm:"reviewversion" json:"reviewversion"`) 审核中的版本号,客户端报此版本时可隐藏未过审功能
int32 status = 12; //@go_tags(`gorm:"status" json:"status"`) 上架状态 0草稿 1已上架 2审核中 3已下架
string touristsversion = 13; //@go_tags(`gorm:"touristsversion" json:"touristsversion"`) 游客登录生效版本号;非空时仅该版本客户端可见游客登录,其余版本一律隐藏
bool idverify = 14; //@go_tags(`gorm:"-" json:"idverify"`) 这一版客户端是否显示实名认证并按实名限制功能(服务端算好的结论,目前与 tourists 同值,见 comm.AppRelease.ShowIdVerify)
}

18
apps/services/comm/apprelease.go

@ -34,6 +34,12 @@ type AppRelease struct {
IosVersion string `gorm:"column:ios_version;size:32" json:"ios_version"`
IosMinVersion string `gorm:"column:ios_min_version;size:32" json:"ios_min_version"`
// ── 下载落地页(2026-09-23 加)──
// 分发桶上那份静态下载页的公开地址(外包装二维码指向它),如 https://dl.ymaikj.com/EAIMAR/download.html。
// 后台「版本与分发」页编辑页面源码、点「保存」发布到 OSS 时一并写这一列;留空 = 用默认地址
// <分发域名>/<应用名>/download.html。只有 console 读写它,不下发给客户端。
DownloadPage string `gorm:"column:download_page" json:"download_page"`
// ── 游客显隐 ──
Tourists bool `gorm:"column:tourists" json:"tourists"` // 允许游客登录(总开关)
TouristsVersion string `gorm:"column:touristsversion;size:32" json:"touristsversion"` // 生效版本,白名单语义见 ShowTourists
@ -159,6 +165,18 @@ func (r *AppRelease) ShowTourists(clientVersion string) bool {
return CompareVersion(clientVersion, want) == 0
}
// ShowIdVerify 判断某客户端版本是否显示「实名认证」、并按实名限制功能(2026-09-23 定的口径)。
//
// 与游客登录**同一个口子**:两者都是为应对应用商店审核,所以后台只有「游客显隐控制」一个开关,
// 能看到游客登录的那一版同时显示实名认证并要求实名,其余版本实名整个隐藏、功能不受实名限制。
// 不分 Android / iOS(用户定的)。
//
// 单独成函数、单独下发字段(DBChannelApp.idverify)而不是让客户端直接读 tourists:
// 将来要把实名和游客拆开控制时只改这里和后台,不用再发一版 App。
func (r *AppRelease) ShowIdVerify(clientVersion string) bool {
return r.ShowTourists(clientVersion)
}
// DownloadAddress 取该渠道实际生效的下载地址:渠道填了就用渠道的,没填回退到应用默认。
func (r *AppRelease) DownloadAddress(channelAddress string) string {
if s := strings.TrimSpace(channelAddress); s != "" {

17
apps/services/comm/apprelease_test.go

@ -161,3 +161,20 @@ func TestPickAppRelease(t *testing.T) {
t.Fatalf("既无应用行也无全局行时应返回 nil,实际 %+v", got)
}
}
// 实名认证与游客登录同一个口子:两者对任何版本的结论必须一致(2026-09-23 口径)。
func TestAppReleaseShowIdVerifyFollowsTourists(t *testing.T) {
rels := []*AppRelease{
nil,
{Tourists: false, TouristsVersion: "1.0.2"},
{Tourists: true, TouristsVersion: ""},
{Tourists: true, TouristsVersion: "1.0.2"},
}
for i, r := range rels {
for _, v := range []string{"", "1.0.1", "1.0.2", "1.0.3"} {
if r.ShowIdVerify(v) != r.ShowTourists(v) {
t.Errorf("#%d v=%q: ShowIdVerify=%v 与 ShowTourists=%v 不一致", i, v, r.ShowIdVerify(v), r.ShowTourists(v))
}
}
}
}

2
apps/services/modules/console/api_config.go

@ -97,6 +97,8 @@ func ensureConfigTables() error {
for _, s := range []string{
"ALTER TABLE " + comm.TableAppRelease + " ADD COLUMN IF NOT EXISTS ios_version varchar(32) DEFAULT ''",
"ALTER TABLE " + comm.TableAppRelease + " ADD COLUMN IF NOT EXISTS ios_min_version varchar(32) DEFAULT ''",
// 2026-09-23 加的下载落地页地址,见 comm.AppRelease.DownloadPage。
"ALTER TABLE " + comm.TableAppRelease + " ADD COLUMN IF NOT EXISTS download_page text DEFAULT ''",
} {
if res := postgres.Exec(s); res.Error != nil {
return res.Error

229
apps/services/modules/console/api_distpage.go

@ -0,0 +1,229 @@
package console
// 下载落地页管理(2026-09-23 加):后台「版本与分发」页的「下载页」一行。
//
// 分发桶(dl.ymaikj.com)上每个应用放一份静态下载页,外包装二维码指向它,
// 如 https://dl.ymaikj.com/EAIMAR/download.html。以前只能改仓库里的 deploy/dl 再手传 OSS,
// 现在由后台读回源码、编辑、发布:
//
// api_getdistpage 读线上那份 HTML(服务端 GetObject,不经 CDN/浏览器缓存)
// api_savedistpage 发布:PutObject 覆盖,并把地址写进 app_release.download_page
//
// 读写都走服务端而不是浏览器直传:能强制 Content-Type 与 Cache-Control(见 saveDistPage),
// 读到的也一定是桶里的最新版本,而不是某一层缓存。草稿只存在浏览器本地,不经过这里。
import (
"bytes"
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"yunyan/comm"
"yunyan/pb"
"yunyan/sys/aliyun/oss"
aoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/gin-gonic/gin"
)
// distPageDefaultName 下载页默认文件名:<分发域名>/<应用名>/download.html。
const distPageDefaultName = "download.html"
// distPageMaxBytes 下载页源码上限。现网那份内联了 App 图标也才 70KB,
// 给到 2MB 足够,同时挡住误把安装包之类的大文件当页面发布。
const distPageMaxBytes = 2 << 20
// distPageNameRe 文件名只允许一层、字母数字开头、.html 结尾。
// 分发桶是公共读的,这里放宽一点就等于让人能在 dl.ymaikj.com 上随意挂页面。
var distPageNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.html$`)
// distHost 从分发域名配置里取出 host(运营可能连 scheme、路径一起填)。
func distHost(domain string) string {
d := strings.TrimSpace(domain)
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")
d = strings.Trim(d, "/")
if i := strings.Index(d, "/"); i >= 0 {
d = d[:i]
}
return d
}
// defaultDistPageURL 某应用下载页的默认地址。
func defaultDistPageURL(domain, appSeg string) string {
return (&url.URL{Scheme: "https", Host: distHost(domain), Path: "/" + appSeg + "/" + distPageDefaultName}).String()
}
// parseDistPageURL 校验后台输入框里的地址,返回 object key 与规范化后的地址。
//
// 规则:域名必须是分发域名;路径必须恰好是 /<应用名>/<文件名>.html。
// 前缀由服务端按选中的应用定死,页面上只能改文件名——不然一个应用的运营
// 就能覆盖掉别的应用的下载页,或者覆盖 android/ 下的安装包。
func parseDistPageURL(raw, domain, appSeg string) (key, canonical string, err error) {
host := distHost(domain)
if host == "" {
return "", "", fmt.Errorf("未配置分发域名")
}
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", fmt.Errorf("下载页地址不能为空")
}
u, perr := url.Parse(raw)
if perr != nil || (u.Scheme != "http" && u.Scheme != "https") {
return "", "", fmt.Errorf("下载页地址格式不对,应形如 https://%s/%s/%s", host, appSeg, distPageDefaultName)
}
if !strings.EqualFold(u.Host, host) {
return "", "", fmt.Errorf("下载页只能放在分发域名 %s 下", host)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("下载页地址必须是 https://%s/%s/<文件名>.html,不能多也不能少一层目录", host, appSeg)
}
if parts[0] != appSeg {
return "", "", fmt.Errorf("下载页只能放在本应用的目录 /%s/ 下", appSeg)
}
name := parts[1]
if !distPageNameRe.MatchString(name) {
return "", "", fmt.Errorf("文件名只能用字母、数字、. _ -,且以小写 .html 结尾")
}
key = path.Join(appSeg, name)
canonical = (&url.URL{Scheme: "https", Host: host, Path: "/" + key}).String()
return key, canonical, nil
}
// distPageScope 解析一次下载页请求的作用域:应用分段 + 分发桶句柄 + 分发域名。
func (this *serverComp) distPageScope(appName string) (appSeg string, bucket *oss.OSS, domain string, err error) {
appName = strings.TrimSpace(appName)
if appName == "" {
return "", nil, "", fmt.Errorf("请先在页面顶部选择应用:下载页按应用分目录存放")
}
// 必须是注册过的应用,不能让任意字符串在公共桶里造目录。
app, aerr := this.module.model.getAppByNameOrAppName(appName)
if aerr != nil || app == nil {
return "", nil, "", fmt.Errorf("应用 %s 未在应用注册表中登记", appName)
}
appSeg = distAppSeg(appName)
if appSeg == "" || comm.DistUploadDirs[appSeg] {
return "", nil, "", fmt.Errorf("应用名 %s 不能用作下载页目录", appName)
}
conf, cerr := this.loadAliyunOSSConf(app.Name)
if cerr != nil {
return "", nil, "", cerr
}
conf = conf.ForDist()
bucket, err = oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
if err != nil {
return "", nil, "", fmt.Errorf("初始化阿里云 OSS 失败: %v", err)
}
return appSeg, bucket, conf.Domain, nil
}
// getDistPage 读某应用的下载页源码。
//
// 请求 { app_name, url? }:url 为空时取已保存的地址,再空取默认地址。
// 返回 { url, saved_url, default_url, content, exists };对象不存在时 exists=false、content 为空,
// 不当成错误——新应用第一次用本功能时本来就没有页面。
func (this *serverComp) getDistPage(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
URL string `json:"url"`
}
_ = c.ShouldBindJSON(&req)
appSeg, bucket, domain, err := this.distPageScope(req.AppName)
if err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
row, err := this.loadAppRelease(req.AppName)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
defURL := defaultDistPageURL(domain, appSeg)
target := strings.TrimSpace(req.URL)
if target == "" {
target = strings.TrimSpace(row.DownloadPage)
}
if target == "" {
target = defURL
}
key, canonical, err := parseDistPageURL(target, domain, appSeg)
if err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
content, exists := "", true
data, gerr := bucket.GetObject(key)
if gerr != nil {
if se, ok := gerr.(aoss.ServiceError); ok && se.StatusCode == http.StatusNotFound {
exists = false
} else {
writeErr(c, pb.ErrorCode_SystemError, "读取下载页失败: "+gerr.Error())
return
}
} else {
content = string(data)
}
writeOK(c, gin.H{
"url": canonical,
"saved_url": row.DownloadPage,
"default_url": defURL,
"content": content,
"exists": exists,
})
}
// saveDistPage 发布下载页:覆盖写到分发桶,并把地址记进 app_release.download_page。
//
// 请求 { app_name, url, content }。
func (this *serverComp) saveDistPage(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
URL string `json:"url"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if strings.TrimSpace(req.Content) == "" {
// 发一个空页面上去,扫码的人就是一片白——比保留旧页面糟得多,直接拒。
writeErr(c, pb.ErrorCode_ReqParameterError, "页面内容为空,未发布")
return
}
if len(req.Content) > distPageMaxBytes {
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("页面超过 %dMB 上限", distPageMaxBytes>>20))
return
}
appSeg, bucket, domain, err := this.distPageScope(req.AppName)
if err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
key, canonical, err := parseDistPageURL(req.URL, domain, appSeg)
if err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
// ⚠️ 两个头都不能省:
// Content-Type 不带 charset 时,部分安卓浏览器按 GBK 猜,中文整页乱码;
// Cache-Control 不设时浏览器/微信会按启发式缓存,点了保存扫码的人还看到旧页面。
if err := bucket.UploadObject(key, bytes.NewReader([]byte(req.Content)),
aoss.ContentType("text/html; charset=utf-8"),
aoss.CacheControl("no-cache"),
); err != nil {
writeErr(c, pb.ErrorCode_SystemError, "发布到 OSS 失败: "+err.Error())
return
}
// 页面已经发出去了,地址落库失败只影响下次打开时回填哪个地址,不回滚发布。
m, err := this.loadAppRelease(req.AppName)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "页面已发布,但保存地址失败: "+err.Error())
return
}
m.AppName = req.AppName
m.DownloadPage = canonical
this.saveAppRelease(c, m)
}

39
apps/services/modules/console/api_distpage_test.go

@ -0,0 +1,39 @@
package console
import "testing"
// 下载页地址的边界:分发桶是公共读的,这里放宽一点就等于任人挂页面 / 覆盖别的应用。
func TestParseDistPageURL(t *testing.T) {
const domain = "https://dl.ymaikj.com/"
ok := []struct{ in, key, url string }{
{"https://dl.ymaikj.com/EAIMAR/download.html", "EAIMAR/download.html", "https://dl.ymaikj.com/EAIMAR/download.html"},
{"http://DL.ymaikj.com/EAIMAR/download2.html?x=1", "EAIMAR/download2.html", "https://dl.ymaikj.com/EAIMAR/download2.html"},
{"https://dl.ymaikj.com/EAIMAR/a_b-c.html", "EAIMAR/a_b-c.html", "https://dl.ymaikj.com/EAIMAR/a_b-c.html"},
}
for _, c := range ok {
key, u, err := parseDistPageURL(c.in, domain, "EAIMAR")
if err != nil || key != c.key || u != c.url {
t.Errorf("%s => (%q,%q,%v), want (%q,%q)", c.in, key, u, err, c.key, c.url)
}
}
bad := []string{
"",
"dl.ymaikj.com/EAIMAR/download.html", // 没有 scheme
"https://evil.com/EAIMAR/download.html", // 别的域名
"https://dl.ymaikj.com/deepGlass/download.html", // 别的应用
"https://dl.ymaikj.com/android/EAIMAR/x.html", // 多一层 / 安装包目录
"https://dl.ymaikj.com/download.html", // 少一层
"https://dl.ymaikj.com/EAIMAR/x.apk", // 不是 html
"https://dl.ymaikj.com/EAIMAR/../x.html", // 穿越
"https://dl.ymaikj.com/EAIMAR/.x.html", // 隐藏文件
"https://dl.ymaikj.com/EAIMAR/x.HTML", // 扩展名只认小写
}
for _, in := range bad {
if key, _, err := parseDistPageURL(in, domain, "EAIMAR"); err == nil {
t.Errorf("%q 应被拒,却得到 key=%q", in, key)
}
}
if got := defaultDistPageURL(domain, "EAIMAR"); got != "https://dl.ymaikj.com/EAIMAR/download.html" {
t.Errorf("默认地址 = %s", got)
}
}

7
apps/services/modules/console/server.go

@ -421,6 +421,13 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_saveapptourists":
this.saveAppTourists(c)
return
// 下载落地页:读 / 发布分发桶上那份静态 download.html(「版本与分发」页的「下载页」一行)。
case "api_getdistpage":
this.getDistPage(c)
return
case "api_savedistpage":
this.saveDistPage(c)
return
// 渠道分发(channelapp):各应用在各分发渠道的下载地址/上架状态/支付渠道开关,存 console 主库,按 app_name 作用域。
case "api_getchannelapps":
this.getChannelApps(c)

1
apps/services/modules/user/model_user.go

@ -485,6 +485,7 @@ func toPbChannelApp(channel int32, c *comm.ChannelApp, rel *comm.AppRelease, cli
out.Reviewversion = rel.ReviewVersion
out.Tourists = rel.ShowTourists(clientVer)
out.Touristsversion = rel.TouristsVersion
out.Idverify = rel.ShowIdVerify(clientVer)
}
return out
}

11
apps/services/modules/user/module_test.go

@ -1061,3 +1061,14 @@ func TestCollationCharset(t *testing.T) {
}
}
}
// 实名认证的显隐随游客走:点名的那一版下发 idverify=true,其余版本 false(客户端据此整个放行实名闸门)。
func TestToPbChannelAppIdVerifyFollowsTourists(t *testing.T) {
rel := &comm.AppRelease{Version: "1.0.3", Tourists: true, TouristsVersion: "1.0.3"}
if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.3", ""); !got.Idverify || !got.Tourists {
t.Fatalf("送审版本应同时显示游客与实名,实际 tourists=%v idverify=%v", got.Tourists, got.Idverify)
}
if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.2", ""); got.Idverify || got.Tourists {
t.Fatalf("非送审版本应同时隐藏游客与实名,实际 tourists=%v idverify=%v", got.Tourists, got.Idverify)
}
}

13
apps/services/pb/db.pb.go

@ -2553,6 +2553,7 @@ type DBChannelApp struct {
Reviewversion string `protobuf:"bytes,11,opt,name=reviewversion,proto3" json:"reviewversion" gorm:"reviewversion"` //@go_tags(`gorm:"reviewversion" json:"reviewversion"`) 审核中的版本号,客户端报此版本时可隐藏未过审功能
Status int32 `protobuf:"varint,12,opt,name=status,proto3" json:"status" gorm:"status"` //@go_tags(`gorm:"status" json:"status"`) 上架状态 0草稿 1已上架 2审核中 3已下架
Touristsversion string `protobuf:"bytes,13,opt,name=touristsversion,proto3" json:"touristsversion" gorm:"touristsversion"` //@go_tags(`gorm:"touristsversion" json:"touristsversion"`) 游客登录生效版本号;非空时仅该版本客户端可见游客登录,其余版本一律隐藏
Idverify bool `protobuf:"varint,14,opt,name=idverify,proto3" json:"idverify" gorm:"-"` //@go_tags(`gorm:"-" json:"idverify"`) 这一版客户端是否显示实名认证并按实名限制功能(服务端算好的结论,目前与 tourists 同值,见 comm.AppRelease.ShowIdVerify)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -2678,6 +2679,13 @@ func (x *DBChannelApp) GetTouristsversion() string {
return ""
}
func (x *DBChannelApp) GetIdverify() bool {
if x != nil {
return x.Idverify
}
return false
}
// 授权码数据(按产品分表 license_<pid十六进制>;核心=productid+number 产品级全局序号)
type DBAuthCode struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -4151,7 +4159,7 @@ const file_db_proto_rawDesc = "" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" +
"\aversion\x18\x03 \x01(\tR\aversion\x12 \n" +
"\vdescription\x18\x04 \x01(\tR\vdescription\x122\n" +
"\x14updatepackageaddress\x18\x05 \x01(\tR\x14updatepackageaddress\"\xbc\x03\n" +
"\x14updatepackageaddress\x18\x05 \x01(\tR\x14updatepackageaddress\"\xd8\x03\n" +
"\fDBChannelApp\x12\x18\n" +
"\achannel\x18\x01 \x01(\x05R\achannel\x12\x18\n" +
"\aversion\x18\x02 \x01(\tR\aversion\x12 \n" +
@ -4168,7 +4176,8 @@ const file_db_proto_rawDesc = "" +
" \x01(\tR\tchangelog\x12$\n" +
"\rreviewversion\x18\v \x01(\tR\rreviewversion\x12\x16\n" +
"\x06status\x18\f \x01(\x05R\x06status\x12(\n" +
"\x0ftouristsversion\x18\r \x01(\tR\x0ftouristsversion\"\xeb\x03\n" +
"\x0ftouristsversion\x18\r \x01(\tR\x0ftouristsversion\x12\x1a\n" +
"\bidverify\x18\x0e \x01(\bR\bidverify\"\xeb\x03\n" +
"\n" +
"DBAuthCode\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12%\n" +

6
apps/services/services/home/configreload.go

@ -5,6 +5,7 @@ import (
"yunyan/comm"
"yunyan/lego/sys/mysql"
wechat_auth "yunyan/sys/auth/wechat"
"yunyan/sys/email"
"yunyan/sys/pay/alipay"
"yunyan/sys/pay/appleiap"
@ -22,7 +23,7 @@ import (
// registerModuleReloaders 注册可热重载的业务模块(键 = ModuleDef.SysKey)。
//
// 只有在此登记的模块才会真的热更;其余(各 auth)重载时如实回 restart_required——
// 只有在此登记的模块才会真的热更;其余(google_auth 等)重载时如实回 restart_required——
// 它们的 sys 尚未提供「校验失败不 panic 且保留旧实例」的 Reload,贸然复用 OnInit 会在
// 配置填错时把线上进程打挂。要扩展就照 sys/email 的 Reload 补齐后再加一行。
//
@ -35,6 +36,9 @@ func registerModuleReloaders() {
comm.RegisterModuleReloader("paypal", func(cfg map[string]interface{}) error { return paypal.Reload(cfg) })
comm.RegisterModuleReloader("appleiap", func(cfg map[string]interface{}) error { return appleiap.Reload(cfg) })
comm.RegisterModuleReloader("googleiap", func(cfg map[string]interface{}) error { return googleiap.Reload(cfg) })
// 微信登录:2026-09-23 正式服在后台补填 AppSecret 后因没有这一行而不生效,
// 登录持续报 41004 appsecret missing,直到重启。
comm.RegisterModuleReloader("wechat_auth", func(cfg map[string]interface{}) error { return wechat_auth.Reload(cfg) })
}
// setupModuleReload 登记重载上下文。必须在各 sys OnInit 与 mysql.OnInit 之后调用——

50
apps/services/sys/auth/wechat/core.go

@ -3,6 +3,7 @@ package wechat_auth
import (
"context"
"errors"
"sync"
)
type (
@ -32,18 +33,56 @@ type (
}
)
var defsys ISys
// defsys 由 OnInit(启动) 与 Reload(后台改配置后热更) 写、Auth 读,故必须加锁:
// 无锁并发读写接口值是 data race。
var (
mu sync.RWMutex
defsys ISys
)
func get() ISys {
mu.RLock()
defer mu.RUnlock()
return defsys
}
func set(sys ISys) {
mu.Lock()
defsys = sys
mu.Unlock()
}
// ErrNotInited 微信登录未初始化。OnInit 失败时调用方可选择降级;此时包级函数返回本错误,而不是 panic。
var ErrNotInited = errors.New("wechat_auth 未初始化:配置缺失")
// OnInit 配置缺失时返回 error(调用方降级为「微信登录不可用」),不再像以前那样空着 AppSecret
// 也报 success——那会让启动日志说一切正常、实际每次登录都被微信回 41004 appsecret missing。
func OnInit(config map[string]interface{}, option ...Option) error {
sys, err := newSys(newOptions(config, option...))
options, err := newOptionsChecked(config, option...)
if err != nil {
return err
}
sys, err := newSys(options)
if err != nil {
// 出错时不要给 defsys 赋值,避免「非 nil 接口 + nil 指针」让守卫失效。
return err
}
defsys = sys
set(sys)
return nil
}
// Reload 后台「业务功能配置 → 微信登录」保存后热更,无需重启。
// 校验不过直接返回 error、保留旧实例继续服务(与 sys/sms 的 Reload 同口径)。
func Reload(config map[string]interface{}, option ...Option) error {
options, err := newOptionsChecked(config, option...)
if err != nil {
return err
}
sys, err := newSys(options)
if err != nil {
return err
}
set(sys)
return nil
}
@ -53,8 +92,9 @@ func NewSys(option ...Option) (sys ISys, err error) {
}
func Auth(ctx context.Context, idToken string) (info *WeChatUserInfoResponse, err error) {
if defsys == nil {
sys := get()
if sys == nil {
return nil, ErrNotInited
}
return defsys.Auth(ctx, idToken)
return sys.Auth(ctx, idToken)
}

19
apps/services/sys/auth/wechat/options.go

@ -1,6 +1,9 @@
package wechat_auth
import (
"errors"
"strings"
"yunyan/lego/sys/log"
"yunyan/lego/utils/mapstructure"
)
@ -47,3 +50,19 @@ func newOptionsByOption(opts ...Option) Options {
}
return options
}
// newOptionsChecked 解析 map 配置并校验,不合法时返回 error 而非 panic。
// 启动(OnInit)与热重载(Reload)共用:配置缺失只应让微信登录降级 / 本次重载失败,不该打挂进程。
func newOptionsChecked(config map[string]interface{}, opts ...Option) (Options, error) {
options := newOptions(config, opts...)
// 后台表单粘贴时常带首尾空白,原样拼进 URL 微信会判 invalid appsecret。
options.AppID = strings.TrimSpace(options.AppID)
options.AppSecret = strings.TrimSpace(options.AppSecret)
if options.AppID == "" {
return options, errors.New("wechat_auth 配置缺失: AppID 为空")
}
if options.AppSecret == "" {
return options, errors.New("wechat_auth 配置缺失: AppSecret 为空")
}
return options, nil
}

44
apps/services/sys/auth/wechat/reload_test.go

@ -0,0 +1,44 @@
package wechat_auth
import "testing"
// 空 AppSecret 必须判失败:以前 OnInit 照单全收、日志报 success,线上每次登录都被微信回 41004。
func TestReloadRejectsMissingAndKeepsOld(t *testing.T) {
set(nil)
defer set(nil)
if err := OnInit(map[string]interface{}{"AppID": "wx1", "AppSecret": ""}); err == nil {
t.Fatal("AppSecret 为空时 OnInit 应返回 error")
}
if get() != nil {
t.Fatal("OnInit 失败不应留下实例")
}
if err := Reload(map[string]interface{}{"AppID": " wx1 ", "AppSecret": " s1\n"}); err != nil {
t.Fatalf("合法配置 Reload 失败: %v", err)
}
old := get().(*WeChat)
if old.options.AppID != "wx1" || old.options.AppSecret != "s1" {
t.Fatalf("首尾空白未去掉: %+v", old.options)
}
for _, cfg := range []map[string]interface{}{
{"AppID": "wx2", "AppSecret": ""},
{"AppID": "", "AppSecret": "s2"},
{"AppID": "wx2", "AppSecret": " "},
} {
if err := Reload(cfg); err == nil {
t.Fatalf("非法配置 %v 应返回 error", cfg)
}
if get() != old {
t.Fatalf("非法配置 %v 不应替换旧实例", cfg)
}
}
if err := Reload(map[string]interface{}{"AppID": "wx3", "AppSecret": "s3"}); err != nil {
t.Fatal(err)
}
if get().(*WeChat).options.AppID != "wx3" {
t.Fatal("合法配置应替换实例")
}
}

4
deploy/dl/README.md

@ -1,5 +1,9 @@
# 下载落地页(dl.ymaikj.com)
> ⚠️ 2026-09-23 起**线上页面由管理后台维护**:「服务配置 → 版本与分发 → 下载页」编辑/保存/查看,
> 直接发布到 `ymai-dl/<应用>/download.html`。本目录只作初始模板,改这里不会生效,
> 拿这里的文件手传 OSS 会覆盖后台改过的版本。
一个纯静态页,托管在阿里云 OSS 上,**不占服务器、不经 NPM**。
安装包也放同一个桶里,页面按 UA 决定跳商店还是给 APK 直链。

14
deploy/dl/index.html → deploy/dl/download.html

@ -222,14 +222,14 @@ h1{font-size:28px;margin:0 0 6px;letter-spacing:1.5px;font-weight:700}
// · 安卓各商店填各自开发者后台的应用详情页链接
// · apk 填安装包地址。安装包与本页同桶(ymai-dl),所以走 dl.ymaikj.com;
// 文件名按仓库约定 eaimar-release-<版本号>-<时间>.apk,别留 app-release.apk
appStore: 'https://emai.ymaikj.com/1223',
apk: 'https://dl.ymaikj.com/android/eaimar-release-1.0.2-latest.apk',
appStore: 'https://emai.ymaikj.com/',
apk: 'https://dl.ymaikj.com/android/EAIMAR/eaimar-release-1.0.2-20260923_2042-prod.apk',
stores: {
huawei: 'https://emai.ymaikj.com/1223',
honor: 'https://emai.ymaikj.com/1223',
xiaomi: 'https://emai.ymaikj.com/1223',
oppo: 'https://emai.ymaikj.com/1223',
vivo: 'https://emai.ymaikj.com/1223'
huawei: 'https://dl.ymaikj.com/android/EAIMAR/eaimar-release-1.0.2-20260923_2042-prod.apk',
honor: 'https://dl.ymaikj.com/android/EAIMAR/eaimar-release-1.0.2-20260923_2042-prod.apk',
xiaomi: 'https://dl.ymaikj.com/android/EAIMAR/eaimar-release-1.0.2-20260923_2042-prod.apk',
oppo: 'https://dl.ymaikj.com/android/EAIMAR/eaimar-release-1.0.2-20260923_2042-prod.apk',
vivo: 'https://dl.ymaikj.com/android/EAIMAR/eaimar-release-1.0.2-20260923_2042-prod.apk'
}
},
Loading…
Cancel
Save