Compare commits

..

31 Commits

Author SHA1 Message Date
王鹏
b904dcf52e Expand publishing and virtual product workflows 2026-09-30 11:00:01 +08:00
王鹏
4ed8743934 Add WeChat draft publishing 2026-09-18 09:30:59 +08:00
王鹏
c9237cd480 add 2026-09-18 08:50:28 +08:00
王鹏
a9789fe324 Award points to new mini-program users 2026-09-16 17:11:46 +08:00
王鹏
f4673f0613 add 2026-09-16 16:56:33 +08:00
王鹏
ca853b70d2 Refine multi-platform publishing status handling 2026-09-16 16:53:52 +08:00
王鹏
8f387b492b Add technology stack management 2026-09-16 16:53:39 +08:00
王鹏
e7ab83c564 Add custom request management 2026-09-16 16:53:24 +08:00
王鹏
05e04ce021 Add configurable contact page management 2026-09-16 16:53:07 +08:00
王鹏
e31af58d58 Add points task submission and review workflow 2026-09-16 16:50:05 +08:00
王鹏
0e24c9e235 Add multi-platform publishing workflow 2026-09-14 17:40:52 +08:00
王鹏
a466caa793 feat: 优化文案模板分组与复制预览 2026-09-07 15:40:29 +08:00
王鹏
074bbda460 feat: add screenshot archive and article sync services 2026-08-28 14:48:24 +08:00
王鹏
21eb026789 add 2026-08-28 13:15:47 +08:00
王鹏
a1a806f266 feat: support article demo videos and category counts 2026-08-17 15:50:17 +08:00
王鹏
2bf0a6db59 支持自定义积分充值来源 2026-07-30 16:39:08 +08:00
王鹏
4bda30f9c2 feat: 支持S/K精品源码付费规格转换 2026-07-30 14:25:22 +08:00
王鹏
e9d627df9c feat: 优化代码生成及文案模板渲染 2026-07-29 16:17:49 +08:00
王鹏
c631d058c1 fix 2026-07-29 14:36:54 +08:00
王鹏
349f1c0374 feat: add dashboard and virtual pay enhancements 2026-07-29 14:34:54 +08:00
王鹏
2250f744d1 feat: 新增虚拟支付订单与网盘链接检测功能 2026-07-28 13:42:30 +08:00
王鹏
4070179f46 fix: 开放接口支持模糊匹配项目名,添加 TtFile 导入
- TtCodeMapper.xml: selectTtCodeByCodeName 改为模糊匹配
- TtCopyTemplateController: 添加 TtFile import,var 改为显式类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:40:39 +08:00
王鹏
b169a6662c fix: HtmlUtils 特殊字符改用 Unicode 转义避免编译错误
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:30:19 +08:00
王鹏
eb6339c9a7 feat: 新增开放接口,支持通过模板名称和项目名称生成文案
- 新增 HtmlUtils 工具类,支持 HTML 转纯文本
- 新增 selectTtCodeByCodeName 方法,支持按名称查询源码
- 新增 /office/copyTemplate/open/generate 开放接口
  无需登录,参数:templateName(模板名称)、codeName(项目名称)
  返回:填充占位符后的完整文案内容

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:28:15 +08:00
王鹏
6940541216 feat: 新增文案模板管理功能,源码明细页按钮支持动态配置
- 新增 tt_copy_template 表及 CRUD 后端接口
- 新增文案模板管理页面,支持 {变量} 占位符动态替换
- 源码明细页复制按钮由硬编码改为数据库驱动
- 支持占位符:codeName、projectCode、projectName、codeDesc、codeEnvironment、codeTechnology、diskLink、screenshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:20:32 +08:00
王鹏
5e7bbe6c9d fixed 2026-03-31 20:33:20 +08:00
王鹏
a48757160a fixed 2026-03-31 19:36:34 +08:00
王鹏
7647f8ad75 fixed 2025-12-05 16:42:20 +08:00
王鹏
c390e4a033 add 付费资源 2025-12-05 14:18:36 +08:00
王鹏
de0fea0dbd add 每日新增用户和每日新增支付金额图表 2025-10-31 11:02:52 +08:00
王鹏
da05a94acb add 每日新增用户和每日新增支付金额图表 2025-10-31 09:44:37 +08:00
269 changed files with 25081 additions and 793 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"kiroAgent.configureMCP": "Disabled"
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,60 @@
# 易代码多平台发布助手
该扩展与后台“发布管理”页面配合,将视频、图片、封面、标题、文案、标签和商品信息填写到小红书、抖音、视频号、B站或闲鱼卖家工作台。
## 安装
1. 在 Chrome 地址栏打开 `chrome://extensions/`。
2. 打开右上角“开发者模式”。
3. 点击“加载已解压的扩展程序”。
4. 选择解压后的目录 `browser-extension/multi-platform-publisher-1.1.17`。
5. 登录易代码后台以及需要使用的创作者平台。
6. 重新打开后台“发布管理”页面,确认页面显示“发布扩展已连接”。
修改扩展代码后,需要在扩展管理页点击该扩展的“重新加载”,并刷新后台和平台页面。
点击浏览器工具栏中的扩展图标会打开右侧“多平台发布助手”。侧边栏展示凭证有效期内各平台的最新发布任务;点击平台名称可定位对应标签页,点击刷新图标会重新打开干净的发布页面并重试填写。已发布成功的任务禁止重试,避免重复发布。
## 工作范围
- 后台页面创建带短期凭证的发布任务。
- 多平台发布会一次创建多个任务,扩展按小红书、抖音、视频号、B站、闲鱼的顺序逐个平台处理,避免同时上传多个大文件。
- 批次中的平台达到待手动发布、失败或需要登录后会继续下一个;页面状态异常时最多等待4分钟,避免整个批次永久卡住。
- 扩展打开目标平台、读取任务资料、上传素材并填写文本。
- 填写完成后任务状态为 `READY_TO_PUBLISH`(待手动发布)。
- 扩展不会查找或点击最终发布按钮。
- 用户手动发布后,扩展只观察平台新增的明确成功或失败提示并回写结果。
- 扩展不会处理扫码、验证码、安全校验或平台密码。
- B站视频会打开创作中心投稿页,上传视频并填写标题、简介和标签文案;分区等投稿选项由用户检查或手动选择。
- 小红书文案会按模板原有段落写入富文本编辑器,保留换行和空行。
- 抖音文案使用原生选区替换并兼容平台的话题节点,重复校验时不会再次追加全文。
- 抖音文案一次性写入带换行节点的富文本,保留模板换行且避免编辑器重绘造成内容错乱。
- 视频号只填写视频描述,不再填写短标题。
- 多平台批次在平台启动失败或状态回写短暂异常时仍会继续处理后续平台。
- 闲鱼商品只在闲管家网页中上传图片、选择分类并填写标题和文案;有历史分类时优先使用,否则固定选择“家居/服务/IT技术/软件/程序/网站开发”。
- 扩展沿用页面当前选中的闲鱼店铺,不自动切换店铺;遇到“高级版已过期”阻断提示时只关闭提示并继续填写。
- 扩展不填写闲鱼商品售价、原价和库存,不调用闲管家开放接口,也不读取或保存闲管家 Cookie。
## 权限说明
- `tabs`:打开对应平台创作者中心,并将任务绑定到该标签页。
- `storage`:在浏览器重载或平台登录跳转期间保存任务上下文。
- 后台及五个发布平台的站点权限:建立任务桥接、填写表单和上传素材。
扩展只允许从以下后台来源接收任务:
- `https://feast.yidaima.cn/office/publish*`
- 本地开发环境的 `localhost` 或 `127.0.0.1` 发布管理页面
## 测试清单
1. 页面能识别扩展已连接。
2. 创建任务后只打开所选平台。
3. 未登录时状态变为“需要登录”,不伪装成功。
4. 视频或图片能够进入平台上传控件。
5. 标题、文案和标签填写正确;B站还需确认投稿分区,闲鱼商品还需确认图片、分类、标题和描述正确。
6. 页面停在最终发布按钮前,扩展不点击该按钮。
7. 关闭未完成的平台标签页后,后台状态变为“已取消”。
8. 找不到关键控件或平台提示上传失败时,后台状态变为“发布失败”。
平台页面会持续更新。如果某个平台出现“未找到控件”,应根据当时页面 DOM 调整该平台单独的内容脚本,不能放宽到点击任意包含“发布”的按钮。

View File

@@ -0,0 +1,666 @@
const VERSION = chrome.runtime.getManifest().version
const PLATFORM_URLS = Object.freeze({
xiaohongshu: 'https://creator.xiaohongshu.com/publish/publish?source=official',
douyin: 'https://creator.douyin.com/creator-micro/content/upload',
wechat_channels: 'https://channels.weixin.qq.com/platform/post/create',
bilibili: 'https://member.bilibili.com/platform/upload/video/frame',
xianyu: 'https://www.goofish.pro/sale/product/add?from=%2Fon-sale'
})
const PLATFORM_HOSTS = Object.freeze({
xiaohongshu: 'creator.xiaohongshu.com',
douyin: 'creator.douyin.com',
wechat_channels: 'channels.weixin.qq.com',
bilibili: 'member.bilibili.com',
xianyu: 'goofish.pro'
})
const TERMINAL_STATUSES = new Set(['READY_TO_PUBLISH', 'SUCCEEDED', 'FAILED', 'CANCELLED'])
const BATCH_ADVANCE_STATUSES = new Set(['READY_TO_PUBLISH', 'FAILED', 'LOGIN_REQUIRED', 'CANCELLED'])
const BATCH_WATCHDOG_PREFIX = 'publisher_batch_watchdog:'
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)
continueTimedOutBatch(batchId).catch(() => {})
})
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || typeof message.type !== 'string') return false
if (message.type === 'ADMIN_PING') {
sendResponse({ ok: isAllowedAdmin(sender.url), version: VERSION })
return false
}
if (message.type === 'ADMIN_START_PUBLISH') {
startPublish(message.payload, sender)
.then(result => sendResponse({ ok: true, ...result }))
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
return true
}
if (message.type === 'ADMIN_START_PUBLISH_BATCH') {
startPublishBatch(message.payload, sender)
.then(result => sendResponse({ ok: true, ...result }))
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
return true
}
if (message.type === 'PLATFORM_READY') {
resumeTaskForTab(sender.tab, message.platform)
.then(result => sendResponse(result))
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
return true
}
if (message.type === 'PLATFORM_FILL_CONTENT') {
fillPlatformContent(message.payload, sender)
.then(result => sendResponse({ ok: true, ...result }))
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
return true
}
if (message.type === 'PUBLISH_STATUS') {
receivePlatformStatus(message.payload, sender)
.then(() => sendResponse({ ok: true }))
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
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
})
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status !== 'complete') return
getContextForTab(tabId).then(context => {
if (!context || !isExpectedPlatformUrl(tab.url, context.platform)) return
sendTaskMessage(tabId, context)
}).catch(() => {})
})
chrome.tabs.onRemoved.addListener(tabId => {
getContextForTab(tabId).then(async context => {
if (!context) return
if (!TERMINAL_STATUSES.has(context.status)) {
await updateStatus(context, 'CANCELLED', '平台页面已关闭', '')
await advanceBatch(context)
}
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('发布请求不是来自允许的后台页面')
}
validatePayload(payload)
const apiBaseUrl = normalizeApiBase(payload.apiBaseUrl, sender.url)
const task = await fetchBridgeTask(apiBaseUrl, payload.taskId, payload.token)
if (task.platform !== payload.platform || !PLATFORM_URLS[task.platform]) {
throw new Error('任务平台与页面请求不一致')
}
const context = {
taskId: payload.taskId,
token: payload.token,
apiBaseUrl,
platform: task.platform,
task,
adminTabId: sender.tab.id,
batchId: payload.batchId || '',
status: 'OPENING',
createdAt: Date.now()
}
await updateStatus(context, 'OPENING', '正在打开' + platformName(task.platform) + '创作者中心', '')
const tab = await chrome.tabs.create({ url: PLATFORM_URLS[task.platform], active: true })
context.platformTabId = tab.id
await chrome.storage.local.set({
[taskKey(context.taskId)]: context,
[tabKey(tab.id)]: context.taskId
})
await sendTaskMessageWithRetry(tab.id, context, 15000)
return { taskId: context.taskId, tabId: tab.id }
}
async function startPublishBatch(payload, sender) {
if (!isAllowedAdmin(sender.url) || !sender.tab) {
throw new Error('批量发布请求不是来自允许的后台页面')
}
if (!payload || typeof payload.batchId !== 'string'
|| !/^publish_batch_[a-zA-Z0-9_]+$/.test(payload.batchId)
|| !Array.isArray(payload.tasks) || !payload.tasks.length || payload.tasks.length > 5) {
throw new Error('多平台发布批次参数不正确')
}
const platforms = new Set()
const tasks = payload.tasks.map(item => {
const task = {
taskId: item.taskId,
token: item.token,
platform: item.platform,
apiBaseUrl: payload.apiBaseUrl
}
validatePayload(task)
if (platforms.has(task.platform)) throw new Error('多平台发布中存在重复平台')
platforms.add(task.platform)
return task
})
const batch = {
batchId: payload.batchId,
tasks,
nextIndex: 0,
currentTaskId: '',
adminTabId: sender.tab.id,
adminUrl: sender.url,
createdAt: Date.now()
}
await chrome.storage.local.set({ [batchKey(batch.batchId)]: batch })
await processNextBatchTask(batch.batchId)
return { batchId: batch.batchId, taskCount: tasks.length }
}
async function processNextBatchTask(batchId) {
const stored = await chrome.storage.local.get(batchKey(batchId))
const batch = stored[batchKey(batchId)]
if (!batch || batch.currentTaskId) return
if (batch.nextIndex >= batch.tasks.length) {
await chrome.storage.local.remove(batchKey(batchId))
return
}
const task = batch.tasks[batch.nextIndex]
batch.nextIndex += 1
batch.currentTaskId = task.taskId
await chrome.storage.local.set({ [batchKey(batchId)]: batch })
await scheduleBatchWatchdog(batchId)
try {
await startPublish(
{ ...task, batchId },
{ url: batch.adminUrl, tab: { id: batch.adminTabId } }
)
} catch (error) {
const context = {
taskId: task.taskId,
token: task.token,
apiBaseUrl: task.apiBaseUrl,
platform: task.platform,
adminTabId: batch.adminTabId,
batchId
}
try {
await updateStatus(context, 'FAILED', '平台任务启动失败', readableError(error))
} catch (ignored) {}
await advanceBatch(context)
}
}
async function advanceBatch(context) {
if (!context || !context.batchId) return
if (advancingBatches.has(context.batchId)) return
advancingBatches.add(context.batchId)
let shouldProcessNext = false
try {
const key = batchKey(context.batchId)
const stored = await chrome.storage.local.get(key)
const batch = stored[key]
if (!batch || batch.currentTaskId !== context.taskId) return
await chrome.alarms.clear(batchWatchdogName(context.batchId))
batch.currentTaskId = ''
await chrome.storage.local.set({ [key]: batch })
shouldProcessNext = true
} finally {
advancingBatches.delete(context.batchId)
}
// 启动下一项前必须先释放批次锁。否则下一项在启动阶段失败时,
// 它的补偿推进会因同一把锁仍被占用而直接返回,整个批次就会停住。
if (shouldProcessNext) await processNextBatchTask(context.batchId)
}
async function scheduleBatchWatchdog(batchId) {
await chrome.alarms.create(batchWatchdogName(batchId), { delayInMinutes: 4 })
}
async function continueTimedOutBatch(batchId) {
const stored = await chrome.storage.local.get(batchKey(batchId))
const batch = stored[batchKey(batchId)]
if (!batch || !batch.currentTaskId) return
const taskId = batch.currentTaskId
const taskStored = await chrome.storage.local.get(taskKey(taskId))
const context = taskStored[taskKey(taskId)] || {
taskId,
batchId,
token: '',
apiBaseUrl: '',
platform: ''
}
context.batchId = batchId
if (context.token && context.apiBaseUrl && !TERMINAL_STATUSES.has(context.status)) {
try {
await updateStatus(
context,
'FAILED',
'当前平台处理超时,批次已自动继续',
'扩展超过4分钟未收到完成信号,请检查该平台页面'
)
} catch (ignored) {}
}
await advanceBatch({ batchId, taskId })
}
async function recoverStoredBatches() {
const stored = await chrome.storage.local.get(null)
const batches = Object.keys(stored)
.filter(key => key.startsWith('publisher_batch_'))
.map(key => stored[key])
.filter(Boolean)
for (const batch of batches) {
if (!batch.currentTaskId) {
await processNextBatchTask(batch.batchId)
continue
}
const context = stored[taskKey(batch.currentTaskId)]
if (context && BATCH_ADVANCE_STATUSES.has(context.status)) {
await advanceBatch(context)
} else {
await scheduleBatchWatchdog(batch.batchId)
}
}
}
async function resumeTaskForTab(tab, platform) {
if (!tab || !tab.id) return { ok: false, message: '无法识别平台标签页' }
const context = await getContextForTab(tab.id)
if (!context) return { ok: false, message: '当前标签页没有待执行任务' }
if (context.platform !== platform || !isExpectedPlatformUrl(tab.url, platform)) {
return { ok: false, message: '平台标签页与发布任务不匹配' }
}
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(context.status)) {
return { ok: false, message: '发布任务已经结束' }
}
await sendTaskMessage(tab.id, context)
return { ok: true, taskId: context.taskId }
}
async function sendTaskMessage(tabId, context) {
try {
await chrome.tabs.sendMessage(tabId, {
type: context.status === 'READY_TO_PUBLISH' ? 'WATCH_PUBLISH_OUTCOME' : 'RUN_PUBLISH',
payload: {
task: context.task,
taskId: context.taskId,
token: context.token,
apiBaseUrl: context.apiBaseUrl,
batchId: context.batchId || ''
}
})
return true
} catch (error) {
// 页面脚本可能仍在加载,PLATFORM_READY 会再次触发发送。
return false
}
}
async function sendTaskMessageWithRetry(tabId, context, timeout) {
const started = Date.now()
while (Date.now() - started < timeout) {
if (await sendTaskMessage(tabId, context)) return true
await sleep(500)
}
// 登录页或较慢的页面仍可在加载完成后通过 PLATFORM_READY 恢复任务。
return false
}
async function receivePlatformStatus(payload, sender) {
if (!payload || !payload.taskId || !payload.status || !sender.tab) {
throw new Error('平台状态参数不完整')
}
const context = await getContextForTab(sender.tab.id)
if (!context || context.taskId !== payload.taskId) {
throw new Error('未找到当前平台标签页对应的发布任务')
}
if (!isExpectedPlatformUrl(sender.url, context.platform)) {
throw new Error('平台状态来源不可信')
}
let statusError = null
try {
await updateStatus(context, payload.status, payload.message || '', payload.error || '')
} catch (error) {
statusError = error
}
// 批次推进以平台页面的完成信号为准。后台状态接口偶发失败时,
// 仍继续打开下一平台,避免一个回写请求把整个多平台批次卡住。
if (BATCH_ADVANCE_STATUSES.has(payload.status)) {
await advanceBatch(context)
}
if (statusError) throw statusError
}
async function fillPlatformContent(payload, sender) {
if (!sender.tab || !sender.tab.id || !payload || payload.platform !== 'wechat_channels'
|| !isExpectedPlatformUrl(sender.url, payload.platform)) {
throw new Error('文案填写请求来源不可信')
}
const value = String(payload.value || '')
if (!value || value.length > 10000) throw new Error('视频号发布文案为空或过长')
const results = await chrome.scripting.executeScript({
target: { tabId: sender.tab.id, allFrames: true },
world: 'MAIN',
func: fillWechatChannelsContent,
args: [value]
})
const frameResults = results.map(item => item.result).filter(Boolean)
const hit = frameResults.find(item => item.ok)
if (!hit) {
const details = frameResults.map(item => (item.url || 'unknown') + ': ' + (item.error || '未找到编辑器'))
throw new Error('视频号文案写入失败;' + details.join(' | ').slice(0, 800))
}
return { frameUrl: hit.url }
}
function fillWechatChannelsContent(value) {
const normalize = text => String(text || '').replace(/\s+/g, ' ').trim()
const isVisible = element => {
if (!element || !element.isConnected) return false
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0
&& style.display !== 'none' && style.visibility !== 'hidden'
}
const queryAllDeep = (root, selector) => {
const matches = Array.from(root.querySelectorAll(selector))
for (const element of root.querySelectorAll('*')) {
if (element.shadowRoot) matches.push(...queryAllDeep(element.shadowRoot, selector))
}
return matches
}
const selectors = [
'div.input-editor[contenteditable][data-placeholder="添加描述"]',
'[contenteditable][data-placeholder*="描述"]',
'.post-desc-box [contenteditable]'
]
let editor = null
for (const selector of selectors) {
const candidates = queryAllDeep(document, selector).filter(element =>
isVisible(element.closest && element.closest('.post-desc-box') || element)
)
if (candidates.length === 1) {
editor = candidates[0]
break
}
}
if (!editor) {
return { ok: false, url: location.href, error: '可见描述编辑器数量不为 1' }
}
editor.focus()
const selection = getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
let inserted = false
try {
inserted = document.execCommand('insertText', false, value)
if (!inserted) inserted = document.execCommand('insertHTML', false, value)
} catch (error) {
return { ok: false, url: location.href, error: error.message }
}
editor.dispatchEvent(new InputEvent('input', {
bubbles: true, inputType: 'insertText', data: value
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
editor.blur()
const current = normalize(editor.textContent)
return {
ok: inserted && current === normalize(value),
url: location.href,
error: inserted ? '写入后内容校验不一致' : '富文本写入命令未生效'
}
}
async function updateStatus(context, status, message, error) {
const url = bridgeUrl(context, '/status')
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, message, error })
})
const body = await readJson(response)
if (!response.ok || body.code !== 200) {
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 })
}
await broadcastStatus(context, status, message, error)
}
async function broadcastStatus(context, status, message, error) {
const event = {
type: 'ADMIN_PUBLISH_STATUS',
payload: {
taskId: context.taskId,
platform: context.platform,
status,
message,
error
}
}
if (context.adminTabId) {
try {
await chrome.tabs.sendMessage(context.adminTabId, event)
return
} catch (ignored) {}
}
const tabs = await chrome.tabs.query({})
await Promise.all(tabs.filter(tab => isAllowedAdmin(tab.url)).map(tab =>
chrome.tabs.sendMessage(tab.id, event).catch(() => {})
))
}
async function fetchBridgeTask(apiBaseUrl, taskId, token) {
const url = apiBaseUrl + '/office/publish/bridge/task/' + encodeURIComponent(taskId)
+ '?token=' + encodeURIComponent(token)
const response = await fetch(url, { method: 'GET', cache: 'no-store' })
const body = await readJson(response)
if (!response.ok || body.code !== 200 || !body.data) {
throw new Error(body.msg || '读取发布任务失败')
}
return body.data
}
async function readJson(response) {
try {
return await response.json()
} catch (error) {
return { code: response.status, msg: '服务返回了无法识别的内容' }
}
}
async function getContextForTab(tabId) {
const tabEntry = await chrome.storage.local.get(tabKey(tabId))
const taskId = tabEntry[tabKey(tabId)]
if (!taskId) return null
const taskEntry = await chrome.storage.local.get(taskKey(taskId))
return taskEntry[taskKey(taskId)] || null
}
function bridgeUrl(context, suffix) {
return context.apiBaseUrl + '/office/publish/bridge/task/' + encodeURIComponent(context.taskId)
+ suffix + '?token=' + encodeURIComponent(context.token)
}
function validatePayload(payload) {
if (!payload || typeof payload.taskId !== 'string' || typeof payload.token !== 'string'
|| typeof payload.platform !== 'string' || typeof payload.apiBaseUrl !== 'string') {
throw new Error('发布任务参数不完整')
}
if (!PLATFORM_URLS[payload.platform]) throw new Error('不支持的发布平台')
if (!/^publish_[a-f0-9]{32}$/.test(payload.taskId)) throw new Error('任务编号格式不正确')
if (!/^[a-f0-9]{32}$/.test(payload.token)) throw new Error('任务凭证格式不正确')
}
function normalizeApiBase(value, senderUrl) {
const api = new URL(value)
const sender = new URL(senderUrl)
if (api.origin !== sender.origin) throw new Error('任务接口必须与后台页面同源')
return api.origin + api.pathname.replace(/\/$/, '')
}
function isAllowedAdmin(value) {
if (!value) return false
try {
const url = new URL(value)
const allowedHost = url.hostname === 'feast.yidaima.cn'
|| url.hostname === 'localhost'
|| url.hostname === '127.0.0.1'
return allowedHost && url.pathname.indexOf('/office/publish') >= 0
} catch (error) {
return false
}
}
function isExpectedPlatformUrl(value, platform) {
if (!value || !PLATFORM_HOSTS[platform]) return false
try {
const hostname = new URL(value).hostname
return hostname === PLATFORM_HOSTS[platform]
|| (platform === 'xianyu' && hostname === 'www.goofish.pro')
} catch (error) {
return false
}
}
function platformName(platform) {
return { xiaohongshu: '小红书', douyin: '抖音', wechat_channels: '视频号', bilibili: 'B站', xianyu: '闲鱼' }[platform] || platform
}
function taskKey(taskId) { return 'publisher_task_' + taskId }
function tabKey(tabId) { return 'publisher_tab_' + tabId }
function batchKey(batchId) { return 'publisher_batch_' + batchId }
function batchWatchdogName(batchId) { return BATCH_WATCHDOG_PREFIX + batchId }
function readableError(error) { return error && error.message ? error.message : String(error || '未知错误') }
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) }

View File

@@ -0,0 +1,84 @@
(() => {
const SOURCE_ADMIN = 'yidaima-admin'
const SOURCE_EXTENSION = 'yidaima-publisher-extension'
const VERSION = chrome.runtime.getManifest().version
function isPublishPage() {
return location.pathname.indexOf('/office/publish') >= 0
}
function announceReady() {
if (!isPublishPage()) return
window.dispatchEvent(new CustomEvent('YIDAIMA_EXTENSION_READY', {
detail: { source: SOURCE_EXTENSION, version: VERSION }
}))
}
window.addEventListener('YIDAIMA_EXTENSION_PING', event => {
if (!isPublishPage() || !event.detail || event.detail.source !== SOURCE_ADMIN) return
chrome.runtime.sendMessage({ type: 'ADMIN_PING' }, response => {
if (chrome.runtime.lastError || !response || !response.ok) return
announceReady()
})
})
window.addEventListener('YIDAIMA_START_PUBLISH', event => {
const detail = event.detail
if (!isPublishPage() || !detail || detail.source !== SOURCE_ADMIN) return
const payload = {
taskId: detail.taskId,
token: detail.token,
platform: detail.platform,
apiBaseUrl: detail.apiBaseUrl
}
chrome.runtime.sendMessage({ type: 'ADMIN_START_PUBLISH', payload }, response => {
const error = chrome.runtime.lastError
window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_ACCEPTED', {
detail: {
source: SOURCE_EXTENSION,
taskId: payload.taskId,
ok: !error && Boolean(response && response.ok),
message: error ? error.message : (response && response.message)
}
}))
})
})
window.addEventListener('YIDAIMA_START_PUBLISH_BATCH', event => {
const detail = event.detail
if (!isPublishPage() || !detail || detail.source !== SOURCE_ADMIN || !Array.isArray(detail.tasks)) return
const payload = {
batchId: detail.batchId,
apiBaseUrl: detail.apiBaseUrl,
tasks: detail.tasks.map(task => ({
taskId: task.taskId,
token: task.token,
platform: task.platform
}))
}
chrome.runtime.sendMessage({ type: 'ADMIN_START_PUBLISH_BATCH', payload }, response => {
const error = chrome.runtime.lastError
window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_ACCEPTED', {
detail: {
source: SOURCE_EXTENSION,
batchId: payload.batchId,
ok: !error && Boolean(response && response.ok),
message: error ? error.message : (response && response.message)
}
}))
})
})
chrome.runtime.onMessage.addListener(message => {
if (!message || message.type !== 'ADMIN_PUBLISH_STATUS' || !isPublishPage()) return
window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_STATUS', {
detail: { source: SOURCE_EXTENSION, ...message.payload }
}))
})
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', announceReady, { once: true })
} else {
announceReady()
}
})()

View File

@@ -0,0 +1,69 @@
(() => {
const config = {
platform: 'bilibili',
platformName: 'B站',
fillAfterUploadSettled: true,
uploadSettleTimeout: 30 * 60 * 1000,
fillDelay: 1000,
videoModeLabels: [],
imageModeLabels: [],
uploadSelectors: [
'.upload-btn input[type="file"]',
'.upload-wrapper input[type="file"]',
'input[type="file"][accept*="video"]',
'input[type="file"][accept*="mp4"]',
'input[type="file"]'
],
uploadDropZoneSelectors: [
'.upload-btn',
'.upload-wrapper',
'[class*="upload"] [class*="drag"]'
],
videoInputSelectors: [
'.upload-btn input[type="file"]',
'.upload-wrapper input[type="file"]',
'input[type="file"][accept*="video"]',
'input[type="file"][accept*="mp4"]'
],
imageInputSelectors: [],
coverInputSelectors: [
'[class*="cover"] input[type="file"][accept*="image"]',
'[class*="cover"] input[type="file"]',
'[class*="poster"] input[type="file"][accept*="image"]'
],
titleSelectors: [
'input[placeholder*="稿件标题"]',
'input[placeholder*="视频标题"]',
'input[placeholder*="标题"]',
'input[maxlength="80"]',
'[class*="title"] input[type="text"]'
],
contentSelectors: [
'textarea[placeholder*="视频简介"]',
'textarea[placeholder*="稿件简介"]',
'textarea[placeholder*="简介"]',
'[contenteditable="true"][data-placeholder*="简介"]',
'[contenteditable="true"][data-placeholder*="描述"]',
'.ql-editor[contenteditable="true"]',
'[class*="desc"] [contenteditable="true"]',
'[class*="desc"] textarea'
],
progressSelectors: [
'.upload-progress',
'.bcc-progress',
'[class*="upload"] [class*="progress"]',
'[role="progressbar"]'
],
successTexts: [/^投稿成功[!!。]?$/, /^发布成功[!!。]?$/, /^稿件投递成功[!!。]?$/],
failureTexts: [/^投稿失败/, /^发布失败/, /^稿件投递失败/]
}
chrome.runtime.onMessage.addListener(message => {
if (message && message.type === 'RUN_PUBLISH') {
globalThis.YidaimaPublisher.run(config, message.payload)
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
globalThis.YidaimaPublisher.watch(config, message.payload)
}
})
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
})()

View File

@@ -0,0 +1,45 @@
(() => {
const config = {
platform: 'douyin',
platformName: '抖音',
preserveContentLineBreaks: true,
useAtomicHtmlLineBreaks: true,
looseContentVerification: true,
videoModeLabels: [],
imageModeLabels: [],
uploadSelectors: [
'input[type="file"][accept*="video"]',
'input[type="file"]'
],
videoInputSelectors: ['input[type="file"][accept*="video"]'],
imageInputSelectors: [],
coverInputSelectors: [
'[class*="cover"] input[type="file"][accept*="image"]',
'[class*="poster"] input[type="file"][accept*="image"]'
],
titleSelectors: [
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'[class*="title"] input'
],
contentSelectors: [
'[contenteditable="true"][data-placeholder*="作品描述"]',
'[contenteditable="true"][data-placeholder*="描述"]',
'textarea[placeholder*="作品描述"]',
'textarea[placeholder*="描述"]',
'[class*="editor"] [contenteditable="true"]'
],
progressSelectors: ['[class*="progress"]', '[role="progressbar"]'],
successTexts: [/^发布成功[!!。]?$/, /^作品发布成功[!!。]?$/],
failureTexts: [/^发布失败/, /^作品发布失败/]
}
chrome.runtime.onMessage.addListener(message => {
if (message && message.type === 'RUN_PUBLISH') {
globalThis.YidaimaPublisher.run(config, message.payload)
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
globalThis.YidaimaPublisher.watch(config, message.payload)
}
})
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
})()

View File

@@ -0,0 +1,957 @@
(() => {
const runningTasks = new Set()
async function run(config, payload) {
const { task, taskId, token, apiBaseUrl } = payload || {}
if (!task || !taskId || !token || !apiBaseUrl || runningTasks.has(taskId)) return
runningTasks.add(taskId)
const context = { config, task, taskId, token, apiBaseUrl, batchId: payload.batchId || '' }
try {
await report(context, 'DOWNLOADING', '正在准备发布素材')
await activatePublishMode(config, task)
const preparationWarnings = []
const preparationWarning = await prepareForm(config)
if (preparationWarning) preparationWarnings.push(preparationWarning)
if (config.fillBeforeUpload) {
await fillPublishText(context)
}
const uploadTarget = await waitForUploadTarget(config, 60000)
const uploadInputs = uploadTarget.inputs
const uploadDropZone = uploadTarget.dropZone
if (!uploadInputs.length && !uploadDropZone) {
await report(
context,
'LOGIN_REQUIRED',
'未找到发布表单,请登录平台后进入内容发布页面',
createPageDiagnostics()
)
return
}
const uploaded = await uploadAssets(context, uploadInputs, uploadDropZone)
if (!uploaded && !config.allowNoAssets) throw new Error('没有找到与发布素材匹配的上传控件')
if (uploaded && config.fillAfterUploadSettled) {
await waitForUploadSettled(config, config.uploadSettleTimeout || 10 * 60 * 1000)
}
if (!config.fillBeforeUpload || config.refillAfterUpload) {
await fillPublishText(context)
}
await tryUploadCover(context)
if (uploaded && !config.fillAfterUploadSettled) {
try {
const settleTimeout = context.batchId
? (config.batchUploadSettleTimeout || 60000)
: (config.uploadSettleTimeout || 10 * 60 * 1000)
await waitForUploadSettled(config, settleTimeout)
} catch (error) {
if (!context.batchId) throw error
preparationWarnings.push('平台仍显示上传处理中,批次已继续,请稍后检查本页面')
}
}
const combinedWarning = preparationWarnings.join(';')
const readyMessage = combinedWarning
? '标题、文案和素材已处理;部分商品选项未能自动确认,请检查后再发布'
: '资料已填写完成,请检查后手动点击发布按钮'
await report(context, 'READY_TO_PUBLISH', readyMessage, combinedWarning)
showReadyNotice(config.platformName, combinedWarning)
watch(config, payload)
} catch (error) {
await report(context, 'FAILED', '发布资料填写失败', readableError(error))
} finally {
runningTasks.delete(taskId)
}
}
function watch(config, payload) {
const { task, taskId, token, apiBaseUrl } = payload || {}
if (!task || !taskId || !token || !apiBaseUrl) return
const watcherKey = 'watch:' + taskId
if (runningTasks.has(watcherKey)) return
runningTasks.add(watcherKey)
const context = { config, task, taskId, token, apiBaseUrl }
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
const text = String(node.innerText || node.textContent || '').trim()
if (!text || text.length > 500) continue
if (config.successTexts.some(pattern => pattern.test(text))) {
observer.disconnect()
runningTasks.delete(watcherKey)
report(context, 'SUCCEEDED', '检测到用户已完成平台发布').catch(() => {})
return
}
if (config.failureTexts.some(pattern => pattern.test(text))) {
observer.disconnect()
runningTasks.delete(watcherKey)
report(context, 'FAILED', '平台提示发布失败', text.slice(0, 300)).catch(() => {})
return
}
}
}
})
observer.observe(document.documentElement, { childList: true, subtree: true })
setTimeout(() => {
observer.disconnect()
runningTasks.delete(watcherKey)
}, 30 * 60 * 1000)
}
async function uploadAssets(context, initialInputs, uploadDropZone) {
const { task, config } = context
let uploaded = false
if (task.hasVideo) {
await report(context, 'DOWNLOADING', '正在读取发布视频')
const video = await fetchAssetFile(context, 'video', null, 'publish-video.mp4')
const input = findInput(initialInputs, 'video', config.videoInputSelectors)
if (input) {
setInputFiles(input, [video])
} else if (uploadDropZone) {
dropFiles(uploadDropZone, [video])
} else {
throw new Error('未找到视频上传控件')
}
uploaded = true
await report(context, 'UPLOADING', '视频已交给平台上传')
} else if (task.imageCount > 0) {
await report(context, 'DOWNLOADING', '正在读取发布图片')
const images = []
for (let index = 0; index < task.imageCount; index++) {
images.push(await fetchAssetFile(context, 'image', index, 'publish-image-' + (index + 1) + '.jpg'))
}
const latestInputs = queryVisibleInputs(context.config.uploadSelectors)
const input = findInput(latestInputs.length ? latestInputs : initialInputs, 'image', config.imageInputSelectors)
if (!input) throw new Error('未找到图片上传控件')
setInputFiles(input, input.multiple ? images : [images[0]])
uploaded = true
await report(context, 'UPLOADING', '图片已交给平台上传')
}
return uploaded
}
async function tryUploadCover(context) {
if (!context.task.hasCover || !context.config.coverInputSelectors.length) return
const coverInput = queryFirstVisible(context.config.coverInputSelectors)
if (!coverInput) return
const cover = await fetchAssetFile(context, 'cover', null, 'publish-cover.jpg')
setInputFiles(coverInput, [cover])
}
async function fetchAssetFile(context, kind, index, fallbackName) {
const suffix = index === null ? '' : '/' + index
const url = context.apiBaseUrl + '/office/publish/bridge/task/'
+ encodeURIComponent(context.taskId) + '/asset/' + kind + suffix
+ '?token=' + encodeURIComponent(context.token)
const response = await fetch(url, { method: 'GET', cache: 'no-store' })
if (!response.ok) throw new Error('读取' + assetName(kind) + '失败,HTTP ' + response.status)
const blob = await response.blob()
if (!blob.size) throw new Error(assetName(kind) + '文件为空')
const disposition = response.headers.get('Content-Disposition') || ''
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) {
const labels = task.hasVideo ? config.videoModeLabels : config.imageModeLabels
for (const label of labels) {
const element = findClickableByText(label)
if (element) {
element.click()
await sleep(1000)
return
}
}
}
async function prepareForm(config) {
await dismissBlockingNotice(config.blockingNotice)
const warnings = []
try {
await prepareCategory(config.category)
} catch (error) {
if (!config.continueAfterCategoryFailure) throw error
warnings.push(readableError(error))
}
for (const choice of config.prepareTextChoices || []) {
try {
await prepareTextChoice(choice)
} catch (error) {
if (!config.continueAfterCategoryFailure) throw error
warnings.push(readableError(error))
}
}
for (const step of config.prepareClickSteps || []) {
const element = queryFirstVisible(step.selectors || [])
if (!element) continue
element.click()
await sleep(step.wait || 600)
}
return warnings.join(';')
}
async function fillPublishText(context) {
const { config, task } = context
await report(context, 'FILLING', '正在填写标题、文案和标签')
await sleep(config.fillDelay || 500)
const titleElement = config.skipTitle
? null
: await waitForAny(
config.titleSelectors,
config.titleOptional ? (config.optionalTitleTimeout || 3000) : 180000
)
if (!config.skipTitle && !titleElement && !config.titleOptional) {
throw new Error('未找到标题输入框,平台页面可能已经更新')
}
if (titleElement) {
const title = transformTitle(config, task.title || '')
await fillAndVerify(config.titleSelectors, title, '标题')
}
let content = !titleElement && config.mergeTitleIntoContent
? mergeTitleAndContent(task.title, task.content)
: task.content
if (!content && config.fallbackContentToTitle) content = task.title
const fullContent = buildContent(content, task.tags)
if (!fullContent) return
const contentElement = await waitForAny(config.contentSelectors, 180000)
if (!contentElement) throw new Error('未找到文案输入框,平台页面可能已经更新')
if (config.useMainWorldContent) {
const fillResult = await chrome.runtime.sendMessage({
type: 'PLATFORM_FILL_CONTENT',
payload: { platform: config.platform, value: fullContent }
})
if (!fillResult || !fillResult.ok) {
throw new Error(fillResult && fillResult.message ? fillResult.message : '平台文案写入失败')
}
} else {
await fillAndVerify(config.contentSelectors, fullContent, '文案', {
preserveLineBreaks: Boolean(config.preserveContentLineBreaks),
useAtomicHtmlLineBreaks: Boolean(config.useAtomicHtmlLineBreaks),
looseVerification: Boolean(config.looseContentVerification)
})
}
}
async function prepareTextChoice(choice) {
const label = typeof choice === 'string' ? choice : choice.label
const timeout = typeof choice === 'string' ? 5000 : (choice.timeout || 5000)
const started = Date.now()
let element = null
while (Date.now() - started < timeout) {
element = findExactCategoryText(['button, label, [role="button"], body *'], label)
if (element) break
await sleep(250)
}
if (!element) throw new Error('未找到商品选项:' + label)
if (!isTextChoiceSelected(element)) {
dispatchPointerClick(element)
await sleep(typeof choice === 'string' ? 350 : (choice.wait || 350))
element = findExactCategoryText(['button, label, [role="button"], body *'], label) || element
if (!isTextChoiceSelected(element) && element.parentElement) {
dispatchPointerClick(element.parentElement)
await sleep(250)
}
}
}
function isTextChoiceSelected(element) {
let current = element
for (let depth = 0; current && depth < 4; depth++, current = current.parentElement) {
const className = String(current.className || '')
if (/(^|[-_\s])(active|selected|checked|current)([-_\s]|$)/i.test(className)) return true
if (current.getAttribute('aria-selected') === 'true' || current.getAttribute('aria-checked') === 'true') return true
if ('checked' in current && current.checked) return true
}
return false
}
async function dismissBlockingNotice(notice) {
if (!notice) return
for (const currentDocument of collectAccessibleDocuments()) {
const dialogs = queryAllDeep(currentDocument, '[role="dialog"], .el-dialog__wrapper, .el-message-box__wrapper')
.filter(isVisible)
for (const dialog of dialogs) {
const text = String(dialog.textContent || '').replace(/\s+/g, '')
if (!text.includes(notice.messageText)) continue
const dismiss = queryAllDeep(dialog, 'button, [role="button"]').find(element =>
isVisible(element) && String(element.textContent || '').trim() === notice.dismissText
)
if (dismiss) {
dismiss.click()
await sleep(500)
return
}
}
}
}
async function prepareCategory(category) {
if (!category) return
const input = await waitForAny(category.inputSelectors || [], 60000)
if (!input) throw new Error('未找到商品分类选择框,闲鱼页面可能已经更新')
const targetPath = category.path || []
const targetLeaf = targetPath[targetPath.length - 1] || ''
const targetFullPath = category.resultText || targetPath.join('/')
if (isCategorySelected(input, targetFullPath, targetLeaf)) return
const history = queryFirstVisible(category.historySelectors || [])
if (history) {
history.click()
await sleep(800)
if (!targetLeaf || isCategorySelected(input, targetFullPath, targetLeaf)) return
}
input.click()
await sleep(500)
for (const segment of targetPath) {
const option = await waitForCategoryOption(category, segment, category.optionTimeout || 1800)
if (!option) break
clickCategoryResult(option)
await sleep(500)
if (isCategorySelected(input, targetFullPath, targetLeaf)) return
}
// 分级菜单未命中时,使用分类框的关键词搜索固定末级分类。
input.click()
setElementValue(input, targetLeaf, true)
await sleep(800)
const searchResult = await waitForCategorySearchResult(category, targetFullPath, targetLeaf, 6000)
if (!searchResult) throw new Error('分类搜索结果已打开,但未找到可点击的完整分类:' + targetFullPath)
clickCategoryResult(searchResult)
let selected = await waitForCategorySelection(input, category, targetFullPath, targetLeaf, 2500)
if (!selected) {
await selectCategoryWithKeyboard(input)
selected = await waitForCategorySelection(input, category, targetFullPath, targetLeaf, 2500)
}
if (!selected) throw new Error('分类搜索结果未能选中:' + targetFullPath)
}
async function waitForCategorySearchResult(category, fullPath, leaf, timeout) {
const selectors = (category.searchResultSelectors || [])
.concat(category.optionSelectors || [])
.concat(['[role="option"]', '[class*="suggest"]', '[class*="dropdown"] li'])
const started = Date.now()
while (Date.now() - started < timeout) {
const exact = findExactCategoryText(selectors, fullPath)
if (exact) return exact
// 闲管家会把搜索结果渲染成普通 div/span,不一定带下拉选项的固定类名。
const fallback = findExactCategoryText(['body *'], fullPath)
if (fallback) return fallback
const leafOption = await waitForCategoryOption(category, leaf, 300)
if (leafOption) return leafOption
await sleep(250)
}
return null
}
function findExactCategoryText(selectors, expectedText) {
const normalizedExpected = normalizeCategoryText(expectedText)
if (!normalizedExpected) return null
const candidates = []
for (const selector of selectors) {
for (const element of queryAllAccessible(selector)) {
if (!isVisible(element)) continue
if (normalizeCategoryText(element.textContent) !== normalizedExpected) continue
if (!candidates.includes(element)) candidates.push(element)
}
}
return smallestVisible(candidates)
}
function clickCategoryResult(element) {
element.scrollIntoView({ block: 'center', inline: 'nearest' })
const ancestor = element.closest(
'[role="option"], [role="menuitem"], li, button, [class*="suggest"], [class*="option"], [class*="item"]'
) || element
const rect = element.getBoundingClientRect()
const documentAtPoint = element.ownerDocument
const hitTarget = documentAtPoint.elementFromPoint(
Math.max(0, rect.left + Math.min(rect.width / 2, 24)),
Math.max(0, rect.top + rect.height / 2)
)
const clickable = hitTarget && element.contains(hitTarget) ? hitTarget : ancestor
dispatchPointerClick(clickable)
if (clickable !== ancestor) ancestor.click()
}
function dispatchPointerClick(element) {
const view = element.ownerDocument.defaultView || window
const common = { bubbles: true, cancelable: true, composed: true, view }
for (const type of ['pointerover', 'mouseover', 'pointermove', 'mousemove']) {
const EventConstructor = type.startsWith('pointer') && view.PointerEvent
? view.PointerEvent
: view.MouseEvent
element.dispatchEvent(new EventConstructor(type, common))
}
if (view.PointerEvent) element.dispatchEvent(new view.PointerEvent('pointerdown', common))
element.dispatchEvent(new view.MouseEvent('mousedown', common))
if (view.PointerEvent) element.dispatchEvent(new view.PointerEvent('pointerup', common))
element.dispatchEvent(new view.MouseEvent('mouseup', common))
element.click()
}
async function selectCategoryWithKeyboard(input) {
input.focus()
dispatchKeyboardEvent(input, 'keydown', 'ArrowDown', 40)
dispatchKeyboardEvent(input, 'keyup', 'ArrowDown', 40)
await sleep(150)
dispatchKeyboardEvent(input, 'keydown', 'Enter', 13)
dispatchKeyboardEvent(input, 'keyup', 'Enter', 13)
}
function dispatchKeyboardEvent(element, type, key, keyCode) {
const view = element.ownerDocument.defaultView || window
element.dispatchEvent(new view.KeyboardEvent(type, {
key,
code: key === 'Enter' ? 'Enter' : 'ArrowDown',
keyCode,
which: keyCode,
bubbles: true,
cancelable: true,
composed: true
}))
}
async function waitForCategorySelection(input, category, fullPath, leaf, timeout) {
const started = Date.now()
while (Date.now() - started < timeout) {
if (isCategorySelected(input, fullPath, leaf)) return true
const resultStillOpen = findExactCategoryText(
(category.searchResultSelectors || [])
.concat(category.optionSelectors || [])
.concat(['[role="option"]', 'body *']),
fullPath
)
const inputValue = normalizeCategoryText(input.value)
if (!resultStillOpen && inputValue === normalizeCategoryText(leaf)) return true
await sleep(250)
}
return false
}
function isCategorySelected(input, fullPath, leaf) {
const value = normalizeCategoryText(input && input.value)
const normalizedPath = normalizeCategoryText(fullPath)
const normalizedLeaf = normalizeCategoryText(leaf)
return Boolean(value && (
value === normalizedPath
|| value.endsWith('/' + normalizedLeaf)
|| value.endsWith('>' + normalizedLeaf)
))
}
function normalizeCategoryText(value) {
return String(value || '')
.replace(/[>>]/g, '/')
.replace(/\s+/g, '')
.replace(/\/{2,}/g, '/')
.replace(/^\/|\/$/g, '')
}
async function waitForCategoryOption(category, label, timeout) {
const started = Date.now()
while (Date.now() - started < timeout) {
for (const selector of category.optionSelectors || []) {
const candidates = queryAllAccessible(selector).filter(element => {
if (!isVisible(element)) return false
const text = String(element.textContent || '').replace(/\s+/g, '')
return text === label || text.endsWith('/' + label) || text.endsWith(label)
})
if (candidates.length) {
const candidate = smallestVisible(candidates)
return candidate.closest('li, [role="menuitem"], button') || candidate
}
}
if (category.allowGlobalTextOption) {
const candidate = findExactCategoryText(['body *'], label)
if (candidate) return candidate
}
await sleep(250)
}
return null
}
function findClickableByText(label) {
// 这里只允许切换发布类型标签,不扫描或点击普通按钮,避免误触最终发布。
const candidates = document.querySelectorAll('[role="tab"], [class*="tab-item"], [class*="tabItem"]')
for (const candidate of candidates) {
if (!isVisible(candidate)) continue
const text = (candidate.innerText || candidate.textContent || '').trim()
if (text === label || text.indexOf(label) >= 0) return candidate
}
return null
}
async function waitForUploadTarget(config, timeout) {
const started = Date.now()
while (Date.now() - started < timeout) {
const inputs = queryVisibleInputs(config.uploadSelectors)
if (inputs.length) return { inputs, dropZone: null }
const dropZone = findUploadDropZone(config)
if (dropZone) return { inputs: [], dropZone }
await sleep(500)
}
return { inputs: [], dropZone: null }
}
async function waitForAny(selectors, timeout) {
const started = Date.now()
while (Date.now() - started < timeout) {
const element = queryFirstVisible(selectors)
if (element) return element
await sleep(400)
}
return null
}
async function fillAndVerify(selectors, value, label, options = {}) {
const normalize = options.preserveLineBreaks ? normalizeMultilineValue : normalizeFieldValue
const expected = normalize(value)
for (let attempt = 0; attempt < 3; attempt++) {
const element = await waitForAny(selectors, 10000)
if (!element) break
setElementValue(element, value, false, options)
await sleep(500 + attempt * 500)
const current = queryFirstVisible(selectors)
if (current && valuesMatch(readElementValue(current), expected, normalize, options)) return
}
throw new Error(label + '写入后校验失败,平台页面可能已经更新')
}
function valuesMatch(actualValue, expected, normalize, options) {
const actual = normalize(actualValue)
if (actual === expected) return true
if (!options.looseVerification) return false
return normalizeLooseEditorValue(actual) === normalizeLooseEditorValue(expected)
}
function readElementValue(element) {
if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') {
return element.innerText || element.textContent || ''
}
return element.value || ''
}
function normalizeFieldValue(value) {
return String(value || '').replace(/\r\n/g, '\n').trim()
}
function normalizeMultilineValue(value) {
return String(value || '')
.replace(/\r\n?/g, '\n')
.replace(/\u00a0/g, ' ')
.replace(/[\u200b-\u200d\ufeff]/g, '')
.split('\n')
.map(line => line.replace(/[ \t]+$/g, ''))
.join('\n')
.trim()
}
function normalizeLooseEditorValue(value) {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/[\u200b-\u200d\ufeff]/g, '')
.replace(/\s+/g, '')
}
function transformTitle(config, value) {
let title = String(value || '')
if (typeof config.titleTransform === 'function') title = config.titleTransform(title)
if (config.titleMaxLength) title = Array.from(title).slice(0, config.titleMaxLength).join('')
return title.trim()
}
async function waitForUploadSettled(config, timeout) {
const started = Date.now()
let calmChecks = 0
await sleep(5000)
while (Date.now() - started < timeout) {
const pageText = collectAccessibleDocuments()
.map(doc => (doc.body && doc.body.innerText) || '')
.join('\n')
if (/上传失败|文件损坏|格式不支持|重新上传/.test(pageText)) {
throw new Error('平台提示素材上传失败或格式不支持')
}
const progressTexts = queryAllAccessible(config.progressSelectors.join(','))
.filter(isVisible)
.map(element => (element.innerText || element.textContent || '').trim())
const uploading = progressTexts.some(text => /(^|\D)([1-9]|[1-9]\d)%/.test(text) && !/100%/.test(text))
if (uploading) {
calmChecks = 0
} else {
calmChecks++
if (calmChecks >= 3) return
}
await sleep(1500)
}
throw new Error('等待平台上传完成超时')
}
function findInput(inputs, kind, preferredSelectors) {
const preferred = queryFirstVisible(preferredSelectors)
if (preferred) return preferred
const matcher = kind === 'video' ? /video/i : /image|png|jpe?g|webp/i
return inputs.find(input => matcher.test(input.accept || '')) || inputs[0] || null
}
function queryVisibleInputs(selectors) {
const result = []
for (const selector of selectors) {
queryAllAccessible(selector).forEach(element => {
if (isFileInput(element) && !result.includes(element)) {
result.push(element)
}
})
}
return result
}
function queryFirstVisible(selectors) {
for (const selector of selectors) {
const elements = queryAllAccessible(selector)
for (const element of elements) {
if (isVisible(element) || isFileInput(element)) return element
}
}
return null
}
function setInputFiles(input, files) {
const view = input.ownerDocument.defaultView || window
const transfer = new view.DataTransfer()
files.forEach(file => {
const transferable = file instanceof view.File
? file
: new view.File([file], file.name, { type: file.type, lastModified: file.lastModified })
transfer.items.add(transferable)
})
input.files = transfer.files
input.dispatchEvent(new view.Event('input', { bubbles: true, composed: true }))
input.dispatchEvent(new view.Event('change', { bubbles: true, composed: true }))
if (!input.files || input.files.length === 0) throw new Error('浏览器未能把素材交给上传控件')
}
function dropFiles(target, files) {
const view = target.ownerDocument.defaultView || window
const transfer = new view.DataTransfer()
files.forEach(file => {
const transferable = file instanceof view.File
? file
: new view.File([file], file.name, { type: file.type, lastModified: file.lastModified })
transfer.items.add(transferable)
})
for (const type of ['dragenter', 'dragover', 'drop']) {
target.dispatchEvent(new view.DragEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
dataTransfer: transfer
}))
}
}
function setElementValue(element, value, keepFocus, options = {}) {
const view = element.ownerDocument.defaultView || window
element.focus()
if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') {
let nativeInputDispatched = false
if (options.preserveLineBreaks) {
nativeInputDispatched = options.useAtomicHtmlLineBreaks
? setRichTextWithAtomicLineBreaks(element, value)
: setRichTextWithLineBreaks(element, value)
} else {
element.innerHTML = ''
element.textContent = value
}
if (!nativeInputDispatched) {
element.dispatchEvent(new view.InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'insertReplacementText',
data: null
}))
}
} else {
const prototype = element instanceof view.HTMLTextAreaElement
? view.HTMLTextAreaElement.prototype
: view.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value').set
setter.call(element, value)
const inputEvent = view.InputEvent
? new view.InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'insertText',
data: value
})
: new view.Event('input', { bubbles: true, composed: true })
element.dispatchEvent(inputEvent)
element.dispatchEvent(new view.Event('change', { bubbles: true, composed: true }))
}
if (!keepFocus) element.blur()
}
function setRichTextWithLineBreaks(element, value) {
const document = element.ownerDocument
const selection = document.getSelection ? document.getSelection() : null
let inserted = false
if (selection) {
const range = document.createRange()
range.selectNodeContents(element)
selection.removeAllRanges()
selection.addRange(range)
try {
inserted = document.execCommand('insertText', false, String(value || ''))
} catch (ignored) {}
}
// execCommand 已经触发编辑器自身的输入事件。成功后不要再派发携带全文的
// input 事件,否则部分受控富文本编辑器会把同一份内容再次追加。
if (inserted) return true
element.innerHTML = ''
const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n')
lines.forEach(line => {
const block = document.createElement('div')
if (line) {
block.appendChild(document.createTextNode(line))
} else {
block.appendChild(document.createElement('br'))
}
element.appendChild(block)
})
return false
}
function setRichTextWithAtomicLineBreaks(element, value) {
const document = element.ownerDocument
const selection = document.getSelection ? document.getSelection() : null
if (!selection) return false
const container = document.createElement('div')
const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n')
lines.forEach((line, index) => {
if (index > 0) container.appendChild(document.createElement('br'))
container.appendChild(document.createTextNode(line))
})
const range = document.createRange()
range.selectNodeContents(element)
selection.removeAllRanges()
selection.addRange(range)
try {
// 一次命令同时替换全文和换行,避免受控编辑器在逐行写入期间重绘,
// 导致光标复位并反复插入文案开头。
if (document.execCommand('insertHTML', false, container.innerHTML)) return true
} catch (ignored) {}
element.replaceChildren(...Array.from(container.childNodes).map(node => node.cloneNode(true)))
return false
}
async function report(context, status, message, error) {
await chrome.runtime.sendMessage({
type: 'PUBLISH_STATUS',
payload: { taskId: context.taskId, status, message, error: error || '' }
})
}
function showReadyNotice(platformName, preparationWarning) {
const existing = document.getElementById('yidaima-publisher-ready-notice')
if (existing) existing.remove()
const notice = document.createElement('div')
notice.id = 'yidaima-publisher-ready-notice'
notice.textContent = preparationWarning
? platformName + '资料已填写;部分商品选项请手动检查后发布'
: platformName + '资料已填写完成,请检查后手动点击发布按钮'
Object.assign(notice.style, {
position: 'fixed', top: '20px', left: '50%', transform: 'translateX(-50%)',
zIndex: '2147483647', padding: '14px 22px', color: '#fff', background: '#1677ff',
borderRadius: '8px', boxShadow: '0 8px 28px rgba(0,0,0,.24)', fontSize: '15px'
})
document.documentElement.appendChild(notice)
setTimeout(() => notice.remove(), 12000)
}
function buildContent(content, tags) {
const tagText = (tags || []).map(tag => '#' + String(tag).replace(/^#/, '')).join(' ')
return [content || '', tagText].filter(Boolean).join('\n\n')
}
function mergeTitleAndContent(title, content) {
const normalizedTitle = String(title || '').trim()
const normalizedContent = String(content || '').trim()
if (!normalizedTitle || normalizedContent.indexOf(normalizedTitle) >= 0) return normalizedContent
return [normalizedTitle, normalizedContent].filter(Boolean).join('\n\n')
}
function parseFileName(disposition) {
const utf = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf) {
try { return decodeURIComponent(utf[1]) } catch (ignored) {}
}
const plain = disposition.match(/filename="?([^";]+)"?/i)
return plain ? plain[1] : ''
}
function mimeForName(name) {
const extension = String(name).toLowerCase().split('.').pop()
return {
mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/x-m4v', webm: 'video/webm',
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp'
}[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] || '素材'
}
function isVisible(element) {
if (!element || !element.isConnected) return false
const view = element.ownerDocument.defaultView || window
const style = view.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
&& style.display !== 'none' && style.visibility !== 'hidden'
}
function isFileInput(element) {
if (!element || !element.ownerDocument) return false
const view = element.ownerDocument.defaultView || window
return element instanceof view.HTMLInputElement && element.type === 'file'
}
function queryAllDeep(root, selector) {
const matches = Array.from(root.querySelectorAll(selector))
for (const element of root.querySelectorAll('*')) {
if (element.shadowRoot) matches.push(...queryAllDeep(element.shadowRoot, selector))
}
return matches
}
function collectAccessibleDocuments(rootWindow = window) {
const documents = []
const seen = new Set()
const visit = currentWindow => {
try {
const currentDocument = currentWindow.document
if (!currentDocument || seen.has(currentDocument)) return
seen.add(currentDocument)
documents.push(currentDocument)
for (const frame of queryAllDeep(currentDocument, 'iframe, frame')) {
if (frame.contentWindow) visit(frame.contentWindow)
}
} catch (ignored) {}
}
visit(rootWindow)
return documents
}
function queryAllAccessible(selector) {
if (!selector) return []
const matches = []
const seen = new Set()
for (const currentDocument of collectAccessibleDocuments()) {
for (const element of queryAllDeep(currentDocument, selector)) {
if (!seen.has(element)) {
seen.add(element)
matches.push(element)
}
}
}
return matches
}
function findUploadDropZone(config) {
for (const selector of config.uploadDropZoneSelectors || []) {
const candidates = queryAllAccessible(selector).filter(isVisible)
if (candidates.length) return smallestVisible(candidates)
}
const candidates = queryAllAccessible('div, label, section').filter(element => {
if (!isVisible(element)) return false
const text = String(element.textContent || '').replace(/\s+/g, ' ').trim()
return text.indexOf('上传时长') >= 0
&& (text.indexOf('20GB') >= 0 || text.indexOf('MP4/H.264') >= 0)
})
return smallestVisible(candidates)
}
function smallestVisible(elements) {
return elements.slice().sort((left, right) => {
const leftRect = left.getBoundingClientRect()
const rightRect = right.getBoundingClientRect()
return leftRect.width * leftRect.height - rightRect.width * rightRect.height
})[0] || null
}
function createPageDiagnostics() {
const documents = collectAccessibleDocuments().map(currentDocument => ({
url: currentDocument.defaultView && currentDocument.defaultView.location.href,
fileInputs: queryAllDeep(currentDocument, 'input[type="file"]').length,
editables: queryAllDeep(currentDocument, '[contenteditable]').length,
hasUploadText: String(currentDocument.body && currentDocument.body.innerText || '').indexOf('上传时长') >= 0
}))
return '页面结构诊断:' + JSON.stringify(documents).slice(0, 900)
}
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) }
function readableError(error) { return error && error.message ? error.message : String(error || '未知错误') }
const publisherApi = { run, watch }
if (globalThis.__YIDAIMA_PUBLISHER_TEST__) {
publisherApi.test = Object.freeze({
normalizedAssetMimeType,
validateAssetMimeType,
fileNameForMimeType
})
}
globalThis.YidaimaPublisher = Object.freeze(publisherApi)
})()

View File

@@ -0,0 +1,67 @@
(() => {
const config = {
platform: 'wechat_channels',
platformName: '视频号',
skipTitle: true,
mergeTitleIntoContent: false,
useMainWorldContent: true,
titleMaxLength: 30,
titleTransform: value => String(value || '')
.replace(/[【】\[\]()(){}<>]/g, ' ')
.replace(/[^\u3400-\u9fffA-Za-z0-9《》“”‘’"'::++??%%℃,,、。!!\s]/g, ' ')
.replace(/\s+/g, ' '),
batchUploadSettleTimeout: 30000,
videoModeLabels: [],
imageModeLabels: [],
uploadSelectors: [
'input[type="file"][accept*="video"]',
'input[type="file"]'
],
uploadDropZoneSelectors: [
'div.upload-content',
'[class*="upload-content"]',
'[class*="upload-wrapper"]'
],
videoInputSelectors: ['input[type="file"][accept*="video"]'],
imageInputSelectors: [],
coverInputSelectors: [
'[class*="cover"] input[type="file"][accept*="image"]',
'[class*="poster"] input[type="file"][accept*="image"]'
],
titleSelectors: [
'input[type="text"][placeholder="填写短标题有机会获得更多流量"]',
'input[type="text"][placeholder*="短标题"]',
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'[class*="title"] input'
],
contentSelectors: [
'div.input-editor[contenteditable][data-placeholder="添加描述"]',
'[contenteditable="true"][data-placeholder*="添加描述"]',
'[contenteditable="true"][data-placeholder*="描述"]',
'[contenteditable="true"][placeholder*="描述"]',
'[contenteditable="true"][aria-label*="描述"]',
'textarea[placeholder*="描述"]',
'textarea[placeholder*="文案"]',
'[class*="post-desc"] [contenteditable="true"]',
'[class*="post_desc"] [contenteditable="true"]',
'[class*="desc"] [contenteditable="true"]',
'.post-desc-box [contenteditable]',
'[class*="editor"][contenteditable="true"]',
'[class*="editor"] [contenteditable="true"]',
'.ProseMirror[contenteditable="true"]'
],
progressSelectors: ['[class*="progress"]', '[role="progressbar"]'],
successTexts: [/^发表成功[!!。]?$/, /^发布成功[!!。]?$/, /^动态发表成功[!!。]?$/],
failureTexts: [/^发表失败/, /^发布失败/]
}
chrome.runtime.onMessage.addListener(message => {
if (message && message.type === 'RUN_PUBLISH') {
globalThis.YidaimaPublisher.run(config, message.payload)
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
globalThis.YidaimaPublisher.watch(config, message.payload)
}
})
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
})()

View File

@@ -0,0 +1,80 @@
(() => {
const config = {
platform: 'xianyu',
platformName: '闲鱼',
fillAfterUploadSettled: true,
fillBeforeUpload: true,
refillAfterUpload: true,
fillDelay: 500,
uploadSettleTimeout: 30000,
fallbackContentToTitle: true,
allowNoAssets: true,
continueAfterCategoryFailure: true,
videoModeLabels: [],
imageModeLabels: [],
prepareClickSteps: [],
prepareTextChoices: ['Bug修改次数', '1-5天', '元/次'],
blockingNotice: {
messageText: '高级版已过期',
dismissText: '再想想'
},
category: {
historySelectors: ['.release-history'],
inputSelectors: [
'.channelCat input[placeholder="请输入关键词"]',
'.channelCat input',
'input[placeholder="请输入关键词"]'
],
optionSelectors: [
'.el-cascader-node__label',
'.el-popper [role="menuitem"]',
'.el-popper li',
'.custom-cascader [class*="item"]'
],
searchResultSelectors: [
'[role="option"]',
'.el-select-dropdown__item',
'.el-cascader__suggestion-item',
'[class*="cascader"] [class*="suggest"]',
'[class*="category"] [class*="item"]'
],
resultText: '家居/服务/IT技术/软件/程序/网站开发',
allowGlobalTextOption: true,
optionTimeout: 1800,
path: ['家居', '服务', 'IT技术', '软件', '程序', '网站开发']
},
uploadSelectors: [
'input[type="file"][accept*="image"]',
'input[type="file"][multiple]',
'input[type="file"]'
],
videoInputSelectors: [],
imageInputSelectors: [
'input[type="file"][accept*="image"]',
'input[type="file"][multiple]'
],
coverInputSelectors: [],
titleSelectors: [
'input[placeholder="请输入商品标题,最多允许输入30个汉字"]',
'input[placeholder*="商品标题"]',
'input[maxlength="30"]'
],
contentSelectors: [
'textarea[placeholder="请输入商品描述"]',
'textarea[placeholder*="商品描述"]',
'[contenteditable="true"][data-placeholder*="商品描述"]'
],
progressSelectors: ['[class*="progress"]', '.el-progress', '[role="progressbar"]'],
successTexts: [/^发布成功[!!。]?$/, /^商品发布成功[!!。]?$/, /^提交成功[!!。]?$/],
failureTexts: [/^发布失败/, /^提交失败/, /^商品发布失败/]
}
chrome.runtime.onMessage.addListener(message => {
if (message && message.type === 'RUN_PUBLISH') {
globalThis.YidaimaPublisher.run(config, message.payload)
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
globalThis.YidaimaPublisher.watch(config, message.payload)
}
})
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
})()

View File

@@ -0,0 +1,44 @@
(() => {
const config = {
platform: 'xiaohongshu',
platformName: '小红书',
preserveContentLineBreaks: true,
videoModeLabels: ['上传视频', '视频笔记'],
imageModeLabels: ['上传图文', '图文笔记'],
uploadSelectors: [
'input[type="file"][accept*="video"]',
'input[type="file"][accept*="image"]',
'input[type="file"]'
],
videoInputSelectors: ['input[type="file"][accept*="video"]'],
imageInputSelectors: ['input[type="file"][accept*="image"]'],
coverInputSelectors: [
'[class*="cover"] input[type="file"][accept*="image"]',
'[class*="poster"] input[type="file"][accept*="image"]'
],
titleSelectors: [
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'[class*="title"] input'
],
contentSelectors: [
'[contenteditable="true"][data-placeholder*="正文"]',
'[contenteditable="true"][data-placeholder*="描述"]',
'textarea[placeholder*="正文"]',
'textarea[placeholder*="描述"]',
'[class*="editor"] [contenteditable="true"]'
],
progressSelectors: ['[class*="progress"]', '[role="progressbar"]'],
successTexts: [/^发布成功[!!。]?$/, /^笔记发布成功[!!。]?$/],
failureTexts: [/^发布失败/, /^笔记发布失败/]
}
chrome.runtime.onMessage.addListener(message => {
if (message && message.type === 'RUN_PUBLISH') {
globalThis.YidaimaPublisher.run(config, message.payload)
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
globalThis.YidaimaPublisher.watch(config, message.payload)
}
})
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
})()

View File

@@ -0,0 +1,72 @@
{
"manifest_version": 3,
"name": "易代码多平台发布助手",
"version": "1.1.17",
"description": "从易代码后台读取发布任务,为小红书、抖音、视频号、B站和闲鱼填写发布资料,最终发布由用户手动确认。",
"permissions": [
"storage",
"tabs",
"scripting",
"alarms",
"sidePanel"
],
"host_permissions": [
"https://feast.yidaima.cn/*",
"http://localhost/*",
"http://127.0.0.1/*",
"https://creator.xiaohongshu.com/*",
"https://creator.douyin.com/*",
"https://channels.weixin.qq.com/*",
"https://member.bilibili.com/*",
"https://goofish.pro/*",
"https://www.goofish.pro/*"
],
"background": {
"service_worker": "background/service-worker.js"
},
"action": {
"default_title": "打开多平台发布助手"
},
"side_panel": {
"default_path": "sidepanel/panel.html"
},
"content_scripts": [
{
"matches": [
"https://feast.yidaima.cn/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"js": ["content/admin-bridge.js"],
"run_at": "document_start"
},
{
"matches": ["https://creator.xiaohongshu.com/*"],
"js": ["content/platform-common.js", "content/xiaohongshu.js"],
"run_at": "document_idle"
},
{
"matches": ["https://creator.douyin.com/*"],
"js": ["content/platform-common.js", "content/douyin.js"],
"run_at": "document_idle"
},
{
"matches": ["https://channels.weixin.qq.com/*"],
"js": ["content/platform-common.js", "content/wechat-channels.js"],
"run_at": "document_idle"
},
{
"matches": ["https://member.bilibili.com/*"],
"js": ["content/platform-common.js", "content/bilibili.js"],
"run_at": "document_idle"
},
{
"matches": [
"https://goofish.pro/*",
"https://www.goofish.pro/*"
],
"js": ["content/platform-common.js", "content/xianyu.js"],
"run_at": "document_idle"
}
]
}

View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>易代码多平台发布助手</title>
<style>
body { width: 300px; margin: 0; color: #253044; font-family: Arial, "Microsoft YaHei", sans-serif; background: #f6f8fb; }
.header { padding: 18px 18px 14px; color: #fff; background: linear-gradient(135deg, #1677ff, #6558f5); }
.header h1 { margin: 0 0 5px; font-size: 17px; }
.header p { margin: 0; opacity: .85; font-size: 12px; }
.content { padding: 15px 18px 18px; }
.status { display: flex; align-items: center; padding: 11px; background: #fff; border: 1px solid #e8ebf0; border-radius: 8px; }
.dot { width: 9px; height: 9px; margin-right: 9px; background: #38b26d; border-radius: 50%; box-shadow: 0 0 0 4px #e8f8ef; }
.hint { margin: 13px 0 0; color: #697386; font-size: 12px; line-height: 1.65; }
.version { margin-top: 12px; color: #a0a7b2; font-size: 11px; text-align: right; }
</style>
</head>
<body>
<div class="header">
<h1>多平台发布助手</h1>
<p>小红书 · 抖音 · 视频号 · B站 · 闲鱼</p>
</div>
<div class="content">
<div class="status"><span class="dot"></span><span>扩展已启用</span></div>
<p class="hint">请在易代码后台打开“发布管理”。扩展会上传并填写资料,但不会点击平台的最终发布按钮。</p>
<div id="version" class="version"></div>
</div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
document.getElementById('version').textContent = '版本 ' + chrome.runtime.getManifest().version

View File

@@ -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; }

View File

@@ -0,0 +1,50 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>多平台发布助手</title>
<link rel="stylesheet" href="panel.css">
</head>
<body>
<header class="panel-header">
<div>
<h1><span class="header-mark">✓</span> 多平台发布助手</h1>
<p>点击平台名称定位页面,点击刷新图标重新填写</p>
</div>
<button id="refresh-list" class="header-action" type="button" title="刷新任务列表" aria-label="刷新任务列表">↻</button>
</header>
<main>
<div id="notice" class="notice" hidden></div>
<div id="task-list" class="task-list"></div>
<section id="empty-state" class="empty-state" hidden>
<div class="empty-icon">⇧</div>
<strong>暂无发布任务</strong>
<p>请先在南音工作室系统的“发布管理”中创建任务。</p>
</section>
</main>
<footer>
<span id="connection-dot" class="connection-dot"></span>
<span>扩展已连接</span>
<span id="version" class="version"></span>
</footer>
<template id="task-template">
<article class="task-card">
<button class="task-main" type="button">
<span class="platform-icon"></span>
<span class="task-copy">
<strong class="platform-name"></strong>
<small class="task-title"></small>
<span class="status-line"><i></i><span></span></span>
</span>
</button>
<button class="retry-button" type="button" title="重新打开并重试" aria-label="重新打开并重试">↻</button>
</article>
</template>
<script src="panel.js"></script>
</body>
</html>

View File

@@ -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()

View File

@@ -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), /不能重试/)
})

68
docs/platform-publish.md Normal file
View File

@@ -0,0 +1,68 @@
# 多平台发布管理部署与验证
## 部署顺序
1. 在业务数据库执行 `sql/platform_publish.sql`,创建发布任务表和“发布管理”菜单。
2. 重新构建并部署后端与 `ruoyi-ui`。
3. 在 Chrome 中加载 `browser-extension/multi-platform-publisher` 目录。
4. 分别登录小红书、抖音、视频号、B站创作者中心和闲管家卖家工作台。
5. 重新进入后台“工作室运营 → 发布管理”,确认右上角显示“发布扩展已连接”。
非管理员角色还需要在角色管理中勾选新菜单及“发布资料查询”“创建发布任务”权限。
## 使用流程
1. 在项目所在行点击“发布小红书”“发布抖音”“发布视频号”“发布B站视频”或“发布闲鱼商品”。
2. 检查后台自动带出的标题和模板文案,并手动选择本次发布使用的图片、视频或封面。
3. 点击“交给扩展填写”。
4. 扩展打开对应平台并依次上传、填写。
5. 后台状态显示“待手动发布”后,在平台页面检查全部资料。
6. 用户手动点击平台最终发布按钮。
7. 扩展观察到平台新增的明确成功提示后,将后台状态更新为“发布成功”。
### 多平台发布
1. 在项目名称下点击“多平台发布”,勾选本次需要的平台。
2. 视频由抖音、视频号和B站共用;图片由小红书和闲鱼共用;各平台标题、文案和标签可分别修改。
3. 后台通过一个事务批量创建任务,不会出现只创建部分平台任务的情况。
4. 扩展按小红书、抖音、视频号、B站、闲鱼的顺序执行;当前平台到达“待手动发布”、失败、需要登录或页面被关闭后,再继续下一个平台。
5. 所有平台的最终发布按钮仍由用户手动确认。
6. 视频号批次等待上传状态最多30秒;任一平台超过4分钟没有返回状态时会标记超时并继续后续平台,避免整批任务停住。
### 发布文案模板规则
- 发布小红书时匹配“小红书”分组,发布抖音时匹配“抖音”分组,发布视频号时匹配“视频号”分组,发布B站时匹配“B站”分组,发布闲鱼时匹配“闲鱼”分组。
- 也支持“抖音、视频号、小红书”这类包含平台名称的共享分组。
- 平台专用的精确分组优先于共享分组。
- 匹配分组只有一个启用模板时直接使用;有多个启用模板时随机选择一个。
- 模板中的项目变量由后端渲染后自动写入发布文案,弹窗会显示本次选择的分组和模板名称。
## 安全边界
- 后台页面不会向扩展提供后台登录 Token、Cookie 或平台密码。
- 扩展用随机短期凭证读取单个发布任务,凭证默认 4 小时失效。
- 素材下载地址由后端根据已保存任务生成,网页不能要求扩展访问任意脚本或选择器。
- 扩展只切换“图文/视频”类型标签、设置上传控件和填写文本,不扫描或点击普通发布按钮。
- B站扩展会上传视频并填写标题、简介和标签文案,投稿分区等选项保留给用户检查或手动选择。
- 闲鱼发布仅通过扩展操作闲管家网页表单,不调用闲管家开放接口,也不读取或保存闲管家 Cookie。
- 闲鱼有历史分类快捷项时优先使用;没有时固定选择“家居/服务/IT技术/软件/程序/网站开发”,且不填写售价、原价和库存。
- 扫码、验证码和安全校验必须由用户处理。
## 状态说明
- `PENDING`:等待扩展接收。
- `OPENING`:正在打开创作者中心。
- `LOGIN_REQUIRED`:平台尚未登录或发布表单不可见。
- `DOWNLOADING`:读取后台素材。
- `UPLOADING`:素材已交给平台上传。
- `FILLING`:填写标题、文案和标签。
- `READY_TO_PUBLISH`:填写完成,等待用户手动发布。
- `SUCCEEDED`:观察到平台明确的发布成功提示。
- `FAILED`:关键控件缺失、素材读取失败或平台明确提示失败。
- `CANCELLED`:任务未完成时平台标签页被关闭。
## 平台适配验证
五个平台的发布页面会不定期改版。每次正式部署前,都应使用测试账号真实验证上传控件、标题控件、文案控件、商品字段和上传进度。未经过登录账号实测的平台不能标记为“已完成适配”。
扩展没有最终发布动作。检索扩展内容脚本中的 `.click()` 时,只应看到发布类型标签切换逻辑。

48
docs/point-task-setup.md Normal file
View File

@@ -0,0 +1,48 @@
# 积分任务中心上线说明
## 1. 初始化数据库
在目标业务数据库执行:
```text
sql/point_task.sql
```
脚本会创建任务、提交、附件、审核日志表,为积分流水增加业务幂等字段,并添加“积分任务”和“任务审核”后台菜单。
## 2. 七牛云配置
确认 `ruoyi-admin/src/main/resources/qiniu.properties` 中以下配置与七牛空间一致:
```properties
qiniu.bucket=blog
qiniu.zone=zone0
qiniu.domain-of-bucket=img.yidaima.cn
qiniu.upload-host=https://upload.qiniup.com
```
任务截图使用客户端直传。后端签发10分钟有效、绑定单个对象Key、限制5MB且仅允许 JPG/PNG/WebP 的上传凭证。
上传凭证接口按IP限制每分钟最多20次;每个用户最多保留9张未提交截图,每天最多申请30张截图凭证。
后端每天凌晨 03:30 自动清理超过24小时仍未绑定任务的七牛临时截图和数据库临时记录。
## 3. 微信公众平台合法域名
在小程序后台配置:
- `uploadFile` 合法域名:`https://upload.qiniup.com`
- `downloadFile` 合法域名:七牛图片域名,例如 `https://img.yidaima.cn`
- `request` 合法域名:现有业务接口域名
如果七牛空间区域发生变化,请同步修改 `qiniu.upload-host` 和微信小程序合法域名。
## 4. 发布顺序
1. 执行数据库脚本。
2. 发布 RuoYi 后端。
3. 发布 RuoYi 管理端。
4. 在后台创建一次性或每日任务。
5. 发布小程序前端。
未执行数据库脚本前,不要调用新的任务接口。

90
docs/virtual-pay-setup.md Normal file
View File

@@ -0,0 +1,90 @@
# 资源直购虚拟支付上线配置
本项目使用微信小程序虚拟支付的 `short_series_goods`(道具直购)模式,不再提供人民币充值积分功能。
## 1. 执行数据库迁移
首次部署依次执行:
1. `sql/virtual_pay_resource.sql`
2. `sql/virtual_pay_resource_specs.sql`
3. `sql/virtual_pay_order_guard.sql`
第二个脚本把每条资源下载项改造成可单独购买的规格,第三个脚本阻止同一用户对同一规格同时产生多笔待支付或已支付订单。已经执行前两个脚本的环境只需补执行第三个脚本。
脚本会:
- 为资源增加分单位价格 `price_fen`;
- 为每条资源下载项增加规格价格、状态和排序;
- 创建价格档位表 `app_virtual_product`;
- 创建虚拟支付订单表 `app_virtual_order`;
- 创建资源访问权益表 `app_resource_entitlement`;
- 为待支付/已支付订单增加数据库唯一防重键;
- 将已有 `is_ad=3` 资源的 `ad_number`(元)迁移为 `price_fen`(分)。
- 旧订单与权益保留整项解锁能力,新订单按下载项规格解锁。
## 2. 配置虚拟支付环境变量
```text
WX_VIRTUAL_PAY_ENABLED=true
WX_VIRTUAL_PAY_OFFER_ID=微信虚拟支付OfferId
WX_VIRTUAL_PAY_APP_KEY=微信虚拟支付现网AppKey
WX_VIRTUAL_PAY_ENV=0
WX_VIRTUAL_PAY_CALLBACK_TOKEN=CHANGE_ME_TO_A_RANDOM_SECRET
```
AppKey 和小程序 Secret 不应提交到 Git,生产环境应由部署平台注入。
未设置这些环境变量时,虚拟支付默认关闭。
## 3. 配置价格档位道具
在微信公众平台的虚拟支付后台按价格创建并发布道具,例如:
| 价格 | productId 示例 |
| --- | --- |
| 1 元 | `resource_100` |
| 5 元 | `resource_500` |
| 10 元 | `resource_1000` |
执行 `sql/virtual_product_menu.sql` 并重新登录若依后台,然后在“虚拟道具管理”中新增或维护对应记录。微信道具 ID 必须与微信公众平台保持完全一致,价格以“分”为单位。
然后在若依后台编辑付费资源:
- 获取方式选择“付费”;
- 在“资源规格”中按版本从低到高填写排序,例如源码版 `1`、文档版 `2`、部署版 `3`;
- 高排序版本自动包含所有低排序版本,且版本价格必须随排序递增;
- 为每个版本填写价格,例如 5 元填写 `500`;
- 规格价格必须存在于 `app_virtual_product` 的已启用价格档位中,系统会自动匹配对应的 `productId`。
- 所有可能产生的升级差价也必须配置价格档位。例如版本价格分别为 `9900`、`19900`、`29900` 分,还需配置 `10000`、`20000` 分两个差价档位。
“虚拟道具管理”会校验“价格 -> productId”唯一映射;一个价格只能配置一个道具,一个 productId 也不能绑定多个价格。
用户升级时,服务端按“目标版本当前价格 - 已拥有最高版本当前价格”计算实付金额。前端展示金额仅供参考,签名和订单金额始终由服务端重新计算。高版本退款后,只撤销该高版本订单对应的权益,用户更早单独购买的低版本权益仍然保留。
## 4. 配置消息推送
在小程序后台配置:
```text
URL: https://你的域名/prod-api/app/virtual-pay/callback
Token: 与 WX_VIRTUAL_PAY_CALLBACK_TOKEN 相同
数据格式: JSON
消息加密方式: 明文模式
```
支付发货通知由服务端验签、校验 OpenID/订单/productId/价格后幂等发放资源权益。退款成功通知会撤销对应权益。
回调成功时接口返回 `{"ErrCode":0,"ErrMsg":"success"}`;业务校验或处理失败时返回非零错误码,微信会自动重试。
## 5. 上线检查
1. 后端启动时确认 `WX_VIRTUAL_PAY_ENABLED=true`。
2. 确认道具已审核发布并等待配置生效。
3. 用一条最低价格资源完成真机支付。
4. 检查 `app_virtual_order.status=1` 和 `app_resource_entitlement.status=1`。
5. 重新进入资源详情,确认下载链接只对购买用户返回。
## 6. 启用后台管理
执行 `sql/virtual_product_menu.sql` 和 `sql/virtual_pay_order_menu.sql`,然后重新登录若依后台。在原“支付订单”菜单的同级位置会出现“虚拟道具管理”和“虚拟支付订单”。道具页面支持价格档位的新增、修改、停用、删除和导出;订单页面支持按订单号、微信交易号、用户、资源、状态和创建时间查询,并可查看详情或导出 Excel。
虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。

27
docs/wechat-draft.md Normal file
View File

@@ -0,0 +1,27 @@
# 公众号草稿箱集成部署
## 部署
1. 执行 `sql/wechat_draft.sql`。
2. 为后端设置至少 16 位的环境变量 `WECHAT_MP_SECRET_KEY`。该值用于 AES-GCM 加密 AppSecret,上线后不要随意更换。
3. 将后端服务器的固定出口 IP 添加到微信公众平台 IP 白名单。
4. 在角色管理中授予“公众号草稿”菜单及相关按钮权限。
5. 打开“公众号草稿 → 公众号账号”,录入 AppID/AppSecret 后先执行“测试”。
Spring Boot 也支持使用配置项 `wechat.mp.secret-key`,生产环境建议始终使用环境变量。
## 使用
1. 进入“源码管理”,在项目行点击“公众号草稿”。
2. 选择公众号、文案模板、排版主题(经典/优雅/简洁)、字号(14~18px)和主题色,修改 Markdown 后点击“刷新预览”。
3. 确认后点击“推送到草稿箱”。后端会下载正文图片,转存到微信图床,生成封面并创建草稿。
4. 可在“公众号草稿 → 草稿推送记录”查看 MediaID、错误信息或重试失败任务。
内置“经典、优雅、简洁”三套排版主题,以及“更小、稍小、推荐、稍大、更大”五档字号。主题结构与视觉规则参考 `doocs/md`,后端会转换为微信公众号可用的内联样式 HTML。
## 限制
- 正文图片会统一转换为 JPEG 并压缩至 1MB 以内。
- 封面优先使用正文第一张图片,并压缩后上传为永久缩略图素材;正文没有图片时使用现有 `CoverGenerator` 生成项目文字封面。
- 当 `draft/add` 已返回 MediaID,但 `draft/get` 二次校验失败时,记录仍标记为成功并保留警告,避免重试产生重复草稿。
- 系统只推送到草稿箱,不会自动发表。

View File

@@ -2,6 +2,8 @@ package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppBlogArticle;
import com.ruoyi.app.mapper.AppBlogArticleMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.service.ImageUrlService;
import com.ruoyi.app.service.IAppBlogArticleService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
@@ -18,7 +20,9 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 文章Controller
@@ -32,9 +36,15 @@ public class AppBlogArticleController extends BaseController {
@Autowired
private IAppBlogArticleService appBlogArticleService;
@Autowired
private ImageUrlService imageUrlService;
@Resource
private AppBlogArticleMapper appBlogArticleMapper;
@Resource
private AppResourceMapper appResourceMapper;
@Resource
private ITtFileService fileService;
@@ -45,6 +55,7 @@ public class AppBlogArticleController extends BaseController {
public TableDataInfo list(AppBlogArticle appBlogArticle) {
startPage();
List<AppBlogArticle> list = appBlogArticleService.selectAppBlogArticleList(appBlogArticle);
imageUrlService.decorateArticles(list);
return getDataTable(list);
}
@@ -72,6 +83,7 @@ public class AppBlogArticleController extends BaseController {
@GetMapping(value = "/app/{id}")
public AjaxResult appGetInfo(@PathVariable("id") Long id) {
AppBlogArticle appBlogArticle = appBlogArticleService.selectAppBlogArticleById(id);
imageUrlService.decorateArticle(appBlogArticle);
List<String> list = new ArrayList<>();
List<TtFile> picList = fileService.selectTtFileByCodeName(appBlogArticle.getTitle());
for (TtFile ttFile : picList) {
@@ -81,6 +93,19 @@ public class AppBlogArticleController extends BaseController {
return success(appBlogArticle);
}
/**
* 获取首页四个分类的可见内容数量
*/
@GetMapping(value = "/app/homeCategoryCounts")
public AjaxResult homeCategoryCounts() {
Map<String, Long> counts = new LinkedHashMap<>();
counts.put("highQuality", appBlogArticleMapper.countByArticleType(7L, 1L));
counts.put("resource", appResourceMapper.countByIsShow(1L));
counts.put("integral", appBlogArticleMapper.countByArticleType(4L, 1L));
counts.put("free", appBlogArticleMapper.countByArticleType(8L, 1L));
return success(counts);
}
/**
* 新增文章
*/

View File

@@ -0,0 +1,39 @@
package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppContactPage;
import com.ruoyi.app.service.IAppContactService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
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/contact")
public class AppContactController extends BaseController
{
@Autowired
private IAppContactService contactService;
@PreAuthorize("@ss.hasPermi('app:contact:query')")
@GetMapping
public AjaxResult getInfo()
{
return success(contactService.selectAdminContactPage());
}
@PreAuthorize("@ss.hasPermi('app:contact:edit')")
@Log(title = "联系页面配置", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody AppContactPage page)
{
page.setUpdateBy(getUsername());
return toAjax(contactService.updateContactPage(page));
}
}

View File

@@ -0,0 +1,83 @@
package com.ruoyi.web.controller.app;
import java.util.List;
import com.ruoyi.app.domain.AppCustomRequest;
import com.ruoyi.app.service.IAppCustomRequestService;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
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;
/**
* 项目定制需求Controller。
*/
@RestController
@RequestMapping("/app/customRequest")
public class AppCustomRequestController extends BaseController
{
@Autowired
private IAppCustomRequestService customRequestService;
@PreAuthorize("isAuthenticated()")
@Log(title = "项目定制需求", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult submit(@RequestBody AppCustomRequest request)
{
return success(customRequestService.submitCustomRequest(request, getUserId()));
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/my")
public TableDataInfo myList()
{
startPage();
return getDataTable(customRequestService.selectMyCustomRequestList(getUserId()));
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/my/{id}")
public AjaxResult myDetail(@PathVariable Long id)
{
AppCustomRequest request = customRequestService.selectAppCustomRequestById(id);
if (request == null || !getUserId().equals(request.getUserId()))
{
return error("定制需求不存在");
}
return success(request);
}
@PreAuthorize("@ss.hasPermi('app:customRequest:list')")
@GetMapping("/list")
public TableDataInfo list(AppCustomRequest request)
{
startPage();
List<AppCustomRequest> list = customRequestService.selectAppCustomRequestList(request);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('app:customRequest:query')")
@GetMapping("/{id}")
public AjaxResult detail(@PathVariable Long id)
{
return success(customRequestService.selectAppCustomRequestById(id));
}
@PreAuthorize("@ss.hasPermi('app:customRequest:edit')")
@Log(title = "项目定制需求", businessType = BusinessType.UPDATE)
@PutMapping("/status")
public AjaxResult updateStatus(@RequestBody AppCustomRequest request)
{
request.setUpdateBy(getUsername());
return toAjax(customRequestService.updateCustomRequestStatus(request));
}
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.web.controller.app;
import com.ruoyi.app.service.IAppDashboardService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 首页业务指标接口。
*/
@RestController
@RequestMapping("/app/dashboard")
public class AppDashboardController extends BaseController
{
@Autowired
private IAppDashboardService appDashboardService;
@PreAuthorize("@ss.hasAnyPermi('app:virtualOrder:list,app:order:list,system:user:list')")
@GetMapping("/summary")
public AjaxResult summary()
{
return success(appDashboardService.selectDashboardSummary());
}
}

View File

@@ -4,6 +4,8 @@ import com.ruoyi.app.domain.AppIntegralRecord;
import com.ruoyi.app.domain.AppLotteryGoods;
import com.ruoyi.app.domain.AppLotteryLog;
import com.ruoyi.app.mapper.AppIntegralRecordMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.service.IAppIntegralRecordService;
import com.ruoyi.app.service.IAppLotteryGoodsService;
import com.ruoyi.app.service.IAppLotteryLogService;
@@ -13,6 +15,7 @@ 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 com.ruoyi.framework.web.service.PermissionService;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysConfigService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -42,6 +45,10 @@ public class AppIntegralRecordController extends BaseController
private AppIntegralRecordMapper appIntegralRecordMapper;
@Resource
private SysUserMapper sysUserMapper;
@Resource
private AppResourceMapper appResourceMapper;
@Resource
private PermissionService permissionService;
@Autowired
private IAppLotteryGoodsService appLotteryGoodsService;
@Autowired
@@ -50,14 +57,30 @@ public class AppIntegralRecordController extends BaseController
/**
* 查询积分记录列表
*/
@PreAuthorize("isAuthenticated()")
@GetMapping("/list")
public TableDataInfo list(AppIntegralRecord appIntegralRecord)
{
startPage();
if (!permissionService.hasPermi("app:appIntegra:list"))
{
return getDataTable(appIntegralRecordMapper.selectMyIntegralRecordList(getUserId()));
}
List<AppIntegralRecord> list = appIntegralRecordService.selectAppIntegralRecordList(appIntegralRecord);
return getDataTable(list);
}
/**
* 小程序“积分记录”,只查询当前登录用户的积分增减,不包含现金购买记录。
*/
@PreAuthorize("isAuthenticated()")
@GetMapping("/my")
public TableDataInfo myList()
{
startPage();
return getDataTable(appIntegralRecordMapper.selectMyIntegralRecordList(getUserId()));
}
/**
* 导出积分记录列表
*/
@@ -187,19 +210,38 @@ public class AppIntegralRecordController extends BaseController
* 积分记录,增减用户积分通用
*/
@Transactional
@PreAuthorize("isAuthenticated()")
@PostMapping("/resource")
public AjaxResult resource(@RequestBody AppIntegralRecord appIntegralRecord)
{
Date date = new Date();
appIntegralRecord.setIntegralTime(date);
appIntegralRecordService.insertAppIntegralRecord(appIntegralRecord);
if (appIntegralRecord.getIsAdd() == 0){
sysUserMapper.addIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId());
}else {
sysUserMapper.delIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId());
Long userId = getUserId();
AppResource resource = appResourceMapper.selectAppResourceById(appIntegralRecord.getResourceId());
if (resource == null || resource.getIsAd() == null || resource.getIsAd() != 2L
|| resource.getAdNumber() == null || resource.getAdNumber() <= 0)
{
return error("该资源不支持积分兑换");
}
return toAjax(1);
AppIntegralRecord exists = new AppIntegralRecord();
exists.setUserId(userId);
exists.setResourceId(resource.getId());
if (appIntegralRecordMapper.selectAppIntegralRecordCount(exists) > 0)
{
return success("资源已解锁");
}
int points = Math.toIntExact(resource.getAdNumber());
if (sysUserMapper.delIntegralByUserId(points, userId) != 1)
{
return error("积分不足");
}
appIntegralRecord.setSource("资源兑换");
appIntegralRecord.setIsAdd(1L);
appIntegralRecord.setIntegralNumber(resource.getAdNumber());
appIntegralRecord.setUserId(userId);
appIntegralRecord.setIntegralTime(new Date());
appIntegralRecordService.insertAppIntegralRecord(appIntegralRecord);
return success("兑换成功");
}
/**

View File

@@ -1,17 +1,11 @@
package com.ruoyi.web.controller.app;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -101,4 +95,15 @@ public class AppPayOrderController extends BaseController
{
return toAjax(appPayOrderService.deleteAppPayOrderByIds(ids));
}
/**
* 查询每日新增支付金额统计
*/
@PreAuthorize("@ss.hasPermi('app:order:list')")
@GetMapping("/dailyPayAmount")
public AjaxResult dailyPayAmount(@RequestParam(defaultValue = "30") Integer days)
{
List<Map<String, Object>> list = appPayOrderService.selectDailyPayAmount(days);
return AjaxResult.success(list);
}
}

View File

@@ -0,0 +1,267 @@
package com.ruoyi.web.controller.app;
import java.io.UnsupportedEncodingException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.ruoyi.app.domain.AppPointTask;
import com.ruoyi.app.domain.AppPointTaskAttachment;
import com.ruoyi.app.domain.AppPointTaskSubmission;
import com.ruoyi.app.domain.AppPointTaskUploadRequest;
import com.ruoyi.app.service.IAppPointTaskService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.annotation.RateLimiter;
import com.ruoyi.common.annotation.RepeatSubmit;
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.enums.LimitType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.web.qiniu.QiNiuConfig;
import com.ruoyi.web.qiniu.QiNiuUtil;
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/pointTask")
public class AppPointTaskController extends BaseController
{
private static final long MAX_FILE_SIZE = 5L * 1024L * 1024L;
private static final int UPLOAD_TOKEN_EXPIRES = 600;
@Autowired
private IAppPointTaskService pointTaskService;
@PreAuthorize("isAuthenticated()")
@GetMapping("/active")
public AjaxResult activeList()
{
return success(pointTaskService.selectActiveTaskList(getUserId()));
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/active/{id}")
public AjaxResult activeDetail(@PathVariable Long id)
{
return success(pointTaskService.selectActiveTaskDetail(id, getUserId()));
}
@PreAuthorize("isAuthenticated()")
@RateLimiter(time = 60, count = 20, limitType = LimitType.IP)
@PostMapping("/upload-token")
public AjaxResult uploadToken(@RequestBody AppPointTaskUploadRequest request)
{
validateUploadRequest(request);
Long userId = getUserId();
String extension = extensionOf(request.getFileName(), request.getContentType());
String key = "point-task/" + userId + "/"
+ LocalDate.now(ZoneId.of("Asia/Shanghai")).toString().substring(0, 7).replace("-", "")
+ "/" + IdUtils.fastSimpleUUID() + extension;
AppPointTaskAttachment attachment = new AppPointTaskAttachment();
attachment.setUserId(userId);
attachment.setObjectKey(key);
attachment.setOriginalName(request.getFileName());
attachment.setFileSize(request.getFileSize());
attachment.setFileType(request.getContentType());
pointTaskService.createTempAttachment(attachment);
Map<String, Object> data = new HashMap<>();
data.put("uploadToken", QiNiuUtil.createTaskImageUploadToken(key, UPLOAD_TOKEN_EXPIRES, MAX_FILE_SIZE));
data.put("objectKey", key);
data.put("uploadHost", QiNiuConfig.getInstance().getUploadHost());
data.put("expiresIn", UPLOAD_TOKEN_EXPIRES);
return success(data);
}
@PreAuthorize("isAuthenticated()")
@Log(title = "积分任务提交", businessType = BusinessType.INSERT)
@RepeatSubmit(interval = 3000)
@PostMapping("/{taskId}/submit")
public AjaxResult submit(@PathVariable Long taskId, @RequestBody AppPointTaskSubmission request)
{
verifyUploadedFiles(request.getAttachmentKeys(), getUserId());
return success(pointTaskService.submitTask(taskId, request, getUserId()));
}
@PreAuthorize("isAuthenticated()")
@Log(title = "积分任务重新提交", businessType = BusinessType.UPDATE)
@RepeatSubmit(interval = 3000)
@PostMapping("/submission/{id}/resubmit")
public AjaxResult resubmit(@PathVariable Long id, @RequestBody AppPointTaskSubmission request)
{
verifyUploadedFiles(request.getAttachmentKeys(), getUserId());
return success(pointTaskService.resubmitTask(id, request, getUserId()));
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/submission/my")
public TableDataInfo myList()
{
startPage();
return getDataTable(pointTaskService.selectMySubmissionList(getUserId()));
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/submission/my/{id}")
public AjaxResult myDetail(@PathVariable Long id)
{
AppPointTaskSubmission submission = pointTaskService.selectMySubmissionDetail(id, getUserId());
populatePreviewUrls(submission);
return success(submission);
}
@PreAuthorize("@ss.hasPermi('app:pointTask:list')")
@GetMapping("/list")
public TableDataInfo list(AppPointTask task)
{
startPage();
return getDataTable(pointTaskService.selectAppPointTaskList(task));
}
@PreAuthorize("@ss.hasPermi('app:pointTask:query')")
@GetMapping("/{id}")
public AjaxResult detail(@PathVariable Long id)
{
return success(pointTaskService.selectAppPointTaskById(id));
}
@PreAuthorize("@ss.hasPermi('app:pointTask:add')")
@Log(title = "积分任务", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody AppPointTask task)
{
task.setCreateBy(getUsername());
task.setUpdateBy(getUsername());
return toAjax(pointTaskService.insertAppPointTask(task));
}
@PreAuthorize("@ss.hasPermi('app:pointTask:edit')")
@Log(title = "积分任务", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody AppPointTask task)
{
task.setUpdateBy(getUsername());
return toAjax(pointTaskService.updateAppPointTask(task));
}
@PreAuthorize("@ss.hasPermi('app:pointTask:remove')")
@Log(title = "积分任务", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(pointTaskService.deleteAppPointTaskByIds(ids));
}
@PreAuthorize("@ss.hasPermi('app:pointTaskReview:list')")
@GetMapping("/submission/list")
public TableDataInfo submissionList(AppPointTaskSubmission submission)
{
startPage();
return getDataTable(pointTaskService.selectSubmissionList(submission));
}
@PreAuthorize("@ss.hasPermi('app:pointTaskReview:query')")
@GetMapping("/submission/{id}")
public AjaxResult submissionDetail(@PathVariable Long id)
{
AppPointTaskSubmission submission = pointTaskService.selectSubmissionDetail(id);
if (submission == null)
{
return error("任务记录不存在");
}
populatePreviewUrls(submission);
return success(submission);
}
@PreAuthorize("@ss.hasPermi('app:pointTaskReview:edit')")
@Log(title = "积分任务审核通过", businessType = BusinessType.UPDATE)
@PostMapping("/submission/{id}/approve")
public AjaxResult approve(@PathVariable Long id)
{
return toAjax(pointTaskService.approveSubmission(id, getUsername()));
}
@PreAuthorize("@ss.hasPermi('app:pointTaskReview:edit')")
@Log(title = "积分任务审核驳回", businessType = BusinessType.UPDATE)
@PostMapping("/submission/{id}/reject")
public AjaxResult reject(@PathVariable Long id, @RequestBody AppPointTaskSubmission request)
{
return toAjax(pointTaskService.rejectSubmission(id, request.getRejectReason(), getUsername()));
}
@PreAuthorize("@ss.hasPermi('app:pointTaskReview:revoke')")
@Log(title = "积分任务撤销奖励", businessType = BusinessType.UPDATE)
@PostMapping("/submission/{id}/revoke")
public AjaxResult revoke(@PathVariable Long id, @RequestBody AppPointTaskSubmission request)
{
return toAjax(pointTaskService.revokeSubmission(id, request.getRejectReason(), getUsername()));
}
private void validateUploadRequest(AppPointTaskUploadRequest request)
{
if (request == null || StringUtils.isEmpty(request.getFileName()) || request.getFileSize() == null
|| request.getFileSize() <= 0 || request.getFileSize() > MAX_FILE_SIZE)
{
throw new ServiceException("截图大小不能超过5MB");
}
String contentType = StringUtils.trim(request.getContentType());
if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType) && !"image/webp".equals(contentType))
{
throw new ServiceException("仅支持JPG、PNG和WebP图片");
}
}
private String extensionOf(String fileName, String contentType)
{
if ("image/png".equals(contentType)) return ".png";
if ("image/webp".equals(contentType)) return ".webp";
return ".jpg";
}
private void verifyUploadedFiles(List<String> keys, Long userId)
{
if (keys == null || keys.isEmpty() || keys.size() > 3)
{
throw new ServiceException("请上传1至3张任务截图");
}
String prefix = "point-task/" + userId + "/";
for (String key : keys)
{
if (StringUtils.isEmpty(key) || !key.startsWith(prefix) || !QiNiuUtil.exists(key))
{
throw new ServiceException("截图上传未完成,请重新上传");
}
}
}
private void populatePreviewUrls(AppPointTaskSubmission submission)
{
if (submission == null || submission.getAttachments() == null) return;
for (AppPointTaskAttachment attachment : submission.getAttachments())
{
try
{
attachment.setPreviewUrl(QiNiuUtil.fileUrl(attachment.getObjectKey()));
}
catch (UnsupportedEncodingException ex)
{
attachment.setPreviewUrl(null);
}
}
}
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.service.ImageUrlService;
import com.ruoyi.app.service.IAppResourceService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
@@ -16,6 +17,9 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import org.springframework.security.core.Authentication;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 资源Controller
@@ -30,6 +34,9 @@ public class AppResourceController extends BaseController
@Autowired
private IAppResourceService appResourceService;
@Autowired
private ImageUrlService imageUrlService;
@Resource
private AppResourceMapper appResourceMapper;
@@ -41,6 +48,7 @@ public class AppResourceController extends BaseController
{
startPage();
List<AppResource> list = appResourceService.selectAppResourceList(appResource);
imageUrlService.decorateResources(list);
return getDataTable(list);
}
@@ -70,16 +78,28 @@ public class AppResourceController extends BaseController
@GetMapping(value = "/app/{id}")
public AjaxResult getInfoApp(@PathVariable("id") Long id)
{
return success(appResourceService.selectAppResourceById(id));
return success(imageUrlService.decorateResource(
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
}
/**
* 根据用户ID获取资源详细信息
*/
@GetMapping(value = "/app/user/{id}/{userId}")
public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long userId)
public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long ignoredUserId)
{
return success(appResourceMapper.selectAppResourceByIdAndUserId(id, userId));
return success(imageUrlService.decorateResource(
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
}
/**
* 小程序资源详情。用户身份只从 JWT 读取,不接收客户端 userId。
*/
@GetMapping(value = "/app/user/{id}")
public AjaxResult getResourceForCurrentUser(@PathVariable("id") Long id)
{
return success(imageUrlService.decorateResource(
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
}
/**
@@ -123,4 +143,14 @@ public class AppResourceController extends BaseController
{
return toAjax( appResourceMapper.lookAddNumber(appResource.getId()));
}
private Long currentUserId()
{
Authentication authentication = SecurityUtils.getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof LoginUser)
{
return ((LoginUser) authentication.getPrincipal()).getUserId();
}
return null;
}
}

View File

@@ -0,0 +1,78 @@
package com.ruoyi.web.controller.app;
import java.util.List;
import com.ruoyi.app.domain.AppTechStack;
import com.ruoyi.app.service.IAppTechStackService;
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 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/techStack")
public class AppTechStackController extends BaseController
{
@Autowired
private IAppTechStackService techStackService;
/** 小程序可选择的启用技术栈。 */
@GetMapping("/options")
public AjaxResult options()
{
return success(techStackService.selectEnabledAppTechStackList());
}
@PreAuthorize("@ss.hasPermi('app:techStack:list')")
@GetMapping("/list")
public TableDataInfo list(AppTechStack techStack)
{
startPage();
List<AppTechStack> list = techStackService.selectAppTechStackList(techStack);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('app:techStack:query')")
@GetMapping("/{id}")
public AjaxResult detail(@PathVariable Long id)
{
return success(techStackService.selectAppTechStackById(id));
}
@PreAuthorize("@ss.hasPermi('app:techStack:add')")
@Log(title = "定制技术栈", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody AppTechStack techStack)
{
techStack.setCreateBy(getUsername());
techStack.setUpdateBy(getUsername());
return toAjax(techStackService.insertAppTechStack(techStack));
}
@PreAuthorize("@ss.hasPermi('app:techStack:edit')")
@Log(title = "定制技术栈", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody AppTechStack techStack)
{
techStack.setUpdateBy(getUsername());
return toAjax(techStackService.updateAppTechStack(techStack));
}
@PreAuthorize("@ss.hasPermi('app:techStack:remove')")
@Log(title = "定制技术栈", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(techStackService.deleteAppTechStackByIds(ids));
}
}

View File

@@ -0,0 +1,59 @@
package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppVirtualOrder;
import com.ruoyi.app.service.IAppVirtualOrderService;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 虚拟支付订单管理接口。
*
* 支付订单由微信回调和主动查单流程维护,管理端仅提供只读查询和导出。
*/
@RestController
@RequestMapping("/app/virtualOrder")
public class AppVirtualOrderController extends BaseController
{
@Autowired
private IAppVirtualOrderService appVirtualOrderService;
@PreAuthorize("@ss.hasPermi('app:virtualOrder:list')")
@GetMapping("/list")
public TableDataInfo list(AppVirtualOrder order)
{
startPage();
List<AppVirtualOrder> list = appVirtualOrderService.selectAppVirtualOrderList(order);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('app:virtualOrder:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id)
{
return success(appVirtualOrderService.selectAppVirtualOrderById(id));
}
@PreAuthorize("@ss.hasPermi('app:virtualOrder:export')")
@Log(title = "虚拟支付订单", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, AppVirtualOrder order)
{
List<AppVirtualOrder> list = appVirtualOrderService.selectAppVirtualOrderList(order);
ExcelUtil<AppVirtualOrder> util = new ExcelUtil<>(AppVirtualOrder.class);
util.exportExcel(response, list, "虚拟支付订单数据");
}
}

View File

@@ -0,0 +1,125 @@
package com.ruoyi.web.controller.app;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.app.domain.request.CreateVirtualOrderRequest;
import com.ruoyi.app.service.IAppVirtualPayService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
@RestController
@RequestMapping("/app/virtual-pay")
public class AppVirtualPayController extends BaseController
{
private static final Logger log = LoggerFactory.getLogger(AppVirtualPayController.class);
private final IAppVirtualPayService virtualPayService;
private final ObjectMapper objectMapper;
public AppVirtualPayController(IAppVirtualPayService virtualPayService, ObjectMapper objectMapper)
{
this.virtualPayService = virtualPayService;
this.objectMapper = objectMapper;
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/resource-orders")
public AjaxResult createOrder(@Validated @RequestBody CreateVirtualOrderRequest request)
{
return success(virtualPayService.createOrder(request));
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/resource-orders")
public TableDataInfo listMyOrders()
{
startPage();
return getDataTable(virtualPayService.listMyOrders());
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/resource-orders/{orderNo}")
public AjaxResult queryOrder(@PathVariable String orderNo,
@RequestParam(defaultValue = "false") boolean sync)
{
return success(virtualPayService.queryOrder(orderNo, sync));
}
@PreAuthorize("isAuthenticated()")
@DeleteMapping("/resource-orders/{orderNo}")
public AjaxResult cancelOrder(@PathVariable String orderNo)
{
virtualPayService.cancelOrder(orderNo);
return success("订单已取消");
}
/**
* 在小程序后台保存消息推送配置时使用。
*/
@GetMapping(value = "/callback", produces = MediaType.TEXT_PLAIN_VALUE)
public String verifyCallback(@RequestParam String signature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestParam String echostr)
{
if (!virtualPayService.verifyCallbackSignature(signature, timestamp, nonce))
{
log.warn("微信虚拟支付回调 URL 验证失败");
return "";
}
return echostr;
}
/**
* 小程序消息推送请配置为 JSON 数据格式。
*/
@PostMapping("/callback")
public Map<String, Object> callback(@RequestParam String signature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestBody String body)
{
if (!virtualPayService.verifyCallbackSignature(signature, timestamp, nonce))
{
log.warn("拒绝签名无效的微信虚拟支付回调");
return callbackResponse(1, "invalid signature");
}
try
{
JsonNode json = objectMapper.readTree(body);
virtualPayService.handleCallback(json);
return callbackResponse(0, "success");
}
catch (Exception e)
{
// 返回非零 ErrCode 让微信重试,异常详情只记录在服务端日志中。
log.error("微信虚拟支付回调处理失败", e);
return callbackResponse(1, "callback failed");
}
}
private static Map<String, Object> callbackResponse(int code, String message)
{
Map<String, Object> response = new LinkedHashMap<>();
response.put("ErrCode", code);
response.put("ErrMsg", message);
return response;
}
}

View File

@@ -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<AppVirtualProduct> 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<AppVirtualProduct> list = virtualProductService.selectAppVirtualProductList(product);
ExcelUtil<AppVirtualProduct> util = new ExcelUtil<>(AppVirtualProduct.class);
util.exportExcel(response, list, "虚拟道具数据");
}
}

View File

@@ -2,9 +2,9 @@ package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.ShuiYinVo;
import com.ruoyi.app.mapper.AppPunlicMapper;
import com.ruoyi.app.service.IAppContactService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.system.service.ISysConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -21,20 +21,8 @@ public class PublicController extends BaseController {
AppPunlicMapper appPunlicMapper;
@Autowired
private ISysConfigService configService;
/**
* 获取微信openid信息
*/
@GetMapping(value = "/autoLoginWx/{code}")
public AjaxResult autoLoginWx(@PathVariable("code") String code)
{
HttpUtils httpUtils = new HttpUtils();
String appid = configService.selectConfigByKey("miniapp.wx.appId");
String secret = configService.selectConfigByKey("miniapp.wx.secret");
String param = "appid="+appid+"&secret="+secret+"&js_code="+code+"&grant_type=authorization_code";
String s = httpUtils.httpGet("https://api.weixin.qq.com/sns/jscode2session", param);
return success(s);
}
@Autowired
private IAppContactService contactService;
/**
* 去水印 key 免费申请联系作者微信:yimoziyuan666
* @return
@@ -43,7 +31,7 @@ public class PublicController extends BaseController {
private String delWatermarkKey;
@PostMapping(value = "/delSHuiYin")
public AjaxResult delSHuiYin(HttpServletRequest request, @RequestBody ShuiYinVo shuiYinVo) {
HttpUtils httpUtils = new HttpUtils();
com.ruoyi.common.utils.http.HttpUtils httpUtils = new com.ruoyi.common.utils.http.HttpUtils();
String key = configService.selectConfigByKey("miniapp.shuiyin.key");
String param = "key="+key+"&url="+shuiYinVo.getUrl();
String s = httpUtils.httpGet("https://api.emoboy.vip/api/shuiyin/delWatermark", param);
@@ -91,4 +79,11 @@ public class PublicController extends BaseController {
return map;
}
/** 获取小程序联系页面公开配置。 */
@GetMapping(value = "/contact-page")
public AjaxResult getContactPage()
{
return success(contactService.selectPublicContactPage());
}
}

View File

@@ -1,13 +1,12 @@
package com.ruoyi.web.controller.app;
import com.ruoyi.app.service.IAppPayOrderService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.wx.*;
import com.wechat.pay.java.service.payments.jsapi.model.PrepayWithRequestPaymentResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@@ -26,20 +25,6 @@ import java.io.IOException;
public class WxMiniappPayController extends BaseController {
@Autowired
private WxMiniappPayService wxMiniappPayService;
@Autowired
private IAppPayOrderService appPayOrderService;
/**
* 预支付订单/统一下单
* @param req
* @return
*/
@RequestMapping("/createOrder")
public Response<PrepayWithRequestPaymentResponse> createOrder(@Validated @RequestBody CreateOrderReq req) {
log.info("------预支付订单/统一下单------");
//微信小程序登录用户openid,用户标识 说明:用户在商户appid下的唯一标识。
return this.wxMiniappPayService.createOrder(req);
}
/**
* 支付回调
@@ -65,6 +50,7 @@ public class WxMiniappPayController extends BaseController {
* @return
*/
@PostMapping("/queryOrder")
@PreAuthorize("@ss.hasPermi('app:order:query')")
public Response queryOrder(@Validated @RequestBody QueryOrderReq req) {
log.info("------根据支付订单号查询订单------");
return this.wxMiniappPayService.queryOrder(req);
@@ -81,6 +67,7 @@ public class WxMiniappPayController extends BaseController {
* @return
*/
@PostMapping("/queryOrderByOutTradeNo")
@PreAuthorize("@ss.hasPermi('app:order:query')")
public Response queryOrderByOutTradeNo(@Validated @RequestBody QueryOrderReq req) {
log.info("------根据商户订单号查询订单------");
return this.wxMiniappPayService.queryOrderByOutTradeNo(req);
@@ -93,6 +80,7 @@ public class WxMiniappPayController extends BaseController {
*/
@PostMapping("/closeOrder")
@PreAuthorize("@ss.hasPermi('app:order:edit')")
public Response closeOrder(@Validated @RequestBody QueryOrderReq req) {
log.info("------微信小程序支付关闭订单------");
return this.wxMiniappPayService.closeOrder(req);
@@ -104,6 +92,7 @@ public class WxMiniappPayController extends BaseController {
* @return
*/
@PostMapping("/refund")
@PreAuthorize("@ss.hasPermi('app:order:refund')")
public Response refund(@Validated @RequestBody RefundOrderReq req) {
log.info("------微信支付退款------");
return this.wxMiniappPayService.refund(req);
@@ -115,6 +104,7 @@ public class WxMiniappPayController extends BaseController {
* @return
*/
@GetMapping("/queryByOutRefundNo")
@PreAuthorize("@ss.hasPermi('app:order:query')")
public Response queryByOutRefundNo(String outRefundNo) {
log.info("------微信支付查询单笔退款------");
return this.wxMiniappPayService.queryByOutRefundNo(outRefundNo);
@@ -134,4 +124,4 @@ public class WxMiniappPayController extends BaseController {
}
}

View File

@@ -94,21 +94,36 @@ public class CommonController {
@PostMapping("/qiniuUpload")
public AjaxResult qiniuUploadFile(MultipartFile file) throws Exception {
try {
// 上传并返回新文件名称
if (file == null || file.isEmpty()) {
return AjaxResult.error("上传文件不能为空");
}
String originalFilename = file.getOriginalFilename();
String fname = originalFilename.substring(0, originalFilename.lastIndexOf('.')) + "-" + UUID.randomUUID().toString().replace("-", "");
String fkey = "http://" + QiNiuConfig.getInstance().getDomainOfBucket() + "/" + fname;
String extension = "";
if (originalFilename != null && originalFilename.lastIndexOf('.') >= 0) {
extension = originalFilename.substring(originalFilename.lastIndexOf('.')).toLowerCase();
}
if (!extension.equals(".jpg") && !extension.equals(".jpeg")
&& !extension.equals(".png") && !extension.equals(".webp")) {
return AjaxResult.error("仅支持JPG、PNG和WebP图片");
}
String fname = originalFilename.substring(0, originalFilename.lastIndexOf('.'))
+ "-" + UUID.randomUUID().toString().replace("-", "");
String domain = QiNiuConfig.getInstance().getDomainOfBucket();
if (domain.startsWith("http://")) {
domain = "https://" + domain.substring("http://".length());
} else if (!domain.startsWith("https://")) {
domain = "https://" + domain;
}
String fkey = domain.replaceAll("/$", "") + "/" + fname;
if(!StringUtils.isNull(QiNiuConfig.getInstance().getPictureStyle()) && !QiNiuConfig.getInstance().getPictureStyle().equals("")){
fkey = fkey + "?" + QiNiuConfig.getInstance().getPictureStyle();
}
try {
QiNiuUtil.uploadMultipartFile(file, fname, false);
} catch (Exception e) {
e.printStackTrace();
if (!QiNiuUtil.uploadMultipartFile(file, fname, false)) {
return AjaxResult.error("七牛云上传失败,请检查七牛配置或网络");
}
AjaxResult ajax = AjaxResult.success();
ajax.put("url", fkey);
ajax.put("fileName", file.getOriginalFilename());
ajax.put("fileName", originalFilename);
return ajax;
} catch (Exception e) {
return AjaxResult.error(e.getMessage());

View File

@@ -16,6 +16,8 @@ import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.system.service.ISysMenuService;
import com.ruoyi.common.wx.WxCodeSession;
import com.ruoyi.common.wx.WxCodeSessionService;
import javax.servlet.http.HttpServletRequest;
@@ -36,6 +38,9 @@ public class SysLoginController
@Autowired
private SysPermissionService permissionService;
@Autowired
private WxCodeSessionService wxCodeSessionService;
/**
* 登录方法
*
@@ -55,15 +60,13 @@ public class SysLoginController
/**
* 微信openID登陆
* @param
* @return
* 使用微信临时 code 登录。OpenID 与 session_key 只在服务端获取。
*/
@PostMapping("/wxLogin")
public AjaxResult wxLogin(@RequestBody LoginBody loginBody) {
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.wxLogin(loginBody.getOpenId(),loginBody.getOldUserId());
WxCodeSession codeSession = wxCodeSessionService.exchange(loginBody.getCode());
String token = loginService.wxLogin(codeSession.getOpenId(), loginBody.getOldUserId());
ajax.put(Constants.TOKEN, token);
return ajax;
}

View File

@@ -290,4 +290,14 @@ public class SysUserController extends BaseController
ajax.put("user", sysUser);
return ajax;
}
/**
* 获取每天新增用户数量
*/
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/countByDay")
public AjaxResult countByDay()
{
return AjaxResult.success(userService.selectUserCountByDay());
}
}

View File

@@ -10,6 +10,7 @@ public class QiNiuConfig {
private String bucket;
private Zone zone;
private String domainOfBucket;
private String uploadHost;
private long expireInSeconds;
private String pictureStyle;
@@ -23,6 +24,7 @@ public class QiNiuConfig {
secretKey = prop.getProperty("qiniu.secret-key");
bucket = prop.getProperty("qiniu.bucket");
domainOfBucket = prop.getProperty("qiniu.domain-of-bucket");
uploadHost = prop.getProperty("qiniu.upload-host", "https://upload.qiniup.com");
expireInSeconds = Long.parseLong(prop.getProperty("qiniu.expire-in-seconds"));
pictureStyle = prop.getProperty("qiniu.picture-style");
String zoneName = prop.getProperty("qiniu.zone");
@@ -88,6 +90,14 @@ public class QiNiuConfig {
this.domainOfBucket = domainOfBucket;
}
public String getUploadHost() {
return uploadHost;
}
public void setUploadHost(String uploadHost) {
this.uploadHost = uploadHost;
}
public long getExpireInSeconds() {
return expireInSeconds;
}

View File

@@ -7,7 +7,9 @@ import com.qiniu.storage.BucketManager;
import com.qiniu.storage.Configuration;
import com.qiniu.storage.UploadManager;
import com.qiniu.storage.model.DefaultPutRet;
import com.qiniu.storage.model.FileInfo;
import com.qiniu.util.Auth;
import com.qiniu.util.StringMap;
import com.qiniu.util.UrlSafeBase64;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@@ -101,8 +103,19 @@ public class QiNiuUtil {
* @throws UnsupportedEncodingException
*/
public static String fileUrl(String fileName) throws UnsupportedEncodingException {
String encodedFileName = URLEncoder.encode(fileName, "utf-8");
String publicUrl = String.format("%s/%s", QiNiuConfig.getInstance().getDomainOfBucket(), encodedFileName);
String[] parts = fileName.split("/");
StringBuilder encoded = new StringBuilder();
for (String part : parts) {
if (encoded.length() > 0) {
encoded.append('/');
}
encoded.append(URLEncoder.encode(part, "utf-8").replace("+", "%20"));
}
String domain = QiNiuConfig.getInstance().getDomainOfBucket();
if (!domain.startsWith("http://") && !domain.startsWith("https://")) {
domain = "https://" + domain;
}
String publicUrl = String.format("%s/%s", domain.replaceAll("/$", ""), encoded);
Auth auth = getAuth();
long expireInSeconds = QiNiuConfig.getInstance().getExpireInSeconds();
if(-1 == expireInSeconds){
@@ -168,20 +181,46 @@ public class QiNiuUtil {
return auth;
}
/** 生成只允许上传指定任务截图的短期凭证。 */
public static String createTaskImageUploadToken(String key, int expiresSeconds, long fileSizeLimit) {
StringMap policy = new StringMap();
policy.put("insertOnly", 1);
policy.put("fsizeLimit", fileSizeLimit);
policy.put("mimeLimit", "image/jpeg;image/png;image/webp");
return getAuth().uploadToken(QiNiuConfig.getInstance().getBucket(), key, expiresSeconds, policy);
}
/** 判断七牛对象是否真实存在。 */
public static boolean exists(String key) {
Configuration cfg = new Configuration(QiNiuConfig.getInstance().getZone());
BucketManager bucketManager = new BucketManager(getAuth(), cfg);
try {
FileInfo info = bucketManager.stat(QiNiuConfig.getInstance().getBucket(), key);
return info != null && info.fsize > 0;
} catch (QiniuException ex) {
return false;
}
}
/**
* 删除文件
* @param key
*/
public static void delete(String key){
public static boolean delete(String key){
Configuration cfg = new Configuration(QiNiuConfig.getInstance().getZone());
Auth auth = getAuth();
BucketManager bucketManager = new BucketManager(auth, cfg);
try {
bucketManager.delete(QiNiuConfig.getInstance().getBucket(), key);
return true;
} catch (QiniuException ex) {
if (ex.code() == 612) {
return true;
}
//如果遇到异常,说明删除失败
System.err.println(ex.code());
System.err.println(ex.response.toString());
return false;
}
}
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.web.task;
import java.util.Date;
import java.util.List;
import com.ruoyi.app.domain.AppPointTaskAttachment;
import com.ruoyi.app.mapper.AppPointTaskSubmissionMapper;
import com.ruoyi.web.qiniu.QiNiuUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
/** 每天清理超过24小时仍未绑定任务的七牛临时截图。 */
@Configuration
@EnableScheduling
public class PointTaskAttachmentCleanupJob
{
private static final long TEMP_RETENTION_MILLIS = 24L * 60L * 60L * 1000L;
@Autowired
private AppPointTaskSubmissionMapper submissionMapper;
@Scheduled(cron = "0 30 3 * * ?")
public void cleanExpiredTaskImages()
{
Date beforeTime = new Date(System.currentTimeMillis() - TEMP_RETENTION_MILLIS);
List<AppPointTaskAttachment> attachments = submissionMapper.selectExpiredTempAttachments(beforeTime);
for (AppPointTaskAttachment attachment : attachments)
{
if (QiNiuUtil.delete(attachment.getObjectKey()))
{
submissionMapper.deleteTempAttachmentById(attachment.getId());
}
}
}
}

View File

@@ -9,8 +9,8 @@ ruoyi:
# 实例演示开关
demoEnabled: true
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
# profile: /home/upload
profilee: D:/ruoyi/uploadPath
profile: /home/upload
# profilee: D:/ruoyi/uploadPath
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
@@ -42,10 +42,10 @@ wx:
appid: wx17b46f75e762c184 # 微信小程序appid
secret: 1fa844dc33e70f7d813f24cd2af7678b # 微信小程序密钥
merchantId: 1704387455 # 商户号
privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径(测试环境)
publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径(测试环境)
# privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径(正式环境)
# publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径(正式环境)
# privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径(测试环境)
# publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径(测试环境)
privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径(正式环境)
publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径(正式环境)
publicKeyId: PUB_KEY_ID_0117043874552025011100188700000234
merchantSerialNumber: 743FBCB9F5DFD76104A468C6AC6EDD41268634A3 # 商户API证书序列号
apiV3Key: G7kL2mN8pQ4rT1vX9yZ3bC5dF6hJ0sW1 # 商户APIV3密钥
@@ -53,6 +53,19 @@ wx:
# refundNotifyUrl: http://www.yidaima.cn:6001/app/pay/refundNotify # 退款通知地址(测试环境)
payNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/payNotify # 支付通知地址(正式环境)
refundNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/refundNotify # 退款通知地址(正式环境)
# 虚拟支付配置。生产环境请通过环境变量注入,不要提交真实 AppKey。
virtual-pay:
enabled: ${WX_VIRTUAL_PAY_ENABLED:true}
offer-id: ${WX_VIRTUAL_PAY_OFFER_ID:1450602603}
app-key: ${WX_VIRTUAL_PAY_APP_KEY:iVRy8soX3JTHd8dWQxruDtOIDjrcIKYL}
env: ${WX_VIRTUAL_PAY_ENV:0}
callback-token: ${WX_VIRTUAL_PAY_CALLBACK_TOKEN:zSgMvGAa289XyYoWDnpc1gUUPEBQ2me9}
# 微信公众号草稿配置
wechat:
mp:
# AppSecret 数据库加密密钥;生产环境可通过同名环境变量覆盖,配置后请勿随意更换
secret-key: "${WECHAT_MP_SECRET_KEY:rW3KBMA+Hw6x8cfW9wXGP/hOlc+kOvZ8oG5YfR2rcB0=}"
# 日志配置
logging:
@@ -144,6 +157,13 @@ swagger:
# 请求前缀
pathMapping: /dev-api
# 博客网站文章同步配置
website-sync:
base-url: https://www.yidaima.cn/prod-api
api-key: 9628e0e5d85e541591a47a0e768fabddbf62c9d0fcc6c21da3a41ea65afd505c
connect-timeout: 5000
read-timeout: 30000
# 防止XSS攻击
xss:
# 过滤开关

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 日志存放路径 -->
<property name="log.path" value="D:/ruoyi/log" />
<!-- <property name="log.path" value="/home/ruoyi/logs" />-->
<!-- <property name="log.path" value="D:/ruoyi/log" />-->
<property name="log.path" value="/home/ruoyi/logs" />
<!-- 日志输出格式 -->
<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />

View File

@@ -3,6 +3,7 @@ qiniu.secret-key=SKfXj0r5zSv2uk54Bc7uh8g88y_00VkxXQpR3_jf
qiniu.bucket=blog
qiniu.zone=zone0
qiniu.domain-of-bucket=img.yidaima.cn
qiniu.upload-host=https://upload.qiniup.com
qiniu.expire-in-seconds=-1
qiniu.picture-style =
# qiniu.picture-style = imageView2/0/q/75|watermark/2/text/ZmVhc3Rjb2RpbmcuY24=/font/5a6L5L2T/fontsize/1600/fill/IzBERDBGNQ==/dissolve/100/gravity/Center/dx/10/dy/10
# qiniu.picture-style = imageView2/0/q/75|watermark/2/text/ZmVhc3Rjb2RpbmcuY24=/font/5a6L5L2T/fontsize/1600/fill/IzBERDBGNQ==/dissolve/100/gravity/Center/dx/10/dy/10

View File

@@ -0,0 +1,60 @@
package com.ruoyi.common.utils;
import java.util.regex.Pattern;
/**
* HTML工具类
*
* @author ruoyi
*/
public class HtmlUtils {
/**
* 将HTML内容转换为纯文本
* 移除所有HTML标签,将&nbsp;等转义字符转换为普通空格,合并多余空白符
*/
public static String htmlToText(String html) {
if (StringUtils.isEmpty(html)) {
return "";
}
String text = html;
// 移除script和style标签及其内容
text = Pattern.compile("<script[^>]*?>.*?</script>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(text).replaceAll("");
text = Pattern.compile("<style[^>]*?>.*?</style>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(text).replaceAll("");
// 移除HTML注释
text = Pattern.compile("<!--.*?-->", Pattern.DOTALL).matcher(text).replaceAll("");
// 移除所有HTML标签
text = Pattern.compile("<[^>]+>").matcher(text).replaceAll("");
// 转义字符转换
text = text.replaceAll("&nbsp;", " ");
text = text.replaceAll("&amp;", "&");
text = text.replaceAll("&lt;", "<");
text = text.replaceAll("&gt;", ">");
text = text.replaceAll("&quot;", "\"");
text = text.replaceAll("&apos;", "'");
text = text.replaceAll("&#39;", "'");
text = text.replaceAll("&mdash;", "\u2014");
text = text.replaceAll("&ndash;", "\u2013");
text = text.replaceAll("&hellip;", "\u2026");
text = text.replaceAll("&ldquo;", "\u201C");
text = text.replaceAll("&rdquo;", "\u201D");
text = text.replaceAll("&lsquo;", "\u2018");
text = text.replaceAll("&rsquo;", "\u2019");
text = text.replaceAll("\\r\\n", "\n");
text = text.replaceAll("\\r", "\n");
text = text.replaceAll("\\n\\n+", "\n\n");
// 合并每行首尾空白并移除全空行
String[] lines = text.split("\n");
StringBuilder sb = new StringBuilder();
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.length() > 0) {
if (sb.length() > 0) {
sb.append("\n");
}
sb.append(trimmed);
}
}
return sb.toString();
}
}

View File

@@ -19,6 +19,8 @@ public class CreateOrderReq {
private Long points;
private Long resourceId;
public Long getUserId() {
return userId;
}
@@ -42,4 +44,12 @@ public class CreateOrderReq {
public void setPoints(Long points) {
this.points = points;
}
public Long getResourceId() {
return resourceId;
}
public void setResourceId(Long resourceId) {
this.resourceId = resourceId;
}
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.common.wx;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 微信小程序虚拟支付配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "wx.miniapp.virtual-pay")
public class VirtualPayConfig
{
/** 是否启用虚拟支付。 */
private boolean enabled;
/** 虚拟支付 OfferId。 */
private String offerId;
/** 虚拟支付现网 AppKey。 */
private String appKey;
/** 0-正式环境,1-沙箱环境。 */
private int env = 0;
/** 小程序消息推送 Token,用于校验支付通知。 */
private String callbackToken;
}

View File

@@ -0,0 +1,26 @@
package com.ruoyi.common.wx;
/**
* 微信 code2Session 的服务端结果。该对象禁止返回给客户端。
*/
public class WxCodeSession
{
private final String openId;
private final String sessionKey;
public WxCodeSession(String openId, String sessionKey)
{
this.openId = openId;
this.sessionKey = sessionKey;
}
public String getOpenId()
{
return openId;
}
public String getSessionKey()
{
return sessionKey;
}
}

View File

@@ -0,0 +1,81 @@
package com.ruoyi.common.wx;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.exception.ServiceException;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* 仅在服务端使用小程序临时 code 换取 OpenID 与 session_key。
*/
@Service
public class WxCodeSessionService
{
private static final String CODE_TO_SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
private final WxPayConfig wxPayConfig;
private final ObjectMapper objectMapper;
private final OkHttpClient httpClient;
public WxCodeSessionService(WxPayConfig wxPayConfig, ObjectMapper objectMapper)
{
this.wxPayConfig = wxPayConfig;
this.objectMapper = objectMapper;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build();
}
public WxCodeSession exchange(String code)
{
if (StringUtils.isBlank(code))
{
throw new ServiceException("微信登录凭证不能为空");
}
if (StringUtils.isAnyBlank(wxPayConfig.getAppid(), wxPayConfig.getSecret()))
{
throw new ServiceException("微信小程序 AppID 或 Secret 未配置");
}
HttpUrl url = HttpUrl.parse(CODE_TO_SESSION_URL).newBuilder()
.addQueryParameter("appid", wxPayConfig.getAppid())
.addQueryParameter("secret", wxPayConfig.getSecret())
.addQueryParameter("js_code", code)
.addQueryParameter("grant_type", "authorization_code")
.build();
Request request = new Request.Builder().url(url).get().build();
try (Response response = httpClient.newCall(request).execute())
{
if (!response.isSuccessful() || response.body() == null)
{
throw new ServiceException("微信登录服务暂不可用");
}
JsonNode result = objectMapper.readTree(response.body().string());
if (result.path("errcode").asInt(0) != 0)
{
throw new ServiceException("微信登录失败:" + result.path("errmsg").asText("未知错误"));
}
String openId = result.path("openid").asText();
String sessionKey = result.path("session_key").asText();
if (StringUtils.isAnyBlank(openId, sessionKey))
{
throw new ServiceException("微信登录结果不完整");
}
return new WxCodeSession(openId, sessionKey);
}
catch (IOException e)
{
throw new ServiceException("调用微信登录服务失败");
}
}
}

View File

@@ -117,7 +117,6 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
"/app/**/list",
"/app/**/app/**",
"/system/dict/data/type/**",
"/app/public/autoLoginWx/**",
"/app/public/delSHuiYin",
"/app/public/getSysSet",
"/app/pay/notify",

View File

@@ -53,6 +53,8 @@ import java.util.Date;
@Component
public class SysLoginService
{
private static final int NEW_USER_REGISTER_POINTS = 50;
@Autowired
private TokenService tokenService;
@@ -312,7 +314,24 @@ public class SysLoginService
sysUser.setUserName(openid);
sysUser.setNickName("微信用户");
sysUser.setOpenId(openid);
sysUserMapper.insertUser(sysUser);
if (sysUserMapper.insertUser(sysUser) != 1 || sysUser.getUserId() == null)
{
throw new ServiceException("新用户创建失败,请重试");
}
if (sysUserMapper.addIntegralByUserId(NEW_USER_REGISTER_POINTS, sysUser.getUserId()) != 1)
{
throw new ServiceException("新用户注册积分发放失败,请重试");
}
AppIntegralRecord registerReward = new AppIntegralRecord();
registerReward.setSource("新用户注册");
registerReward.setIsAdd(0L);
registerReward.setIntegralNumber((long) NEW_USER_REGISTER_POINTS);
registerReward.setUserId(sysUser.getUserId());
registerReward.setIntegralTime(new Date());
if (appIntegralRecordService.insertAppIntegralRecord(registerReward) != 1)
{
throw new ServiceException("新用户注册积分流水记录失败,请重试");
}
userName = openid;
}else {
userName = user.getUserName();

View File

@@ -28,6 +28,40 @@
<artifactId>ruoyi-system</artifactId>
</dependency>
<!-- Markdown 公众号排版 -->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.21.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>0.21.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
<version>0.21.0</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.16.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
</project>

View File

@@ -0,0 +1,30 @@
package com.ruoyi.office.config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 网盘检测线程池。限制并发数,避免批量检测时触发平台风控。
*/
@Configuration
public class NetDiskCheckExecutorConfig
{
@Bean(name = "netDiskCheckExecutor", destroyMethod = "shutdown")
public ExecutorService netDiskCheckExecutor()
{
AtomicInteger threadNumber = new AtomicInteger(1);
ThreadFactory threadFactory = runnable ->
{
Thread thread = new Thread(runnable,
"net-disk-check-" + threadNumber.getAndIncrement());
thread.setDaemon(true);
return thread;
};
return Executors.newFixedThreadPool(4, threadFactory);
}
}

View File

@@ -0,0 +1,57 @@
package com.ruoyi.office.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 网站文章同步配置。
*/
@Component
@ConfigurationProperties(prefix = "website-sync")
public class WebsiteSyncProperties
{
private String baseUrl;
private String apiKey;
private int connectTimeout = 5000;
private int readTimeout = 30000;
public String getBaseUrl()
{
return baseUrl;
}
public void setBaseUrl(String baseUrl)
{
this.baseUrl = baseUrl;
}
public String getApiKey()
{
return apiKey;
}
public void setApiKey(String apiKey)
{
this.apiKey = apiKey;
}
public int getConnectTimeout()
{
return connectTimeout;
}
public void setConnectTimeout(int connectTimeout)
{
this.connectTimeout = connectTimeout;
}
public int getReadTimeout()
{
return readTimeout;
}
public void setReadTimeout(int readTimeout)
{
this.readTimeout = readTimeout;
}
}

View File

@@ -1,5 +1,6 @@
package com.ruoyi.office.controller;
import java.io.IOException;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -19,6 +20,8 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.IWebsiteArticleSyncService;
import com.ruoyi.office.service.TtScreenshotArchiveService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@@ -35,6 +38,12 @@ public class TtCodeController extends BaseController
@Autowired
private ITtCodeService ttCodeService;
@Autowired
private IWebsiteArticleSyncService websiteArticleSyncService;
@Autowired
private TtScreenshotArchiveService screenshotArchiveService;
/**
* 查询源码管理列表
*/
@@ -60,6 +69,17 @@ public class TtCodeController extends BaseController
util.exportExcel(response, list, "源码管理数据");
}
/**
* 下载所选源码项目在文件管理中关联的全部截图。
*/
@PreAuthorize("@ss.hasPermi('office:code:export')")
@Log(title = "下载项目截图", businessType = BusinessType.EXPORT)
@GetMapping("/screenshots/{codeId}/download")
public void downloadScreenshots(@PathVariable Long codeId, HttpServletResponse response) throws IOException
{
screenshotArchiveService.download(codeId, response);
}
/**
* 获取源码管理详细信息
*/
@@ -107,22 +127,23 @@ public class TtCodeController extends BaseController
* 转文章
*/
@PreAuthorize("@ss.hasPermi('office:code:edit')")
@Log(title = "源码管理", businessType = BusinessType.UPDATE)
@GetMapping("/transToArticle/{codeId}")
public AjaxResult transToArticle(@PathVariable Long codeId)
{
String msg = ttCodeService.transToArticle(codeId);
@Log(title = "转文章(南音)", businessType = BusinessType.UPDATE)
@GetMapping("/transToArticle1/{codeId}")
public AjaxResult transToArticle1(@PathVariable Long codeId, @RequestParam Long templateId) {
String msg = ttCodeService.transToArticle1(codeId, templateId);
return success(msg);
}
/**
* 转文章
* 将“复制网站文案”同步为网站Markdown文章。
*/
@PreAuthorize("@ss.hasPermi('office:code:edit')")
@Log(title = "转文章(南音)", businessType = BusinessType.UPDATE)
@GetMapping("/transToArticle1/{codeId}")
public AjaxResult transToArticle1(@PathVariable Long codeId, @RequestParam String coverUrl) {
String msg = ttCodeService.transToArticle1(codeId, coverUrl);
return success(msg);
@Log(title = "同步网站", businessType = BusinessType.UPDATE)
@PostMapping("/syncWebsite/{codeId}")
public AjaxResult syncWebsite(@PathVariable Long codeId) {
com.ruoyi.office.domain.WebsiteArticleSyncResult result =
websiteArticleSyncService.syncWebsite(codeId);
String message = result.isCreated() ? "同步网站成功,已创建文章" : "同步网站成功,已更新文章";
return AjaxResult.success(message, result);
}
}

View File

@@ -0,0 +1,151 @@
package com.ruoyi.office.controller;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
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;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 文案模板Controller
*
* @author ruoyi
* @date 2026-04-10
*/
@RestController
@RequestMapping("/office/copyTemplate")
public class TtCopyTemplateController extends BaseController {
@Autowired
private ITtCopyTemplateService ttCopyTemplateService;
@Autowired
private ITtCodeService ttCodeService;
@Autowired
private CopyTemplateRenderer copyTemplateRenderer;
/**
* 查询文案模板列表
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:list')")
@GetMapping("/list")
public TableDataInfo list(TtCopyTemplate ttCopyTemplate) {
startPage();
List<TtCopyTemplate> list = ttCopyTemplateService.selectTtCopyTemplateList(ttCopyTemplate);
return getDataTable(list);
}
/**
* 查询模板分组及各分组模板数量
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:list')")
@GetMapping("/groups")
public AjaxResult groups() {
List<TtCopyTemplateGroupStat> groups = ttCopyTemplateService.selectCopyTemplateGroupStats();
return success(groups);
}
/**
* 按场景查询所有启用的文案模板(无需分页权限)
*/
@GetMapping("/enabled")
public AjaxResult listEnabled(@RequestParam(value = "templateScene", required = false) String templateScene) {
return success(ttCopyTemplateService.selectEnabledTemplates(templateScene));
}
/**
* 导出文案模板列表
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:export')")
@Log(title = "文案模板", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TtCopyTemplate ttCopyTemplate) {
List<TtCopyTemplate> list = ttCopyTemplateService.selectTtCopyTemplateList(ttCopyTemplate);
ExcelUtil<TtCopyTemplate> util = new ExcelUtil<TtCopyTemplate>(TtCopyTemplate.class);
util.exportExcel(response, list, "文案模板数据");
}
/**
* 获取文案模板详细信息
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:query')")
@GetMapping(value = "/{templateId}")
public AjaxResult getInfo(@PathVariable("templateId") Long templateId) {
return success(ttCopyTemplateService.selectTtCopyTemplateByTemplateId(templateId));
}
/**
* 新增文案模板
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:add')")
@Log(title = "文案模板", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TtCopyTemplate ttCopyTemplate) {
return toAjax(ttCopyTemplateService.insertTtCopyTemplate(ttCopyTemplate));
}
/**
* 修改文案模板
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:edit')")
@Log(title = "文案模板", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TtCopyTemplate ttCopyTemplate) {
return toAjax(ttCopyTemplateService.updateTtCopyTemplate(ttCopyTemplate));
}
/**
* 删除文案模板
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:remove')")
@Log(title = "文案模板", businessType = BusinessType.DELETE)
@DeleteMapping("/{templateIds}")
public AjaxResult remove(@PathVariable Long[] templateIds) {
return toAjax(ttCopyTemplateService.deleteTtCopyTemplateByTemplateIds(templateIds));
}
/**
* 开放接口:通过文案模板名称和项目名称生成文案
* 无需登录,浏览器可直接访问
*/
@Anonymous
@GetMapping("/open/generate")
public AjaxResult generateByOpen(@RequestParam String templateName, @RequestParam String codeName) {
// 1. 查询模板
TtCopyTemplate template = ttCopyTemplateService.selectEnabledTemplates().stream()
.filter(t -> templateName.equals(t.getTemplateName()))
.findFirst()
.orElse(null);
if (template == null) {
return error("未找到名称为【" + templateName + "】的启用模板");
}
// 2. 查询源码
TtCode code = ttCodeService.selectTtCodeByCodeName(codeName);
if (code == null) {
return error("未找到名称为【" + codeName + "】的源码项目");
}
return success(copyTemplateRenderer.render(template, code));
}
}

View File

@@ -8,8 +8,10 @@ import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse;
import com.ruoyi.common.utils.CoverGenerator;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.IProjectLinkCheckService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -19,7 +21,9 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -42,6 +46,8 @@ public class TtProjectInfoController extends BaseController {
private ITtProjectInfoService ttProjectInfoService;
@Autowired
private ITtCodeService ttCodeService;
@Autowired
private IProjectLinkCheckService projectLinkCheckService;
/**
@@ -67,6 +73,18 @@ public class TtProjectInfoController extends BaseController {
util.exportExcel(response, list, "项目清单数据");
}
/**
* 按项目名称导入夸克或百度网盘链接。
*/
@PreAuthorize("@ss.hasPermi('office:project:edit')")
@Log(title = "导入项目网盘链接", businessType = BusinessType.IMPORT)
@PostMapping("/import-links")
public AjaxResult importLinks(@RequestParam("file") MultipartFile file,
@RequestParam("diskType") String diskType) {
ProjectLinkImportResult result = ttProjectInfoService.importProjectLinks(file, diskType);
return AjaxResult.success(result.buildMessage(), result);
}
/**
* 获取项目清单详细信息
*/
@@ -76,6 +94,26 @@ public class TtProjectInfoController extends BaseController {
return success(ttProjectInfoService.selectTtProjectInfoById(id));
}
/**
* 检测单个项目的网盘链接。
*/
@PreAuthorize("@ss.hasPermi('office:project:edit')")
@Log(title = "检测项目网盘链接", businessType = BusinessType.OTHER)
@PostMapping("/link-check/single/{id}")
public AjaxResult checkProjectLink(@PathVariable("id") Integer id) {
return success(projectLinkCheckService.checkProject(id));
}
/**
* 检测选中项目的网盘链接。
*/
@PreAuthorize("@ss.hasPermi('office:project:edit')")
@Log(title = "批量检测项目网盘链接", businessType = BusinessType.OTHER)
@PostMapping("/link-check/batch")
public AjaxResult checkProjectLinks(@RequestBody Integer[] ids) {
return success(projectLinkCheckService.checkProjects(ids));
}
/**
* 新增项目清单
*/

View File

@@ -0,0 +1,105 @@
package com.ruoyi.office.controller;
import java.io.IOException;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.CrossOrigin;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Anonymous;
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.office.domain.PublishBridgeTask;
import com.ruoyi.office.domain.PublishMaterial;
import com.ruoyi.office.domain.PublishProjectRow;
import com.ruoyi.office.domain.PublishStatusUpdate;
import com.ruoyi.office.domain.TtPublishTask;
import com.ruoyi.office.service.ITtPublishTaskService;
/**
* 后台发布管理与浏览器扩展桥接接口。
*/
@RestController
@RequestMapping("/office/publish")
public class TtPublishTaskController extends BaseController
{
@Autowired
private ITtPublishTaskService publishTaskService;
@PreAuthorize("@ss.hasPermi('office:publish:list')")
@GetMapping("/list")
public TableDataInfo list(PublishProjectRow query)
{
startPage();
List<PublishProjectRow> list = publishTaskService.selectPublishProjectList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('office:publish:query')")
@GetMapping("/material/{codeId}")
public AjaxResult material(@PathVariable Long codeId, @RequestParam String platform)
{
PublishMaterial material = publishTaskService.selectProjectMaterial(codeId, platform);
return AjaxResult.success(material);
}
@PreAuthorize("@ss.hasPermi('office:publish:add')")
@Log(title = "创建平台发布任务", businessType = BusinessType.INSERT)
@PostMapping("/task")
public AjaxResult createTask(@RequestBody TtPublishTask task)
{
return AjaxResult.success("发布任务已创建", publishTaskService.createPublishTask(task));
}
@PreAuthorize("@ss.hasPermi('office:publish:add')")
@Log(title = "创建多平台发布任务", businessType = BusinessType.INSERT)
@PostMapping("/tasks")
public AjaxResult createTasks(@RequestBody List<TtPublishTask> tasks)
{
return AjaxResult.success("多平台发布任务已创建", publishTaskService.createPublishTasks(tasks));
}
@Anonymous
@CrossOrigin(origins = "*")
@GetMapping("/bridge/task/{taskId}")
public AjaxResult bridgeTask(@PathVariable String taskId, @RequestParam String token)
{
PublishBridgeTask task = publishTaskService.selectBridgeTask(taskId, token);
return AjaxResult.success(task);
}
@Anonymous
@CrossOrigin(origins = "*")
@PostMapping("/bridge/task/{taskId}/status")
public AjaxResult bridgeStatus(@PathVariable String taskId,
@RequestParam String token,
@RequestBody PublishStatusUpdate update)
{
return AjaxResult.success(publishTaskService.updateBridgeStatus(taskId, token, update));
}
@Anonymous
@CrossOrigin(origins = "*")
@GetMapping({
"/bridge/task/{taskId}/asset/{kind}",
"/bridge/task/{taskId}/asset/{kind}/{index}"
})
public void bridgeAsset(@PathVariable String taskId,
@PathVariable String kind,
@PathVariable(required = false) Integer index,
@RequestParam String token,
HttpServletResponse response) throws IOException
{
publishTaskService.writeBridgeAsset(taskId, token, kind, index, response);
}
}

View File

@@ -0,0 +1,92 @@
package com.ruoyi.office.controller;
import java.util.List;
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.SecurityUtils;
import com.ruoyi.office.domain.TtWechatAccount;
import com.ruoyi.office.service.ITtWechatAccountService;
import com.ruoyi.office.service.IWechatDraftService;
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("/office/wechat/account")
public class TtWechatAccountController extends BaseController
{
private final ITtWechatAccountService accountService;
private final IWechatDraftService draftService;
public TtWechatAccountController(ITtWechatAccountService accountService, IWechatDraftService draftService)
{
this.accountService = accountService;
this.draftService = draftService;
}
@PreAuthorize("@ss.hasPermi('office:wechat:account:list')")
@GetMapping("/list")
public TableDataInfo list(TtWechatAccount account)
{
startPage();
return getDataTable(accountService.selectList(account));
}
@PreAuthorize("@ss.hasAnyPermi('office:wechat:account:list,office:wechat:draft:push')")
@GetMapping("/enabled")
public AjaxResult enabled()
{
List<TtWechatAccount> list = accountService.selectEnabled();
return success(list);
}
@PreAuthorize("@ss.hasPermi('office:wechat:account:query')")
@GetMapping("/{accountId}")
public AjaxResult getInfo(@PathVariable Long accountId)
{
return success(accountService.selectById(accountId));
}
@PreAuthorize("@ss.hasPermi('office:wechat:account:add')")
@Log(title = "公众号账号", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TtWechatAccount account)
{
account.setCreateBy(SecurityUtils.getUsername());
return toAjax(accountService.insert(account));
}
@PreAuthorize("@ss.hasPermi('office:wechat:account:edit')")
@Log(title = "公众号账号", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TtWechatAccount account)
{
account.setUpdateBy(SecurityUtils.getUsername());
return toAjax(accountService.update(account));
}
@PreAuthorize("@ss.hasPermi('office:wechat:account:remove')")
@Log(title = "公众号账号", businessType = BusinessType.DELETE)
@DeleteMapping("/{accountIds}")
public AjaxResult remove(@PathVariable Long[] accountIds)
{
return toAjax(accountService.delete(accountIds));
}
@PreAuthorize("@ss.hasPermi('office:wechat:account:test')")
@PostMapping("/{accountId}/test")
public AjaxResult test(@PathVariable Long accountId)
{
draftService.testAccount(accountId);
return success("连接成功");
}
}

View File

@@ -0,0 +1,60 @@
package com.ruoyi.office.controller;
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.office.domain.TtWechatArticle;
import com.ruoyi.office.domain.WechatDraftPushRequest;
import com.ruoyi.office.service.IWechatDraftService;
import org.springframework.security.access.prepost.PreAuthorize;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/office/wechat/draft")
public class TtWechatDraftController extends BaseController
{
private final IWechatDraftService draftService;
public TtWechatDraftController(IWechatDraftService draftService)
{
this.draftService = draftService;
}
@PreAuthorize("@ss.hasPermi('office:wechat:draft:list')")
@GetMapping("/list")
public TableDataInfo list(TtWechatArticle article)
{
startPage();
return getDataTable(draftService.selectList(article));
}
@PreAuthorize("@ss.hasPermi('office:wechat:draft:push')")
@PostMapping("/preview")
public AjaxResult preview(@RequestBody WechatDraftPushRequest request)
{
return success(draftService.preview(request));
}
@PreAuthorize("@ss.hasPermi('office:wechat:draft:push')")
@Log(title = "公众号草稿", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult push(@RequestBody WechatDraftPushRequest request)
{
return AjaxResult.success("已推送到公众号草稿箱", draftService.push(request));
}
@PreAuthorize("@ss.hasPermi('office:wechat:draft:retry')")
@Log(title = "公众号草稿重试", businessType = BusinessType.UPDATE)
@PostMapping("/{articleId}/retry")
public AjaxResult retry(@PathVariable Long articleId)
{
return AjaxResult.success("重试成功,已推送到草稿箱", draftService.retry(articleId));
}
}

View File

@@ -0,0 +1,71 @@
package com.ruoyi.office.domain;
/**
* 项目网盘链接导入明细。
*/
public class ProjectLinkImportDetail
{
/** 原文件中的行号。 */
private int rowNumber;
/** 项目名称。 */
private String projectName;
/** SKIPPED / FAILED。 */
private String resultType;
/** 跳过或失败原因。 */
private String reason;
public ProjectLinkImportDetail()
{
}
public ProjectLinkImportDetail(int rowNumber, String projectName, String resultType, String reason)
{
this.rowNumber = rowNumber;
this.projectName = projectName;
this.resultType = resultType;
this.reason = reason;
}
public int getRowNumber()
{
return rowNumber;
}
public void setRowNumber(int rowNumber)
{
this.rowNumber = rowNumber;
}
public String getProjectName()
{
return projectName;
}
public void setProjectName(String projectName)
{
this.projectName = projectName;
}
public String getResultType()
{
return resultType;
}
public void setResultType(String resultType)
{
this.resultType = resultType;
}
public String getReason()
{
return reason;
}
public void setReason(String reason)
{
this.reason = reason;
}
}

View File

@@ -0,0 +1,98 @@
package com.ruoyi.office.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 项目网盘链接导入结果。
*/
public class ProjectLinkImportResult
{
private int totalCount;
private int addedCount;
private int updatedCount;
private int unchangedCount;
private int skippedCount;
private int failedCount;
private final List<ProjectLinkImportDetail> details = new ArrayList<>();
public void addSkipped(int rowNumber, String projectName, String reason)
{
skippedCount++;
details.add(new ProjectLinkImportDetail(rowNumber, projectName, "SKIPPED", reason));
}
public void addFailed(int rowNumber, String projectName, String reason)
{
failedCount++;
details.add(new ProjectLinkImportDetail(rowNumber, projectName, "FAILED", reason));
}
public String buildMessage()
{
return "导入完成:共 " + totalCount + " 条,新增 " + addedCount + " 条,更新 "
+ updatedCount + " 条,未变化 " + unchangedCount + " 条,跳过 "
+ skippedCount + " 条,失败 " + failedCount + " 条";
}
public int getTotalCount()
{
return totalCount;
}
public void setTotalCount(int totalCount)
{
this.totalCount = totalCount;
}
public int getAddedCount()
{
return addedCount;
}
public void incrementAddedCount()
{
addedCount++;
}
public int getUpdatedCount()
{
return updatedCount;
}
public void incrementUpdatedCount()
{
updatedCount++;
}
public int getUnchangedCount()
{
return unchangedCount;
}
public void incrementUnchangedCount()
{
unchangedCount++;
}
public int getSkippedCount()
{
return skippedCount;
}
public int getFailedCount()
{
return failedCount;
}
public List<ProjectLinkImportDetail> getDetails()
{
return details;
}
}

View File

@@ -0,0 +1,67 @@
package com.ruoyi.office.domain;
/**
* 项目网盘链接导入文件中的一行原始数据。
*/
public class ProjectLinkImportRow
{
private int rowNumber;
private String projectName;
private String shareAddress;
private String extractCode;
private String shareStatus;
public int getRowNumber()
{
return rowNumber;
}
public void setRowNumber(int rowNumber)
{
this.rowNumber = rowNumber;
}
public String getProjectName()
{
return projectName;
}
public void setProjectName(String projectName)
{
this.projectName = projectName;
}
public String getShareAddress()
{
return shareAddress;
}
public void setShareAddress(String shareAddress)
{
this.shareAddress = shareAddress;
}
public String getExtractCode()
{
return extractCode;
}
public void setExtractCode(String extractCode)
{
this.extractCode = extractCode;
}
public String getShareStatus()
{
return shareStatus;
}
public void setShareStatus(String shareStatus)
{
this.shareStatus = shareStatus;
}
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.office.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 浏览器扩展可读取的最小发布任务数据,不包含后台身份凭证。
*/
public class PublishBridgeTask
{
private String taskId;
private String platform;
private String title;
private String content;
private List<String> tags = new ArrayList<>();
private boolean hasVideo;
private boolean hasCover;
private int imageCount;
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public List<String> getTags() { return tags; }
public void setTags(List<String> tags) { this.tags = tags; }
public boolean isHasVideo() { return hasVideo; }
public void setHasVideo(boolean hasVideo) { this.hasVideo = hasVideo; }
public boolean isHasCover() { return hasCover; }
public void setHasCover(boolean hasCover) { this.hasCover = hasCover; }
public int getImageCount() { return imageCount; }
public void setImageCount(int imageCount) { this.imageCount = imageCount; }
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.office.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 创建发布任务前返回给管理页面的项目素材。
*/
public class PublishMaterial
{
private Long codeId;
private String codeName;
private String title;
private String content;
private Long templateId;
private String templateName;
private String templateGroup;
private String tags;
private String videoUrl;
private String coverUrl;
private List<String> imageUrls = new ArrayList<>();
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public String getCodeName() { return codeName; }
public void setCodeName(String codeName) { this.codeName = codeName; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public Long getTemplateId() { return templateId; }
public void setTemplateId(Long templateId) { this.templateId = templateId; }
public String getTemplateName() { return templateName; }
public void setTemplateName(String templateName) { this.templateName = templateName; }
public String getTemplateGroup() { return templateGroup; }
public void setTemplateGroup(String templateGroup) { this.templateGroup = templateGroup; }
public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; }
public String getVideoUrl() { return videoUrl; }
public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; }
public String getCoverUrl() { return coverUrl; }
public void setCoverUrl(String coverUrl) { this.coverUrl = coverUrl; }
public List<String> getImageUrls() { return imageUrls; }
public void setImageUrls(List<String> imageUrls) { this.imageUrls = imageUrls; }
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.office.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 发布管理页面中的项目聚合行。
*/
public class PublishProjectRow extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long codeId;
private String codeName;
private String xiaohongshuTaskId;
private String xiaohongshuStatus;
private String douyinTaskId;
private String douyinStatus;
private String wechatChannelsTaskId;
private String wechatChannelsStatus;
private String bilibiliTaskId;
private String bilibiliStatus;
private String xianyuTaskId;
private String xianyuStatus;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date lastPublishTime;
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public String getCodeName() { return codeName; }
public void setCodeName(String codeName) { this.codeName = codeName; }
public String getXiaohongshuTaskId() { return xiaohongshuTaskId; }
public void setXiaohongshuTaskId(String xiaohongshuTaskId) { this.xiaohongshuTaskId = xiaohongshuTaskId; }
public String getXiaohongshuStatus() { return xiaohongshuStatus; }
public void setXiaohongshuStatus(String xiaohongshuStatus) { this.xiaohongshuStatus = xiaohongshuStatus; }
public String getDouyinTaskId() { return douyinTaskId; }
public void setDouyinTaskId(String douyinTaskId) { this.douyinTaskId = douyinTaskId; }
public String getDouyinStatus() { return douyinStatus; }
public void setDouyinStatus(String douyinStatus) { this.douyinStatus = douyinStatus; }
public String getWechatChannelsTaskId() { return wechatChannelsTaskId; }
public void setWechatChannelsTaskId(String wechatChannelsTaskId) { this.wechatChannelsTaskId = wechatChannelsTaskId; }
public String getWechatChannelsStatus() { return wechatChannelsStatus; }
public void setWechatChannelsStatus(String wechatChannelsStatus) { this.wechatChannelsStatus = wechatChannelsStatus; }
public String getBilibiliTaskId() { return bilibiliTaskId; }
public void setBilibiliTaskId(String bilibiliTaskId) { this.bilibiliTaskId = bilibiliTaskId; }
public String getBilibiliStatus() { return bilibiliStatus; }
public void setBilibiliStatus(String bilibiliStatus) { this.bilibiliStatus = bilibiliStatus; }
public String getXianyuTaskId() { return xianyuTaskId; }
public void setXianyuTaskId(String xianyuTaskId) { this.xianyuTaskId = xianyuTaskId; }
public String getXianyuStatus() { return xianyuStatus; }
public void setXianyuStatus(String xianyuStatus) { this.xianyuStatus = xianyuStatus; }
public Date getLastPublishTime() { return lastPublishTime; }
public void setLastPublishTime(Date lastPublishTime) { this.lastPublishTime = lastPublishTime; }
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.office.domain;
/** 浏览器扩展发布状态回传。 */
public class PublishStatusUpdate
{
private String status;
private String message;
private String error;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getError() { return error; }
public void setError(String error) { this.error = error; }
}

View File

@@ -40,11 +40,29 @@ public class TtCode extends BaseEntity {
private String codeEnvironment;
/**
* 项目技术
* 其他技术
*/
@Excel(name = "项目技术")
@Excel(name = "其他技术")
private String codeTechnology;
/**
* 前端
*/
@Excel(name = "前端")
private String frontendTechnology;
/**
* 后端
*/
@Excel(name = "后端")
private String backendTechnology;
/**
* 数据库
*/
@Excel(name = "数据库")
private String databaseTechnology;
/**
* 来源
*/
@@ -123,6 +141,30 @@ public class TtCode extends BaseEntity {
return codeTechnology;
}
public void setFrontendTechnology(String frontendTechnology) {
this.frontendTechnology = frontendTechnology;
}
public String getFrontendTechnology() {
return frontendTechnology;
}
public void setBackendTechnology(String backendTechnology) {
this.backendTechnology = backendTechnology;
}
public String getBackendTechnology() {
return backendTechnology;
}
public void setDatabaseTechnology(String databaseTechnology) {
this.databaseTechnology = databaseTechnology;
}
public String getDatabaseTechnology() {
return databaseTechnology;
}
public void setCodeSource(String codeSource) {
this.codeSource = codeSource;
}
@@ -187,6 +229,9 @@ public class TtCode extends BaseEntity {
.append("codeDesc", getCodeDesc())
.append("codeEnvironment", getCodeEnvironment())
.append("codeTechnology", getCodeTechnology())
.append("frontendTechnology", getFrontendTechnology())
.append("backendTechnology", getBackendTechnology())
.append("databaseTechnology", getDatabaseTechnology())
.append("codeSource", getCodeSource())
.append("paymentType", getPaymentType())
.append("diskLink", getDiskLink())

View File

@@ -0,0 +1,114 @@
package com.ruoyi.office.domain;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 文案模板对象 tt_copy_template
*
* @author ruoyi
* @date 2026-04-10
*/
public class TtCopyTemplate extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 模板ID */
private Long templateId;
/** 按钮显示名称 */
@Excel(name = "按钮名称")
private String templateName;
/** 模板分组 */
@Excel(name = "模板分组")
private String templateGroup;
/** 模板场景(CODE源码文案 DELIVERY发货文案) */
@Excel(name = "模板场景")
private String templateScene;
/** 文案模板内容,支持 {变量} 占位符 */
@Excel(name = "模板内容")
private String templateBody;
/** 排序号 */
@Excel(name = "排序")
private Integer sortNum;
/** 状态(0正常 1停用) */
@Excel(name = "状态")
private String status;
public void setTemplateId(Long templateId) {
this.templateId = templateId;
}
public Long getTemplateId() {
return templateId;
}
public void setTemplateName(String templateName) {
this.templateName = templateName;
}
public String getTemplateName() {
return templateName;
}
public void setTemplateGroup(String templateGroup) {
this.templateGroup = templateGroup;
}
public String getTemplateGroup() {
return templateGroup;
}
public void setTemplateScene(String templateScene) {
this.templateScene = templateScene;
}
public String getTemplateScene() {
return templateScene;
}
public void setTemplateBody(String templateBody) {
this.templateBody = templateBody;
}
public String getTemplateBody() {
return templateBody;
}
public void setSortNum(Integer sortNum) {
this.sortNum = sortNum;
}
public Integer getSortNum() {
return sortNum;
}
public void setStatus(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("templateId", getTemplateId())
.append("templateName", getTemplateName())
.append("templateGroup", getTemplateGroup())
.append("templateScene", getTemplateScene())
.append("templateBody", getTemplateBody())
.append("sortNum", getSortNum())
.append("status", getStatus())
.append("createTime", getCreateTime())
.append("updateTime", getUpdateTime())
.toString();
}
}

View File

@@ -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;
}
}

View File

@@ -4,6 +4,7 @@ import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import java.util.Date;
/**
* 项目清单对象 tt_project_info
@@ -50,6 +51,24 @@ public class TtProjectInfo extends BaseEntity
@Excel(name = "项目百度链接")
private String projectBaiduUrl;
/** 夸克网盘最近检测状态 */
private String quarkCheckStatus;
/** 夸克网盘最近检测说明 */
private String quarkCheckMessage;
/** 夸克网盘最近检测时间 */
private Date quarkCheckedAt;
/** 百度网盘最近检测状态 */
private String baiduCheckStatus;
/** 百度网盘最近检测说明 */
private String baiduCheckMessage;
/** 百度网盘最近检测时间 */
private Date baiduCheckedAt;
public void setId(Integer id)
{
this.id = id;
@@ -131,6 +150,54 @@ public class TtProjectInfo extends BaseEntity
this.projectBaiduUrl = projectBaiduUrl;
}
public String getQuarkCheckStatus() {
return quarkCheckStatus;
}
public void setQuarkCheckStatus(String quarkCheckStatus) {
this.quarkCheckStatus = quarkCheckStatus;
}
public String getQuarkCheckMessage() {
return quarkCheckMessage;
}
public void setQuarkCheckMessage(String quarkCheckMessage) {
this.quarkCheckMessage = quarkCheckMessage;
}
public Date getQuarkCheckedAt() {
return quarkCheckedAt;
}
public void setQuarkCheckedAt(Date quarkCheckedAt) {
this.quarkCheckedAt = quarkCheckedAt;
}
public String getBaiduCheckStatus() {
return baiduCheckStatus;
}
public void setBaiduCheckStatus(String baiduCheckStatus) {
this.baiduCheckStatus = baiduCheckStatus;
}
public String getBaiduCheckMessage() {
return baiduCheckMessage;
}
public void setBaiduCheckMessage(String baiduCheckMessage) {
this.baiduCheckMessage = baiduCheckMessage;
}
public Date getBaiduCheckedAt() {
return baiduCheckedAt;
}
public void setBaiduCheckedAt(Date baiduCheckedAt) {
this.baiduCheckedAt = baiduCheckedAt;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
@@ -141,6 +208,7 @@ public class TtProjectInfo extends BaseEntity
.append("projectName1", getProjectName1())
.append("projectDesc", getProjectDesc())
.append("projectUrl", getProjectUrl())
.append("projectBaiduUrl", getProjectBaiduUrl())
.append("projectVurl", getProjectVurl())
.toString();
}

View File

@@ -0,0 +1,117 @@
package com.ruoyi.office.domain;
import java.util.Date;
/**
* 项目网盘链接检测结果对象 tt_project_link_check。
*/
public class TtProjectLinkCheck
{
private Long id;
private Integer projectId;
private String diskType;
private String linkUrl;
private String checkStatus;
private String providerCode;
private String checkMessage;
private Long responseTimeMs;
private Date checkedAt;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public Integer getProjectId()
{
return projectId;
}
public void setProjectId(Integer projectId)
{
this.projectId = projectId;
}
public String getDiskType()
{
return diskType;
}
public void setDiskType(String diskType)
{
this.diskType = diskType;
}
public String getLinkUrl()
{
return linkUrl;
}
public void setLinkUrl(String linkUrl)
{
this.linkUrl = linkUrl;
}
public String getCheckStatus()
{
return checkStatus;
}
public void setCheckStatus(String checkStatus)
{
this.checkStatus = checkStatus;
}
public String getProviderCode()
{
return providerCode;
}
public void setProviderCode(String providerCode)
{
this.providerCode = providerCode;
}
public String getCheckMessage()
{
return checkMessage;
}
public void setCheckMessage(String checkMessage)
{
this.checkMessage = checkMessage;
}
public Long getResponseTimeMs()
{
return responseTimeMs;
}
public void setResponseTimeMs(Long responseTimeMs)
{
this.responseTimeMs = responseTimeMs;
}
public Date getCheckedAt()
{
return checkedAt;
}
public void setCheckedAt(Date checkedAt)
{
this.checkedAt = checkedAt;
}
}

View File

@@ -0,0 +1,81 @@
package com.ruoyi.office.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 多平台发布任务对象 tt_publish_task。
*/
public class TtPublishTask extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long publishId;
private String taskId;
private Long codeId;
private String codeName;
private String platform;
private String title;
private String content;
private String tags;
private String videoUrl;
private String imageUrls;
private String coverUrl;
private String status;
private String statusMessage;
private String lastError;
private String bridgeToken;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date bridgeExpiresAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startedAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date readyAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date finishedAt;
public Long getPublishId() { return publishId; }
public void setPublishId(Long publishId) { this.publishId = publishId; }
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public String getCodeName() { return codeName; }
public void setCodeName(String codeName) { this.codeName = codeName; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; }
public String getVideoUrl() { return videoUrl; }
public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; }
public String getImageUrls() { return imageUrls; }
public void setImageUrls(String imageUrls) { this.imageUrls = imageUrls; }
public String getCoverUrl() { return coverUrl; }
public void setCoverUrl(String coverUrl) { this.coverUrl = coverUrl; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getStatusMessage() { return statusMessage; }
public void setStatusMessage(String statusMessage) { this.statusMessage = statusMessage; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public String getBridgeToken() { return bridgeToken; }
public void setBridgeToken(String bridgeToken) { this.bridgeToken = bridgeToken; }
public Date getBridgeExpiresAt() { return bridgeExpiresAt; }
public void setBridgeExpiresAt(Date bridgeExpiresAt) { this.bridgeExpiresAt = bridgeExpiresAt; }
public Date getStartedAt() { return startedAt; }
public void setStartedAt(Date startedAt) { this.startedAt = startedAt; }
public Date getReadyAt() { return readyAt; }
public void setReadyAt(Date readyAt) { this.readyAt = readyAt; }
public Date getFinishedAt() { return finishedAt; }
public void setFinishedAt(Date finishedAt) { this.finishedAt = finishedAt; }
}

View File

@@ -0,0 +1,103 @@
package com.ruoyi.office.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 微信公众号配置对象 tt_wechat_account。
*/
public class TtWechatAccount extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long accountId;
private String accountName;
private String appId;
/** AppSecret 只允许写入,不允许通过接口序列化返回。 */
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String appSecret;
private String status;
private Boolean secretConfigured;
private String defaultAuthor;
private String defaultSourceUrl;
private Integer needOpenComment;
private Integer onlyFansCanComment;
public Long getAccountId()
{
return accountId;
}
public void setAccountId(Long accountId)
{
this.accountId = accountId;
}
public String getAccountName()
{
return accountName;
}
public void setAccountName(String accountName)
{
this.accountName = accountName;
}
public String getAppId()
{
return appId;
}
public void setAppId(String appId)
{
this.appId = appId;
}
public String getAppSecret()
{
return appSecret;
}
public void setAppSecret(String appSecret)
{
this.appSecret = appSecret;
}
public String getStatus()
{
return status;
}
public void setStatus(String status)
{
this.status = status;
}
public Boolean getSecretConfigured()
{
return secretConfigured;
}
public void setSecretConfigured(Boolean secretConfigured)
{
this.secretConfigured = secretConfigured;
}
public String getDefaultAuthor() { return defaultAuthor; }
public void setDefaultAuthor(String defaultAuthor) { this.defaultAuthor = defaultAuthor; }
public String getDefaultSourceUrl() { return defaultSourceUrl; }
public void setDefaultSourceUrl(String defaultSourceUrl) { this.defaultSourceUrl = defaultSourceUrl; }
public Integer getNeedOpenComment() { return needOpenComment; }
public void setNeedOpenComment(Integer needOpenComment) { this.needOpenComment = needOpenComment; }
public Integer getOnlyFansCanComment() { return onlyFansCanComment; }
public void setOnlyFansCanComment(Integer onlyFansCanComment) { this.onlyFansCanComment = onlyFansCanComment; }
}

View File

@@ -0,0 +1,87 @@
package com.ruoyi.office.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 公众号草稿推送记录对象 tt_wechat_article。
*/
public class TtWechatArticle extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long articleId;
private Long accountId;
private String accountName;
private Long codeId;
private Long templateId;
private String requestNo;
private String title;
private String author;
private String digest;
private String contentMarkdown;
private String renderedHtml;
private String contentHtml;
private String contentSourceUrl;
private String thumbMediaId;
private Integer showCoverPic;
private Integer needOpenComment;
private Integer onlyFansCanComment;
private String wechatMediaId;
/** 0成功 1失败 2处理中。 */
private String pushStatus;
private Integer errorCode;
private String errorMessage;
private Integer retryCount;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date pushTime;
public Long getArticleId() { return articleId; }
public void setArticleId(Long articleId) { this.articleId = articleId; }
public Long getAccountId() { return accountId; }
public void setAccountId(Long accountId) { this.accountId = accountId; }
public String getAccountName() { return accountName; }
public void setAccountName(String accountName) { this.accountName = accountName; }
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public Long getTemplateId() { return templateId; }
public void setTemplateId(Long templateId) { this.templateId = templateId; }
public String getRequestNo() { return requestNo; }
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getDigest() { return digest; }
public void setDigest(String digest) { this.digest = digest; }
public String getContentMarkdown() { return contentMarkdown; }
public void setContentMarkdown(String contentMarkdown) { this.contentMarkdown = contentMarkdown; }
public String getRenderedHtml() { return renderedHtml; }
public void setRenderedHtml(String renderedHtml) { this.renderedHtml = renderedHtml; }
public String getContentHtml() { return contentHtml; }
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
public String getContentSourceUrl() { return contentSourceUrl; }
public void setContentSourceUrl(String contentSourceUrl) { this.contentSourceUrl = contentSourceUrl; }
public String getThumbMediaId() { return thumbMediaId; }
public void setThumbMediaId(String thumbMediaId) { this.thumbMediaId = thumbMediaId; }
public Integer getShowCoverPic() { return showCoverPic; }
public void setShowCoverPic(Integer showCoverPic) { this.showCoverPic = showCoverPic; }
public Integer getNeedOpenComment() { return needOpenComment; }
public void setNeedOpenComment(Integer needOpenComment) { this.needOpenComment = needOpenComment; }
public Integer getOnlyFansCanComment() { return onlyFansCanComment; }
public void setOnlyFansCanComment(Integer onlyFansCanComment) { this.onlyFansCanComment = onlyFansCanComment; }
public String getWechatMediaId() { return wechatMediaId; }
public void setWechatMediaId(String wechatMediaId) { this.wechatMediaId = wechatMediaId; }
public String getPushStatus() { return pushStatus; }
public void setPushStatus(String pushStatus) { this.pushStatus = pushStatus; }
public Integer getErrorCode() { return errorCode; }
public void setErrorCode(Integer errorCode) { this.errorCode = errorCode; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Integer getRetryCount() { return retryCount; }
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
public Date getPushTime() { return pushTime; }
public void setPushTime(Date pushTime) { this.pushTime = pushTime; }
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.office.domain;
/**
* 从“复制网站文案”中拆出的文章标题和正文。
*/
public class WebsiteArticleDocument
{
private final String title;
private final String contentMarkdown;
private final String firstImageUrl;
public WebsiteArticleDocument(String title, String contentMarkdown, String firstImageUrl)
{
this.title = title;
this.contentMarkdown = contentMarkdown;
this.firstImageUrl = firstImageUrl;
}
public String getTitle()
{
return title;
}
public String getContentMarkdown()
{
return contentMarkdown;
}
public String getFirstImageUrl()
{
return firstImageUrl;
}
}

View File

@@ -0,0 +1,118 @@
package com.ruoyi.office.domain;
/**
* 博客系统文章推送请求。
*/
public class WebsiteArticlePushRequest
{
private String externalSource;
private String externalId;
private String title;
private String type;
private String contentType;
private String contentMarkdown;
private String top;
private String status;
private String blogPicType;
private String blogPicLink;
public String getExternalSource()
{
return externalSource;
}
public void setExternalSource(String externalSource)
{
this.externalSource = externalSource;
}
public String getExternalId()
{
return externalId;
}
public void setExternalId(String externalId)
{
this.externalId = externalId;
}
public String getTitle()
{
return title;
}
public void setTitle(String title)
{
this.title = title;
}
public String getType()
{
return type;
}
public void setType(String type)
{
this.type = type;
}
public String getContentType()
{
return contentType;
}
public void setContentType(String contentType)
{
this.contentType = contentType;
}
public String getContentMarkdown()
{
return contentMarkdown;
}
public void setContentMarkdown(String contentMarkdown)
{
this.contentMarkdown = contentMarkdown;
}
public String getTop()
{
return top;
}
public void setTop(String top)
{
this.top = top;
}
public String getStatus()
{
return status;
}
public void setStatus(String status)
{
this.status = status;
}
public String getBlogPicType()
{
return blogPicType;
}
public void setBlogPicType(String blogPicType)
{
this.blogPicType = blogPicType;
}
public String getBlogPicLink()
{
return blogPicLink;
}
public void setBlogPicLink(String blogPicLink)
{
this.blogPicLink = blogPicLink;
}
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.office.domain;
/**
* 博客系统文章推送响应。
*/
public class WebsiteArticlePushResponse
{
private Integer code;
private String msg;
private WebsiteArticleSyncResult data;
public Integer getCode()
{
return code;
}
public void setCode(Integer code)
{
this.code = code;
}
public String getMsg()
{
return msg;
}
public void setMsg(String msg)
{
this.msg = msg;
}
public WebsiteArticleSyncResult getData()
{
return data;
}
public void setData(WebsiteArticleSyncResult data)
{
this.data = data;
}
}

View File

@@ -0,0 +1,30 @@
package com.ruoyi.office.domain;
/**
* 网站文章同步结果,同时用于接收博客接口的data字段。
*/
public class WebsiteArticleSyncResult
{
private Long articleId;
private boolean created;
public Long getArticleId()
{
return articleId;
}
public void setArticleId(Long articleId)
{
this.articleId = articleId;
}
public boolean isCreated()
{
return created;
}
public void setCreated(boolean created)
{
this.created = created;
}
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.office.domain;
/**
* 公众号草稿预览结果。
*/
public class WechatDraftPreview
{
private String requestNo;
private String title;
private String author;
private String digest;
private String contentMarkdown;
private String contentHtml;
private String contentSourceUrl;
private Integer needOpenComment;
private Integer onlyFansCanComment;
public String getRequestNo() { return requestNo; }
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getDigest() { return digest; }
public void setDigest(String digest) { this.digest = digest; }
public String getContentMarkdown() { return contentMarkdown; }
public void setContentMarkdown(String contentMarkdown) { this.contentMarkdown = contentMarkdown; }
public String getContentHtml() { return contentHtml; }
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
public String getContentSourceUrl() { return contentSourceUrl; }
public void setContentSourceUrl(String contentSourceUrl) { this.contentSourceUrl = contentSourceUrl; }
public Integer getNeedOpenComment() { return needOpenComment; }
public void setNeedOpenComment(Integer needOpenComment) { this.needOpenComment = needOpenComment; }
public Integer getOnlyFansCanComment() { return onlyFansCanComment; }
public void setOnlyFansCanComment(Integer onlyFansCanComment) { this.onlyFansCanComment = onlyFansCanComment; }
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.office.domain;
/**
* 公众号草稿预览及推送请求对象。
*/
public class WechatDraftPushRequest
{
private Long accountId;
private Long codeId;
private Long templateId;
private String requestNo;
private String title;
private String author;
private String digest;
private String contentHtml;
private String contentMarkdown;
/** 排版主题:default 经典、grace 优雅、simple 简洁。 */
private String themeName;
private String themeColor;
/** 正文字号,支持 14-18px。 */
private Integer fontSize;
private String contentSourceUrl;
private String thumbMediaId;
private Integer showCoverPic;
private Integer needOpenComment;
private Integer onlyFansCanComment;
public Long getAccountId() { return accountId; }
public void setAccountId(Long accountId) { this.accountId = accountId; }
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public Long getTemplateId() { return templateId; }
public void setTemplateId(Long templateId) { this.templateId = templateId; }
public String getRequestNo() { return requestNo; }
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getDigest() { return digest; }
public void setDigest(String digest) { this.digest = digest; }
public String getContentHtml() { return contentHtml; }
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
public String getContentMarkdown() { return contentMarkdown; }
public void setContentMarkdown(String contentMarkdown) { this.contentMarkdown = contentMarkdown; }
public String getThemeName() { return themeName; }
public void setThemeName(String themeName) { this.themeName = themeName; }
public String getThemeColor() { return themeColor; }
public void setThemeColor(String themeColor) { this.themeColor = themeColor; }
public Integer getFontSize() { return fontSize; }
public void setFontSize(Integer fontSize) { this.fontSize = fontSize; }
public String getContentSourceUrl() { return contentSourceUrl; }
public void setContentSourceUrl(String contentSourceUrl) { this.contentSourceUrl = contentSourceUrl; }
public String getThumbMediaId() { return thumbMediaId; }
public void setThumbMediaId(String thumbMediaId) { this.thumbMediaId = thumbMediaId; }
public Integer getShowCoverPic() { return showCoverPic; }
public void setShowCoverPic(Integer showCoverPic) { this.showCoverPic = showCoverPic; }
public Integer getNeedOpenComment() { return needOpenComment; }
public void setNeedOpenComment(Integer needOpenComment) { this.needOpenComment = needOpenComment; }
public Integer getOnlyFansCanComment() { return onlyFansCanComment; }
public void setOnlyFansCanComment(Integer onlyFansCanComment) { this.onlyFansCanComment = onlyFansCanComment; }
}

View File

@@ -13,12 +13,20 @@ public interface TtCodeMapper
{
/**
* 查询源码管理
*
*
* @param codeId 源码管理主键
* @return 源码管理
*/
public TtCode selectTtCodeByCodeId(Long codeId);
/**
* 根据项目名称查询源码管理
*
* @param codeName 项目名称
* @return 源码管理
*/
public TtCode selectTtCodeByCodeName(String codeName);
/**
* 查询源码管理列表
*

View File

@@ -0,0 +1,55 @@
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;
/**
* 文案模板Mapper接口
*
* @author ruoyi
* @date 2026-04-10
*/
public interface TtCopyTemplateMapper {
/**
* 查询文案模板
*/
public TtCopyTemplate selectTtCopyTemplateByTemplateId(Long templateId);
/**
* 查询文案模板列表
*/
public List<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
/**
* 查询模板分组及模板数量
*/
public List<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats();
/**
* 查询所有启用的文案模板(按排序号升序)
*/
public List<TtCopyTemplate> selectEnabledTemplates(@Param("templateScene") String templateScene);
/**
* 新增文案模板
*/
public int insertTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 修改文案模板
*/
public int updateTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 批量删除文案模板
*/
public int deleteTtCopyTemplateByTemplateIds(Long[] templateIds);
/**
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtProjectInfo;
import org.apache.ibatis.annotations.Param;
/**
* 项目清单Mapper接口
@@ -62,4 +63,10 @@ public interface TtProjectInfoMapper
List<TtProjectInfo> lastUpdateList(String searchKey);
TtProjectInfo selectTtProjectInfoByName(String codeName);
int updateProjectQuarkUrl(@Param("id") Integer id, @Param("projectUrl") String projectUrl);
int updateProjectBaiduUrl(@Param("id") Integer id, @Param("projectBaiduUrl") String projectBaiduUrl);
int updateProjectNumIfBlank(@Param("id") Integer id, @Param("projectNum") String projectNum);
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.office.mapper;
import com.ruoyi.office.domain.TtProjectLinkCheck;
import org.apache.ibatis.annotations.Param;
/**
* 项目网盘链接检测结果 Mapper。
*/
public interface TtProjectLinkCheckMapper
{
int upsertTtProjectLinkCheck(TtProjectLinkCheck linkCheck);
int deleteByProjectId(Integer projectId);
int deleteByProjectIds(Integer[] projectIds);
int deleteByProjectIdAndDiskType(@Param("projectId") Integer projectId,
@Param("diskType") String diskType);
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.office.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.office.domain.PublishProjectRow;
import com.ruoyi.office.domain.TtPublishTask;
public interface TtPublishTaskMapper
{
List<PublishProjectRow> selectPublishProjectList(PublishProjectRow query);
TtPublishTask selectPublishTaskByTaskId(String taskId);
TtPublishTask selectPublishTaskByBridge(@Param("taskId") String taskId,
@Param("bridgeToken") String bridgeToken);
int insertPublishTask(TtPublishTask task);
int updatePublishTaskStatus(TtPublishTask task);
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtWechatAccount;
public interface TtWechatAccountMapper
{
TtWechatAccount selectTtWechatAccountByAccountId(Long accountId);
List<TtWechatAccount> selectTtWechatAccountList(TtWechatAccount account);
List<TtWechatAccount> selectEnabledTtWechatAccounts();
int insertTtWechatAccount(TtWechatAccount account);
int updateTtWechatAccount(TtWechatAccount account);
int deleteTtWechatAccountByAccountIds(Long[] accountIds);
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtWechatArticle;
public interface TtWechatArticleMapper
{
TtWechatArticle selectTtWechatArticleByArticleId(Long articleId);
TtWechatArticle selectTtWechatArticleByRequestNo(String requestNo);
List<TtWechatArticle> selectTtWechatArticleList(TtWechatArticle article);
int insertTtWechatArticle(TtWechatArticle article);
int updateTtWechatArticleResult(TtWechatArticle article);
int updateTtWechatArticleProcessing(TtWechatArticle article);
}

View File

@@ -0,0 +1,99 @@
package com.ruoyi.office.service;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.HtmlUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtFile;
/**
* 文案模板占位符渲染器。
*/
@Component
public class CopyTemplateRenderer
{
private static final Pattern PROJECT_CODE_PATTERN = Pattern.compile("【(.+?)】");
private static final Pattern PROJECT_NAME_PATTERN = Pattern.compile("实现的(.+?)$");
public String render(TtCopyTemplate template, TtCode code)
{
String codeName = value(code.getCodeName());
String projectCode = extractProjectCode(codeName);
String projectName = extractProjectName(codeName);
String content = value(template.getTemplateBody());
content = content.replace("{codeName}", codeName);
content = content.replace("{projectCode}", projectCode);
content = content.replace("{projectName}", projectName);
content = content.replace("{codeDesc}", toPlainDescription(code.getCodeDesc()));
content = content.replace("{codeEnvironment}", value(code.getCodeEnvironment()));
content = content.replace("{frontendTechnology}", value(code.getFrontendTechnology()));
content = content.replace("{backendTechnology}", value(code.getBackendTechnology()));
content = content.replace("{databaseTechnology}", value(code.getDatabaseTechnology()));
content = content.replace("{codeTechnology}", value(code.getCodeTechnology()));
content = content.replace("{diskLink}", value(code.getDiskLink()));
content = content.replace("{screenshots}", buildScreenshots(code.getFileList()));
return content;
}
private String extractProjectCode(String codeName)
{
Matcher matcher = PROJECT_CODE_PATTERN.matcher(codeName);
return matcher.find() ? matcher.group(1) : "";
}
private String extractProjectName(String codeName)
{
String projectName = PROJECT_CODE_PATTERN.matcher(codeName).replaceFirst("").trim();
if (projectName.startsWith("基于"))
{
Matcher matcher = PROJECT_NAME_PATTERN.matcher(projectName);
if (matcher.find())
{
return matcher.group(1);
}
}
return projectName;
}
private String toPlainDescription(String codeDesc)
{
if (StringUtils.isEmpty(codeDesc))
{
return "";
}
return HtmlUtils.htmlToText(codeDesc.replaceAll("(?i)</p>", "</p>\n").trim());
}
private String buildScreenshots(List<TtFile> fileList)
{
if (fileList == null || fileList.isEmpty())
{
return "请前往微信小程序:南音源码库。查看项目详情!\n\n";
}
StringBuilder screenshots = new StringBuilder();
int index = 0;
for (TtFile file : fileList)
{
screenshots.append(++index)
.append(".")
.append(value(file.getFileName()))
.append("\n![](")
.append(value(file.getFileUrl()))
.append(")\n\n");
}
return screenshots.toString();
}
private String value(String value)
{
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.office.service;
import java.util.Map;
/**
* 项目网盘链接检测服务。
*/
public interface IProjectLinkCheckService
{
Map<String, Object> checkProject(Integer projectId);
Map<String, Object> checkProjects(Integer[] projectIds);
}

View File

@@ -19,6 +19,14 @@ public interface ITtCodeService {
*/
public TtCode selectTtCodeByCodeId(Long codeId);
/**
* 根据项目名称查询源码管理
*
* @param codeName 项目名称
* @return 源码管理
*/
public TtCode selectTtCodeByCodeName(String codeName);
/**
* 查询源码管理列表
*
@@ -59,16 +67,8 @@ public interface ITtCodeService {
*/
public int deleteTtCodeByCodeId(Long codeId);
/**
* 转文章
*
* @param codeId 源码管理主键
* @return 结果
*/
public String transToArticle(Long codeId);
/**
* 转文章(南音)
*/
public String transToArticle1(Long codeId, String coverUrl);
public String transToArticle1(Long codeId, Long templateId);
}

View File

@@ -0,0 +1,59 @@
package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtCopyTemplateGroupStat;
/**
* 文案模板Service接口
*
* @author ruoyi
* @date 2026-04-10
*/
public interface ITtCopyTemplateService {
/**
* 查询文案模板
*/
public TtCopyTemplate selectTtCopyTemplateByTemplateId(Long templateId);
/**
* 查询文案模板列表(带分页、筛选)
*/
public List<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
/**
* 查询模板分组及模板数量
*/
public List<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats();
/**
* 查询所有启用的文案模板(供源码明细页按钮使用)
*/
public List<TtCopyTemplate> selectEnabledTemplates();
/**
* 按场景查询所有启用的文案模板
*/
public List<TtCopyTemplate> selectEnabledTemplates(String templateScene);
/**
* 新增文案模板
*/
public int insertTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 修改文案模板
*/
public int updateTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 批量删除文案模板
*/
public int deleteTtCopyTemplateByTemplateIds(Long[] templateIds);
/**
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}

View File

@@ -2,6 +2,8 @@ package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import org.springframework.web.multipart.MultipartFile;
/**
* 项目清单Service接口
@@ -64,4 +66,13 @@ public interface ITtProjectInfoService
List<?> lastUpdateList(String searchKey);
TtProjectInfo selectTtProjectInfoByName(String codeName);
/**
* 导入夸克或百度网盘分享链接。
*
* @param file 网盘客户端导出的 CSV/Excel 文件
* @param diskType QUARK/BAIDU
* @return 导入结果
*/
ProjectLinkImportResult importProjectLinks(MultipartFile file, String diskType);
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.office.service;
import java.io.IOException;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import com.ruoyi.office.domain.PublishBridgeTask;
import com.ruoyi.office.domain.PublishMaterial;
import com.ruoyi.office.domain.PublishProjectRow;
import com.ruoyi.office.domain.PublishStatusUpdate;
import com.ruoyi.office.domain.TtPublishTask;
public interface ITtPublishTaskService
{
List<PublishProjectRow> selectPublishProjectList(PublishProjectRow query);
PublishMaterial selectProjectMaterial(Long codeId, String platform);
TtPublishTask createPublishTask(TtPublishTask task);
List<TtPublishTask> createPublishTasks(List<TtPublishTask> tasks);
PublishBridgeTask selectBridgeTask(String taskId, String token);
TtPublishTask updateBridgeStatus(String taskId, String token, PublishStatusUpdate update);
void writeBridgeAsset(String taskId, String token, String kind, Integer index,
HttpServletResponse response) throws IOException;
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtWechatAccount;
public interface ITtWechatAccountService
{
TtWechatAccount selectById(Long accountId);
List<TtWechatAccount> selectList(TtWechatAccount account);
List<TtWechatAccount> selectEnabled();
int insert(TtWechatAccount account);
int update(TtWechatAccount account);
int delete(Long[] accountIds);
TtWechatAccount selectUsableWithSecret(Long accountId);
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.office.service;
import com.ruoyi.office.domain.WebsiteArticleSyncResult;
public interface IWebsiteArticleSyncService
{
WebsiteArticleSyncResult syncWebsite(Long codeId);
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtWechatArticle;
import com.ruoyi.office.domain.WechatDraftPreview;
import com.ruoyi.office.domain.WechatDraftPushRequest;
public interface IWechatDraftService
{
WechatDraftPreview preview(WechatDraftPushRequest request);
TtWechatArticle push(WechatDraftPushRequest request);
TtWechatArticle retry(Long articleId);
TtWechatArticle selectById(Long articleId);
List<TtWechatArticle> selectList(TtWechatArticle article);
void testAccount(Long accountId);
}

View File

@@ -0,0 +1,386 @@
package com.ruoyi.office.service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import javax.servlet.http.HttpServletResponse;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtFile;
import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.mapper.TtFileMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 源码项目截图压缩下载服务。
*/
@Service
public class TtScreenshotArchiveService
{
private static final int CONNECT_TIMEOUT_MILLIS = 10_000;
private static final int READ_TIMEOUT_MILLIS = 30_000;
private static final int MAX_REDIRECTS = 5;
private static final long MAX_IMAGE_BYTES = 20L * 1024 * 1024;
private static final long MAX_TOTAL_BYTES = 120L * 1024 * 1024;
private static final SSLSocketFactory INSECURE_SSL_SOCKET_FACTORY = createInsecureSslSocketFactory();
@Autowired
private TtCodeMapper codeMapper;
@Autowired
private TtFileMapper fileMapper;
/**
* 将指定项目关联的截图打包后写入响应。
*/
public void download(Long codeId, HttpServletResponse response) throws IOException
{
TtCode code = codeMapper.selectTtCodeByCodeId(codeId);
if (code == null)
{
throw new ServiceException("源码项目不存在");
}
List<TtFile> screenshots = fileMapper.selectTtFileByCodeName(code.getCodeName());
if (screenshots == null || screenshots.isEmpty())
{
throw new ServiceException("该项目暂无截图");
}
Path archive = Files.createTempFile("ruoyi-screenshots-", ".zip");
try
{
buildArchive(archive, screenshots);
String projectName = sanitizeFileName(code.getCodeName(), "项目");
response.setContentType("application/zip");
response.setContentLengthLong(Files.size(archive));
FileUtils.setAttachmentResponseHeader(response, projectName + "截图.zip");
Files.copy(archive, response.getOutputStream());
}
finally
{
Files.deleteIfExists(archive);
}
}
private void buildArchive(Path archive, List<TtFile> screenshots) throws IOException
{
long[] totalBytes = new long[] { 0L };
try (OutputStream output = Files.newOutputStream(archive);
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8))
{
for (int index = 0; index < screenshots.size(); index++)
{
addScreenshot(zip, screenshots.get(index), index + 1, totalBytes);
}
}
}
private void addScreenshot(ZipOutputStream zip, TtFile screenshot, int sequence,
long[] totalBytes) throws IOException
{
String displayName = StringUtils.isEmpty(screenshot.getFileName())
? "截图" : screenshot.getFileName();
URLConnection connection;
try
{
connection = openConnection(screenshot.getFileUrl(), displayName);
}
catch (ServiceException exception)
{
throw exception;
}
catch (IOException exception)
{
throw new ServiceException("截图“" + displayName + "”下载失败,请检查图片链接")
.setDetailMessage(exception.getMessage());
}
try
{
long contentLength = connection.getContentLengthLong();
if (contentLength > MAX_IMAGE_BYTES)
{
throw new ServiceException("截图“" + displayName + "”超过20MB,无法打包");
}
String entryName = buildEntryName(sequence, displayName,
screenshot.getFileUrl(), connection.getContentType());
zip.putNextEntry(new ZipEntry(entryName));
try (InputStream input = connection.getInputStream())
{
copyWithLimit(input, zip, displayName, totalBytes);
}
finally
{
zip.closeEntry();
}
}
catch (ServiceException exception)
{
throw exception;
}
catch (IOException exception)
{
throw new ServiceException("截图“" + displayName + "”下载失败,请检查图片链接")
.setDetailMessage(exception.getMessage());
}
finally
{
if (connection instanceof HttpURLConnection)
{
((HttpURLConnection) connection).disconnect();
}
}
}
private URLConnection openConnection(String fileUrl, String displayName) throws IOException
{
if (StringUtils.isEmpty(fileUrl))
{
throw new ServiceException("截图“" + displayName + "”未配置图片链接");
}
URL url;
try
{
url = toAsciiUrl(fileUrl);
}
catch (Exception exception)
{
throw new ServiceException("截图“" + displayName + "”的图片链接无效");
}
String protocol = url.getProtocol().toLowerCase(Locale.ROOT);
if (!"http".equals(protocol) && !"https".equals(protocol))
{
throw new ServiceException("截图“" + displayName + "”仅支持HTTP或HTTPS图片链接");
}
URL currentUrl = url;
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++)
{
HttpURLConnection http = createHttpConnection(currentUrl);
int status = http.getResponseCode();
if (isRedirect(status))
{
String location = http.getHeaderField("Location");
http.disconnect();
if (StringUtils.isEmpty(location))
{
throw new ServiceException("截图“" + displayName + "”跳转失败,图片服务未返回新地址");
}
if (redirectCount >= MAX_REDIRECTS)
{
throw new ServiceException("截图“" + displayName + "”重定向次数过多");
}
URL redirectUrl;
try
{
redirectUrl = resolveRedirectUrl(currentUrl, location);
}
catch (Exception exception)
{
throw new ServiceException("截图“" + displayName + "”的跳转地址无效");
}
String redirectProtocol = redirectUrl.getProtocol().toLowerCase(Locale.ROOT);
if (!"http".equals(redirectProtocol) && !"https".equals(redirectProtocol))
{
throw new ServiceException("截图“" + displayName + "”跳转到了不支持的图片地址");
}
currentUrl = redirectUrl;
continue;
}
if (status < 200 || status >= 300)
{
http.disconnect();
throw new ServiceException("截图“" + displayName + "”下载失败,图片服务返回" + status);
}
return http;
}
throw new ServiceException("截图“" + displayName + "”重定向次数过多");
}
private HttpURLConnection createHttpConnection(URL url) throws IOException
{
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
disableCertificateValidation(connection);
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
connection.setReadTimeout(READ_TIMEOUT_MILLIS);
connection.setUseCaches(false);
connection.setRequestProperty("User-Agent", "RuoYi-Screenshot-Downloader/1.0");
return connection;
}
/**
* 仅对截图下载连接跳过 HTTPS 证书链校验,不修改 JVM 全局 HTTPS 配置。
* 域名校验仍由 HttpsURLConnection 的默认 HostnameVerifier 负责。
*/
static void disableCertificateValidation(HttpURLConnection connection)
{
if (connection instanceof HttpsURLConnection)
{
((HttpsURLConnection) connection).setSSLSocketFactory(INSECURE_SSL_SOCKET_FACTORY);
}
}
private static SSLSocketFactory createInsecureSslSocketFactory()
{
try
{
TrustManager[] trustManagers = new TrustManager[] { new X509TrustManager()
{
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
{
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
{
}
@Override
public X509Certificate[] getAcceptedIssuers()
{
return new X509Certificate[0];
}
} };
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());
return sslContext.getSocketFactory();
}
catch (Exception exception)
{
throw new IllegalStateException("初始化截图下载 HTTPS 配置失败", exception);
}
}
/**
* 将包含中文等非 ASCII 字符的图片地址转换为 HttpURLConnection 可安全请求的地址。
*/
private URL toAsciiUrl(String fileUrl) throws Exception
{
URI uri = new URI(fileUrl);
return new URL(uri.toASCIIString());
}
/**
* 解析相对或绝对重定向地址,并保证重定向后的 URL 同样完成百分号编码。
*/
private URL resolveRedirectUrl(URL currentUrl, String location) throws Exception
{
URI redirectUri = currentUrl.toURI().resolve(new URI(location));
return new URL(redirectUri.toASCIIString());
}
private boolean isRedirect(int status)
{
return status == HttpURLConnection.HTTP_MOVED_PERM
|| status == HttpURLConnection.HTTP_MOVED_TEMP
|| status == HttpURLConnection.HTTP_SEE_OTHER
|| status == 307
|| status == 308;
}
private void copyWithLimit(InputStream input, ZipOutputStream zip, String displayName,
long[] totalBytes) throws IOException
{
byte[] buffer = new byte[8192];
long imageBytes = 0L;
int length;
while ((length = input.read(buffer)) != -1)
{
imageBytes += length;
totalBytes[0] += length;
if (imageBytes > MAX_IMAGE_BYTES)
{
throw new ServiceException("截图“" + displayName + "”超过20MB,无法打包");
}
if (totalBytes[0] > MAX_TOTAL_BYTES)
{
throw new ServiceException("项目截图总大小超过120MB,无法打包");
}
zip.write(buffer, 0, length);
}
}
static String buildEntryName(int sequence, String fileName, String fileUrl, String contentType)
{
String normalizedName = sanitizeFileName(fileName, "截图");
if (!hasExtension(normalizedName))
{
normalizedName += "." + resolveExtension(fileUrl, contentType);
}
return String.format(Locale.ROOT, "%02d_%s", sequence, normalizedName);
}
private static boolean hasExtension(String fileName)
{
int dot = fileName.lastIndexOf('.');
return dot > 0 && dot < fileName.length() - 1
&& fileName.substring(dot + 1).matches("[A-Za-z0-9]{1,10}");
}
private static String resolveExtension(String fileUrl, String contentType)
{
if (!StringUtils.isEmpty(fileUrl))
{
String path = fileUrl;
int queryIndex = path.indexOf('?');
if (queryIndex >= 0)
{
path = path.substring(0, queryIndex);
}
int dot = path.lastIndexOf('.');
if (dot >= 0 && dot < path.length() - 1)
{
String extension = path.substring(dot + 1).toLowerCase(Locale.ROOT);
if (extension.matches("jpg|jpeg|png|gif|bmp|webp|svg"))
{
return extension;
}
}
}
if (!StringUtils.isEmpty(contentType))
{
String type = contentType.toLowerCase(Locale.ROOT);
if (type.contains("png")) return "png";
if (type.contains("gif")) return "gif";
if (type.contains("bmp")) return "bmp";
if (type.contains("webp")) return "webp";
if (type.contains("svg")) return "svg";
}
return "jpg";
}
static String sanitizeFileName(String fileName, String fallback)
{
String safeName = StringUtils.isEmpty(fileName) ? fallback : fileName.trim();
safeName = safeName.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_")
.replaceAll("[. ]+$", "");
return StringUtils.isEmpty(safeName) ? fallback : safeName;
}
}

Some files were not shown because too many files have changed in this diff Show More