feat(admin): 富文本与内容管理页调整;链接标签模型与 db 同步;更新 dist
Made-with: Cursor
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>管理后台 - Soul创业派对</title>
|
<title>管理后台 - Soul创业派对</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dv-LWSbq.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dz0mx7au.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DXojA1Za.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-qjssBjc3.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ export interface LinkTagItem {
|
|||||||
label: string
|
label: string
|
||||||
aliases?: string
|
aliases?: string
|
||||||
url: string
|
url: string
|
||||||
type: 'url' | 'miniprogram' | 'ckb'
|
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink'
|
||||||
appId?: string
|
appId?: string
|
||||||
pagePath?: string
|
pagePath?: string
|
||||||
|
/** 管理端列表用:库内是否已存目标小程序 AppSecret(接口不下发明文) */
|
||||||
|
hasAppSecret?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 插入附件 HTML 时转义,防 XSS */
|
/** 插入附件 HTML 时转义,防 XSS */
|
||||||
|
|||||||
@@ -260,8 +260,9 @@ export function ContentPage() {
|
|||||||
label: '',
|
label: '',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
url: '',
|
url: '',
|
||||||
type: 'url' as 'url' | 'miniprogram' | 'ckb',
|
type: 'url' as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
|
||||||
appId: '',
|
appId: '',
|
||||||
|
appSecret: '',
|
||||||
pagePath: '',
|
pagePath: '',
|
||||||
})
|
})
|
||||||
const [linkTagSaving, setLinkTagSaving] = useState(false)
|
const [linkTagSaving, setLinkTagSaving] = useState(false)
|
||||||
@@ -537,7 +538,15 @@ export function ContentPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await get<{
|
const data = await get<{
|
||||||
success?: boolean
|
success?: boolean
|
||||||
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
|
linkTags?: {
|
||||||
|
tagId: string
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
type: string
|
||||||
|
appId?: string
|
||||||
|
pagePath?: string
|
||||||
|
hasAppSecret?: boolean
|
||||||
|
}[]
|
||||||
}>('/api/db/link-tags')
|
}>('/api/db/link-tags')
|
||||||
if (data?.success && data.linkTags) {
|
if (data?.success && data.linkTags) {
|
||||||
setLinkTags(
|
setLinkTags(
|
||||||
@@ -545,9 +554,10 @@ export function ContentPage() {
|
|||||||
id: t.tagId,
|
id: t.tagId,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
url: t.url,
|
url: t.url,
|
||||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
|
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
|
||||||
appId: t.appId || '',
|
appId: t.appId || '',
|
||||||
pagePath: t.pagePath || '',
|
pagePath: t.pagePath || '',
|
||||||
|
hasAppSecret: !!t.hasAppSecret,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -608,7 +618,16 @@ export function ContentPage() {
|
|||||||
if (s) qs.set('search', s)
|
if (s) qs.set('search', s)
|
||||||
const data = await get<{
|
const data = await get<{
|
||||||
success?: boolean
|
success?: boolean
|
||||||
linkTags?: { tagId: string; label: string; aliases?: string; url: string; type: string; appId?: string; pagePath?: string }[]
|
linkTags?: {
|
||||||
|
tagId: string
|
||||||
|
label: string
|
||||||
|
aliases?: string
|
||||||
|
url: string
|
||||||
|
type: string
|
||||||
|
appId?: string
|
||||||
|
pagePath?: string
|
||||||
|
hasAppSecret?: boolean
|
||||||
|
}[]
|
||||||
total?: number
|
total?: number
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
@@ -622,9 +641,10 @@ export function ContentPage() {
|
|||||||
label: t.label,
|
label: t.label,
|
||||||
aliases: t.aliases || '',
|
aliases: t.aliases || '',
|
||||||
url: t.url,
|
url: t.url,
|
||||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
|
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
|
||||||
appId: t.appId || '',
|
appId: t.appId || '',
|
||||||
pagePath: t.pagePath || '',
|
pagePath: t.pagePath || '',
|
||||||
|
hasAppSecret: !!t.hasAppSecret,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
setLinkTagTotal(typeof data.total === 'number' ? data.total : 0)
|
setLinkTagTotal(typeof data.total === 'number' ? data.total : 0)
|
||||||
@@ -2810,7 +2830,7 @@ export function ContentPage() {
|
|||||||
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
|
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLinkTagEditing(null)
|
setLinkTagEditing(null)
|
||||||
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', pagePath: '' })
|
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', appSecret: '', pagePath: '' })
|
||||||
setMpSearchQuery('')
|
setMpSearchQuery('')
|
||||||
setMpDropdownOpen(false)
|
setMpDropdownOpen(false)
|
||||||
setLinkTagModalOpen(true)
|
setLinkTagModalOpen(true)
|
||||||
@@ -2862,6 +2882,7 @@ export function ContentPage() {
|
|||||||
url: t.url,
|
url: t.url,
|
||||||
type: t.type,
|
type: t.type,
|
||||||
appId: t.appId ?? '',
|
appId: t.appId ?? '',
|
||||||
|
appSecret: '',
|
||||||
pagePath: t.pagePath ?? '',
|
pagePath: t.pagePath ?? '',
|
||||||
})
|
})
|
||||||
setMpSearchQuery(t.appId ?? '')
|
setMpSearchQuery(t.appId ?? '')
|
||||||
@@ -2882,12 +2903,12 @@ export function ContentPage() {
|
|||||||
className={`text-[10px] ${
|
className={`text-[10px] ${
|
||||||
t.type === 'ckb'
|
t.type === 'ckb'
|
||||||
? 'bg-green-500/20 text-green-300 border-green-500/30'
|
? 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||||
: t.type === 'miniprogram'
|
: t.type === 'miniprogram' || t.type === 'wxlink'
|
||||||
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
|
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
|
||||||
: 'bg-gray-700 text-gray-300'
|
: 'bg-gray-700 text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
|
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : t.type === 'wxlink' ? '小程序链接' : '小程序'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-300">
|
<td className="px-3 py-2 text-gray-300">
|
||||||
@@ -2903,6 +2924,18 @@ export function ContentPage() {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
{t.pagePath && <div className="text-xs text-gray-500 font-mono">{t.pagePath}</div>}
|
{t.pagePath && <div className="text-xs text-gray-500 font-mono">{t.pagePath}</div>}
|
||||||
|
<div
|
||||||
|
className={`text-xs ${t.hasAppSecret ? 'text-emerald-400/90' : 'text-amber-500/80'}`}
|
||||||
|
>
|
||||||
|
AppSecret:{t.hasAppSecret ? '已保存(仅服务端)' : '未配置'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : t.type === 'wxlink' ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-xs text-[#38bdac] truncate max-w-[420px] font-mono" title={t.url}>
|
||||||
|
{t.url || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500">小程序内点击 → web-view 打开 → 自动唤起目标小程序</div>
|
||||||
</div>
|
</div>
|
||||||
) : t.url ? (
|
) : t.url ? (
|
||||||
<a
|
<a
|
||||||
@@ -2932,6 +2965,7 @@ export function ContentPage() {
|
|||||||
url: t.url,
|
url: t.url,
|
||||||
type: t.type,
|
type: t.type,
|
||||||
appId: t.appId ?? '',
|
appId: t.appId ?? '',
|
||||||
|
appSecret: '',
|
||||||
pagePath: t.pagePath ?? '',
|
pagePath: t.pagePath ?? '',
|
||||||
})
|
})
|
||||||
setMpSearchQuery(t.appId ?? '')
|
setMpSearchQuery(t.appId ?? '')
|
||||||
@@ -2996,7 +3030,7 @@ export function ContentPage() {
|
|||||||
<DialogHeader className="gap-1">
|
<DialogHeader className="gap-1">
|
||||||
<DialogTitle className="text-base">{linkTagEditing ? '编辑链接标签' : '添加链接标签'}</DialogTitle>
|
<DialogTitle className="text-base">{linkTagEditing ? '编辑链接标签' : '添加链接标签'}</DialogTitle>
|
||||||
<DialogDescription className="text-gray-400 text-xs">
|
<DialogDescription className="text-gray-400 text-xs">
|
||||||
配置后可在富文本编辑器中通过 #标签 插入,并在小程序端点击跳转
|
配置后可在富文本编辑器中通过 #标签 插入,并在小程序端点击跳转。小程序类型需填 mpKey 或微信 AppID;AppSecret 仅存服务端(不下发小程序),供后续开放接口与台账使用。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -3006,7 +3040,7 @@ export function ContentPage() {
|
|||||||
<Label className="text-gray-300 text-sm">标签ID</Label>
|
<Label className="text-gray-300 text-sm">标签ID</Label>
|
||||||
<Input
|
<Input
|
||||||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
|
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
|
||||||
placeholder="留空自动生成;或填 12位数字 / z开头12位"
|
placeholder="留空自动生成;或自定义短 ID(如 kr),最长 50 字符"
|
||||||
value={linkTagForm.tagId}
|
value={linkTagForm.tagId}
|
||||||
disabled={!!linkTagEditing}
|
disabled={!!linkTagEditing}
|
||||||
onChange={(e) => setLinkTagForm((p) => ({ ...p, tagId: e.target.value }))}
|
onChange={(e) => setLinkTagForm((p) => ({ ...p, tagId: e.target.value }))}
|
||||||
@@ -3038,7 +3072,7 @@ export function ContentPage() {
|
|||||||
<Select
|
<Select
|
||||||
value={linkTagForm.type}
|
value={linkTagForm.type}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' }))
|
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' | 'wxlink' }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8">
|
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8">
|
||||||
@@ -3046,7 +3080,8 @@ export function ContentPage() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#0f2137] border-gray-700 text-white">
|
<SelectContent className="bg-[#0f2137] border-gray-700 text-white">
|
||||||
<SelectItem value="url">网页链接</SelectItem>
|
<SelectItem value="url">网页链接</SelectItem>
|
||||||
<SelectItem value="miniprogram">小程序</SelectItem>
|
<SelectItem value="miniprogram">小程序(API跳转)</SelectItem>
|
||||||
|
<SelectItem value="wxlink">小程序链接(右上角复制)</SelectItem>
|
||||||
<SelectItem value="ckb">存客宝</SelectItem>
|
<SelectItem value="ckb">存客宝</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -3057,9 +3092,18 @@ export function ContentPage() {
|
|||||||
? 'URL地址'
|
? 'URL地址'
|
||||||
: linkTagForm.type === 'ckb'
|
: linkTagForm.type === 'ckb'
|
||||||
? '存客宝计划URL'
|
? '存客宝计划URL'
|
||||||
: '小程序(选密钥)'}
|
: linkTagForm.type === 'wxlink'
|
||||||
|
? '小程序链接'
|
||||||
|
: '小程序 mpKey / 微信 AppID'}
|
||||||
</Label>
|
</Label>
|
||||||
{linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
|
{linkTagForm.type === 'wxlink' ? (
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
|
||||||
|
placeholder="粘贴小程序右上角 ... → 复制链接 得到的 URL"
|
||||||
|
value={linkTagForm.url}
|
||||||
|
onChange={(e) => setLinkTagForm((p) => ({ ...p, url: e.target.value }))}
|
||||||
|
/>
|
||||||
|
) : linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
|
||||||
<div ref={mpDropdownRef} className="relative">
|
<div ref={mpDropdownRef} className="relative">
|
||||||
<Input
|
<Input
|
||||||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
|
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
|
||||||
@@ -3110,7 +3154,7 @@ export function ContentPage() {
|
|||||||
? 'https://...'
|
? 'https://...'
|
||||||
: linkTagForm.type === 'ckb'
|
: linkTagForm.type === 'ckb'
|
||||||
? 'https://ckbapi.quwanzhi.com/...'
|
? 'https://ckbapi.quwanzhi.com/...'
|
||||||
: '关联小程序的32位密钥'
|
: '关联配置的 key,或直接填 wx 开头的 AppID'
|
||||||
}
|
}
|
||||||
value={linkTagForm.type === 'url' || linkTagForm.type === 'ckb' ? linkTagForm.url : linkTagForm.appId}
|
value={linkTagForm.type === 'url' || linkTagForm.type === 'ckb' ? linkTagForm.url : linkTagForm.appId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -3123,16 +3167,38 @@ export function ContentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{linkTagForm.type === 'wxlink' && (
|
||||||
|
<p className="text-[11px] text-amber-400/80 leading-snug px-0.5">
|
||||||
|
操作:打开目标小程序 → 右上角「...」→「复制链接」→ 粘贴到上面。小程序内点击此标签会在 web-view 中打开,微信自动唤起目标小程序,无需修改小程序版本。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{linkTagForm.type === 'miniprogram' && (
|
{linkTagForm.type === 'miniprogram' && (
|
||||||
<div className="space-y-1">
|
<>
|
||||||
<Label className="text-gray-300 text-sm">页面路径(可选)</Label>
|
<div className="space-y-1">
|
||||||
<Input
|
<Label className="text-gray-300 text-sm">页面路径(可选)</Label>
|
||||||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
|
<Input
|
||||||
placeholder="pages/index/index"
|
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
|
||||||
value={linkTagForm.pagePath}
|
placeholder="pages/index/index"
|
||||||
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
|
value={linkTagForm.pagePath}
|
||||||
/>
|
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-gray-300 text-sm">AppSecret(微信公众平台 · 仅服务端存储)</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
|
||||||
|
placeholder={linkTagEditing?.hasAppSecret ? '已保存密钥,留空不改;填写则覆盖' : '粘贴目标小程序 AppSecret'}
|
||||||
|
value={linkTagForm.appSecret}
|
||||||
|
onChange={(e) => setLinkTagForm((p) => ({ ...p, appSecret: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-gray-500 leading-snug">
|
||||||
|
与 AppID 成对落库;接口响应与小程序配置中均不会返回此字段。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3149,13 +3215,18 @@ export function ContentPage() {
|
|||||||
url: linkTagForm.url.trim(),
|
url: linkTagForm.url.trim(),
|
||||||
type: linkTagForm.type,
|
type: linkTagForm.type,
|
||||||
appId: linkTagForm.appId.trim(),
|
appId: linkTagForm.appId.trim(),
|
||||||
|
appSecret: linkTagForm.appSecret.trim(),
|
||||||
pagePath: linkTagForm.pagePath.trim(),
|
pagePath: linkTagForm.pagePath.trim(),
|
||||||
}
|
}
|
||||||
// 新增:允许留空,后端自动生成;编辑:tagId 已锁定
|
// 留空则后端自动生成;自定义时与库一致:1~50 字符,勿含 #、逗号、换行
|
||||||
if (payload.tagId) {
|
if (payload.tagId) {
|
||||||
const ok = /^\d{12}$/.test(payload.tagId) || /^z[a-z0-9]{11}$/.test(payload.tagId)
|
const id = payload.tagId
|
||||||
if (!ok) {
|
if ([...id].length > 50) {
|
||||||
toast.error('标签ID需为12位数字,或 z 开头的12位(z+11位小写字母数字)')
|
toast.error('标签ID 最长 50 个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (/[#,\n\r\t]/.test(id)) {
|
||||||
|
toast.error('标签ID 不能含 #、逗号或换行')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3164,6 +3235,7 @@ export function ContentPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.type === 'miniprogram') payload.url = ''
|
if (payload.type === 'miniprogram') payload.url = ''
|
||||||
|
if (payload.type === 'wxlink') { payload.appId = ''; payload.pagePath = '' }
|
||||||
setLinkTagSaving(true)
|
setLinkTagSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await post<{ success?: boolean; error?: string }>('/api/db/link-tags', payload)
|
const res = await post<{ success?: boolean; error?: string }>('/api/db/link-tags', payload)
|
||||||
|
|||||||
@@ -317,9 +317,18 @@ func buildMiniprogramConfig() gin.H {
|
|||||||
_ = db.Order("label ASC").Find(&linkTagRows).Error
|
_ = db.Order("label ASC").Find(&linkTagRows).Error
|
||||||
tags := make([]gin.H, 0, len(linkTagRows))
|
tags := make([]gin.H, 0, len(linkTagRows))
|
||||||
for _, t := range linkTagRows {
|
for _, t := range linkTagRows {
|
||||||
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": t.URL, "type": t.Type, "pagePath": t.PagePath}
|
cType := t.Type
|
||||||
|
cURL := t.URL
|
||||||
|
// wxlink(小程序短链):下发给 C 端时转为 url 类型,现有 read.js web-view 可直接跳转,无需升级小程序
|
||||||
|
if strings.EqualFold(cType, "wxlink") {
|
||||||
|
cType = "url"
|
||||||
|
if cURL == "" {
|
||||||
|
cURL = t.AppID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": cURL, "type": cType, "pagePath": t.PagePath}
|
||||||
if t.Type == "miniprogram" {
|
if t.Type == "miniprogram" {
|
||||||
h["mpKey"] = t.AppID // 可为「关联表 key」或「直接 wx AppID」;后者由 mergeDirectMiniProgramLinksFromLinkTags 补全 linkedMiniprograms
|
h["mpKey"] = t.AppID
|
||||||
} else {
|
} else {
|
||||||
h["appId"] = t.AppID
|
h["appId"] = t.AppID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"soul-api/internal/cache"
|
"soul-api/internal/cache"
|
||||||
"soul-api/internal/database"
|
"soul-api/internal/database"
|
||||||
@@ -14,30 +15,64 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func isDigits12(s string) bool {
|
// isValidLinkTagID 自定义 tagId:与库字段 tag_id(50) 对齐,禁止破坏 #标签 解析的字符
|
||||||
if len(s) != 12 {
|
func isValidLinkTagID(s string) bool {
|
||||||
|
if s == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, ch := range s {
|
if utf8.RuneCountInString(s) > 50 {
|
||||||
if ch < '0' || ch > '9' {
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '#' || r == ',' || r == '\n' || r == '\r' || r == '\t' {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isZId12(s string) bool {
|
// linkTagAdminOut 管理端 API 出参:不含 appSecret 明文,仅 hasAppSecret
|
||||||
if len(s) != 12 || s[0] != 'z' {
|
type linkTagAdminOut struct {
|
||||||
return false
|
ID uint `json:"id"`
|
||||||
|
TagID string `json:"tagId"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Aliases string `json:"aliases"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AppID string `json:"appId,omitempty"`
|
||||||
|
PagePath string `json:"pagePath,omitempty"`
|
||||||
|
HasAppSecret bool `json:"hasAppSecret"`
|
||||||
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkTagToAdminOut(t model.LinkTag) linkTagAdminOut {
|
||||||
|
o := linkTagAdminOut{
|
||||||
|
ID: t.ID,
|
||||||
|
TagID: t.TagID,
|
||||||
|
Label: t.Label,
|
||||||
|
Aliases: t.Aliases,
|
||||||
|
URL: t.URL,
|
||||||
|
Type: t.Type,
|
||||||
|
AppID: t.AppID,
|
||||||
|
PagePath: t.PagePath,
|
||||||
|
HasAppSecret: strings.TrimSpace(t.AppSecret) != "",
|
||||||
}
|
}
|
||||||
for i := 1; i < 12; i++ {
|
if !t.CreatedAt.IsZero() {
|
||||||
ch := s[i]
|
o.CreatedAt = t.CreatedAt.Format(time.RFC3339)
|
||||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return true
|
if !t.UpdatedAt.IsZero() {
|
||||||
|
o.UpdatedAt = t.UpdatedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkTagsToAdminOut(rows []model.LinkTag) []linkTagAdminOut {
|
||||||
|
out := make([]linkTagAdminOut, 0, len(rows))
|
||||||
|
for _, t := range rows {
|
||||||
|
out = append(out, linkTagToAdminOut(t))
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func genZId12() string {
|
func genZId12() string {
|
||||||
@@ -63,7 +98,7 @@ func DBLinkTagList(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
|
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": linkTagsToAdminOut(rows)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +134,7 @@ func DBLinkTagList(c *gin.Context) {
|
|||||||
totalPages := (int(total) + pageSize - 1) / pageSize
|
totalPages := (int(total) + pageSize - 1) / pageSize
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"linkTags": rows,
|
"linkTags": linkTagsToAdminOut(rows),
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"pageSize": pageSize,
|
"pageSize": pageSize,
|
||||||
@@ -110,13 +145,14 @@ func DBLinkTagList(c *gin.Context) {
|
|||||||
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
|
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
|
||||||
func DBLinkTagSave(c *gin.Context) {
|
func DBLinkTagSave(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
TagID string `json:"tagId"`
|
TagID string `json:"tagId"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Aliases string `json:"aliases"`
|
Aliases string `json:"aliases"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
AppID string `json:"appId"`
|
AppID string `json:"appId"`
|
||||||
PagePath string `json:"pagePath"`
|
AppSecret string `json:"appSecret"`
|
||||||
|
PagePath string `json:"pagePath"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||||
@@ -128,6 +164,7 @@ func DBLinkTagSave(c *gin.Context) {
|
|||||||
body.URL = strings.TrimSpace(body.URL)
|
body.URL = strings.TrimSpace(body.URL)
|
||||||
body.Type = strings.TrimSpace(body.Type)
|
body.Type = strings.TrimSpace(body.Type)
|
||||||
body.AppID = strings.TrimSpace(body.AppID)
|
body.AppID = strings.TrimSpace(body.AppID)
|
||||||
|
body.AppSecret = strings.TrimSpace(body.AppSecret)
|
||||||
body.PagePath = strings.TrimSpace(body.PagePath)
|
body.PagePath = strings.TrimSpace(body.PagePath)
|
||||||
|
|
||||||
if body.Label == "" {
|
if body.Label == "" {
|
||||||
@@ -141,24 +178,18 @@ func DBLinkTagSave(c *gin.Context) {
|
|||||||
if body.Type == "" {
|
if body.Type == "" {
|
||||||
body.Type = "url"
|
body.Type = "url"
|
||||||
}
|
}
|
||||||
// tagId 规则:12位数字,或 12位且以 z 开头(z + 11位[a-z0-9])
|
// 管理端新增:可不传 tagId,由后端生成 z 开头 12 位;也可自定义任意 1~50 字符(如 kr)
|
||||||
// 管理端新增:可不传 tagId,由后端生成;编辑:通常会携带现有 tagId
|
|
||||||
if body.TagID == "" {
|
if body.TagID == "" {
|
||||||
body.TagID = genZId12()
|
body.TagID = genZId12()
|
||||||
autoCreate = true
|
autoCreate = true
|
||||||
}
|
}
|
||||||
|
if !isValidLinkTagID(body.TagID) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 长度 1~50 字符,且不能含 #、逗号或换行"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
db := database.DB()
|
db := database.DB()
|
||||||
var existing model.LinkTag
|
var existing model.LinkTag
|
||||||
// 若 tagId 不符合新规则:仅允许更新已有记录(兼容历史中文 tagId),禁止新建
|
|
||||||
if !(isDigits12(body.TagID) || isZId12(body.TagID)) {
|
|
||||||
if err := db.Where("tag_id = ?", body.TagID).First(&existing).Error; err == nil {
|
|
||||||
// allow update existing legacy tagId
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 必须为12位数字,或12位且以 z 开头(z+11位小写字母数字)"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 小程序类型:只存 appId + pagePath,不存 weixin:// 到 url
|
// 小程序类型:只存 appId + pagePath,不存 weixin:// 到 url
|
||||||
if body.Type == "miniprogram" {
|
if body.Type == "miniprogram" {
|
||||||
body.URL = ""
|
body.URL = ""
|
||||||
@@ -166,7 +197,7 @@ func DBLinkTagSave(c *gin.Context) {
|
|||||||
// 按 label 查找:仅用于「自动创建」场景(tagId 为空时回落 label),若已存在则直接返回
|
// 按 label 查找:仅用于「自动创建」场景(tagId 为空时回落 label),若已存在则直接返回
|
||||||
if autoCreate {
|
if autoCreate {
|
||||||
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
|
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(existing)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,21 +207,24 @@ func DBLinkTagSave(c *gin.Context) {
|
|||||||
existing.URL = body.URL
|
existing.URL = body.URL
|
||||||
existing.Type = body.Type
|
existing.Type = body.Type
|
||||||
existing.AppID = body.AppID
|
existing.AppID = body.AppID
|
||||||
|
if body.AppSecret != "" {
|
||||||
|
existing.AppSecret = body.AppSecret
|
||||||
|
}
|
||||||
existing.PagePath = body.PagePath
|
existing.PagePath = body.PagePath
|
||||||
db.Save(&existing)
|
db.Save(&existing)
|
||||||
cache.InvalidateConfig()
|
cache.InvalidateConfig()
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(existing)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// body.URL 已在 miniprogram 类型时置空
|
// body.URL 已在 miniprogram 类型时置空
|
||||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
|
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, AppSecret: body.AppSecret, PagePath: body.PagePath}
|
||||||
if err := db.Create(&t).Error; err != nil {
|
if err := db.Create(&t).Error; err != nil {
|
||||||
// 极低概率:生成的 tagId 冲突,重试一次
|
// 极低概率:生成的 tagId 冲突,重试一次
|
||||||
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
|
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
|
||||||
t.TagID = genZId12()
|
t.TagID = genZId12()
|
||||||
if e2 := db.Create(&t).Error; e2 == nil {
|
if e2 := db.Create(&t).Error; e2 == nil {
|
||||||
cache.InvalidateConfig()
|
cache.InvalidateConfig()
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
|
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(t)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +232,7 @@ func DBLinkTagSave(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache.InvalidateConfig()
|
cache.InvalidateConfig()
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
|
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(t)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBLinkTagDelete DELETE /api/db/link-tags?tagId=xxx 管理端-删除链接标签
|
// DBLinkTagDelete DELETE /api/db/link-tags?tagId=xxx 管理端-删除链接标签
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type LinkTag struct {
|
|||||||
URL string `gorm:"column:url;size:500" json:"url"`
|
URL string `gorm:"column:url;size:500" json:"url"`
|
||||||
Type string `gorm:"column:type;size:20" json:"type"`
|
Type string `gorm:"column:type;size:20" json:"type"`
|
||||||
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
|
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
|
||||||
|
// AppSecret 目标小程序 AppSecret,仅存库;列表/保存响应用 hasAppSecret,永不 json 明文下发
|
||||||
|
AppSecret string `gorm:"column:app_secret;size:256;default:''" json:"-"`
|
||||||
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
|
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||||
|
|||||||
Reference in New Issue
Block a user