diff --git a/browser-extension/multi-platform-publisher-1.1.15.zip b/browser-extension/multi-platform-publisher-1.1.16.zip similarity index 58% rename from browser-extension/multi-platform-publisher-1.1.15.zip rename to browser-extension/multi-platform-publisher-1.1.16.zip index 5a37c93..39e4ec6 100644 Binary files a/browser-extension/multi-platform-publisher-1.1.15.zip and b/browser-extension/multi-platform-publisher-1.1.16.zip differ diff --git a/browser-extension/multi-platform-publisher-1.1.17.zip b/browser-extension/multi-platform-publisher-1.1.17.zip new file mode 100644 index 0000000..92b3aa5 Binary files /dev/null and b/browser-extension/multi-platform-publisher-1.1.17.zip differ diff --git a/browser-extension/multi-platform-publisher-1.1.15/README.md b/browser-extension/multi-platform-publisher-1.1.17/README.md similarity index 90% rename from browser-extension/multi-platform-publisher-1.1.15/README.md rename to browser-extension/multi-platform-publisher-1.1.17/README.md index a88a99a..c9b210d 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/README.md +++ b/browser-extension/multi-platform-publisher-1.1.17/README.md @@ -7,12 +7,14 @@ 1. 在 Chrome 地址栏打开 `chrome://extensions/`。 2. 打开右上角“开发者模式”。 3. 点击“加载已解压的扩展程序”。 -4. 选择本目录 `browser-extension/multi-platform-publisher`。 +4. 选择解压后的目录 `browser-extension/multi-platform-publisher-1.1.17`。 5. 登录易代码后台以及需要使用的创作者平台。 6. 重新打开后台“发布管理”页面,确认页面显示“发布扩展已连接”。 修改扩展代码后,需要在扩展管理页点击该扩展的“重新加载”,并刷新后台和平台页面。 +点击浏览器工具栏中的扩展图标会打开右侧“多平台发布助手”。侧边栏展示凭证有效期内各平台的最新发布任务;点击平台名称可定位对应标签页,点击刷新图标会重新打开干净的发布页面并重试填写。已发布成功的任务禁止重试,避免重复发布。 + ## 工作范围 - 后台页面创建带短期凭证的发布任务。 diff --git a/browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js b/browser-extension/multi-platform-publisher-1.1.17/background/service-worker.js similarity index 81% rename from browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js rename to browser-extension/multi-platform-publisher-1.1.17/background/service-worker.js index 10bea31..cf9bc46 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js +++ b/browser-extension/multi-platform-publisher-1.1.17/background/service-worker.js @@ -23,13 +23,17 @@ const advancingBatches = new Set() chrome.runtime.onInstalled.addListener(() => { chrome.storage.local.set({ publisherExtensionVersion: VERSION }) + configureSidePanel().catch(() => {}) recoverStoredBatches().catch(() => {}) }) chrome.runtime.onStartup.addListener(() => { + configureSidePanel().catch(() => {}) recoverStoredBatches().catch(() => {}) }) +configureSidePanel().catch(() => {}) + chrome.alarms.onAlarm.addListener(alarm => { if (!alarm || !alarm.name.startsWith(BATCH_WATCHDOG_PREFIX)) return const batchId = alarm.name.slice(BATCH_WATCHDOG_PREFIX.length) @@ -79,6 +83,27 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true } + if (message.type === 'SIDEPANEL_GET_TASKS') { + listSidePanelTasks() + .then(tasks => sendResponse({ ok: true, tasks })) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + + if (message.type === 'SIDEPANEL_RETRY_TASK') { + retryTask(message.taskId) + .then(result => sendResponse({ ok: true, ...result })) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + + if (message.type === 'SIDEPANEL_ACTIVATE_TASK') { + activateTaskTab(message.taskId) + .then(result => sendResponse({ ok: true, ...result })) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + return false }) @@ -97,10 +122,93 @@ chrome.tabs.onRemoved.addListener(tabId => { await updateStatus(context, 'CANCELLED', '平台页面已关闭', '') await advanceBatch(context) } - await chrome.storage.local.remove([tabKey(tabId), taskKey(context.taskId)]) + context.platformTabId = null + context.closedAt = Date.now() + await chrome.storage.local.set({ [taskKey(context.taskId)]: context }) + await chrome.storage.local.remove(tabKey(tabId)) }).catch(() => {}) }) +async function configureSidePanel() { + if (!chrome.sidePanel || !chrome.sidePanel.setPanelBehavior) return + await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) +} + +async function listSidePanelTasks() { + const stored = await chrome.storage.local.get(null) + const expiry = Date.now() - 4 * 60 * 60 * 1000 + const contexts = Object.keys(stored) + .filter(key => key.startsWith('publisher_task_')) + .map(key => stored[key]) + .filter(context => context && context.taskId && context.platform && context.createdAt >= expiry) + .sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0)) + const latestByPlatform = new Map() + for (const context of contexts) { + if (!latestByPlatform.has(context.platform)) latestByPlatform.set(context.platform, context) + } + return Array.from(latestByPlatform.values()).map(context => ({ + taskId: context.taskId, + platform: context.platform, + platformName: platformName(context.platform), + title: context.task && context.task.title || '', + status: context.status || 'PENDING', + statusMessage: context.statusMessage || '', + platformTabId: context.platformTabId || null, + createdAt: context.createdAt || 0, + canRetry: context.status !== 'SUCCEEDED' + })) +} + +async function activateTaskTab(taskId) { + const context = await requireStoredTask(taskId) + if (!context.platformTabId) throw new Error('平台页面已关闭,可点击刷新按钮重新打开') + const tab = await chrome.tabs.get(context.platformTabId).catch(() => null) + if (!tab) throw new Error('平台页面已关闭,可点击刷新按钮重新打开') + await focusTab(tab) + return { taskId: context.taskId, tabId: tab.id } +} + +async function retryTask(taskId) { + const context = await requireStoredTask(taskId) + if (context.status === 'SUCCEEDED') throw new Error('已发布成功的任务不能重试,以免重复发布') + context.status = 'OPENING' + context.statusMessage = '正在从侧边栏重试' + context.closedAt = null + await updateStatus(context, 'OPENING', '正在重试' + platformName(context.platform) + '发布任务', '') + + let tab = context.platformTabId + ? await chrome.tabs.get(context.platformTabId).catch(() => null) + : null + if (tab) { + await chrome.storage.local.set({ [tabKey(tab.id)]: context.taskId }) + tab = await chrome.tabs.update(tab.id, { url: PLATFORM_URLS[context.platform], active: true }) + } else { + tab = await chrome.tabs.create({ url: PLATFORM_URLS[context.platform], active: true }) + } + context.platformTabId = tab.id + await chrome.storage.local.set({ + [taskKey(context.taskId)]: context, + [tabKey(tab.id)]: context.taskId + }) + await focusTab(tab) + return { taskId: context.taskId, tabId: tab.id } +} + +async function requireStoredTask(taskId) { + if (typeof taskId !== 'string' || !/^publish_[a-f0-9]{32}$/.test(taskId)) { + throw new Error('发布任务编号无效') + } + const stored = await chrome.storage.local.get(taskKey(taskId)) + const context = stored[taskKey(taskId)] + if (!context) throw new Error('发布任务已过期,请从后台重新创建') + return context +} + +async function focusTab(tab) { + if (tab.windowId) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) + await chrome.tabs.update(tab.id, { active: true }) +} + async function startPublish(payload, sender) { if (!isAllowedAdmin(sender.url) || !sender.tab) { throw new Error('发布请求不是来自允许的后台页面') @@ -442,6 +550,8 @@ async function updateStatus(context, status, message, error) { throw new Error(body.msg || '发布状态回写失败') } context.status = status + context.statusMessage = message || '' + context.lastError = error || '' if (context.platformTabId) { await chrome.storage.local.set({ [taskKey(context.taskId)]: context }) } diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js b/browser-extension/multi-platform-publisher-1.1.17/content/admin-bridge.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js rename to browser-extension/multi-platform-publisher-1.1.17/content/admin-bridge.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js b/browser-extension/multi-platform-publisher-1.1.17/content/bilibili.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js rename to browser-extension/multi-platform-publisher-1.1.17/content/bilibili.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/douyin.js b/browser-extension/multi-platform-publisher-1.1.17/content/douyin.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/douyin.js rename to browser-extension/multi-platform-publisher-1.1.17/content/douyin.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js b/browser-extension/multi-platform-publisher-1.1.17/content/platform-common.js similarity index 94% rename from browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js rename to browser-extension/multi-platform-publisher-1.1.17/content/platform-common.js index c387613..13406f7 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js +++ b/browser-extension/multi-platform-publisher-1.1.17/content/platform-common.js @@ -151,8 +151,11 @@ const blob = await response.blob() if (!blob.size) throw new Error(assetName(kind) + '文件为空') const disposition = response.headers.get('Content-Disposition') || '' - const fileName = parseFileName(disposition) || fallbackName - return new File([blob], fileName, { type: blob.type || mimeForName(fileName), lastModified: Date.now() }) + const sourceName = parseFileName(disposition) || fallbackName + const mimeType = normalizedAssetMimeType(blob.type, sourceName) + validateAssetMimeType(kind, mimeType) + const fileName = fileNameForMimeType(sourceName, mimeType) + return new File([blob], fileName, { type: mimeType, lastModified: Date.now() }) } async function activatePublishMode(config, task) { @@ -808,6 +811,45 @@ }[extension] || 'application/octet-stream' } + function normalizedAssetMimeType(responseType, fileName) { + const normalized = String(responseType || '').split(';')[0].trim().toLowerCase() + return normalized && normalized !== 'application/octet-stream' + ? normalized + : mimeForName(fileName) + } + + function validateAssetMimeType(kind, mimeType) { + if ((kind === 'image' || kind === 'cover') + && !['image/png', 'image/jpeg', 'image/webp'].includes(mimeType)) { + throw new Error(assetName(kind) + '返回的不是支持的图片格式(' + mimeType + ')') + } + if (kind === 'video' && ![ + 'video/mp4', 'video/quicktime', 'video/x-m4v', 'video/webm' + ].includes(mimeType)) { + throw new Error('视频返回的不是支持的视频格式(' + mimeType + ')') + } + } + + function fileNameForMimeType(fileName, mimeType) { + const expectedExtension = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'video/mp4': 'mp4', + 'video/quicktime': 'mov', + 'video/x-m4v': 'm4v', + 'video/webm': 'webm' + }[mimeType] + const normalizedName = String(fileName || 'publish-asset').trim() || 'publish-asset' + if (!expectedExtension) return normalizedName + const currentMimeType = mimeForName(normalizedName) + if (currentMimeType === mimeType) return normalizedName + const lastSlash = Math.max(normalizedName.lastIndexOf('/'), normalizedName.lastIndexOf('\\')) + const lastDot = normalizedName.lastIndexOf('.') + const baseName = lastDot > lastSlash ? normalizedName.slice(0, lastDot) : normalizedName + return baseName + '.' + expectedExtension + } + function assetName(kind) { return { video: '视频', image: '图片', cover: '封面' }[kind] || '素材' } @@ -903,5 +945,13 @@ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } function readableError(error) { return error && error.message ? error.message : String(error || '未知错误') } - globalThis.YidaimaPublisher = Object.freeze({ run, watch }) + const publisherApi = { run, watch } + if (globalThis.__YIDAIMA_PUBLISHER_TEST__) { + publisherApi.test = Object.freeze({ + normalizedAssetMimeType, + validateAssetMimeType, + fileNameForMimeType + }) + } + globalThis.YidaimaPublisher = Object.freeze(publisherApi) })() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js b/browser-extension/multi-platform-publisher-1.1.17/content/wechat-channels.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js rename to browser-extension/multi-platform-publisher-1.1.17/content/wechat-channels.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js b/browser-extension/multi-platform-publisher-1.1.17/content/xianyu.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js rename to browser-extension/multi-platform-publisher-1.1.17/content/xianyu.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js b/browser-extension/multi-platform-publisher-1.1.17/content/xiaohongshu.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js rename to browser-extension/multi-platform-publisher-1.1.17/content/xiaohongshu.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/manifest.json b/browser-extension/multi-platform-publisher-1.1.17/manifest.json similarity index 91% rename from browser-extension/multi-platform-publisher-1.1.15/manifest.json rename to browser-extension/multi-platform-publisher-1.1.17/manifest.json index 5524458..ad372e8 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/manifest.json +++ b/browser-extension/multi-platform-publisher-1.1.17/manifest.json @@ -1,13 +1,14 @@ { "manifest_version": 3, "name": "易代码多平台发布助手", - "version": "1.1.15", + "version": "1.1.17", "description": "从易代码后台读取发布任务,为小红书、抖音、视频号、B站和闲鱼填写发布资料,最终发布由用户手动确认。", "permissions": [ "storage", "tabs", "scripting", - "alarms" + "alarms", + "sidePanel" ], "host_permissions": [ "https://feast.yidaima.cn/*", @@ -24,8 +25,10 @@ "service_worker": "background/service-worker.js" }, "action": { - "default_title": "易代码多平台发布助手", - "default_popup": "popup/popup.html" + "default_title": "打开多平台发布助手" + }, + "side_panel": { + "default_path": "sidepanel/panel.html" }, "content_scripts": [ { diff --git a/browser-extension/multi-platform-publisher-1.1.15/popup/popup.html b/browser-extension/multi-platform-publisher-1.1.17/popup/popup.html similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/popup/popup.html rename to browser-extension/multi-platform-publisher-1.1.17/popup/popup.html diff --git a/browser-extension/multi-platform-publisher-1.1.15/popup/popup.js b/browser-extension/multi-platform-publisher-1.1.17/popup/popup.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/popup/popup.js rename to browser-extension/multi-platform-publisher-1.1.17/popup/popup.js diff --git a/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.css b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.css new file mode 100644 index 0000000..b3f649e --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.css @@ -0,0 +1,151 @@ +:root { + color-scheme: light; + font-family: Arial, "Microsoft YaHei", sans-serif; + color: #273142; + background: #f6f8fb; +} + +* { box-sizing: border-box; } +body { min-width: 300px; margin: 0; background: #f6f8fb; } +button { font: inherit; } + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 18px 16px 14px; + background: #fff; + border-bottom: 1px solid #e7ebf1; +} +.panel-header h1 { margin: 0; font-size: 17px; line-height: 24px; } +.panel-header p { margin: 6px 0 0; color: #7a8495; font-size: 12px; line-height: 1.5; } +.header-mark { + display: inline-grid; + width: 20px; + height: 20px; + margin-right: 4px; + place-items: center; + color: #fff; + background: #28b66f; + border-radius: 5px; +} +.header-action, +.retry-button { + border: 0; + cursor: pointer; + transition: background .15s ease, color .15s ease, transform .15s ease; +} +.header-action { + width: 32px; + height: 32px; + color: #657084; + background: #f1f4f8; + border-radius: 8px; + font-size: 19px; +} +.header-action:hover { color: #1677ff; background: #eaf3ff; } + +main { padding: 12px 12px 54px; } +.notice { + margin-bottom: 10px; + padding: 9px 11px; + color: #9b5b00; + background: #fff7e8; + border: 1px solid #ffe0a3; + border-radius: 8px; + font-size: 12px; + line-height: 1.5; +} +.notice.is-success { color: #237849; background: #edf9f2; border-color: #bce8cd; } +.task-list { display: grid; gap: 9px; } +.task-card { + display: flex; + align-items: center; + min-height: 66px; + background: #fff; + border: 1px solid #e5e9f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(35, 47, 70, .04); + overflow: hidden; +} +.task-main { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + padding: 11px 8px 11px 12px; + text-align: left; + background: transparent; + border: 0; + cursor: pointer; +} +.task-main:hover { background: #fafcff; } +.platform-icon { + display: inline-grid; + flex: 0 0 34px; + width: 34px; + height: 34px; + margin-right: 10px; + place-items: center; + color: #fff; + border-radius: 9px; + font-size: 14px; + font-weight: 700; +} +.platform-xiaohongshu { background: #ff2442; } +.platform-douyin { background: #15171d; } +.platform-wechat_channels { background: #19b875; } +.platform-bilibili { background: #00aeec; } +.platform-xianyu { color: #322d00; background: #ffe500; } +.task-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; } +.platform-name { color: #273142; font-size: 14px; line-height: 20px; } +.task-title { + margin-top: 2px; + overflow: hidden; + color: #8a93a3; + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} +.status-line { display: flex; align-items: center; margin-top: 4px; color: #697386; font-size: 11px; } +.status-line i { width: 6px; height: 6px; margin-right: 5px; background: #9aa3b2; border-radius: 50%; } +.status-line.is-running i { background: #1677ff; box-shadow: 0 0 0 3px #e8f2ff; } +.status-line.is-ready i { background: #28b66f; box-shadow: 0 0 0 3px #e8f8ef; } +.status-line.is-failed i { background: #f04438; box-shadow: 0 0 0 3px #ffedeb; } +.status-line.is-login i { background: #f59f00; box-shadow: 0 0 0 3px #fff3d6; } +.retry-button { + flex: 0 0 42px; + width: 42px; + height: 42px; + margin-right: 8px; + color: #667085; + background: #f5f7fa; + border-radius: 9px; + font-size: 21px; +} +.retry-button:hover { color: #1677ff; background: #eaf3ff; transform: rotate(-20deg); } +.retry-button:disabled { color: #b7bec9; background: #f5f6f8; cursor: not-allowed; transform: none; } +.retry-button.is-loading { animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.empty-state { padding: 54px 24px; color: #7b8595; text-align: center; } +.empty-state .empty-icon { margin-bottom: 12px; color: #a9b2c0; font-size: 34px; } +.empty-state strong { display: block; color: #4b5567; font-size: 14px; } +.empty-state p { margin: 8px 0 0; font-size: 12px; line-height: 1.6; } + +footer { + position: fixed; + right: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + padding: 10px 14px; + color: #7f8998; + background: rgba(255, 255, 255, .96); + border-top: 1px solid #e7ebf1; + font-size: 11px; +} +.connection-dot { width: 7px; height: 7px; margin-right: 6px; background: #28b66f; border-radius: 50%; } +.version { margin-left: auto; } diff --git a/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.html b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.html new file mode 100644 index 0000000..ba5ddd0 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.html @@ -0,0 +1,50 @@ + + + + + + 多平台发布助手 + + + +
+
+

✓ 多平台发布助手

+

点击平台名称定位页面,点击刷新图标重新填写

+
+ +
+ +
+ +
+ +
+ + + + + + + + diff --git a/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.js b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.js new file mode 100644 index 0000000..d0232a8 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.js @@ -0,0 +1,103 @@ +const PLATFORM_META = Object.freeze({ + xiaohongshu: { short: '红', className: 'platform-xiaohongshu' }, + douyin: { short: '抖', className: 'platform-douyin' }, + wechat_channels: { short: '视', className: 'platform-wechat_channels' }, + bilibili: { short: 'B', className: 'platform-bilibili' }, + xianyu: { short: '闲', className: 'platform-xianyu' } +}) + +const STATUS_META = Object.freeze({ + PENDING: { text: '等待扩展接收', className: '' }, + OPENING: { text: '正在打开平台', className: 'is-running' }, + LOGIN_REQUIRED: { text: '需要登录', className: 'is-login' }, + DOWNLOADING: { text: '正在读取素材', className: 'is-running' }, + UPLOADING: { text: '正在上传素材', className: 'is-running' }, + FILLING: { text: '正在填写资料', className: 'is-running' }, + READY_TO_PUBLISH: { text: '等待手动发布', className: 'is-ready' }, + SUCCEEDED: { text: '发布成功', className: 'is-ready' }, + FAILED: { text: '处理失败,可重试', className: 'is-failed' }, + CANCELLED: { text: '页面已关闭,可重试', className: 'is-failed' } +}) + +const taskList = document.getElementById('task-list') +const emptyState = document.getElementById('empty-state') +const notice = document.getElementById('notice') +const template = document.getElementById('task-template') +document.getElementById('version').textContent = 'v' + chrome.runtime.getManifest().version +document.getElementById('refresh-list').addEventListener('click', loadTasks) + +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return + if (Object.keys(changes).some(key => key.startsWith('publisher_task_'))) loadTasks() +}) + +async function loadTasks() { + const response = await sendMessage({ type: 'SIDEPANEL_GET_TASKS' }) + if (!response || !response.ok) { + showNotice(response && response.message || '读取发布任务失败') + return + } + renderTasks(response.tasks || []) +} + +function renderTasks(tasks) { + taskList.replaceChildren() + emptyState.hidden = tasks.length > 0 + for (const task of tasks) { + const card = template.content.firstElementChild.cloneNode(true) + const meta = PLATFORM_META[task.platform] || { short: '?', className: '' } + const status = STATUS_META[task.status] || { text: task.status || '未知状态', className: '' } + const icon = card.querySelector('.platform-icon') + icon.textContent = meta.short + icon.classList.add(meta.className) + card.querySelector('.platform-name').textContent = task.platformName || task.platform + card.querySelector('.task-title').textContent = task.title || '当前发布任务' + const statusLine = card.querySelector('.status-line') + statusLine.classList.add(status.className) + statusLine.querySelector('span').textContent = task.statusMessage || status.text + + card.querySelector('.task-main').addEventListener('click', async () => { + const response = await sendMessage({ type: 'SIDEPANEL_ACTIVATE_TASK', taskId: task.taskId }) + if (!response || !response.ok) showNotice(response && response.message || '无法定位平台页面') + }) + + const retryButton = card.querySelector('.retry-button') + retryButton.disabled = !task.canRetry + retryButton.title = task.canRetry ? '重新打开并重试' : '已发布成功,不能重复执行' + retryButton.addEventListener('click', async () => { + retryButton.disabled = true + retryButton.classList.add('is-loading') + const response = await sendMessage({ type: 'SIDEPANEL_RETRY_TASK', taskId: task.taskId }) + retryButton.classList.remove('is-loading') + if (!response || !response.ok) { + retryButton.disabled = !task.canRetry + showNotice(response && response.message || '重试失败') + return + } + showNotice('已重新打开' + (task.platformName || task.platform) + '并开始填写', true) + await loadTasks() + }) + taskList.appendChild(card) + } +} + +function showNotice(message, success = false) { + notice.textContent = message + notice.classList.toggle('is-success', success) + notice.hidden = false + window.setTimeout(() => { notice.hidden = true }, 4000) +} + +function sendMessage(message) { + return new Promise(resolve => { + chrome.runtime.sendMessage(message, response => { + if (chrome.runtime.lastError) { + resolve({ ok: false, message: chrome.runtime.lastError.message }) + return + } + resolve(response) + }) + }) +} + +loadTasks() diff --git a/browser-extension/multi-platform-publisher-1.1.17/tests/sidepanel-service-worker.test.mjs b/browser-extension/multi-platform-publisher-1.1.17/tests/sidepanel-service-worker.test.mjs new file mode 100644 index 0000000..1ceb87a --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/tests/sidepanel-service-worker.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import vm from 'node:vm' +import test from 'node:test' + +const testDirectory = dirname(fileURLToPath(import.meta.url)) +const script = readFileSync(resolve(testDirectory, '../background/service-worker.js'), 'utf8') + +function createRuntime(initialStorage = {}) { + const storage = { ...initialStorage } + const createdTabs = [] + let nextTabId = 900 + const event = () => ({ addListener() {} }) + const chrome = { + runtime: { + getManifest: () => ({ version: '1.1.17' }), + onInstalled: event(), + onStartup: event(), + onMessage: event(), + sendMessage: async () => undefined + }, + alarms: { + onAlarm: event(), + create: async () => undefined, + clear: async () => true + }, + sidePanel: { setPanelBehavior: async () => undefined }, + storage: { + local: { + get: async key => { + if (key == null) return { ...storage } + if (Array.isArray(key)) return Object.fromEntries(key.filter(item => item in storage).map(item => [item, storage[item]])) + return key in storage ? { [key]: storage[key] } : {} + }, + set: async values => Object.assign(storage, values), + remove: async keys => { + for (const key of Array.isArray(keys) ? keys : [keys]) delete storage[key] + } + } + }, + tabs: { + onUpdated: event(), + onRemoved: event(), + create: async options => { + const tab = { id: nextTabId++, windowId: 12, ...options } + createdTabs.push(tab) + return tab + }, + get: async () => { throw new Error('tab not found') }, + update: async (id, options) => ({ id, windowId: 12, ...options }), + query: async () => [], + sendMessage: async () => undefined + }, + windows: { update: async () => undefined }, + scripting: { executeScript: async () => [] } + } + const context = { + chrome, + console, + URL, + setTimeout, + clearTimeout, + fetch: async () => ({ ok: true, json: async () => ({ code: 200 }) }) + } + vm.createContext(context) + vm.runInContext(script, context) + return { context, storage, createdTabs } +} + +function task(taskId, platform, createdAt, status = 'FAILED') { + return { + taskId, + token: 'a'.repeat(32), + apiBaseUrl: 'https://feast.yidaima.cn/prod-api', + platform, + task: { title: platform + ' title' }, + status, + statusMessage: '测试状态', + createdAt, + platformTabId: null + } +} + +test('side panel lists only the latest task for each platform', async () => { + const now = Date.now() + const firstId = 'publish_' + '1'.repeat(32) + const latestId = 'publish_' + '2'.repeat(32) + const douyinId = 'publish_' + '3'.repeat(32) + const runtime = createRuntime({ + ['publisher_task_' + firstId]: task(firstId, 'xiaohongshu', now - 2000), + ['publisher_task_' + latestId]: task(latestId, 'xiaohongshu', now - 1000), + ['publisher_task_' + douyinId]: task(douyinId, 'douyin', now) + }) + + const tasks = await runtime.context.listSidePanelTasks() + + assert.equal(tasks.length, 2) + assert.equal(tasks.find(item => item.platform === 'xiaohongshu').taskId, latestId) + assert.equal(tasks.find(item => item.platform === 'douyin').taskId, douyinId) +}) + +test('retry reopens a closed platform tab and stores the new mapping', async () => { + const taskId = 'publish_' + '4'.repeat(32) + const runtime = createRuntime({ + ['publisher_task_' + taskId]: task(taskId, 'xianyu', Date.now()) + }) + + const result = await runtime.context.retryTask(taskId) + + assert.equal(result.tabId, 900) + assert.equal(runtime.createdTabs[0].url, 'https://www.goofish.pro/sale/product/add?from=%2Fon-sale') + assert.equal(runtime.storage['publisher_task_' + taskId].status, 'OPENING') + assert.equal(runtime.storage.publisher_tab_900, taskId) +}) + +test('retry rejects a successfully published task', async () => { + const taskId = 'publish_' + '5'.repeat(32) + const runtime = createRuntime({ + ['publisher_task_' + taskId]: task(taskId, 'bilibili', Date.now(), 'SUCCEEDED') + }) + + await assert.rejects(() => runtime.context.retryTask(taskId), /不能重试/) +}) diff --git a/docs/virtual-pay-setup.md b/docs/virtual-pay-setup.md index 1f86d6e..be44d23 100644 --- a/docs/virtual-pay-setup.md +++ b/docs/virtual-pay-setup.md @@ -46,6 +46,8 @@ AppKey 和小程序 Secret 不应提交到 Git,生产环境应由部署平台 | 5 元 | `resource_500` | | 10 元 | `resource_1000` | +执行 `sql/virtual_product_menu.sql` 并重新登录若依后台,然后在“虚拟道具管理”中新增或维护对应记录。微信道具 ID 必须与微信公众平台保持完全一致,价格以“分”为单位。 + 然后在若依后台编辑付费资源: - 获取方式选择“付费”; @@ -55,7 +57,7 @@ AppKey 和小程序 Secret 不应提交到 Git,生产环境应由部署平台 - 规格价格必须存在于 `app_virtual_product` 的已启用价格档位中,系统会自动匹配对应的 `productId`。 - 所有可能产生的升级差价也必须配置价格档位。例如版本价格分别为 `9900`、`19900`、`29900` 分,还需配置 `10000`、`20000` 分两个差价档位。 -后台会自动维护“价格 -> productId”唯一映射;一个 productId 不能绑定多个价格。 +“虚拟道具管理”会校验“价格 -> productId”唯一映射;一个价格只能配置一个道具,一个 productId 也不能绑定多个价格。 用户升级时,服务端按“目标版本当前价格 - 已拥有最高版本当前价格”计算实付金额。前端展示金额仅供参考,签名和订单金额始终由服务端重新计算。高版本退款后,只撤销该高版本订单对应的权益,用户更早单独购买的低版本权益仍然保留。 @@ -81,8 +83,8 @@ Token: 与 WX_VIRTUAL_PAY_CALLBACK_TOKEN 相同 4. 检查 `app_virtual_order.status=1` 和 `app_resource_entitlement.status=1`。 5. 重新进入资源详情,确认下载链接只对购买用户返回。 -## 6. 启用后台订单管理 +## 6. 启用后台管理 -执行 `sql/virtual_pay_order_menu.sql`,然后重新登录若依后台。在原“支付订单”菜单的同级位置会出现“虚拟支付订单”,支持按订单号、微信交易号、用户、资源、状态和创建时间查询,并可查看详情或导出 Excel。 +执行 `sql/virtual_product_menu.sql` 和 `sql/virtual_pay_order_menu.sql`,然后重新登录若依后台。在原“支付订单”菜单的同级位置会出现“虚拟道具管理”和“虚拟支付订单”。道具页面支持价格档位的新增、修改、停用、删除和导出;订单页面支持按订单号、微信交易号、用户、资源、状态和创建时间查询,并可查看详情或导出 Excel。 虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualProductController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualProductController.java new file mode 100644 index 0000000..971b79b --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualProductController.java @@ -0,0 +1,83 @@ +package com.ruoyi.web.controller.app; + +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import com.ruoyi.app.domain.AppVirtualProduct; +import com.ruoyi.app.service.IAppVirtualProductService; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 微信虚拟支付道具管理接口。 + */ +@RestController +@RequestMapping("/app/virtualProduct") +public class AppVirtualProductController extends BaseController +{ + @Autowired + private IAppVirtualProductService virtualProductService; + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:list')") + @GetMapping("/list") + public TableDataInfo list(AppVirtualProduct product) + { + startPage(); + List list = virtualProductService.selectAppVirtualProductList(product); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:query')") + @GetMapping("/{id}") + public AjaxResult getInfo(@PathVariable Long id) + { + return success(virtualProductService.selectAppVirtualProductById(id)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:add')") + @Log(title = "虚拟道具", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody AppVirtualProduct product) + { + return toAjax(virtualProductService.insertAppVirtualProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:edit')") + @Log(title = "虚拟道具", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody AppVirtualProduct product) + { + return toAjax(virtualProductService.updateAppVirtualProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:remove')") + @Log(title = "虚拟道具", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(virtualProductService.deleteAppVirtualProductByIds(ids)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:export')") + @Log(title = "虚拟道具", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, AppVirtualProduct product) + { + List list = virtualProductService.selectAppVirtualProductList(product); + ExcelUtil util = new ExcelUtil<>(AppVirtualProduct.class); + util.exportExcel(response, list, "虚拟道具数据"); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java index 351dc5d..301f4ea 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java @@ -20,7 +20,6 @@ import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.constant.Constants; import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.file.FileUploadUtils; import com.ruoyi.common.utils.file.FileUtils; @@ -107,10 +106,12 @@ public class CommonController { && !extension.equals(".png") && !extension.equals(".webp")) { return AjaxResult.error("仅支持JPG、PNG和WebP图片"); } - String fname = "admin-images/" + DateUtils.datePath() + "/" - + UUID.randomUUID().toString().replace("-", "") + extension; + String fname = originalFilename.substring(0, originalFilename.lastIndexOf('.')) + + "-" + UUID.randomUUID().toString().replace("-", ""); String domain = QiNiuConfig.getInstance().getDomainOfBucket(); - if (!domain.startsWith("http://") && !domain.startsWith("https://")) { + if (domain.startsWith("http://")) { + domain = "https://" + domain.substring("http://".length()); + } else if (!domain.startsWith("https://")) { domain = "https://" + domain; } String fkey = domain.replaceAll("/$", "") + "/" + fname; diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java index c2596be..b149b7a 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java @@ -20,6 +20,7 @@ import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; import com.ruoyi.office.service.CopyTemplateRenderer; import com.ruoyi.office.service.ITtCodeService; import com.ruoyi.office.service.ITtCopyTemplateService; @@ -56,6 +57,16 @@ public class TtCopyTemplateController extends BaseController { return getDataTable(list); } + /** + * 查询模板分组及各分组模板数量 + */ + @PreAuthorize("@ss.hasPermi('office:copyTemplate:list')") + @GetMapping("/groups") + public AjaxResult groups() { + List groups = ttCopyTemplateService.selectCopyTemplateGroupStats(); + return success(groups); + } + /** * 按场景查询所有启用的文案模板(无需分页权限) */ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplateGroupStat.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplateGroupStat.java new file mode 100644 index 0000000..b34880f --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplateGroupStat.java @@ -0,0 +1,37 @@ +package com.ruoyi.office.domain; + +import java.io.Serializable; + +/** + * 文案模板分组统计。 + */ +public class TtCopyTemplateGroupStat implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 分组名称;__UNGROUPED__ 表示未分组。 */ + private String templateGroup; + + /** 分组下的模板数量。 */ + private Long templateCount; + + public String getTemplateGroup() + { + return templateGroup; + } + + public void setTemplateGroup(String templateGroup) + { + this.templateGroup = templateGroup; + } + + public Long getTemplateCount() + { + return templateCount; + } + + public void setTemplateCount(Long templateCount) + { + this.templateCount = templateCount; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java index b9f8d79..731c426 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.office.mapper; import java.util.List; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; import org.apache.ibatis.annotations.Param; /** @@ -22,6 +23,11 @@ public interface TtCopyTemplateMapper { */ public List selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate); + /** + * 查询模板分组及模板数量 + */ + public List selectCopyTemplateGroupStats(); + /** * 查询所有启用的文案模板(按排序号升序) */ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java index ef08177..d9bd401 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java @@ -2,6 +2,7 @@ package com.ruoyi.office.service; import java.util.List; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; /** * 文案模板Service接口 @@ -21,6 +22,11 @@ public interface ITtCopyTemplateService { */ public List selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate); + /** + * 查询模板分组及模板数量 + */ + public List selectCopyTemplateGroupStats(); + /** * 查询所有启用的文案模板(供源码明细页按钮使用) */ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java index 358d08c..7fd5562 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java @@ -19,6 +19,16 @@ public class WebsiteArticleDocumentParser private static final Pattern MARKDOWN_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)"); public WebsiteArticleDocument parse(String renderedContent) + { + return parse(renderedContent, true); + } + + public WebsiteArticleDocument parseForWechat(String renderedContent) + { + return parse(renderedContent, false); + } + + private WebsiteArticleDocument parse(String renderedContent, boolean enforceWebsiteTitleLimit) { if (StringUtils.isBlank(renderedContent)) { @@ -44,7 +54,7 @@ public class WebsiteArticleDocumentParser { throw new ServiceException("复制网站文案第一行标题为空"); } - if (title.length() > 50) + if (enforceWebsiteTitleLimit && title.length() > 50) { throw new ServiceException("网站文章标题不能超过50个字符:" + title); } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java index d48a350..33db974 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java @@ -50,7 +50,7 @@ public class WechatDraftContentService if (code == null) throw new ServiceException("源码项目不存在"); if (template == null || !"0".equals(template.getStatus())) throw new ServiceException("文案模板不存在或已停用"); - WebsiteArticleDocument generated = documentParser.parse(templateRenderer.render(template, code)); + WebsiteArticleDocument generated = documentParser.parseForWechat(templateRenderer.render(template, code)); String markdown = StringUtils.isNotBlank(request.getContentMarkdown()) ? request.getContentMarkdown() : generated.getContentMarkdown(); String title = StringUtils.isNotBlank(request.getTitle()) ? request.getTitle() : generated.getTitle(); @@ -59,7 +59,7 @@ public class WechatDraftContentService WechatDraftPreview preview = new WechatDraftPreview(); preview.setRequestNo(StringUtils.isNotBlank(request.getRequestNo()) ? request.getRequestNo() : UUID.randomUUID().toString().replace("-", "")); - preview.setTitle(limit(title, 32)); + preview.setTitle(limit(title, 60)); preview.setAuthor(limit(firstNonBlank(request.getAuthor(), account == null ? null : account.getDefaultAuthor()), 16)); String html = articleRenderer.render(markdown, request.getThemeColor(), request.getThemeName(), request.getFontSize()); // 摘要默认留空,交由微信使用正文内容生成展示摘要;手动摘要遵循微信 120 字限制。 diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java index ae23a81..e2cf29a 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java @@ -38,13 +38,14 @@ import com.ruoyi.office.service.ITtCodeService; @Service public class TtCodeServiceImpl implements ITtCodeService { private static final Pattern PREMIUM_PROJECT_CODE_PATTERN = - Pattern.compile("(?i)(?:^|【)\\s*([SK])\\s*\\d+"); + Pattern.compile("(?i)(?:^|【)\\s*([SKF])\\s*\\d+"); private static final long SOURCE_SHARE_RESOURCE_TYPE = 5L; private static final long PREMIUM_PROJECT_RESOURCE_TYPE = 6L; private static final long SOURCE_SHARE_ARTICLE_TYPE = 4L; private static final long PREMIUM_SOURCE_ARTICLE_TYPE = 7L; private static final long POINTS_ACCESS_TYPE = 2L; private static final long PAID_ACCESS_TYPE = 3L; + private static final String F_BASIC_SPEC_NAME = "源码 + 数据库 + 万字文档 + PPT"; private static final String BASIC_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT"; private static final String DEPLOYMENT_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT + 项目部署"; private static final String DEPLOYMENT_CONTACT = "调试部署加微信:forfeastcoding"; @@ -286,6 +287,12 @@ public class TtCodeServiceImpl implements ITtCodeService { String projectSeries) { List resourceList = new ArrayList(); + if ("F".equals(projectSeries)) + { + addResourceList(resourceList, resourceId, F_BASIC_SPEC_NAME, + projectInfo.getProjectUrl(), 900, 1); + return resourceList; + } if ("K".equals(projectSeries)) { addResourceList(resourceList, resourceId, BASIC_SPEC_NAME, @@ -346,7 +353,7 @@ public class TtCodeServiceImpl implements ITtCodeService { resource.setResourceType(PREMIUM_PROJECT_RESOURCE_TYPE); resource.setIsAd(PAID_ACCESS_TYPE); resource.setAdNumber(0L); - resource.setPriceFen("K".equals(projectSeries) ? 1900 : 5900); + resource.setPriceFen(resolvePremiumBasePrice(projectSeries)); return; } resource.setResourceType(SOURCE_SHARE_RESOURCE_TYPE); @@ -357,7 +364,17 @@ public class TtCodeServiceImpl implements ITtCodeService { private boolean isPremiumProject(String projectSeries) { - return "S".equals(projectSeries) || "K".equals(projectSeries); + return "S".equals(projectSeries) || "K".equals(projectSeries) + || "F".equals(projectSeries); + } + + private int resolvePremiumBasePrice(String projectSeries) + { + if ("F".equals(projectSeries)) + { + return 900; + } + return "K".equals(projectSeries) ? 1900 : 5900; } private String resolveProjectSeries(TtCode code, TtProjectInfo projectInfo) diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java index 81b9d77..d45f85b 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.common.utils.DateUtils; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; import com.ruoyi.office.mapper.TtCopyTemplateMapper; import com.ruoyi.office.service.ITtCopyTemplateService; @@ -30,6 +31,11 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService { return ttCopyTemplateMapper.selectTtCopyTemplateList(ttCopyTemplate); } + @Override + public List selectCopyTemplateGroupStats() { + return ttCopyTemplateMapper.selectCopyTemplateGroupStats(); + } + @Override public List selectEnabledTemplates() { return selectEnabledTemplates(null); diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java index fdd076f..77d8c34 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java @@ -21,7 +21,7 @@ public class TtFileServiceImpl implements ITtFileService { @Autowired private TtFileMapper ttFileMapper; - private final String SITE_URL = "http://img.yidaima.cn/"; + private final String SITE_URL = "https://img.yidaima.cn/"; /** * 查询文件管理 @@ -58,7 +58,7 @@ public class TtFileServiceImpl implements ITtFileService { String[] pictures = ttFile.getPictureFile().split(","); TreeMap treeMap = new TreeMap(); for (String url : pictures) { - url = url.replaceAll(SITE_URL, ""); + url = url.replace(SITE_URL, ""); String[] urls = url.split("-"); String picName = urls[0]; if(picName.contains("_")){ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java index cd09694..5e8653f 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java @@ -3,11 +3,9 @@ package com.ruoyi.office.service.impl; import java.net.URI; import java.net.URLEncoder; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -171,7 +169,8 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService List validRows = validateRows(sourceRows, normalizedDiskType, result); Set duplicateNames = findDuplicateNames(validRows); - Map> projectsByName = loadProjectsByNormalizedName(); + List projects = new ArrayList<>( + ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo())); for (ValidatedImportRow row : validRows) { @@ -181,23 +180,22 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService continue; } - List matches = projectsByName.get(row.normalizedName); - if (matches != null && matches.size() > 1) + List matches = findMatchingProjects(row, projects); + if (matches.size() > 1) { - result.addFailed(row.rowNumber, row.projectName, "数据库中存在多个同名项目,无法确定更新目标"); + result.addFailed(row.rowNumber, row.projectName, + "项目编号、项目名称或源码名称匹配到多条数据,无法确定更新目标"); continue; } - if (matches == null || matches.isEmpty()) + if (matches.isEmpty()) { TtProjectInfo project = createProject(row, normalizedDiskType); if (ttProjectInfoMapper.insertTtProjectInfo(project) != 1) { throw new ServiceException("新增项目失败:" + row.projectName); } - List inserted = new ArrayList<>(); - inserted.add(project); - projectsByName.put(row.normalizedName, inserted); + projects.add(project); result.incrementAddedCount(); continue; } @@ -344,20 +342,22 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService } } - private Map> loadProjectsByNormalizedName() + private List findMatchingProjects(ValidatedImportRow row, List projects) { - Map> projectsByName = new HashMap<>(); - List projects = ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo()); + List matches = new ArrayList<>(); + String projectNum = normalizeProjectNum(extractProjectNum(row.projectName)); for (TtProjectInfo project : projects) { - String normalizedName = normalizeProjectName(project.getProjectName()); - if (StringUtils.isBlank(normalizedName)) + boolean projectNumMatches = StringUtils.isNotBlank(projectNum) + && projectNum.equals(normalizeProjectNum(project.getProjectNum())); + boolean projectNameMatches = row.normalizedName.equals(normalizeProjectName(project.getProjectName())); + boolean sourceNameMatches = row.normalizedName.equals(normalizeProjectName(project.getProjectName1())); + if (projectNumMatches || projectNameMatches || sourceNameMatches) { - continue; + matches.add(project); } - projectsByName.computeIfAbsent(normalizedName, key -> new ArrayList<>()).add(project); } - return projectsByName; + return matches; } private Set findDuplicateNames(List rows) @@ -419,6 +419,11 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService .trim(); } + private String normalizeProjectNum(String projectNum) + { + return normalizeProjectName(projectNum).toUpperCase(Locale.ROOT); + } + private String normalizeDiskType(String diskType) { String value = StringUtils.trimToEmpty(diskType).toUpperCase(Locale.ROOT); diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java index f0f5652..2376dd5 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java @@ -6,6 +6,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLEncoder; import java.nio.file.Files; @@ -38,16 +40,21 @@ import com.ruoyi.office.domain.PublishProjectRow; import com.ruoyi.office.domain.PublishStatusUpdate; import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtFile; import com.ruoyi.office.domain.TtPublishTask; import com.ruoyi.office.mapper.TtPublishTaskMapper; import com.ruoyi.office.service.CopyTemplateRenderer; import com.ruoyi.office.service.ITtCodeService; import com.ruoyi.office.service.ITtCopyTemplateService; +import com.ruoyi.office.service.ITtFileService; import com.ruoyi.office.service.ITtPublishTaskService; @Service public class TtPublishTaskServiceImpl implements ITtPublishTaskService { + private static final int MAX_PUBLISH_IMAGES = 9; + private static final int MAX_ASSET_REDIRECTS = 5; + private static final Set PLATFORMS = Collections.unmodifiableSet( new LinkedHashSet<>(Arrays.asList("xiaohongshu", "douyin", "wechat_channels", "bilibili", "xianyu"))); @@ -66,6 +73,9 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService @Autowired private ITtCopyTemplateService copyTemplateService; + @Autowired + private ITtFileService fileService; + @Autowired private CopyTemplateRenderer copyTemplateRenderer; @@ -87,6 +97,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService material.setCodeId(code.getCodeId()); material.setCodeName(code.getCodeName()); material.setTitle(code.getCodeName()); + material.setImageUrls(selectProjectImageUrls(code.getCodeName())); TtCopyTemplate selectedTemplate = selectPublishTemplate( copyTemplateService.selectEnabledTemplates("CODE"), templateGroup); if (selectedTemplate != null) @@ -99,6 +110,29 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService return material; } + /** + * 直接复用文件管理中与项目关联的图片,避免发布时再次上传同一批素材。 + */ + private List selectProjectImageUrls(String codeName) + { + List files = fileService.selectTtFileByCodeName(codeName); + List urls = new ArrayList<>(); + if (files != null) + { + for (TtFile file : files) + { + if (file != null) + { + urls.add(file.getFileUrl()); + } + } + } + List cleaned = cleanUrls(urls); + return cleaned.size() <= MAX_PUBLISH_IMAGES + ? cleaned + : new ArrayList<>(cleaned.subList(0, MAX_PUBLISH_IMAGES)); + } + private TtCopyTemplate selectPublishTemplate(List templates, String targetGroup) { if (templates == null || templates.isEmpty()) @@ -295,6 +329,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService } response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length"); response.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); response.setHeader("Cache-Control", "private, no-store"); @@ -374,22 +409,8 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService private void writeRemoteAsset(String source, HttpServletResponse response) throws IOException { - URL url = new URL(source); - if (!"http".equalsIgnoreCase(url.getProtocol()) && !"https".equalsIgnoreCase(url.getProtocol())) - { - throw new ServiceException("不支持的发布素材地址"); - } - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setConnectTimeout(10000); - connection.setReadTimeout(120000); - connection.setInstanceFollowRedirects(true); - connection.setRequestProperty("User-Agent", "RuoYi-Publish-Bridge/1.0"); - int status = connection.getResponseCode(); - if (status < 200 || status >= 300) - { - connection.disconnect(); - throw new ServiceException("读取远程发布素材失败,HTTP " + status); - } + HttpURLConnection connection = openRemoteAssetConnection(source); + URL finalUrl = connection.getURL(); String contentType = connection.getContentType(); response.setContentType(contentType == null ? "application/octet-stream" : contentType); long length = connection.getContentLengthLong(); @@ -397,7 +418,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService { response.setContentLengthLong(length); } - String fileName = new File(url.getPath()).getName(); + String fileName = new File(finalUrl.getPath()).getName(); response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encode(fileName)); try (InputStream input = connection.getInputStream()) { @@ -409,6 +430,74 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService } } + /** + * HttpURLConnection 不会自动跟随 HTTP 到 HTTPS 的跨协议重定向,图床地址需要在这里显式处理。 + */ + private HttpURLConnection openRemoteAssetConnection(String source) throws IOException + { + URL currentUrl = requireHttpAssetUrl(source); + for (int redirectCount = 0; redirectCount <= MAX_ASSET_REDIRECTS; redirectCount++) + { + HttpURLConnection connection = (HttpURLConnection) currentUrl.openConnection(); + connection.setConnectTimeout(10000); + connection.setReadTimeout(120000); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "RuoYi-Publish-Bridge/1.0"); + int status = connection.getResponseCode(); + if (status >= 200 && status < 300) + { + return connection; + } + if (!isRedirectStatus(status)) + { + connection.disconnect(); + throw new ServiceException("读取远程发布素材失败,HTTP " + status); + } + String location = connection.getHeaderField("Location"); + connection.disconnect(); + if (StringUtils.isEmpty(location)) + { + throw new ServiceException("远程发布素材重定向地址为空"); + } + if (redirectCount == MAX_ASSET_REDIRECTS) + { + throw new ServiceException("远程发布素材重定向次数过多"); + } + currentUrl = requireHttpAssetUrl(new URL(currentUrl, location).toString()); + } + throw new ServiceException("读取远程发布素材失败"); + } + + private URL requireHttpAssetUrl(String source) throws IOException + { + final URI uri; + try + { + uri = new URI(source); + } + catch (URISyntaxException ex) + { + throw new IOException("发布素材地址格式错误", ex); + } + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) + { + throw new ServiceException("不支持的发布素材地址"); + } + // URL/HttpURLConnection 不会自动把中文路径转换为 UTF-8 百分号编码。 + // 图床会原样重定向乱码路径,最终导致本来存在的图片返回 404。 + return new URL(uri.toASCIIString()); + } + + private boolean isRedirectStatus(int status) + { + return status == HttpURLConnection.HTTP_MOVED_PERM + || status == HttpURLConnection.HTTP_MOVED_TEMP + || status == HttpURLConnection.HTTP_SEE_OTHER + || status == 307 + || status == 308; + } + private void copy(InputStream input, OutputStream output) throws IOException { byte[] buffer = new byte[8192]; diff --git a/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml index 9767438..31022ae 100644 --- a/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml +++ b/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml @@ -16,6 +16,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + select template_id, template_name, template_group, template_scene, template_body, sort_num, status, create_time, update_time from tt_copy_template @@ -25,13 +30,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and template_name like concat('%', #{templateName}, '%') - and template_group like concat('%', #{templateGroup}, '%') + + + + and (template_group is null or trim(template_group) = '') + + + and template_group = #{templateGroup} + + + and template_scene = #{templateScene} and status = #{status} order by sort_num asc, create_time asc + + + select id, product_id, price_fen, product_name, status, create_time, update_time from app_virtual_product + + + + + + + + + + + insert into app_virtual_product(product_id, price_fen, product_name, status, create_time) values(#{productId}, #{priceFen}, #{productName}, #{status}, #{createTime}) + + update app_virtual_product + set product_id = #{productId}, + price_fen = #{priceFen}, + product_name = #{productName}, + status = #{status}, + update_time = #{updateTime} + where id = #{id} + + update app_virtual_product set product_id = #{productId}, product_name = #{productName}, status = 1, update_time = #{updateTime} where price_fen = #{priceFen} + + + delete from app_virtual_product where id in + #{id} + diff --git a/ruoyi-ui/src/api/app/virtualProduct.js b/ruoyi-ui/src/api/app/virtualProduct.js new file mode 100644 index 0000000..341411c --- /dev/null +++ b/ruoyi-ui/src/api/app/virtualProduct.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export function listVirtualProduct(query) { + return request({ url: '/app/virtualProduct/list', method: 'get', params: query }) +} + +export function getVirtualProduct(id) { + return request({ url: '/app/virtualProduct/' + id, method: 'get' }) +} + +export function addVirtualProduct(data) { + return request({ url: '/app/virtualProduct', method: 'post', data }) +} + +export function updateVirtualProduct(data) { + return request({ url: '/app/virtualProduct', method: 'put', data }) +} + +export function delVirtualProduct(ids) { + return request({ url: '/app/virtualProduct/' + ids, method: 'delete' }) +} diff --git a/ruoyi-ui/src/api/office/copyTemplate.js b/ruoyi-ui/src/api/office/copyTemplate.js index 7909572..b9454ea 100644 --- a/ruoyi-ui/src/api/office/copyTemplate.js +++ b/ruoyi-ui/src/api/office/copyTemplate.js @@ -9,6 +9,14 @@ export function listCopyTemplate(query) { }) } +// 查询模板分组及各分组模板数量 +export function listCopyTemplateGroups() { + return request({ + url: '/office/copyTemplate/groups', + method: 'get' + }) +} + // 按场景查询所有启用的文案模板 export function listEnabledTemplates(templateScene) { return request({ diff --git a/ruoyi-ui/src/views/app/virtualProduct/index.vue b/ruoyi-ui/src/views/app/virtualProduct/index.vue new file mode 100644 index 0000000..92165a2 --- /dev/null +++ b/ruoyi-ui/src/views/app/virtualProduct/index.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/ruoyi-ui/src/views/office/code/index.vue b/ruoyi-ui/src/views/office/code/index.vue index ca5e5c9..5d971f0 100644 --- a/ruoyi-ui/src/views/office/code/index.vue +++ b/ruoyi-ui/src/views/office/code/index.vue @@ -416,7 +416,7 @@ - + diff --git a/ruoyi-ui/src/views/office/copyTemplate/index.vue b/ruoyi-ui/src/views/office/copyTemplate/index.vue index 3d08702..4fa991c 100644 --- a/ruoyi-ui/src/views/office/copyTemplate/index.vue +++ b/ruoyi-ui/src/views/office/copyTemplate/index.vue @@ -9,14 +9,6 @@ @keyup.enter.native="handleQuery" /> - - - @@ -48,36 +40,76 @@ - - - - - - - - - - - - - - - - - +
+ - +
+ + + + + + + + + + + + + + + + + + + +
+
@@ -157,7 +189,7 @@ + + diff --git a/ruoyi-ui/src/views/office/publish/index.vue b/ruoyi-ui/src/views/office/publish/index.vue index f063b9f..5d80f06 100644 --- a/ruoyi-ui/src/views/office/publish/index.vue +++ b/ruoyi-ui/src/views/office/publish/index.vue @@ -180,6 +180,9 @@ :file-size="activePlatform === 'xianyu' ? 10 : 20" :file-type="activePlatform === 'xianyu' ? ['png', 'jpg', 'jpeg'] : ['png', 'jpg', 'jpeg', 'webp']" /> +
+ 已自动使用文件管理中与当前项目关联的图片,可删除、排序或继续添加 +
-
小红书和闲鱼共用,最多上传 9 张,可拖动调整顺序。
+
自动使用文件管理中的项目图片;小红书和闲鱼共用,最多 9 张,可拖动调整顺序。
@@ -671,6 +674,10 @@ export default { templateGroup: material.templateGroup || '' }) }) + const sharedMaterial = results.find(result => (result.material.imageUrls || []).length) + this.multiPublishForm.imageUrls = sharedMaterial + ? sharedMaterial.material.imageUrls.join(',') + : '' }).finally(() => { this.multiMaterialLoading = false }) @@ -943,6 +950,7 @@ export default { .last-publish-time.is-empty { color: #a0a4aa; } .template-source { margin-top: 7px; color: #67c23a; font-size: 12px; line-height: 1.5; } .template-source.is-warning { color: #e6a23c; } +.material-source-hint { margin-top: 7px; color: #67c23a; font-size: 12px; line-height: 1.5; } .multi-publish-dialog { min-height: 460px; max-height: calc(88vh - 176px); diff --git a/sql/source_code_technology_backfill.sql b/sql/source_code_technology_backfill.sql new file mode 100644 index 0000000..ceb25ca --- /dev/null +++ b/sql/source_code_technology_backfill.sql @@ -0,0 +1,140 @@ +-- 将旧版 tt_code.code_technology 中的混合技术栈拆分到前端、后端和数据库字段。 +-- +-- 安全性: +-- 1. 只填充 NULL 或空字符串,不覆盖已经维护的新字段; +-- 2. 保留 code_technology 原值,便于核对和回退; +-- 3. 可重复执行,第二次执行不会再次修改已经填充的数据。 + +START TRANSACTION; + +UPDATE `tt_code` +SET + `frontend_technology` = CASE + WHEN NULLIF(TRIM(`frontend_technology`), '') IS NOT NULL + THEN `frontend_technology` + WHEN LOWER(`code_technology`) LIKE '%vue3%' + THEN CONCAT( + 'Vue 3', + IF(LOWER(`code_technology`) LIKE '%elementui%', ' + Element UI', ''), + IF(LOWER(`code_technology`) LIKE '%nodejs14%', ' + Node.js 14', + IF(LOWER(`code_technology`) LIKE '%nodejs%', ' + Node.js', '')), + IF(`code_technology` LIKE '%微信小程序%', ' + 微信小程序', ''), + IF(`code_technology` LIKE '%原生小程序%', ' + 原生小程序', '') + ) + WHEN LOWER(`code_technology`) LIKE '%vue2%' + THEN CONCAT( + 'Vue 2', + IF(LOWER(`code_technology`) LIKE '%elementui%', ' + Element UI', ''), + IF(LOWER(`code_technology`) LIKE '%nodejs14%', ' + Node.js 14', + IF(LOWER(`code_technology`) LIKE '%nodejs%', ' + Node.js', '')), + IF(`code_technology` LIKE '%微信小程序%', ' + 微信小程序', ''), + IF(`code_technology` LIKE '%原生小程序%', ' + 原生小程序', '') + ) + WHEN LOWER(`code_technology`) LIKE '%vue%' + THEN CONCAT( + 'Vue', + IF(LOWER(`code_technology`) LIKE '%elementui%', ' + Element UI', ''), + IF(LOWER(`code_technology`) LIKE '%nodejs14%', ' + Node.js 14', + IF(LOWER(`code_technology`) LIKE '%nodejs%', ' + Node.js', '')) + ) + WHEN LOWER(`code_technology`) LIKE '%thymeleaf%' + THEN CONCAT( + 'Thymeleaf', + IF(LOWER(`code_technology`) LIKE '%bootstrap%', ' + Bootstrap', ''), + IF(LOWER(`code_technology`) LIKE '%jquery%', ' + jQuery', '') + ) + WHEN LOWER(`code_technology`) LIKE '%jsp%' + THEN CONCAT( + 'JSP', + IF(LOWER(`code_technology`) LIKE '%jstl%', ' + JSTL', ''), + IF(LOWER(`code_technology`) LIKE '%bootstrap%', ' + Bootstrap', ''), + IF(LOWER(`code_technology`) LIKE '%layui%', ' + Layui', '') + ) + WHEN LOWER(`code_technology`) LIKE '%html%' + THEN 'HTML' + WHEN LOWER(`code_technology`) LIKE '%bootstrap%' + THEN CONCAT( + 'Bootstrap', + IF(LOWER(`code_technology`) LIKE '%highcharts%', ' + Highcharts', '') + ) + ELSE '未单独记录' + END, + `backend_technology` = CASE + WHEN NULLIF(TRIM(`backend_technology`), '') IS NOT NULL + THEN `backend_technology` + WHEN LOWER(`code_technology`) LIKE '%php%' + THEN 'PHP' + WHEN LOWER(`code_technology`) LIKE '%servlet%' + THEN CONCAT( + 'Java 8 + Servlet', + IF(LOWER(`code_technology`) LIKE '%jdbc%', ' + JDBC', '') + ) + WHEN LOWER(`code_technology`) LIKE '%springboot2%' + THEN CONCAT( + 'Java 8 + Spring Boot 2', + IF(LOWER(`code_technology`) LIKE '%mybatis-plus%' + OR LOWER(`code_technology`) LIKE '%mybatisplus%', ' + MyBatis-Plus', + IF(LOWER(`code_technology`) LIKE '%mybatis%', ' + MyBatis', '')) + ) + WHEN LOWER(`code_technology`) LIKE '%springboot%' + THEN CONCAT( + 'Java 8 + Spring Boot', + IF(LOWER(`code_technology`) LIKE '%mybatis-plus%' + OR LOWER(`code_technology`) LIKE '%mybatisplus%', ' + MyBatis-Plus', + IF(LOWER(`code_technology`) LIKE '%mybatis%', ' + MyBatis', '')) + ) + WHEN LOWER(`code_technology`) LIKE '%springmvc%' + OR LOWER(`code_technology`) LIKE '%springmmvc%' + OR LOWER(`code_technology`) LIKE '%ssm%' + THEN CONCAT( + 'Java 8 + Spring + Spring MVC', + IF(LOWER(`code_technology`) LIKE '%mybatis-plus%' + OR LOWER(`code_technology`) LIKE '%mybatisplus%', ' + MyBatis-Plus', + IF(LOWER(`code_technology`) LIKE '%mybatis%' + OR LOWER(`code_technology`) LIKE '%mabtis%', ' + MyBatis', '')) + ) + ELSE '未单独记录' + END, + `database_technology` = CASE + WHEN NULLIF(TRIM(`database_technology`), '') IS NOT NULL + THEN `database_technology` + WHEN LOWER(`code_environment`) LIKE '%mysql8.0%' + OR LOWER(`code_environment`) LIKE '%mysql 8.0%' + OR LOWER(`code_environment`) LIKE '%mysql8%' + OR LOWER(`code_environment`) LIKE '%mysql 8%' + THEN CONCAT( + 'MySQL 8.0', + IF(LOWER(`code_environment`) LIKE '%redis%', ' + Redis', '') + ) + WHEN LOWER(`code_environment`) LIKE '%mysql5.7%' + OR LOWER(`code_environment`) LIKE '%mysql 5.7%' + THEN CONCAT( + 'MySQL 5.7', + IF(LOWER(`code_environment`) LIKE '%redis%', ' + Redis', '') + ) + WHEN LOWER(`code_environment`) LIKE '%mysql%' + OR LOWER(`code_technology`) LIKE '%mysql%' + THEN CONCAT( + 'MySQL', + IF(LOWER(`code_environment`) LIKE '%redis%', ' + Redis', '') + ) + ELSE '未单独记录' + END +WHERE NULLIF(TRIM(`code_technology`), '') IS NOT NULL + AND ( + NULLIF(TRIM(`frontend_technology`), '') IS NULL + OR NULLIF(TRIM(`backend_technology`), '') IS NULL + OR NULLIF(TRIM(`database_technology`), '') IS NULL + ); + +SELECT ROW_COUNT() AS `backfilled_rows`; + +COMMIT; + +-- 执行结果检查:三个 blank_* 都应为 0。 +SELECT + COUNT(*) AS `total`, + SUM(NULLIF(TRIM(`frontend_technology`), '') IS NULL) AS `frontend_blank`, + SUM(NULLIF(TRIM(`backend_technology`), '') IS NULL) AS `backend_blank`, + SUM(NULLIF(TRIM(`database_technology`), '') IS NULL) AS `database_blank` +FROM `tt_code`; diff --git a/sql/source_code_technology_fields.sql b/sql/source_code_technology_fields.sql index cfa7878..b1bcae0 100644 --- a/sql/source_code_technology_fields.sql +++ b/sql/source_code_technology_fields.sql @@ -1,5 +1,6 @@ -- 源码信息新增前端、后端、数据库技术字段。 -- 执行前请先备份数据库。适用于当前项目的 MySQL 数据库。 +-- 新增字段后,可执行 source_code_technology_backfill.sql 补齐旧数据。 ALTER TABLE `tt_code` ADD COLUMN `frontend_technology` VARCHAR(255) NULL COMMENT '前端' AFTER `code_technology`, diff --git a/sql/virtual_pay_resource.sql b/sql/virtual_pay_resource.sql index dcf6245..018b1a9 100644 --- a/sql/virtual_pay_resource.sql +++ b/sql/virtual_pay_resource.sql @@ -60,8 +60,8 @@ CREATE TABLE app_resource_entitlement ( -- 执行迁移后: -- 1. 在微信公众平台创建各价格档位的道具并发布; --- 2. 在若依后台编辑付费资源,填写“价格(分)”和对应的 productId; --- 3. 同一价格填写同一个 productId,后台会自动维护 app_virtual_product 映射。 +-- 2. 执行 sql/virtual_product_menu.sql,重新登录后在“虚拟道具管理”中维护相同的 productId 和价格; +-- 3. 在若依后台编辑付费资源,规格只需填写“价格(分)”,系统按价格自动匹配已启用道具。 -- 当前小程序已发布的微信虚拟道具价格映射。 -- product_id 必须与微信后台保持完全一致(包括 resourse 的现有拼写)。 diff --git a/sql/virtual_product_menu.sql b/sql/virtual_product_menu.sql new file mode 100644 index 0000000..c2d213a --- /dev/null +++ b/sql/virtual_product_menu.sql @@ -0,0 +1,69 @@ +-- 虚拟道具管理菜单(可重复执行) +-- 默认与“虚拟支付订单”或“支付订单”同级;均未找到时作为一级菜单显示。 + +SET @virtual_product_parent_id := COALESCE( + (SELECT parent_id FROM sys_menu WHERE perms = 'app:virtualOrder:list' LIMIT 1), + (SELECT parent_id FROM sys_menu WHERE perms = 'app:order:list' LIMIT 1), + 0 +); + +SET @virtual_product_order_num := COALESCE( + (SELECT MAX(order_num) + 1 FROM sys_menu WHERE parent_id = @virtual_product_parent_id), + 1 +); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT + '虚拟道具管理', @virtual_product_parent_id, @virtual_product_order_num, + 'virtualProduct', 'app/virtualProduct/index', '', 1, 0, + 'C', '0', '0', 'app:virtualProduct:list', 'shopping', 'admin', NOW(), '', NULL, + '微信虚拟支付价格档位与道具映射管理' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:list' +); + +SET @virtual_product_menu_id := ( + SELECT menu_id FROM sys_menu WHERE perms = 'app:virtualProduct:list' LIMIT 1 +); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '虚拟道具查询', @virtual_product_menu_id, 1, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:query', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:query'); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '虚拟道具新增', @virtual_product_menu_id, 2, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:add', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:add'); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '虚拟道具修改', @virtual_product_menu_id, 3, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:edit', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:edit'); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '虚拟道具删除', @virtual_product_menu_id, 4, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:remove', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:remove'); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '虚拟道具导出', @virtual_product_menu_id, 5, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:export', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:export');