Compare commits
3 Commits
98e640ecfe
...
d6892eb3d6
| Author | SHA1 | Date |
|---|---|---|
|
|
d6892eb3d6 | 3 weeks ago |
|
|
d50edc6635 | 3 weeks ago |
|
|
3dc66fb396 | 3 weeks ago |
29 changed files with 1171 additions and 37 deletions
@ -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> |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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("合法配置应替换实例") |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue