From ee65fc7fd2a5e20a85d6316219a87e50b3e52ca4 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Nov 2026 00:09:43 +0800 Subject: [PATCH] feat(forum): add complete forum workflows --- assets/admin/src/constants/sections/forum.js | 27 ++ assets/admin/src/constants/sections/index.js | 3 + assets/admin/src/pages/SchemaPage.vue | 4 +- assets/css/routes/forum.css | 100 +++++ assets/js/forum-runtime.js | 266 +++++++++++++ functions.php | 5 + inc/helpers/template-tags/search-meta.php | 8 + inc/modules/user-notifications.php | 6 +- inc/modules/user-parts/template-registry.php | 6 + search.php | 10 + src/Admin/Schema/SchemaForum.php | 79 ++++ src/Admin/SchemaProvider.php | 18 + src/Core/DomainModel.php | 4 + src/Forum/Access.php | 104 +++++ src/Forum/Commerce.php | 47 +++ src/Forum/Core.php | 208 ++++++++++ src/Forum/Integration.php | 175 +++++++++ src/Forum/Lock.php | 29 ++ src/Forum/Rest.php | 127 ++++++ src/Forum/RestActions.php | 229 +++++++++++ src/Forum/RestCommerce.php | 201 ++++++++++ src/Forum/RestMedia.php | 26 ++ src/Forum/RestPlates.php | 174 +++++++++ src/Forum/RestPosts.php | 251 ++++++++++++ src/Forum/RestReplies.php | 132 +++++++ src/Forum/View.php | 387 +++++++++++++++++++ src/Forum/Widget.php | 58 +++ src/User/Home.php | 3 + src/WidgetSystem/WidgetAreas.php | 7 + templates/pages/page-forum-edit.php | 57 +++ templates/pages/page-forum-list.php | 3 + templates/pages/page-forum-post.php | 85 ++++ templates/pages/page-forums.php | 12 + templates/parts/user-center/forum.php | 3 + templates/parts/user-home/panel-forum.php | 6 + tests/forum/browser-smoke.cjs | 207 ++++++++++ tests/forum/docker-compose.yml | 40 ++ tests/forum/forum-contract.test.cjs | 47 +++ tests/forum/smoke.php | 275 +++++++++++++ 39 files changed, 3425 insertions(+), 4 deletions(-) create mode 100644 assets/admin/src/constants/sections/forum.js create mode 100644 assets/css/routes/forum.css create mode 100644 assets/js/forum-runtime.js create mode 100644 src/Admin/Schema/SchemaForum.php create mode 100644 src/Forum/Access.php create mode 100644 src/Forum/Commerce.php create mode 100644 src/Forum/Core.php create mode 100644 src/Forum/Integration.php create mode 100644 src/Forum/Lock.php create mode 100644 src/Forum/Rest.php create mode 100644 src/Forum/RestActions.php create mode 100644 src/Forum/RestCommerce.php create mode 100644 src/Forum/RestMedia.php create mode 100644 src/Forum/RestPlates.php create mode 100644 src/Forum/RestPosts.php create mode 100644 src/Forum/RestReplies.php create mode 100644 src/Forum/View.php create mode 100644 src/Forum/Widget.php create mode 100644 templates/pages/page-forum-edit.php create mode 100644 templates/pages/page-forum-list.php create mode 100644 templates/pages/page-forum-post.php create mode 100644 templates/pages/page-forums.php create mode 100644 templates/parts/user-center/forum.php create mode 100644 templates/parts/user-home/panel-forum.php create mode 100644 tests/forum/browser-smoke.cjs create mode 100644 tests/forum/docker-compose.yml create mode 100644 tests/forum/forum-contract.test.cjs create mode 100644 tests/forum/smoke.php diff --git a/assets/admin/src/constants/sections/forum.js b/assets/admin/src/constants/sections/forum.js new file mode 100644 index 00000000..7baeb5b3 --- /dev/null +++ b/assets/admin/src/constants/sections/forum.js @@ -0,0 +1,27 @@ +import { renderFaIcon, schemaPage } from './helpers' + +const pages = [ + ['general', '全局设置'], + ['home', '首页与列表'], + ['publishing', '发布与审核'], + ['access', '权限与版主'], + ['replies', '回复与互动'], + ['payment', '付费与积分'], + ['urls', '链接设置'], +] + +export const forumSection = { + key: 'forum', + label: '社区论坛', + icon: renderFaIcon('fa fas fa-comments'), + route: '/forum', + children: pages.map(([key, label]) => schemaPage({ + key: `forum-${key}`, + name: `Forum_${key}`, + label, + title: label, + route: `/forum/${key}`, + canonicalDomain: 'forum', + section: key, + })), +} diff --git a/assets/admin/src/constants/sections/index.js b/assets/admin/src/constants/sections/index.js index dbb6ef40..57fc28a3 100644 --- a/assets/admin/src/constants/sections/index.js +++ b/assets/admin/src/constants/sections/index.js @@ -8,6 +8,7 @@ import { extensionsSection } from './extensions' import { themeInfoSection } from './theme-info' import { docsSection } from './docs' import { toolsSection } from './tools' +import { forumSection } from './forum' const SECTION_DEFAULT_CHILDREN = { global: 'global-common', @@ -18,6 +19,7 @@ const SECTION_DEFAULT_CHILDREN = { payment: 'payment-knowledge', extensions: 'extensions-system', tools: 'tools-import-export', + forum: 'forum-general', 'theme-info': 'theme-info-dashboard', docs: 'docs-dashboard', } @@ -43,6 +45,7 @@ export const ADMIN_SECTIONS = [ attachSectionDefaultRoute(permissionsSection), attachSectionDefaultRoute(usersSection), attachSectionDefaultRoute(paymentSection), + attachSectionDefaultRoute(forumSection), attachSectionDefaultRoute(extensionsSection), attachSectionDefaultRoute(toolsSection), attachSectionDefaultRoute(themeInfoSection), diff --git a/assets/admin/src/pages/SchemaPage.vue b/assets/admin/src/pages/SchemaPage.vue index 0ccd679b..05424695 100644 --- a/assets/admin/src/pages/SchemaPage.vue +++ b/assets/admin/src/pages/SchemaPage.vue @@ -170,7 +170,7 @@ function resolveSectionFromPath(path) { /** Load schema from the Vue-side registry */ const pageMeta = computed(() => { - const schema = (isEmailPage.value || resolvedDomain.value === 'page_display') + const schema = (isEmailPage.value || ['page_display', 'forum'].includes(resolvedDomain.value)) ? serverSchema.value : getSchema(resolvedDomain.value, resolvedSection.value) if (schema && schema.groups && schema.groups.length > 0) { @@ -192,7 +192,7 @@ const pageMeta = computed(() => { /** Default values from schema */ const defaults = computed(() => { - const schema = (isEmailPage.value || resolvedDomain.value === 'page_display') + const schema = (isEmailPage.value || ['page_display', 'forum'].includes(resolvedDomain.value)) ? serverSchema.value : getSchema(resolvedDomain.value, resolvedSection.value) if (!schema) return {} diff --git a/assets/css/routes/forum.css b/assets/css/routes/forum.css new file mode 100644 index 00000000..f9396270 --- /dev/null +++ b/assets/css/routes/forum.css @@ -0,0 +1,100 @@ +#forum{--forum-ink:var(--key-color,#24282c);--forum-muted:var(--muted-color,#737b82);--forum-line:var(--muted-4-color,#e6e9eb);--forum-accent:var(--focus-color,#2869d1);color:var(--forum-ink)} +#forum .jio-forum__wrap{width:min(1200px,calc(100% - 32px));margin:22px auto 70px} +#forum .jio-forum__breadcrumbs{display:flex;gap:9px;align-items:center;color:var(--forum-muted);font-size:13px;margin-bottom:18px} +#forum .jio-forum__breadcrumbs a{color:inherit} +#forum .jio-forum__layout{display:grid;grid-template-columns:minmax(0,1fr) 286px;gap:24px;align-items:start} +#forum .jio-forum__main{min-width:0} +#forum .jio-forum__hero,#forum .jio-forum__page-head{padding:20px 0;border-bottom:1px solid var(--forum-line)} +#forum h1{font-size:27px;line-height:1.25;margin:0 0 10px} +#forum .jio-forum__hero p,#forum .jio-forum__page-head p{margin:0;color:var(--forum-muted)} +#forum .jio-forum__tabs{display:flex;gap:26px;overflow:auto;white-space:nowrap;border-bottom:1px solid var(--forum-line);margin-bottom:16px} +#forum .jio-forum__tabs a{padding:14px 0;color:var(--forum-muted);border-bottom:2px solid transparent} +#forum .jio-forum__tabs .is-active{color:var(--forum-accent);border-color:var(--forum-accent);font-weight:600} +#forum .jio-forum__chips{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0} +#forum .jio-forum__chips a,#forum .jio-forum__badge,#forum .jio-forum__badges a,#forum .jio-forum__badges span{padding:4px 8px;background:#f1f5f7;color:#48717c;border-radius:4px;font-size:12px} +#forum .jio-forum__post{display:flex;gap:13px;padding:20px 2px;border-bottom:1px solid var(--forum-line)} +#forum .jio-forum__avatar{flex:0 0 44px}#forum .jio-forum__avatar img{width:44px;height:44px;border-radius:50%} +#forum .jio-forum__post-body{min-width:0;flex:1}#forum .jio-forum__post-top{display:flex;gap:12px;color:var(--forum-muted);font-size:12px} +#forum .jio-forum__post h2{font-size:17px;line-height:1.45;margin:7px 0}#forum .jio-forum__post h2 a{color:var(--forum-ink)} +#forum .jio-forum__post p{color:var(--forum-muted);font-size:14px;line-height:1.6;margin:0 0 10px} +#forum .jio-forum__post-meta{display:flex;align-items:center;gap:10px;color:var(--forum-muted);font-size:12px;flex-wrap:wrap} +#forum .jio-forum__post-meta a{color:var(--forum-accent)} +#forum .jio-forum__aside{border-left:1px solid var(--forum-line);padding-left:20px} +#forum .jio-forum__new,#forum .jio-forum__primary{display:inline-flex;align-items:center;justify-content:center;background:var(--forum-accent);color:#fff;border:0;border-radius:4px;min-height:40px;padding:8px 16px;cursor:pointer} +#forum .jio-forum__new{width:100%}#forum .jio-forum__aside-section{padding:22px 0;border-bottom:1px solid var(--forum-line)} +#forum .jio-forum__aside-section h2{font-size:16px;margin:0 0 12px}#forum .jio-forum__aside-section a{display:block;padding:6px 0;color:var(--forum-ink)} +#forum .jio-forum__plates{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px} +#forum .jio-forum__plate{display:flex;gap:13px;padding:14px;border:1px solid var(--forum-line);border-radius:6px} +#forum .jio-forum__plate-cover{width:80px;height:80px;flex:none;background:#edf2f5;border-radius:5px;overflow:hidden} +#forum .jio-forum__plate-cover img{width:100%;height:100%;object-fit:cover}#forum .jio-forum__plate h2{font-size:16px;margin:3px 0 8px} +#forum .jio-forum__plate p{font-size:13px;color:var(--forum-muted);margin:0} +#forum .jio-forum__single{padding:20px 0}#forum .jio-forum__single-meta{display:flex;align-items:center;gap:10px;color:var(--forum-muted);font-size:13px} +#forum .jio-forum__single-meta img{border-radius:50%}#forum .jio-forum__single-head h1{margin:18px 0} +#forum .jio-forum__badges,#forum .jio-forum__actions{display:flex;gap:8px;flex-wrap:wrap} +#forum .jio-forum__single-content{font-size:15px;line-height:1.8;padding:22px 0;overflow-wrap:anywhere} +#forum .jio-forum__actions{border-top:1px solid var(--forum-line);padding:16px 0} +#forum .jio-forum__action{border:1px solid var(--forum-line);border-radius:4px;background:transparent;color:var(--forum-muted);padding:7px 12px;cursor:pointer} +#forum .jio-forum__action:hover{color:var(--forum-accent);border-color:var(--forum-accent)} +#forum .jio-forum__form{display:grid;gap:15px;margin:20px 0}#forum .jio-forum__form label{display:grid;gap:6px;font-size:14px;font-weight:600} +#forum .jio-forum__form input,#forum .jio-forum__form select,#forum .jio-forum__form textarea{width:100%;border:1px solid #d6dce1;border-radius:4px;padding:10px 12px;background:var(--body-bg,#fff);color:inherit;font:inherit} +#forum .jio-forum__form-extra[hidden]{display:none}#forum .jio-forum__message{color:#b64343;min-height:20px} +#forum .jio-forum__vote{border-top:1px solid var(--forum-line);padding:20px 0}#forum .jio-forum__vote h2{font-size:17px} +#forum .jio-forum__vote label{display:flex;gap:12px;padding:12px;border:1px solid var(--forum-line);margin-bottom:8px;border-radius:4px} +#forum .jio-forum__vote-count{margin-left:auto}#forum .jio-forum__locked{padding:30px;text-align:center;background:#f7f9fa} +#forum .jio-forum__pagination ul{list-style:none;display:flex;gap:7px;padding:0;margin:24px 0} +#forum .jio-forum__pagination a,#forum .jio-forum__pagination span{display:block;padding:6px 10px;border:1px solid var(--forum-line);border-radius:4px} +#forum .jio-forum__empty{padding:36px;text-align:center;color:var(--forum-muted)} +@media(max-width:800px){#forum .jio-forum__layout{grid-template-columns:1fr}#forum .jio-forum__aside{border-left:0;padding-left:0}#forum .jio-forum__plates{grid-template-columns:1fr}} +@media(max-width:480px){#forum .jio-forum__wrap{width:calc(100% - 24px)}#forum h1{font-size:23px}#forum .jio-forum__post{padding:16px 0}#forum .jio-forum__tabs{gap:18px}} +#forum .jio-forum__create-plate{width:100%;margin-top:10px} +#forum .jio-forum__section-title{font-size:18px;margin:28px 0 0} +#forum .jio-forum__reply{display:flex;gap:12px;padding:17px 0;border-bottom:1px solid var(--forum-line)} +#forum .jio-forum__reply-avatar img{border-radius:50%} +#forum .jio-forum__reply-main{flex:1;min-width:0} +#forum .jio-forum__reply-head{display:flex;gap:12px;align-items:center;font-size:13px} +#forum .jio-forum__reply-head time{color:var(--forum-muted)}#forum .jio-forum__reply-head strong{color:#147e70} +#forum .jio-forum__reply-text{padding:10px 0;line-height:1.7;overflow-wrap:anywhere} +#forum .jio-forum__reply-actions button{border:0;background:none;color:var(--forum-accent);padding:4px 10px 4px 0;cursor:pointer} +#forum .jio-forum__reply-form{display:grid;gap:12px;margin:24px 0} +#forum .jio-forum__reply-form h3{font-size:17px;margin:0} +#forum .jio-forum__reply-form textarea{width:100%;border:1px solid var(--forum-line);padding:12px;border-radius:4px;background:var(--body-bg,#fff);color:inherit} +#forum .jio-forum__reply-form button{justify-self:start} +#forum .jio-forum__widget-link{display:flex;justify-content:space-between;gap:10px;padding:7px 0;color:var(--forum-ink)} +#forum .jio-forum__widget-link span{color:var(--forum-muted)} +.jio-forum__dialog{width:min(440px,calc(100% - 28px));max-height:90vh;border:1px solid #dce2e5;border-radius:6px;padding:22px;background:var(--body-bg,#fff);color:inherit;box-shadow:0 20px 60px #0003} +.jio-forum__dialog::backdrop{background:#10182088} +.jio-forum__dialog h2{font-size:20px;margin:0 0 18px;overflow-wrap:anywhere} +.jio-forum__dialog form,.jio-forum__dialog-fields{display:grid;gap:14px} +.jio-forum__dialog label{display:grid;gap:6px;font-size:14px} +.jio-forum__dialog input,.jio-forum__dialog select,.jio-forum__dialog textarea{width:100%;min-height:39px;border:1px solid #cfd7dc;padding:9px;border-radius:4px;background:var(--body-bg,#fff);color:inherit} +.jio-forum__dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:10px} +.jio-forum__dialog-actions button{border:1px solid #d6dce0;border-radius:4px;padding:8px 14px;cursor:pointer} +.jio-forum__qr{width:180px;height:180px;margin:auto} +.jio-forum__user-panel h2{font-size:18px;margin:0 0 14px} +.jio-forum__user-row{display:flex;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid #e6e9eb} +#forum .jio-forum__applications{margin:18px 0;padding:14px 0;border-top:1px solid var(--forum-line)} +#forum .jio-forum__applications h2{font-size:16px;margin:0 0 8px} +#forum .jio-forum__application{display:flex;align-items:center;gap:10px;padding:6px 0} +#forum .jio-forum__application button{border:1px solid var(--forum-line);border-radius:4px;background:transparent;padding:4px 9px;cursor:pointer} +#forum .jio-forum__reply-parent{color:var(--forum-muted);font-size:12px;margin:8px 0 0} +#forum .jio-forum__hot-reply{margin:0 0 10px 58px;padding:9px 12px;background:#f5f8f9;color:var(--forum-muted);font-size:13px;border-radius:4px} +#forum .jio-forum__pending{padding:16px 0;border-bottom:1px solid var(--forum-line)} +#forum .jio-forum__pending h2{font-size:16px;margin:0 0 10px} +#forum .jio-forum__pending>div{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 0} +#forum .jio-forum__pending button{border:1px solid var(--forum-line);background:transparent;border-radius:4px;padding:5px 9px;cursor:pointer} +#forum .jio-forum__editor-tools{display:flex;gap:8px;flex-wrap:wrap} +#forum .wp-editor-wrap{border:1px solid var(--forum-line)} +body.dark-theme #forum .jio-forum__chips a,body.dark-theme #forum .jio-forum__badge,body.dark-theme #forum .jio-forum__badges a,body.dark-theme #forum .jio-forum__badges span{background:var(--muted-bg-2-color);color:var(--forum-accent)} +body.dark-theme #forum .jio-forum__plate-cover{background:var(--muted-bg-2-color)} +body.dark-theme #forum .jio-forum__locked,body.dark-theme #forum .jio-forum__hot-reply{background:var(--muted-bg-2-color)} +body.dark-theme #forum .jio-forum__form input,body.dark-theme #forum .jio-forum__form select,body.dark-theme #forum .jio-forum__form textarea,body.dark-theme #forum .jio-forum__reply-form textarea{background:var(--main-bg-color);border-color:var(--forum-line);color:var(--forum-ink)} +body.dark-theme .jio-forum__dialog{background:var(--main-bg-color);color:var(--key-color);border-color:var(--muted-4-color)} +body.dark-theme .jio-forum__dialog input,body.dark-theme .jio-forum__dialog select,body.dark-theme .jio-forum__dialog textarea{background:var(--main-bg-color);color:var(--key-color);border-color:var(--muted-4-color)} +body.dark-theme #forum .jio-forum__action{color:var(--forum-muted)} +#forum .jio-forum__term-management{margin:16px 0;padding:16px 0;border-bottom:1px solid var(--forum-line)} +#forum .jio-forum__term-management h2{font-size:16px} +#forum .jio-forum__term-management form{display:flex;gap:8px;align-items:end;flex-wrap:wrap} +#forum .jio-forum__term-management label{display:grid;gap:5px;font-size:13px} +#forum .jio-forum__term-management input,#forum .jio-forum__term-management select{min-height:34px;padding:5px 8px;border:1px solid var(--forum-line);border-radius:4px;background:var(--main-bg-color);color:var(--forum-ink)} +#forum .jio-forum__term-row{display:flex;gap:8px;align-items:center;padding:5px 0} +#forum .jio-forum__term-row button{border:1px solid var(--forum-line);border-radius:4px;background:transparent;padding:4px 7px;cursor:pointer} diff --git a/assets/js/forum-runtime.js b/assets/js/forum-runtime.js new file mode 100644 index 00000000..d88c5e4b --- /dev/null +++ b/assets/js/forum-runtime.js @@ -0,0 +1,266 @@ +(() => { + const config = window.jioForum || {} + const root = document.getElementById('forum') + if (!root) return + + const send = async (path, method = 'GET', data = {}, idempotencyKey = '') => { + const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce } + if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey + const response = await fetch(config.api + path, { + method, + credentials: 'same-origin', + headers, + body: method === 'GET' ? undefined : JSON.stringify(data), + }) + const payload = await response.json() + if (!response.ok || payload.success === false) throw new Error(payload.message || '操作失败') + return payload + } + + const message = (element, value) => { + const target = element.querySelector('.jio-forum__message') || element.closest('.jio-forum__single, .jio-forum__replies, .jio-forum__page-head')?.querySelector('.jio-forum__message') + if (target) target.textContent = value + else if (value) window.alert(value) + } + + const modal = (title, fields, submitText = '确认') => new Promise((resolve) => { + const dialog = document.createElement('dialog') + dialog.className = 'jio-forum__dialog' + const form = document.createElement('form') + form.innerHTML = '

' + form.querySelector('h2').textContent = title + form.querySelector('[type="submit"]').textContent = submitText + const body = form.querySelector('.jio-forum__dialog-fields') + fields.forEach(({ name, label, type = 'text', options = [], value = '' }) => { + const wrapper = document.createElement('label') + wrapper.textContent = label + const control = type === 'select' ? document.createElement('select') : document.createElement(type === 'textarea' ? 'textarea' : 'input') + control.name = name + control.required = true + if (type === 'select') options.forEach(({ value: optionValue, label: optionLabel }) => { + const option = document.createElement('option') + option.value = optionValue + option.textContent = optionLabel + control.append(option) + }) + else if (type === 'text') control.type = 'text' + control.value = value + wrapper.append(control) + body.append(wrapper) + }) + dialog.append(form) + root.append(dialog) + const close = (value) => { dialog.close(); dialog.remove(); resolve(value) } + form.querySelector('[data-cancel]').addEventListener('click', () => close(null)) + dialog.addEventListener('cancel', (event) => { event.preventDefault(); close(null) }) + form.addEventListener('submit', (event) => { event.preventDefault(); close(Object.fromEntries(new FormData(form))) }) + dialog.showModal() + }) + + const checkout = async (kind, id) => { + const preview = (await send(`${kind}s/${id}/checkout`)).data + const methods = (preview.methods || []).map((value) => ({ value, label: { + points: '积分', balance: '站内余额', alipay: '支付宝', wechatpay: '微信支付', paypal: 'PayPal', stripe: '银行卡', + }[value] || value })) + const data = await modal(`解锁 ${preview.title} · ${preview.price}${preview.mode === 'points' ? '积分' : '元'}`, [ + { name: 'payment_method', label: '支付方式', type: 'select', options: methods }, + ], '确认支付') + if (!data) return + const key = crypto.randomUUID() + const result = (await send(`${kind}s/${id}/checkout`, 'POST', data, key)).data + if (result.mode === 'redirect' && result.payment_url) window.location.href = result.payment_url + else if (result.mode === 'form' && result.form_html) { + const wrapper = document.createElement('div') + wrapper.innerHTML = result.form_html + document.body.append(wrapper) + wrapper.querySelector('form')?.submit() + } else if (result.mode === 'qrcode' && result.code_url && window.QRCode) { + const dialog = document.createElement('dialog') + dialog.className = 'jio-forum__dialog' + dialog.innerHTML = '

扫码支付

支付完成后刷新页面

' + root.append(dialog) + new window.QRCode(dialog.querySelector('.jio-forum__qr'), { text: result.code_url, width: 180, height: 180 }) + dialog.querySelector('button').addEventListener('click', () => window.location.reload()) + dialog.showModal() + } else if (result.mode) window.location.href = config.orders + else window.location.reload() + } + + const typeSelect = root.querySelector('select[name="type"]') + const syncType = () => { + const vote = root.querySelector('[data-forum-type="vote"]') + if (vote) vote.hidden = typeSelect?.value !== 'vote' + } + typeSelect?.addEventListener('change', syncType) + syncType() + + const insertMedia = (kind, url, name) => { + const element = document.createElement(kind === 'image' ? 'img' : kind === 'video' ? 'video' : 'a') + if (kind === 'resource') { element.href = url; element.textContent = name || '下载附件' } + else element.src = url + if (kind === 'video') element.controls = true + if (kind === 'image') element.alt = name || '' + const html = element.outerHTML + const editor = window.tinymce?.get('jio_forum_content') + if (editor && !editor.isHidden()) editor.insertContent(html) + else { + const textarea = root.querySelector('textarea[name="content"]') + if (textarea) textarea.setRangeText(html, textarea.selectionStart, textarea.selectionEnd, 'end') + } + } + + root.addEventListener('change', async (event) => { + const input = event.target.closest('[data-forum-file]') + if (!input?.files?.[0]) return + const kind = input.dataset.kind + const data = new FormData() + data.append('file', input.files[0]) + try { + const response = await fetch(config.api + `upload/${kind}`, { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': config.nonce }, body: data }) + const payload = await response.json() + if (!response.ok || !payload.success) throw new Error(payload.message || '上传失败') + insertMedia(kind, payload.data.url, payload.data.name) + } catch (error) { + message(input.closest('.jio-forum__form') || root, error.message) + } finally { + input.value = '' + } + }) + + root.addEventListener('submit', async (event) => { + const form = event.target.closest('[data-forum-form]') + if (!form) return + event.preventDefault() + const button = form.querySelector('[type="submit"]') + const kind = form.dataset.forumForm + const id = Number(form.dataset.id || 0) + if (kind === 'post') window.tinymce?.get('jio_forum_content')?.save() + const body = Object.fromEntries(new FormData(form).entries()) + if (kind === 'vote' && form.querySelector('input[name="option[]"]')) { + body.options = new FormData(form).getAll('option[]') + } + button.disabled = true + try { + const result = kind === 'vote' ? await send(`posts/${id}/vote`, 'POST', body) + : kind === 'plate' ? await send(id ? `plates/${id}` : 'plates', id ? 'PATCH' : 'POST', body) + : kind === 'term' ? await send('terms', 'POST', body) + : kind === 'moderator' ? await send(`plates/${id}/moderators`, 'POST', { ...body, active: true }) + : kind === 'reply' ? await send(`posts/${id}/replies`, 'POST', body) + : await send(id ? `posts/${id}` : 'posts', id ? 'PATCH' : 'POST', body) + if (result.data?.url) window.location.href = result.data.url + else if (kind === 'moderator') window.location.reload() + else if (kind === 'term') window.location.reload() + else if (kind === 'vote') window.location.reload() + else if (kind === 'reply' && result.data?.status === 'publish') window.location.reload() + else message(form, result.message || '已保存') + } catch (error) { + message(form, error.message) + } finally { + button.disabled = false + } + }) + + root.addEventListener('click', async (event) => { + const upload = event.target.closest('[data-forum-upload]') + if (upload) { + const input = root.querySelector('[data-forum-file]') + if (input) { + input.dataset.kind = upload.dataset.forumUpload + input.accept = { image: 'image/*', video: 'video/*', resource: '' }[input.dataset.kind] || '' + input.click() + } + return + } + const open = event.target.closest('[data-forum-open]') + if (open) { root.querySelector(`[data-forum-dialog="${open.dataset.forumOpen}"]`)?.showModal(); return } + const close = event.target.closest('[data-forum-close]') + if (close) { close.closest('dialog')?.close(); return } + const reply = event.target.closest('[data-forum-reply-to]') + if (reply) { + const form = root.querySelector('[data-forum-form="reply"]') + if (form) { form.querySelector('[name="parent_id"]').value = reply.dataset.forumReplyTo; form.scrollIntoView({ behavior: 'smooth' }); form.querySelector('textarea')?.focus() } + return + } + const button = event.target.closest('[data-forum-action]') + if (!button) return + const action = button.dataset.forumAction + const id = Number(button.dataset.id || 0) + if (action === 'delete' && !window.confirm('确定删除此内容?')) return + button.disabled = true + try { + let result + if (action === 'unlock') { await checkout('post', id); return } + if (action === 'plate-unlock') { await checkout('plate', id); return } + if (action === 'plate-audit') { + result = await send(`plates/${id}/audit`, 'POST', { status: 'publish' }) + button.closest('.jio-forum__pending > div')?.remove() + } else if (action === 'plate-delete') { + const data = await modal('删除版块', [ + { name: 'posts_action', label: '关联帖子', type: 'select', options: [{ value: 'trash', label: '一并移入回收站' }, { value: 'move', label: '移动到其他版块' }] }, + { name: 'target_plate_id', label: '目标版块', type: 'select', options: [{ value: '0', label: '无需选择' }, ...(config.plates || []).filter((plate) => plate.id !== id).map((plate) => ({ value: String(plate.id), label: plate.title }))] }, + ], '确认删除') + if (!data) return + result = await send(`plates/${id}/delete`, 'POST', data) + button.dataset.forumAction = 'plate-restore' + button.textContent = '撤销删除' + } else if (action === 'plate-restore') { + result = await send(`plates/${id}/restore`, 'POST') + button.dataset.forumAction = 'plate-delete' + button.textContent = '删除版块' + } else if (action === 'reply-like' || action === 'reply-hot') { + result = await send(`replies/${id}/${action === 'reply-like' ? 'like' : 'hot'}`, 'POST') + } else if (action === 'reply-delete') { + if (!window.confirm('确定删除此回复?')) return + result = await send(`replies/${id}/delete`, 'POST') + button.closest('.jio-forum__reply')?.remove() + } else if (action === 'term-edit') { + const name = button.closest('.jio-forum__term-row')?.querySelector('span')?.textContent || '' + const data = await modal('编辑分类', [{ name: 'name', label: '名称', value: name }], '保存') + if (!data) return + result = await send(`terms/${id}`, 'PATCH', data) + button.closest('.jio-forum__term-row').querySelector('span').textContent = data.name + } else if (action === 'term-delete') { + if (!window.confirm('确定删除这个分类?')) return + result = await send(`terms/${id}`, 'DELETE') + button.closest('.jio-forum__term-row')?.remove() + } else if (action === 'follow') { + try { result = await send(`plates/${id}/follow`, 'POST') } + catch (error) { if (error.message.includes('付费')) { await checkout('plate', id); return } throw error } + } else if (action === 'adopt') result = await send(`posts/${id}/adopt`, 'POST', { comment_id: Number(button.dataset.commentId) }) + else if (action === 'report') { + const data = await modal('举报帖子', [{ name: 'category', label: '类别', type: 'select', options: [{ value: '违规内容', label: '违规内容' }, { value: '侵权', label: '侵权' }, { value: '其他', label: '其他' }] }, { name: 'reason', label: '原因', type: 'textarea' }], '提交举报') + if (!data) return + result = await send(`posts/${id}/report`, 'POST', data) + } else if (action === 'apply') { + const data = await modal('申请版主', [{ name: 'reason', label: '申请理由', type: 'textarea' }], '提交申请') + if (!data) return + result = await send(`plates/${id}/apply`, 'POST', data) + } else if (action === 'move') { + const data = await modal('移动帖子', [{ name: 'plate_id', label: '目标版块', type: 'select', options: (config.plates || []).map((plate) => ({ value: String(plate.id), label: plate.title })) }], '确认移动') + if (!data) return + result = await send(`posts/${id}/move`, 'POST', data) + } else if (action === 'application') { + result = await send(`plates/${id}/applications/${Number(button.dataset.userId)}`, 'POST', { status: button.dataset.status }) + button.closest('.jio-forum__application')?.remove() + } else if (action === 'moderator-remove') { + result = await send(`plates/${id}/moderators`, 'POST', { user_id: Number(button.dataset.userId), active: false }) + button.closest('.jio-forum__application')?.remove() + } else if (action === 'comments') { + const status = button.textContent.includes('关闭') ? 'closed' : 'open' + result = await send(`posts/${id}/comments`, 'POST', { status }) + button.textContent = status === 'closed' ? '开放回复' : '关闭回复' + } else result = await send(`posts/${id}/${action}`, 'POST', action === 'score' ? { direction: button.dataset.direction || 'up' } : {}) + if (action === 'delete') { + button.dataset.forumAction = 'restore' + button.textContent = '撤销删除' + } else if (action === 'adopt' || action === 'audit') window.location.reload() + else button.textContent = result.data?.label || result.message || button.textContent + message(button, result.message || '') + } catch (error) { + message(button, error.message) + if (error.message.includes('登录') && config.login) window.location.href = config.login + } finally { + button.disabled = false + } + }) +})() diff --git a/functions.php b/functions.php index 5246ba66..d9148c39 100644 --- a/functions.php +++ b/functions.php @@ -94,6 +94,11 @@ if (!class_exists(\Jio\Core\DomainModel::class, true)) { // ============================================================ require_once JIO_DIR . '/inc/bootstrap.php'; +\Jio\Forum\Core::init(); +\Jio\Forum\Rest::init(); +\Jio\Forum\Commerce::init(); +\Jio\Forum\Integration::init(); + // §3.4 custom_filter: register custom query vars for archive filter add_filter('query_vars', function ($vars) { $vars[] = 'jio_filter_cat'; diff --git a/inc/helpers/template-tags/search-meta.php b/inc/helpers/template-tags/search-meta.php index f2fc487f..45b76a3d 100644 --- a/inc/helpers/template-tags/search-meta.php +++ b/inc/helpers/template-tags/search-meta.php @@ -160,6 +160,10 @@ function jio_get_search_type_options(): array { 'post' => __('文章', 'jio'), 'user' => __('用户', 'jio'), ]; + if (class_exists('\\Jio\\Forum\\Core') && \Jio\Forum\Core::enabled() && jio_get_setting('jio_forum_search_enabled', true)) { + $label_map['forum'] = __('帖子', 'jio'); + $label_map['plate'] = __('版块', 'jio'); + } if (!class_exists('\Jio\Settings\Settings')) { return ['post' => $label_map['post']]; @@ -175,6 +179,10 @@ function jio_get_search_type_options(): array { $options[$type] = $label_map[$type]; } } + if (isset($label_map['forum'])) { + $options['forum'] = $label_map['forum']; + $options['plate'] = $label_map['plate']; + } if (empty($options)) { $options['post'] = $label_map['post']; diff --git a/inc/modules/user-notifications.php b/inc/modules/user-notifications.php index 0673da1a..5410239f 100644 --- a/inc/modules/user-notifications.php +++ b/inc/modules/user-notifications.php @@ -417,8 +417,10 @@ function jio_notify_post_author_of_comment(WP_Comment $comment): void { if ($owner_id <= 0 || $owner_id === (int) $comment->user_id) return; jio_add_user_notification($owner_id, [ 'type' => 'comment', - 'title' => __('文章收到评论', 'jio'), - 'desc' => sprintf(__('%1$s 评论了你的文章《%2$s》。', 'jio'), get_comment_author($comment), get_the_title($post)), + 'title' => $post->post_type === \Jio\Forum\Core::POST ? __('帖子收到回复', 'jio') : __('文章收到评论', 'jio'), + 'desc' => $post->post_type === \Jio\Forum\Core::POST + ? sprintf(__('%1$s 回复了你的帖子《%2$s》。', 'jio'), get_comment_author($comment), get_the_title($post)) + : sprintf(__('%1$s 评论了你的文章《%2$s》。', 'jio'), get_comment_author($comment), get_the_title($post)), 'url' => get_comment_link($comment), ]); update_comment_meta($comment->comment_ID, 'jio_post_comment_notified', 1); diff --git a/inc/modules/user-parts/template-registry.php b/inc/modules/user-parts/template-registry.php index 6fc7669a..184eabd8 100644 --- a/inc/modules/user-parts/template-registry.php +++ b/inc/modules/user-parts/template-registry.php @@ -43,6 +43,12 @@ $_jio_special_templates = [ 'title' => '发布文章', 'fallback' => '/submit/', ], + 'forums' => [ + 'file' => 'templates/pages/page-forums.php', + 'slug' => 'forums', + 'title' => '论坛', + 'fallback' => '/forums/', + ], ]; /** @var array 缓存 key=模板文件路径 => value=WP_Post|null */ diff --git a/search.php b/search.php index b4917dd5..90cc2f30 100644 --- a/search.php +++ b/search.php @@ -131,6 +131,16 @@ if (is_active_sidebar('all_top_fluid') || is_active_sidebar('search_top_fluid')) echo jio_get_ajax_next_paginate($search_total, $user_page, 12, $user_next_url, 'text-center theme-pagination ajax-pag', 'next-page ajax-next'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } ?> + have_posts()) : ?> +
+ have_posts()) : $wp_query->the_post(); \Jio\Forum\View::post_card(get_post()); endwhile; wp_reset_postdata(); ?> +
+ + have_posts()) : ?> +
+ have_posts()) : $wp_query->the_post(); \Jio\Forum\View::plate_card(get_post()); endwhile; wp_reset_postdata(); ?> +
+ have_posts()) : ?> 'search', 'highlight_query' => $search_query, 'no_margin' => false]) : ['context' => 'search', 'mode' => 'list']; diff --git a/src/Admin/Schema/SchemaForum.php b/src/Admin/Schema/SchemaForum.php new file mode 100644 index 00000000..f687ffb4 --- /dev/null +++ b/src/Admin/Schema/SchemaForum.php @@ -0,0 +1,79 @@ + 'forum', 'title' => '社区论坛', 'sections' => [ + 'general' => self::section('general', '全局设置', [ + self::field('jio_forum_enabled', '启用论坛', 'switcher', true), + self::field('jio_forum_admin_users', '论坛管理员 ID', 'input', ''), + self::field('jio_forum_forum_name', '论坛名称', 'input', '论坛'), + self::field('jio_forum_plate_name', '版块名称', 'input', '版块'), + self::field('jio_forum_post_name', '帖子名称', 'input', '帖子'), + self::field('jio_forum_topic_name', '话题名称', 'input', '话题'), + ]), + 'home' => self::section('home', '首页与列表', [ + self::field('jio_forum_page_size', '每页帖子数', 'number', 20, ['min' => 1, 'max' => 50]), + self::field('jio_forum_home_tabs', '首页栏目', 'checkbox', ['latest', 'popular', 'essence', 'question', 'vote', 'plates', 'follow'], ['options' => self::options(['latest' => '最新', 'popular' => '热门', 'essence' => '精华', 'question' => '问答', 'vote' => '投票', 'plates' => '版块', 'follow' => '已关注'])]), + self::field('jio_forum_show_plate_cover', '显示版块封面', 'switcher', true), + self::field('jio_forum_search_enabled', '搜索包含帖子', 'switcher', true), + ]), + 'publishing' => self::section('publishing', '发布与审核', [ + self::field('jio_forum_post_review', '新帖子需要审核', 'switcher', true), + self::field('jio_forum_plate_review', '新版块需要审核', 'switcher', true), + self::field('jio_forum_user_plates', '允许用户创建版块', 'switcher', true), + self::field('jio_forum_user_topics', '允许用户创建话题', 'switcher', true), + self::field('jio_forum_daily_post_limit', '每日发帖上限', 'number', 20, ['min' => 0, 'max' => 500]), + self::field('jio_forum_vote_max', '投票选项上限', 'number', 10, ['min' => 2, 'max' => 20]), + self::field('jio_forum_upload_image', '允许上传图片', 'switcher', true), + self::field('jio_forum_upload_video', '允许上传视频', 'switcher', false), + self::field('jio_forum_upload_file', '允许上传附件', 'switcher', false), + ]), + 'access' => self::section('access', '权限与版主', [ + self::field('jio_forum_moderator_apply', '允许申请版主', 'switcher', true), + self::field('jio_forum_follow_required', '关注后允许发帖', 'switcher', false), + self::field('jio_forum_allow_guest_read', '允许游客阅读', 'switcher', true), + ]), + 'replies' => self::section('replies', '回复与互动', [ + self::field('jio_forum_reply_page_size', '每页回复数', 'number', 20, ['min' => 1, 'max' => 100]), + self::field('jio_forum_reply_review', '回复需要审核', 'switcher', false), + self::field('jio_forum_vote_enabled', '启用投票', 'switcher', true), + self::field('jio_forum_vote_number_type', '投票结果显示', 'radio', 'percentage', ['options' => self::options(['percentage' => '百分比', 'number' => '票数', 'hide' => '不显示'])]), + self::field('jio_forum_question_enabled', '启用问答采纳', 'switcher', true), + self::field('jio_forum_score_enabled', '启用评分', 'switcher', true), + self::field('jio_forum_score_up_max', '单人最多加分', 'number', 5, ['min' => 0, 'max' => 100]), + self::field('jio_forum_score_down_max', '单人最多减分', 'number', 5, ['min' => 0, 'max' => 100]), + ]), + 'payment' => self::section('payment', '付费与积分', [ + self::field('jio_forum_paid_content', '允许帖子付费解锁', 'switcher', true), + self::field('jio_forum_paid_follow', '允许付费关注版块', 'switcher', true), + self::field('jio_forum_points_enabled', '允许积分解锁', 'switcher', true), + self::field('jio_forum_vip_price_enabled', '启用会员价', 'switcher', true), + ]), + 'urls' => self::section('urls', '链接设置', [ + self::field('jio_forum_slug_plate', '版块路径', 'input', 'forum'), + self::field('jio_forum_slug_post', '帖子路径', 'input', 'forum-post'), + self::field('jio_forum_slug_edit', '发帖路径', 'input', 'posts-edit'), + ]), + ]]; + } + + private static function section(string $key, string $title, array $fields): array { + return ['key' => $key, 'title' => $title, 'version' => '1.0.0', 'option_key' => 'jio_settings', 'groups' => [['key' => $key, 'fields' => $fields]]]; + } + + private static function field(string $key, string $label, string $type, $default, array $extra = []): array { + return array_merge(['key' => $key, 'label' => $label, 'type' => $type, 'default' => $default], $extra); + } + + private static function options(array $values): array { + $result = []; + foreach ($values as $value => $label) { + $result[] = ['value' => $value, 'label' => $label]; + } + return $result; + } +} diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 263ed449..c3f74099 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -46,6 +46,7 @@ class SchemaProvider { 'payment', 'extensions', 'tools', + 'forum', ]; } @@ -93,6 +94,7 @@ class SchemaProvider { 'payment' => 'jio_settings', 'extensions' => 'jio_settings', 'tools' => 'jio_settings', + 'forum' => 'jio_settings', ]; return $primary[$this->normalize_domain($domain)] ?? 'jio_settings'; @@ -278,6 +280,21 @@ class SchemaProvider { } $errors = array_merge($errors, $this->validate_oauth_settings($merged, is_array($stored) ? $stored : [])); } + if ($domain === 'forum' && ($section === 'urls' || $section === '')) { + $stored = get_option('jio_settings', []); + $merged = array_merge(is_array($stored) ? $stored : [], $data); + $slugs = []; + foreach (['plate' => 'forum', 'post' => 'forum-post', 'edit' => 'posts-edit'] as $type => $default) { + $slug = (string) ($merged['jio_forum_slug_' . $type] ?? $default); + if ($slug === '' || $slug !== sanitize_title($slug) || in_array($slug, ['forums', 'user', 'author', 'wp-admin', 'wp-json'], true)) { + $errors[] = __('论坛路径无效或与保留路径冲突', 'jio'); + } + $slugs[] = $slug; + } + if (count(array_unique($slugs)) !== count($slugs)) { + $errors[] = __('论坛路径不能重复', 'jio'); + } + } if ($domain === 'payment' && $section === 'withdrawal') { $stored = get_option('jio_settings', []); @@ -377,6 +394,7 @@ class SchemaProvider { 'payment' => Schema\SchemaPayment::class, 'extensions' => Schema\SchemaExtensions::class, 'tools' => Schema\SchemaTools::class, + 'forum' => Schema\SchemaForum::class, ]; } diff --git a/src/Core/DomainModel.php b/src/Core/DomainModel.php index 1fa017bc..31401aee 100644 --- a/src/Core/DomainModel.php +++ b/src/Core/DomainModel.php @@ -47,6 +47,8 @@ class DomainModel { public const ORDER_PRODUCT_VIP = 'vip'; public const ORDER_PRODUCT_RECHARGE = 'recharge'; public const ORDER_PRODUCT_POINTS = 'points'; + public const ORDER_PRODUCT_FORUM_POST = 'forum_post'; + public const ORDER_PRODUCT_FORUM_FOLLOW = 'forum_plate_follow'; /** * Human labels for jio_order.product_type (front-end order center). @@ -62,6 +64,8 @@ class DomainModel { self::ORDER_PRODUCT_VIP => __('购买会员', 'jio'), self::ORDER_PRODUCT_RECHARGE => __('余额充值', 'jio'), self::ORDER_PRODUCT_POINTS => __('购买积分', 'jio'), + self::ORDER_PRODUCT_FORUM_POST => __('论坛帖子解锁', 'jio'), + self::ORDER_PRODUCT_FORUM_FOLLOW => __('论坛版块关注', 'jio'), ]; } diff --git a/src/Forum/Access.php b/src/Forum/Access.php new file mode 100644 index 00000000..0639a838 --- /dev/null +++ b/src/Forum/Access.php @@ -0,0 +1,104 @@ + 'ids']); + foreach (is_array($terms) ? $terms : [] as $term_id) { + $ids = array_map('absint', (array) get_term_meta($term_id, '_jio_forum_moderators', true)); + if (in_array($user_id, $ids, true)) { + return true; + } + } + return false; + } + + public static function can_manage_moderators(int $user_id, int $plate_id): bool { + return self::is_admin($user_id) || ($user_id > 0 && get_post_type($plate_id) === Core::PLATE && (int) get_post_field('post_author', $plate_id) === $user_id); + } + + public static function can_manage_post(int $user_id, int $post_id): bool { + if (get_post_type($post_id) !== Core::POST || $user_id <= 0) { + return false; + } + return (int) get_post_field('post_author', $post_id) === $user_id + || self::is_moderator($user_id, (int) get_post_meta($post_id, '_jio_forum_plate_id', true)); + } + + public static function can_publish(int $user_id, int $plate_id): bool { + if ($user_id <= 0 || get_post_type($plate_id) !== Core::PLATE || get_post_status($plate_id) !== 'publish') { + return false; + } + if (self::is_moderator($user_id, $plate_id)) { + return true; + } + if (jio_get_setting('jio_forum_follow_required', false) && !self::follows($user_id, $plate_id)) { + return false; + } + $limit = (string) get_post_meta($plate_id, '_jio_forum_publish_limit', true); + return $limit === '' || $limit === 'all' || ($limit === 'follow' && self::follows($user_id, $plate_id)); + } + + public static function follows(int $user_id, int $plate_id): bool { + return $user_id > 0 && (bool) get_user_meta($user_id, '_jio_forum_follow_' . $plate_id, true); + } + + public static function can_view(int $user_id, int $post_id): bool { + $post = get_post($post_id); + if (!$post instanceof \WP_Post || $post->post_type !== Core::POST) { + return false; + } + if ($post->post_status !== 'publish' && !self::can_manage_post($user_id, $post_id)) { + return false; + } + if ($user_id <= 0 && !jio_get_setting('jio_forum_allow_guest_read', true)) { + return false; + } + if (self::can_manage_post($user_id, $post_id)) { + return true; + } + $plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true); + $gate = (string) get_post_meta($post_id, '_jio_forum_view_limit', true); + $plate_gate = (string) get_post_meta($plate, '_jio_forum_view_limit', true); + foreach (['plate' => $plate_gate, 'post' => $gate] as $scope => $rule) { + if ($rule === 'login' && $user_id <= 0) { + return false; + } + if ($rule === 'follow' && !self::follows($user_id, $plate)) { + return false; + } + if ($rule === 'points' || $rule === 'pay') { + $allowed = $scope === 'plate' + ? Commerce::has_plate_entitlement($user_id, $plate) && self::follows($user_id, $plate) + : $user_id > 0 && \Jio\Payment\Order::get_instance()->has_paid_access($user_id, 'forum_post', $post_id); + if (!$allowed) { + return false; + } + } + } + return true; + } +} diff --git a/src/Forum/Commerce.php b/src/Forum/Commerce.php new file mode 100644 index 00000000..569f909f --- /dev/null +++ b/src/Forum/Commerce.php @@ -0,0 +1,47 @@ +has_paid_access($user_id, 'forum_plate_follow', $plate_id); + } + + public static function revoke_plate_entitlement(int $user_id, int $plate_id): void { + delete_user_meta($user_id, '_jio_forum_plate_entitlement_' . $plate_id); + } + + public static function on_paid(string $order_number, array $order): void { + if ((string) ($order['product_type'] ?? '') !== 'forum_plate_follow') { + return; + } + $user = (int) ($order['user_id'] ?? 0); + $plate = (int) ($order['product_id'] ?? 0); + if ($user > 0 && get_post_type($plate) === Core::PLATE) { + update_user_meta($user, '_jio_forum_plate_entitlement_' . $plate, $order_number); + if (add_user_meta($user, '_jio_forum_follow_' . $plate, 1, true)) { + jio_update_post_counter($plate, '_jio_forum_follow_count', 1); + } + } + } + + public static function on_status_changed(string $order_number, string $status, array $order): void { + if ($status !== 'refunded' || (string) ($order['product_type'] ?? '') !== 'forum_plate_follow') return; + $user = (int) ($order['user_id'] ?? 0); + $plate = (int) ($order['product_id'] ?? 0); + if ($user > 0 && $plate > 0 && (string) get_user_meta($user, '_jio_forum_plate_entitlement_' . $plate, true) === $order_number) { + self::revoke_plate_entitlement($user, $plate); + if (get_user_meta($user, '_jio_forum_follow_' . $plate, true)) jio_update_post_counter($plate, '_jio_forum_follow_count', -1); + delete_user_meta($user, '_jio_forum_follow_' . $plate); + } + } +} diff --git a/src/Forum/Core.php b/src/Forum/Core.php new file mode 100644 index 00000000..4bf9fd89 --- /dev/null +++ b/src/Forum/Core.php @@ -0,0 +1,208 @@ + 'forum', 'post' => 'forum-post', 'edit' => 'posts-edit']; + $slug = sanitize_title((string) jio_get_setting('jio_forum_slug_' . $type, $defaults[$type] ?? '')); + return $slug !== '' ? $slug : ($defaults[$type] ?? 'forum'); + } + + public static function home_url(): string { + return function_exists('jio_get_template_url') ? jio_get_template_url('forums') : home_url('/forums/'); + } + + public static function edit_url(int $id = 0): string { + return home_url('/' . self::slug('edit') . '/' . ($id > 0 ? $id . '/' : '')); + } + + public static function register(): void { + if (!self::enabled()) { + return; + } + $plate_name = sanitize_text_field((string) jio_get_setting('jio_forum_plate_name', '版块')) ?: '版块'; + $post_name = sanitize_text_field((string) jio_get_setting('jio_forum_post_name', '帖子')) ?: '帖子'; + $topic_name = sanitize_text_field((string) jio_get_setting('jio_forum_topic_name', '话题')) ?: '话题'; + register_post_type(self::PLATE, [ + 'labels' => ['name' => '论坛' . $plate_name, 'singular_name' => $plate_name, 'add_new_item' => '创建' . $plate_name, 'edit_item' => '编辑' . $plate_name], + 'public' => true, 'show_ui' => true, 'show_in_rest' => false, 'show_in_menu' => true, + 'menu_icon' => 'dashicons-buddicons-forums', 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author'], + 'has_archive' => false, 'rewrite' => false, 'exclude_from_search' => true, + 'capability_type' => 'post', 'capabilities' => self::admin_capabilities(), 'map_meta_cap' => false, + ]); + register_post_type(self::POST, [ + 'labels' => ['name' => '论坛' . $post_name, 'singular_name' => $post_name, 'add_new_item' => '发布' . $post_name, 'edit_item' => '编辑' . $post_name], + 'public' => true, 'show_ui' => true, 'show_in_rest' => false, 'show_in_menu' => true, + 'menu_icon' => 'dashicons-buddicons-topics', 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'comments'], + 'has_archive' => false, 'rewrite' => false, 'exclude_from_search' => false, + 'capability_type' => 'post', 'capabilities' => self::admin_capabilities(), 'map_meta_cap' => false, + ]); + register_taxonomy(self::CATEGORY, [self::PLATE], [ + 'labels' => ['name' => '版块分类', 'singular_name' => '版块分类'], 'public' => true, + 'show_ui' => true, 'show_in_rest' => false, 'show_admin_column' => true, + 'hierarchical' => true, 'rewrite' => ['slug' => 'plate_cat'], + 'capabilities' => self::term_capabilities(), + ]); + foreach ([self::TOPIC => ['论坛' . $topic_name, 'forum_topic'], self::TAG => ['论坛标签', 'forum_tag']] as $taxonomy => [$label, $slug]) { + register_taxonomy($taxonomy, [self::POST], [ + 'labels' => ['name' => $label, 'singular_name' => $label], 'public' => true, + 'show_ui' => true, 'show_in_rest' => false, 'show_admin_column' => true, + 'hierarchical' => false, 'rewrite' => ['slug' => $slug], + 'capabilities' => self::term_capabilities(), + ]); + } + } + + private static function admin_capabilities(): array { + return array_fill_keys([ + 'edit_post', 'read_post', 'delete_post', 'edit_posts', 'edit_others_posts', 'publish_posts', + 'read_private_posts', 'delete_posts', 'delete_private_posts', 'delete_published_posts', + 'delete_others_posts', 'edit_private_posts', 'edit_published_posts', 'create_posts', + ], 'manage_options'); + } + + private static function term_capabilities(): array { + return ['manage_terms' => 'manage_options', 'edit_terms' => 'manage_options', 'delete_terms' => 'manage_options', 'assign_terms' => 'manage_options']; + } + + public static function cascade_trashed_plate(int $plate_id): void { + if (get_post_type($plate_id) !== self::PLATE) return; + $posts = get_posts(['post_type' => self::POST, 'post_status' => ['publish', 'pending', 'draft'], 'meta_key' => '_jio_forum_plate_id', 'meta_value' => $plate_id, 'posts_per_page' => -1, 'fields' => 'ids']); + foreach ($posts as $post_id) { + update_post_meta($post_id, '_jio_forum_trashed_with_plate', $plate_id); + wp_trash_post($post_id); + } + } + + public static function restore_trashed_plate(int $plate_id): void { + if (get_post_type($plate_id) !== self::PLATE) return; + $posts = get_posts(['post_type' => self::POST, 'post_status' => 'trash', 'meta_key' => '_jio_forum_trashed_with_plate', 'meta_value' => $plate_id, 'posts_per_page' => -1, 'fields' => 'ids']); + foreach ($posts as $post_id) { + wp_untrash_post($post_id); + delete_post_meta($post_id, '_jio_forum_trashed_with_plate'); + } + } + + public static function restore_status(string $status, int $post_id): string { + if (!in_array(get_post_type($post_id), [self::PLATE, self::POST], true)) return $status; + $previous = (string) get_post_meta($post_id, '_wp_trash_meta_status', true); + return in_array($previous, ['publish', 'pending', 'draft'], true) ? $previous : $status; + } + + public static function routes(): void { + if (!self::enabled()) { + return; + } + foreach ([self::PLATE => 'plate', self::POST => 'post'] as $post_type => $kind) { + $slug = preg_quote(self::slug($kind), '#'); + add_rewrite_rule('^' . $slug . '/([0-9]+)\.html/?$', 'index.php?post_type=' . $post_type . '&p=$matches[1]', 'top'); + } + $edit = preg_quote(self::slug('edit'), '#'); + add_rewrite_rule('^' . $edit . '/([0-9]+)/?$', 'index.php?jio_forum_edit=$matches[1]', 'top'); + add_rewrite_rule('^' . $edit . '/?$', 'index.php?jio_forum_edit=add', 'top'); + } + + public static function maybe_flush_routes(): void { + $hash = hash('sha256', wp_json_encode([ + self::ROUTE_VERSION, self::enabled(), self::slug('plate'), self::slug('post'), self::slug('edit'), + ])); + if ((string) get_option('jio_forum_rewrite_hash', '') !== $hash) { + flush_rewrite_rules(); + update_option('jio_forum_rewrite_hash', $hash, false); + } + } + + public static function query_vars(array $vars): array { + $vars[] = 'jio_forum_edit'; + return $vars; + } + + public static function permalink(string $link, \WP_Post $post): string { + $kind = $post->post_type === self::PLATE ? 'plate' : ($post->post_type === self::POST ? 'post' : ''); + return $kind ? home_url('/' . self::slug($kind) . '/' . $post->ID . '.html') : $link; + } + + public static function edit_404($preempt, \WP_Query $query) { + if ($query->is_main_query() && get_query_var('jio_forum_edit')) { + $query->is_404 = false; + return true; + } + return $preempt; + } + + public static function template(string $template): string { + if (!self::enabled()) { + return $template; + } + if (get_query_var('jio_forum_edit')) { + return get_theme_file_path('templates/pages/page-forum-edit.php'); + } + if (is_singular(self::POST)) { + return get_theme_file_path('templates/pages/page-forum-post.php'); + } + if (is_singular(self::PLATE) || is_tax([self::CATEGORY, self::TOPIC, self::TAG])) { + return get_theme_file_path('templates/pages/page-forum-list.php'); + } + return $template; + } + + public static function ensure_home(): void { + if (self::enabled() && function_exists('jio_ensure_template_page')) { + jio_ensure_template_page('forums'); + } + } + + public static function assets(): void { + $account_route = get_query_var('jio_user_center_tab') === 'forum' + || (string) get_query_var('jio_author_identifier') !== '' + || is_author(); + if (!self::enabled() || !(is_singular([self::POST, self::PLATE]) || is_tax([self::CATEGORY, self::TOPIC, self::TAG]) || is_page_template('templates/pages/page-forums.php') || $account_route || get_query_var('jio_forum_edit') || (is_search() && in_array(sanitize_key((string) ($_GET['type'] ?? '')), ['forum', 'plate'], true)))) { + return; + } + $css = get_theme_file_path('assets/css/routes/forum.css'); + $js = get_theme_file_path('assets/js/forum-runtime.js'); + wp_enqueue_style('jio-forum', get_theme_file_uri('assets/css/routes/forum.css'), ['jio-public'], (string) filemtime($css)); + wp_enqueue_script('jio-forum', get_theme_file_uri('assets/js/forum-runtime.js'), [], (string) filemtime($js), true); + wp_enqueue_script('jio-qrcode'); + $plates = get_posts(['post_type' => self::PLATE, 'post_status' => 'publish', 'posts_per_page' => 100, 'fields' => 'ids']); + wp_add_inline_script('jio-forum', 'window.jioForum=' . wp_json_encode([ + 'api' => esc_url_raw(rest_url('jio/v1/forum/')), + 'nonce' => wp_create_nonce('wp_rest'), + 'login' => function_exists('jio_get_template_url') ? jio_get_template_url('sign') : wp_login_url(), + 'orders' => function_exists('jio_get_user_center_url') ? jio_get_user_center_url('orders') : home_url('/user/orders/'), + 'plates' => array_map(static fn(int $id): array => ['id' => $id, 'title' => get_the_title($id)], $plates), + ]) . ';', 'before'); + } +} diff --git a/src/Forum/Integration.php b/src/Forum/Integration.php new file mode 100644 index 00000000..8a1fffd1 --- /dev/null +++ b/src/Forum/Integration.php @@ -0,0 +1,175 @@ + '论坛', 'icon' => 'comments', 'priority' => 22, 'type' => 'panel', 'template' => 'templates/parts/user-center/forum']; + } + return $panels; + } + + public static function home_tab(array $tabs): array { + if (Core::enabled()) { + $tabs['forum'] = ['label' => '论坛', 'icon' => 'comments', 'priority' => 15]; + } + return $tabs; + } + + public static function searchable_types(array $types): array { + if (Core::enabled() && jio_get_setting('jio_forum_search_enabled', true)) { + $types[] = Core::POST; + $types[] = Core::PLATE; + } + return $types; + } + + public static function search_query(\WP_Query $query): void { + if (is_admin() || !$query->is_main_query() || !$query->is_search() || !Core::enabled()) { + return; + } + $type = sanitize_key((string) ($_GET['type'] ?? '')); + if (in_array($type, ['forum', 'plate'], true) && jio_get_setting('jio_forum_search_enabled', true)) { + $query->set('post_type', $type === 'forum' ? Core::POST : Core::PLATE); + $query->set('type', $type); + } + } + + public static function sort_forum_posts(array $clauses, \WP_Query $query): array { + $mode = sanitize_key((string) $query->get('jio_forum_list')); + if (!in_array($mode, ['latest', 'popular', 'question', 'vote', 'essence', 'follow'], true)) return $clauses; + global $wpdb; + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_pin ON ({$wpdb->posts}.ID = jio_forum_pin.post_id AND jio_forum_pin.meta_key = '_jio_forum_pinned')"; + $pin = 'CAST(COALESCE(jio_forum_pin.meta_value, 0) AS SIGNED) DESC'; + if ($mode === 'popular') { + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_up ON ({$wpdb->posts}.ID = jio_forum_up.post_id AND jio_forum_up.meta_key = '_jio_forum_score_up_total')"; + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_down ON ({$wpdb->posts}.ID = jio_forum_down.post_id AND jio_forum_down.meta_key = '_jio_forum_score_down_total')"; + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_fav ON ({$wpdb->posts}.ID = jio_forum_fav.post_id AND jio_forum_fav.meta_key = '_jio_forum_favorite_count')"; + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_views ON ({$wpdb->posts}.ID = jio_forum_views.post_id AND jio_forum_views.meta_key = '_jio_forum_views')"; + $rank = "({$wpdb->posts}.comment_count * 2 + CAST(COALESCE(jio_forum_up.meta_value, 0) AS SIGNED) * 10 - CAST(COALESCE(jio_forum_down.meta_value, 0) AS SIGNED) * 15 + CAST(COALESCE(jio_forum_fav.meta_value, 0) AS SIGNED) * 10 + CAST(COALESCE(jio_forum_views.meta_value, 0) AS SIGNED))"; + $clauses['orderby'] = $pin . ', ' . $rank . ' DESC, ' . $wpdb->posts . '.post_date DESC'; + } else { + $clauses['orderby'] = $pin . ', ' . $wpdb->posts . '.post_date DESC'; + } + return $clauses; + } + + public static function track_view(): void { + if (!is_singular(Core::POST) || is_preview()) return; + $id = (int) get_queried_object_id(); + if (!$id || !Access::can_view(get_current_user_id(), $id)) return; + $key = 'jio_forum_view_' . $id; + if (!empty($_COOKIE[$key])) return; + if (function_exists('jio_update_post_counter')) jio_update_post_counter($id, '_jio_forum_views', 1); + else update_post_meta($id, '_jio_forum_views', (int) get_post_meta($id, '_jio_forum_views', true) + 1); + setcookie($key, '1', ['expires' => time() + DAY_IN_SECONDS, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax']); + $_COOKIE[$key] = '1'; + } + + public static function reply_review($approved, array $comment_data) { + $id = (int) ($comment_data['comment_post_ID'] ?? 0); + if (get_post_type($id) === Core::POST && jio_get_setting('jio_forum_reply_review', false) && !Access::is_moderator(get_current_user_id(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) { + return 0; + } + return $approved; + } + + public static function comment_guard(array $data): array { + if (get_post_type((int) ($data['comment_post_ID'] ?? 0)) === Core::POST && !Rest::inside_reply()) { + wp_die(esc_html__('请在帖子页面提交回复', 'jio'), '', ['response' => 403]); + } + return $data; + } + + public static function content_guard(string $content): string { + $id = get_the_ID(); + return $id && get_post_type($id) === Core::POST && !Access::can_view(get_current_user_id(), $id) ? '

内容已隐藏

' : $content; + } + + public static function excerpt_guard(string $excerpt, \WP_Post $post): string { + return $post->post_type === Core::POST && !Access::can_view(get_current_user_id(), $post->ID) ? '内容已隐藏' : $excerpt; + } + + public static function seo_guard(array $payload): array { + $post = $payload['object'] ?? null; + if ($post instanceof \WP_Post && $post->post_type === Core::POST && !Access::can_view(get_current_user_id(), $post->ID)) { + $payload['description'] = '内容已隐藏'; + $payload['keywords'] = ''; + } + return $payload; + } + + public static function post_created(int $post_id, int $user_id, string $status): void { + if ($status !== 'pending') { + return; + } + $plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true); + $owner = (int) get_post_field('post_author', $plate); + if ($owner > 0 && $owner !== $user_id) { + self::notify($owner, '帖子待审核', get_the_title($post_id), Core::edit_url($post_id)); + } + } + + public static function post_reviewed(int $post_id, string $status, int $actor): void { + $owner = (int) get_post_field('post_author', $post_id); + if ($owner > 0 && $owner !== $actor) { + self::notify($owner, $status === 'publish' ? '帖子审核通过' : '帖子待审核', get_the_title($post_id), get_permalink($post_id)); + } + } + + public static function plate_reviewed(int $plate_id, string $status, int $actor): void { + $owner = (int) get_post_field('post_author', $plate_id); + if ($owner > 0 && $owner !== $actor) { + self::notify($owner, $status === 'publish' ? '版块审核通过' : '版块待审核', get_the_title($plate_id), get_permalink($plate_id)); + } + } + + public static function answer_adopted(int $post_id, int $comment_id, int $actor): void { + $comment = get_comment($comment_id); + $user = $comment instanceof \WP_Comment ? (int) $comment->user_id : 0; + if ($user > 0 && $user !== $actor) { + self::notify($user, '回答被采纳', get_the_title($post_id), get_comment_link($comment_id)); + } + } + + public static function moderator_applied(int $plate_id, int $user_id): void { + $owner = (int) get_post_field('post_author', $plate_id); + if ($owner > 0 && $owner !== $user_id) { + self::notify($owner, '收到版主申请', get_the_title($plate_id), get_permalink($plate_id)); + } + } + + public static function application_processed(int $plate_id, int $user_id, string $status): void { + self::notify($user_id, $status === 'approved' ? '版主申请通过' : '版主申请未通过', get_the_title($plate_id), get_permalink($plate_id)); + } + + private static function notify(int $user_id, string $title, string $desc, string $url): void { + if (function_exists('jio_add_user_notification')) { + jio_add_user_notification($user_id, ['type' => 'system', 'title' => $title, 'desc' => $desc, 'url' => $url]); + } + } + +} diff --git a/src/Forum/Lock.php b/src/Forum/Lock.php new file mode 100644 index 00000000..e547e8ae --- /dev/null +++ b/src/Forum/Lock.php @@ -0,0 +1,29 @@ + $token, 'created' => time()], '', false)) return $token; + $old = get_option($key); + if (is_array($old) && time() - (int) ($old['created'] ?? 0) > 120) { + delete_option($key); + if (add_option($key, ['token' => $token, 'created' => time()], '', false)) return $token; + } + return ''; + } + + public static function release(string $scope, int $user_id, int $object_id, string $token): void { + $key = self::key($scope, $user_id, $object_id); + $stored = get_option($key); + if (is_array($stored) && (string) ($stored['token'] ?? '') === $token) delete_option($key); + } + + private static function key(string $scope, int $user_id, int $object_id): string { + return 'jio_forum_lock_' . md5($scope . ':' . $user_id . ':' . $object_id); + } +} diff --git a/src/Forum/Rest.php b/src/Forum/Rest.php new file mode 100644 index 00000000..ab950fc7 --- /dev/null +++ b/src/Forum/Rest.php @@ -0,0 +1,127 @@ +\d+)', 'PATCH', 'update_post'], + ['/forum/plates', 'POST', 'create_plate'], + ['/forum/plates/(?P\d+)', 'PATCH', 'update_plate'], + ['/forum/plates/(?P\d+)/follow', 'POST', 'follow_plate'], + ['/forum/plates/(?P\d+)/audit', 'POST', 'audit_plate'], + ['/forum/plates/(?P\d+)/delete', 'POST', 'delete_plate'], + ['/forum/plates/(?P\d+)/restore', 'POST', 'restore_plate'], + ['/forum/posts/(?P\d+)/score', 'POST', 'score'], + ['/forum/posts/(?P\d+)/favorite', 'POST', 'favorite'], + ['/forum/posts/(?P\d+)/vote', 'POST', 'vote'], + ['/forum/posts/(?P\d+)/adopt', 'POST', 'adopt'], + ['/forum/posts/(?P\d+)/delete', 'POST', 'delete_post'], + ['/forum/posts/(?P\d+)/restore', 'POST', 'restore_post'], + ['/forum/posts/(?P\d+)/audit', 'POST', 'audit_post'], + ['/forum/posts/(?P\d+)/pin', 'POST', 'pin_post'], + ['/forum/posts/(?P\d+)/essence', 'POST', 'essence_post'], + ['/forum/posts/(?P\d+)/move', 'POST', 'move_post'], + ['/forum/posts/(?P\d+)/comments', 'POST', 'comments_status'], + ['/forum/posts/(?P\d+)/report', 'POST', 'report_post'], + ['/forum/posts/(?P\d+)/replies', 'POST', 'reply'], + ['/forum/replies/(?P\d+)/like', 'POST', 'like_reply'], + ['/forum/replies/(?P\d+)/hot', 'POST', 'hot_reply'], + ['/forum/replies/(?P\d+)/audit', 'POST', 'audit_reply'], + ['/forum/replies/(?P\d+)/delete', 'POST', 'delete_reply'], + ['/forum/replies/(?P\d+)/restore', 'POST', 'restore_reply'], + ['/forum/terms', 'POST', 'create_term'], + ['/forum/terms/(?P\d+)', 'PATCH', 'update_term'], + ['/forum/terms/(?P\d+)', 'DELETE', 'delete_term'], + ['/forum/plates/(?P\d+)/moderators', 'POST', 'moderator'], + ['/forum/plates/(?P\d+)/apply', 'POST', 'apply_moderator'], + ['/forum/plates/(?P\d+)/applications/(?P\d+)', 'POST', 'process_application'], + ['/forum/posts/(?P\d+)/checkout', 'POST', 'checkout_post'], + ['/forum/posts/(?P\d+)/checkout', 'GET', 'preview_post'], + ['/forum/plates/(?P\d+)/checkout', 'POST', 'checkout_plate'], + ['/forum/plates/(?P\d+)/checkout', 'GET', 'preview_plate'], + ['/forum/upload/(?Pimage|video|resource)', 'POST', 'upload'], + ]; + foreach ($routes as [$path, $method, $handler]) { + register_rest_route($base, $path, [ + 'methods' => $method, + 'callback' => [self::class, $handler], + 'permission_callback' => $auth, + ]); + } + } + + public static function authorize(\WP_REST_Request $request) { + if (!Core::enabled()) { + return new \WP_Error('jio_forum_disabled', '论坛未启用', ['status' => 404]); + } + if (!is_user_logged_in()) { + return new \WP_Error('jio_forum_login', '请先登录', ['status' => 401]); + } + $nonce = (string) $request->get_header('X-WP-Nonce'); + if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) { + return new \WP_Error('jio_forum_nonce', '安全验证失败,请刷新页面', ['status' => 403]); + } + return true; + } + + private static function fail(string $message, int $status = 400): \WP_Error { + return new \WP_Error('jio_forum_error', $message, ['status' => $status]); + } + + private static function success(array $data = [], string $message = '操作成功'): \WP_REST_Response { + return new \WP_REST_Response(['success' => true, 'data' => $data, 'message' => $message], 200); + } + + private static function input(\WP_REST_Request $request): array { + $json = $request->get_json_params(); + return is_array($json) ? $json : $request->get_params(); + } + + private static function post(int $id): ?\WP_Post { + $post = get_post($id); + return $post instanceof \WP_Post && $post->post_type === Core::POST ? $post : null; + } + + private static function plate(int $id): ?\WP_Post { + $post = get_post($id); + return $post instanceof \WP_Post && $post->post_type === Core::PLATE ? $post : null; + } + + private static function actor(): int { + return get_current_user_id(); + } + + private static function with_lock(string $scope, int $id, callable $operation) { + $user = self::actor(); + $token = Lock::acquire($scope, $user, $id); + if ($token === '') return self::fail('操作正在处理中,请稍后重试', 409); + try { + $object_token = Lock::acquire($scope . '-object', 0, $id); + if ($object_token === '') return self::fail('操作正在处理中,请稍后重试', 409); + try { + return $operation(); + } finally { + Lock::release($scope . '-object', 0, $id, $object_token); + } + } finally { + Lock::release($scope, $user, $id, $token); + } + } +} diff --git a/src/Forum/RestActions.php b/src/Forum/RestActions.php new file mode 100644 index 00000000..4f30a073 --- /dev/null +++ b/src/Forum/RestActions.php @@ -0,0 +1,229 @@ + false, 'label' => '关注版块'], '已取消关注'); + } + $gate = (string) get_post_meta($id, '_jio_forum_follow_mode', true); + if (in_array($gate, ['pay', 'points'], true) && !Commerce::has_plate_entitlement($user, $id)) { + return self::fail('此版块需要先完成付费关注', 402); + } + add_user_meta($user, $key, 1, true); + jio_update_post_counter($id, '_jio_forum_follow_count', 1); + do_action('jio_forum_plate_followed', $id, $user); + return self::success(['following' => true, 'label' => '已关注'], '已关注版块'); + }); + } + + public static function score(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::post($id) || get_post_status($id) !== 'publish') { + return self::fail('帖子不存在', 404); + } + $user = self::actor(); + if (!jio_get_setting('jio_forum_score_enabled', true)) { + return self::fail('评分未启用'); + } + $direction = sanitize_key((string) (self::input($request)['direction'] ?? 'up')); + if (!in_array($direction, ['up', 'down'], true)) { + return self::fail('评分类型无效'); + } + return self::with_lock('score', $id, static function () use ($user, $id, $direction) { + $key = '_jio_forum_score_' . $id; + $previous = (int) get_user_meta($user, $key, true); + $delta = $direction === 'up' ? 1 : -1; + $new_user_score = $previous + $delta; + $limit = max(0, (int) jio_get_setting($direction === 'up' ? 'jio_forum_score_up_max' : 'jio_forum_score_down_max', 5)); + if (($delta > 0 && $new_user_score > $limit) || ($delta < 0 && $new_user_score < -$limit)) { + return self::fail('已达到评分上限'); + } + update_user_meta($user, $key, $new_user_score); + jio_update_post_counter($id, '_jio_forum_score_up_total', max(0, $new_user_score) - max(0, $previous)); + jio_update_post_counter($id, '_jio_forum_score_down_total', max(0, -$new_user_score) - max(0, -$previous)); + $score = (int) get_post_meta($id, '_jio_forum_score', true) + $delta; + update_post_meta($id, '_jio_forum_score', $score); + do_action('jio_forum_post_scored', $id, $user, $direction); + return self::success(['score' => $score, 'user_score' => $new_user_score, 'direction' => $direction, 'label' => '评分 ' . $score], '评分成功'); + }); + } + + public static function favorite(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::post($id) || get_post_status($id) !== 'publish') { + return self::fail('帖子不存在', 404); + } + $key = '_jio_forum_favorite_' . $id; + $user = self::actor(); + return self::with_lock('favorite', $id, static function () use ($user, $id, $key) { + if (get_user_meta($user, $key, true)) { + delete_user_meta($user, $key); + $active = false; + } else { + add_user_meta($user, $key, 1, true); + $active = true; + } + jio_update_post_counter($id, '_jio_forum_favorite_count', $active ? 1 : -1); + do_action('jio_forum_post_favorited', $id, $user, $active); + return self::success(['active' => $active, 'label' => $active ? '已收藏' : '收藏'], $active ? '已收藏' : '已取消收藏'); + }); + } + + public static function vote(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::post($id) || get_post_status($id) !== 'publish' || get_post_meta($id, '_jio_forum_type', true) !== 'vote') { + return self::fail('投票不存在', 404); + } + if (!Access::can_view(self::actor(), $id)) return self::fail('无权查看此投票', 403); + $user = self::actor(); + return self::with_lock('vote', $id, static function () use ($request, $user, $id) { + $key = '_jio_forum_vote_' . $id; + if (get_user_meta($user, $key, true) !== '') { + return self::success(['selected' => get_user_meta($user, $key, true)], '已经投票'); + } + $deadline = (int) get_post_meta($id, '_jio_forum_vote_deadline', true); + if ($deadline > 0 && current_time('timestamp') > $deadline) return self::fail('投票已截止', 409); + $input = self::input($request); + $options = (array) get_post_meta($id, '_jio_forum_vote_options', true); + $type = (string) get_post_meta($id, '_jio_forum_vote_type', true); + $selected = $type === 'multiple' ? (array) ($input['options'] ?? []) : [$input['option'] ?? null]; + $indices = []; + foreach ($selected as $value) { + $index = filter_var($value, FILTER_VALIDATE_INT); + if ($index === false || $index === null || !array_key_exists($index, $options) || in_array($index, $indices, true)) { + return self::fail('请选择有效选项'); + } + $indices[] = $index; + } + if (!$indices || $type !== 'multiple' && count($indices) !== 1) return self::fail('请选择有效选项'); + add_user_meta($user, $key, $type === 'multiple' ? $indices : (string) $indices[0], true); + $counts = (array) get_post_meta($id, '_jio_forum_vote_counts', true); + foreach ($indices as $index) $counts[$index] = (int) ($counts[$index] ?? 0) + 1; + update_post_meta($id, '_jio_forum_vote_counts', $counts); + update_post_meta($id, '_jio_forum_vote_total', (int) get_post_meta($id, '_jio_forum_vote_total', true) + 1); + return self::success(['selected' => $indices, 'counts' => $counts], '投票成功'); + }); + } + + public static function adopt(\WP_REST_Request $request) { + $id = absint($request['id']); + $post = self::post($id); + $comment_id = absint(self::input($request)['comment_id'] ?? 0); + $comment = get_comment($comment_id); + if (!$post || get_post_meta($id, '_jio_forum_type', true) !== 'question' || !$comment instanceof \WP_Comment || (int) $comment->comment_post_ID !== $id || $comment->comment_approved !== '1') { + return self::fail('回复无效'); + } + if (!Access::can_manage_post(self::actor(), $id)) { + return self::fail('无权采纳回复', 403); + } + $old = (int) get_post_meta($id, '_jio_forum_adopted_comment', true); + if ($old && $old !== $comment_id) { + return self::fail('此问题已有采纳回复'); + } + update_post_meta($id, '_jio_forum_adopted_comment', $comment_id); + update_comment_meta($comment_id, '_jio_forum_adopted', 1); + do_action('jio_forum_answer_adopted', $id, $comment_id, self::actor()); + return self::success(['comment_id' => $comment_id], '已采纳回复'); + } + + public static function delete_post(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::post($id) || !Access::can_manage_post(self::actor(), $id)) { + return self::fail('无权删除', 403); + } + wp_trash_post($id); + return self::success(['url' => Core::home_url()], '已删除'); + } + + public static function restore_post(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::post($id) || get_post_status($id) !== 'trash' || !Access::can_manage_post(self::actor(), $id)) { + return self::fail('无权撤销删除', 403); + } + wp_untrash_post($id); + return self::success(['url' => get_permalink($id)], '已恢复'); + } + + public static function audit_post(\WP_REST_Request $request) { + $id = absint($request['id']); + $post = self::post($id); + if (!$post || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) { + return self::fail('无权审核', 403); + } + $status = sanitize_key((string) (self::input($request)['status'] ?? 'publish')); + if (!in_array($status, ['publish', 'pending'], true)) { + return self::fail('审核状态无效'); + } + wp_update_post(['ID' => $id, 'post_status' => $status]); + do_action('jio_forum_post_reviewed', $id, $status, self::actor()); + return self::success(['status' => $status], $status === 'publish' ? '审核通过' : '已转为待审核'); + } + + public static function pin_post(\WP_REST_Request $request) { + return self::flag($request, '_jio_forum_pinned', '置顶'); + } + + public static function essence_post(\WP_REST_Request $request) { + return self::flag($request, '_jio_forum_essence', '精华'); + } + + private static function flag(\WP_REST_Request $request, string $key, string $label) { + $id = absint($request['id']); + if (!self::post($id) || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) { + return self::fail('无权操作', 403); + } + $active = !(bool) get_post_meta($id, $key, true); + update_post_meta($id, $key, $active ? 1 : 0); + return self::success(['active' => $active, 'label' => ($active ? '取消' : '') . $label], $active ? '已设置' . $label : '已取消' . $label); + } + + public static function move_post(\WP_REST_Request $request) { + $id = absint($request['id']); + $old_plate = (int) get_post_meta($id, '_jio_forum_plate_id', true); + $new_plate = absint(self::input($request)['plate_id'] ?? 0); + if (!self::post($id) || !Access::is_moderator(self::actor(), $old_plate) || !self::plate($new_plate) || get_post_status($new_plate) !== 'publish') { + return self::fail('无权移动帖子', 403); + } + update_post_meta($id, '_jio_forum_plate_id', $new_plate); + return self::success(['plate_id' => $new_plate], '已移动帖子'); + } + + public static function comments_status(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::post($id) || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) { + return self::fail('无权管理回复', 403); + } + $open = (string) (self::input($request)['status'] ?? 'closed') === 'open'; + wp_update_post(['ID' => $id, 'comment_status' => $open ? 'open' : 'closed']); + return self::success(['open' => $open], $open ? '已开放回复' : '已关闭回复'); + } + + public static function report_post(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::post($id)) { + return self::fail('帖子不存在', 404); + } + $input = self::input($request); + $reason = sanitize_textarea_field((string) ($input['reason'] ?? '')); + $category = sanitize_text_field((string) ($input['category'] ?? '其他')); + if ($reason === '') { + return self::fail('请填写举报原因'); + } + $result = \Jio\Messages\ReportCenter::get_instance()->submit(self::actor(), 'post', $id, $reason, $category); + return $result ? self::success(['report_id' => $result], '举报已提交') : self::fail('举报提交失败'); + } +} diff --git a/src/Forum/RestCommerce.php b/src/Forum/RestCommerce.php new file mode 100644 index 00000000..c99813e1 --- /dev/null +++ b/src/Forum/RestCommerce.php @@ -0,0 +1,201 @@ +post_status !== 'publish') { + return self::fail('内容不存在', 404); + } + if ($kind === 'post') { + $plate_access = self::parent_plate_access($id, self::actor()); + if (is_wp_error($plate_access)) return $plate_access; + } + $rule = (string) get_post_meta($id, $kind === 'post' ? '_jio_forum_view_limit' : '_jio_forum_follow_mode', true); + if ($kind === 'plate' && !in_array($rule, ['points', 'pay'], true)) { + $rule = (string) get_post_meta($id, '_jio_forum_view_limit', true); + } + if (!in_array($rule, ['points', 'pay'], true)) { + return self::fail('此内容不需要付费'); + } + $price = self::price($kind, $id, $rule, self::actor()); + if (is_wp_error($price)) return $price; + $methods = ['points']; + if ($rule === 'pay') { + $methods = []; + foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe'] as $method) { + if (isset(\Jio\Payment\PaymentOrchestrator::resolve_gateway($method)['gateway'])) { + $methods[] = $method; + } + } + if ($price !== '0.00' && !$methods) return self::fail('暂无可用支付方式', 503); + } + return self::success(['title' => $subject->post_title, 'mode' => $rule, 'price' => $price, 'methods' => $methods], '确认解锁'); + } + + public static function checkout_post(\WP_REST_Request $request) { + return self::checkout('post', absint($request['id']), $request); + } + + public static function checkout_plate(\WP_REST_Request $request) { + return self::checkout('plate', absint($request['id']), $request); + } + + private static function price(string $kind, int $id, string $mode, int $user_id) { + $key = $kind === 'post' ? '_jio_forum_price' : '_jio_forum_follow_price'; + $raw = (string) get_post_meta($id, $key, true); + $vip = function_exists('jio_get_user_vip_level') ? min(2, (int) jio_get_user_vip_level($user_id)) : 0; + if ($vip > 0 && jio_get_setting('jio_forum_vip_price_enabled', true)) { + $override = (string) get_post_meta($id, $key . '_vip' . $vip, true); + if ($override !== '') { + $raw = $override; + } + } + if ($mode === 'points') { + if (!preg_match('/^(0|[1-9]\d*)$/', $raw) || strlen($raw) > strlen((string) PHP_INT_MAX) || (strlen($raw) === strlen((string) PHP_INT_MAX) && strcmp($raw, (string) PHP_INT_MAX) > 0)) { + return self::fail('积分价格无效'); + } + return $raw; + } + try { + return \Jio\Ledger\AssetAmount::balance($raw)->decimal(); + } catch (\InvalidArgumentException $exception) { + return self::fail('付费价格无效'); + } + } + + private static function checkout(string $kind, int $id, \WP_REST_Request $request) { + $subject = $kind === 'post' ? self::post($id) : self::plate($id); + if (!$subject || $subject->post_status !== 'publish') { + return self::fail('内容不存在', 404); + } + $user = self::actor(); + if ($kind === 'post') { + $plate_access = self::parent_plate_access($id, $user); + if (is_wp_error($plate_access)) return $plate_access; + } + $mode = (string) get_post_meta($id, $kind === 'post' ? '_jio_forum_view_limit' : '_jio_forum_follow_mode', true); + if ($kind === 'plate' && !in_array($mode, ['points', 'pay'], true)) { + $mode = (string) get_post_meta($id, '_jio_forum_view_limit', true); + } + if (!in_array($mode, ['points', 'pay'], true)) { + return self::fail('此内容不需要付费'); + } + $product = $kind === 'post' ? 'forum_post' : 'forum_plate_follow'; + $lock = Lock::acquire('checkout-' . $product, $user, $id); + if ($lock === '') return self::fail('正在处理同一内容的支付,请稍后重试', 409); + try { + $orders = \Jio\Payment\Order::get_instance(); + if ($kind === 'plate' ? Commerce::has_plate_entitlement($user, $id) : $orders->has_paid_access($user, $product, $id)) { + return self::success(['paid' => true, 'url' => $kind === 'post' ? get_permalink($id) : get_permalink($id)], '已解锁'); + } + $price = self::price($kind, $id, $mode, $user); + if (is_wp_error($price)) return $price; + $input = self::input($request); + $method = $mode === 'points' ? 'points' : sanitize_key((string) ($input['payment_method'] ?? 'balance')); + $resolved_gateway = null; + if ($mode === 'pay' && $price !== '0.00') { + $resolved_gateway = \Jio\Payment\PaymentOrchestrator::resolve_gateway($method); + if (!isset($resolved_gateway['gateway'])) { + return self::fail((string) ($resolved_gateway['message'] ?? '支付方式不可用')); + } + } + $uuid = (string) $request->get_header('Idempotency-Key'); + if (!wp_is_uuid($uuid)) { + return self::fail('缺少有效的重复请求标识'); + } + $request_key = \Jio\Ledger\OperationKey::deterministic('forum-checkout', [$user, strtolower($uuid)]); + $repo = \Jio\Payment\OrderRepository::get_instance(); + $existing = $repo->get_by_request_key($request_key); + if (!$existing) { + foreach ($repo->get_user_orders($user, ['product_type' => $product, 'status' => 'pending', 'limit' => 100]) as $pending) { + if ((int) ($pending['product_id'] ?? 0) === $id) { + $existing = $pending; + break; + } + } + } + $stored_amount = $mode === 'points' ? (int) $price : $price; + if ($existing && ((int) $existing['user_id'] !== $user || (string) $existing['product_type'] !== $product || (int) $existing['product_id'] !== $id || (string) $existing['currency'] !== ($mode === 'points' ? 'points' : 'cny') || $existing['amount'] !== $stored_amount || (string) $existing['payment_method'] !== $method)) { + return self::fail('重复请求标识已用于其他购买', 409); + } + if ($existing && $existing['status'] === 'paid' && $kind !== 'plate') { + return self::success(['paid' => true, 'url' => get_permalink($id)], '已解锁'); + } + if ($existing && $existing['status'] === 'paid') { + return self::fail('此订单的关注资格已结束,请重新发起支付', 409); + } + $order_number = $existing ? (string) $existing['order_number'] : $repo->create([ + 'user_id' => $user, 'product_type' => $product, 'product_id' => $id, + 'amount' => $stored_amount, 'currency' => $mode === 'points' ? 'points' : 'CNY', + 'status' => \Jio\Payment\Order::STATUS_PENDING, 'payment_method' => $method, + 'request_key' => $request_key, 'note' => wp_json_encode(['forum' => 1, 'mode' => $mode, 'title' => $subject->post_title]), + ]); + if ($order_number === '') { + $existing = $repo->get_by_request_key($request_key); + $order_number = $existing ? (string) $existing['order_number'] : ''; + } + if ($order_number === '') { + return self::fail('创建订单失败', 500); + } + if ((($mode === 'points') && (int) $price === 0) || (($mode === 'pay') && $price === '0.00')) { + if (!$orders->update_status($order_number, \Jio\Payment\Order::STATUS_PAID, ['payment_method' => 'free', 'require_transition' => true])) { + return self::fail('订单确认失败', 409); + } + return self::success(['paid' => true, 'url' => get_permalink($id)], '已解锁'); + } + if ($mode === 'points') { + $result = \Jio\Ledger\LedgerService::get_instance()->debit( + $user, \Jio\Ledger\AssetType::POINTS, (int) $price, + \Jio\Ledger\OperationKey::deterministic('forum-points', [$order_number]), + 'forum_points_pay', ['type' => 'order', 'key' => $order_number], + '论坛内容解锁', $user, ['order_number' => $order_number], + static function () use ($orders, $order_number) { + return $orders->update_status($order_number, \Jio\Payment\Order::STATUS_PAID, ['payment_method' => 'points', 'require_transition' => true]); + }, + static function () use ($repo, $order_number, $user) { + $order = $repo->get($order_number); + return $order && (int) $order['user_id'] === $user && $order['status'] === 'pending'; + } + ); + if (is_wp_error($result) || !$result) { + return self::fail(is_wp_error($result) ? $result->get_error_message() : '积分支付失败', 402); + } + return self::success(['paid' => true, 'url' => get_permalink($id)], '积分支付成功'); + } + $order = $repo->get($order_number); + $order['subject'] = '论坛内容:' . $subject->post_title; + $order['payment_method'] = $method; + $paid = \Jio\Payment\PaymentOrchestrator::pay($resolved_gateway['gateway'], $order, ['paid_note' => '论坛内容解锁']); + if (empty($paid['success'])) { + return self::fail((string) ($paid['message'] ?? '支付失败'), (int) ($paid['status'] ?? 400)); + } + return self::success(array_merge(['order_number' => $order_number, 'url' => get_permalink($id)], \Jio\Payment\PaymentOrchestrator::cashier_payload($paid)), (string) ($paid['message'] ?? '请完成支付')); + } finally { + Lock::release('checkout-' . $product, $user, $id, $lock); + } + } + + private static function parent_plate_access(int $post_id, int $user_id) { + $plate_id = (int) get_post_meta($post_id, '_jio_forum_plate_id', true); + if (!self::plate($plate_id) || get_post_status($plate_id) !== 'publish') return self::fail('所属版块不可用', 404); + if (Access::is_moderator($user_id, $plate_id)) return true; + $rule = (string) get_post_meta($plate_id, '_jio_forum_view_limit', true); + if ($rule === 'follow' && !Access::follows($user_id, $plate_id)) return self::fail('请先关注版块', 403); + if (in_array($rule, ['points', 'pay'], true) && !(Commerce::has_plate_entitlement($user_id, $plate_id) && Access::follows($user_id, $plate_id))) { + return self::fail('请先解锁版块', 402); + } + return true; + } +} diff --git a/src/Forum/RestMedia.php b/src/Forum/RestMedia.php new file mode 100644 index 00000000..61d09d23 --- /dev/null +++ b/src/Forum/RestMedia.php @@ -0,0 +1,26 @@ + 'jio_forum_upload_image', 'video' => 'jio_forum_upload_video', 'resource' => 'jio_forum_upload_file'][$kind] ?? ''; + if ($key === '' || !jio_get_setting($key, $kind === 'image')) { + return self::fail('此类型上传未启用', 403); + } + $files = $request->get_file_params(); + $file = $files['file'] ?? null; + if (!is_array($file)) { + return self::fail('请选择文件'); + } + $front = \Jio\Core\FrontSubmit::get_instance(); + $result = $front->persist_uploaded_file($kind, $file); + if (is_wp_error($result)) return $result; + if (!$result instanceof \WP_REST_Response) return self::fail('上传失败', 500); + $data = $result->get_data(); + return self::success(['id' => absint($data['id'] ?? 0), 'url' => esc_url_raw((string) ($data['url'] ?? '')), 'name' => sanitize_text_field((string) ($data['title'] ?? $file['name'] ?? ''))], '上传成功'); + } +} diff --git a/src/Forum/RestPlates.php b/src/Forum/RestPlates.php new file mode 100644 index 00000000..eb820c9f --- /dev/null +++ b/src/Forum/RestPlates.php @@ -0,0 +1,174 @@ + $id, 'post_status' => $status]); + do_action('jio_forum_plate_reviewed', $id, $status, self::actor()); + return self::success(['status' => $status, 'url' => get_permalink($id)], $status === 'publish' ? '版块审核通过' : '版块待审核'); + } + + public static function delete_plate(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id)) { + return self::fail('无权删除版块', 403); + } + $input = self::input($request); + $action = sanitize_key((string) ($input['posts_action'] ?? 'trash')); + if (!in_array($action, ['trash', 'move'], true)) { + return self::fail('帖子处理方式无效'); + } + if ($action === 'move') { + $target = absint($input['target_plate_id'] ?? 0); + if ($target === $id || !self::plate($target) || get_post_status($target) !== 'publish') { + return self::fail('目标版块无效'); + } + $posts = get_posts(['post_type' => Core::POST, 'post_status' => ['publish', 'pending', 'draft'], 'meta_key' => '_jio_forum_plate_id', 'meta_value' => $id, 'posts_per_page' => -1, 'fields' => 'ids']); + foreach ($posts as $post_id) update_post_meta($post_id, '_jio_forum_plate_id', $target); + } + wp_trash_post($id); + return self::success(['url' => Core::home_url()], '版块已移入回收站'); + } + + public static function restore_plate(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::plate($id) || get_post_status($id) !== 'trash' || !Access::can_manage_moderators(self::actor(), $id)) { + return self::fail('无权恢复版块', 403); + } + wp_untrash_post($id); + return self::success(['url' => get_permalink($id)], '版块已恢复'); + } + + private static function taxonomy(string $name): string { + return match ($name) { + 'category' => Core::CATEGORY, + 'topic' => Core::TOPIC, + 'tag' => Core::TAG, + default => '', + }; + } + + private static function term_permission(string $taxonomy, int $term_id = 0): bool { + if (Access::is_admin(self::actor())) { + return true; + } + return $term_id === 0 && $taxonomy === Core::TOPIC && (bool) jio_get_setting('jio_forum_user_topics', true); + } + + public static function create_term(\WP_REST_Request $request) { + $input = self::input($request); + $taxonomy = self::taxonomy(sanitize_key((string) ($input['type'] ?? ''))); + if ($taxonomy === '' || !self::term_permission($taxonomy)) { + return self::fail('无权创建分类', 403); + } + $name = sanitize_text_field((string) ($input['name'] ?? '')); + if ($name === '' || mb_strlen($name) > 60) { + return self::fail('名称无效'); + } + $result = wp_insert_term($name, $taxonomy, [ + 'description' => sanitize_textarea_field((string) ($input['description'] ?? '')), + 'parent' => $taxonomy === Core::CATEGORY ? absint($input['parent'] ?? 0) : 0, + ]); + if (is_wp_error($result)) { + return self::fail($result->get_error_message()); + } + return self::success(['id' => (int) $result['term_id']], '已创建'); + } + + public static function update_term(\WP_REST_Request $request) { + $id = absint($request['id']); + $term = get_term($id); + if (!$term instanceof \WP_Term || !self::term_permission($term->taxonomy, $id)) { + return self::fail('无权编辑分类', 403); + } + $input = self::input($request); + $result = wp_update_term($id, $term->taxonomy, [ + 'name' => sanitize_text_field((string) ($input['name'] ?? $term->name)), + 'description' => sanitize_textarea_field((string) ($input['description'] ?? $term->description)), + ]); + return is_wp_error($result) ? self::fail($result->get_error_message()) : self::success(['id' => $id], '已更新'); + } + + public static function delete_term(\WP_REST_Request $request) { + $id = absint($request['id']); + $term = get_term($id); + if (!$term instanceof \WP_Term || !self::term_permission($term->taxonomy, $id)) { + return self::fail('无权删除分类', 403); + } + $result = wp_delete_term($id, $term->taxonomy); + return is_wp_error($result) || !$result ? self::fail('删除失败') : self::success([], '已删除'); + } + + public static function moderator(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id)) { + return self::fail('无权设置版主', 403); + } + $input = self::input($request); + $user_id = absint($input['user_id'] ?? 0); + $active = !empty($input['active']); + if (!$user_id || !get_userdata($user_id)) { + return self::fail('用户不存在'); + } + $list = array_map('absint', (array) get_post_meta($id, '_jio_forum_moderators', true)); + $list = array_values(array_unique(array_diff($list, [$user_id]))); + if ($active) { + $list[] = $user_id; + } + update_post_meta($id, '_jio_forum_moderators', $list); + do_action('jio_forum_moderator_changed', $id, $user_id, $active); + return self::success(['moderators' => $list], $active ? '已设为版主' : '已移除版主'); + } + + public static function apply_moderator(\WP_REST_Request $request) { + $id = absint($request['id']); + $user = self::actor(); + if (!self::plate($id) || get_post_status($id) !== 'publish' || Access::is_moderator($user, $id) || !jio_get_setting('jio_forum_moderator_apply', true)) { + return self::fail('无法申请此版块版主', 403); + } + $key = '_jio_forum_application_' . $id; + $state = (string) get_user_meta($user, $key, true); + if ($state === 'pending') { + return self::success(['status' => 'pending'], '申请正在审核'); + } + update_user_meta($user, $key, 'pending'); + update_user_meta($user, $key . '_reason', sanitize_textarea_field((string) (self::input($request)['reason'] ?? ''))); + do_action('jio_forum_moderator_applied', $id, $user); + return self::success(['status' => 'pending'], '申请已提交'); + } + + public static function process_application(\WP_REST_Request $request) { + $id = absint($request['id']); + $user_id = absint($request['user_id']); + if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id) || !get_userdata($user_id)) { + return self::fail('无权处理申请', 403); + } + $key = '_jio_forum_application_' . $id; + if (get_user_meta($user_id, $key, true) !== 'pending') { + return self::fail('申请不存在'); + } + $status = sanitize_key((string) (self::input($request)['status'] ?? '')); + if (!in_array($status, ['approved', 'rejected'], true)) { + return self::fail('处理结果无效'); + } + update_user_meta($user_id, $key, $status); + if ($status === 'approved') { + $list = array_map('absint', (array) get_post_meta($id, '_jio_forum_moderators', true)); + $list[] = $user_id; + update_post_meta($id, '_jio_forum_moderators', array_values(array_unique($list))); + } + do_action('jio_forum_moderator_application_processed', $id, $user_id, $status); + return self::success(['status' => $status], '申请已处理'); + } +} diff --git a/src/Forum/RestPosts.php b/src/Forum/RestPosts.php new file mode 100644 index 00000000..9ee1e6e9 --- /dev/null +++ b/src/Forum/RestPosts.php @@ -0,0 +1,251 @@ + 0 ? self::post($id) : null; + if ($id > 0 && (!$current || !Access::can_manage_post($user_id, $id))) { + return self::fail('无权编辑此帖子', 403); + } + $input = self::input($request); + $plate_id = absint($input['plate_id'] ?? ($current ? get_post_meta($id, '_jio_forum_plate_id', true) : 0)); + if (!self::plate($plate_id) || !Access::can_publish($user_id, $plate_id)) { + return self::fail('没有在此版块发布的权限', 403); + } + if ($id === 0 && !Access::is_moderator($user_id, $plate_id)) { + $limit = max(0, (int) jio_get_setting('jio_forum_daily_post_limit', 20)); + if ($limit > 0) { + $recent = get_posts(['post_type' => Core::POST, 'post_status' => ['publish', 'pending'], 'author' => $user_id, 'date_query' => [['after' => 'today']], 'posts_per_page' => $limit, 'fields' => 'ids']); + if (count($recent) >= $limit) { + return self::fail('已达到今日发帖上限', 429); + } + } + } + $title = sanitize_text_field((string) ($input['title'] ?? ($current->post_title ?? ''))); + $content = wp_kses_post((string) ($input['content'] ?? ($current->post_content ?? ''))); + if (mb_strlen($title) < 2 || mb_strlen($title) > 120 || trim(wp_strip_all_tags($content)) === '') { + return self::fail('请填写标题和正文'); + } + $type = sanitize_key((string) ($input['type'] ?? ($current ? get_post_meta($id, '_jio_forum_type', true) : 'normal'))); + if (!in_array($type, ['normal', 'question', 'vote'], true)) { + return self::fail('帖子类型无效'); + } + if (($type === 'vote' && !jio_get_setting('jio_forum_vote_enabled', true)) || ($type === 'question' && !jio_get_setting('jio_forum_question_enabled', true))) { + return self::fail('当前未启用此帖子类型'); + } + $votes = []; + $vote_type = 'single'; + $vote_title = ''; + $vote_days = 0; + if ($type === 'vote') { + $vote_type = sanitize_key((string) ($input['vote_type'] ?? ($current ? get_post_meta($id, '_jio_forum_vote_type', true) : 'single'))); + if ($vote_type === '') $vote_type = 'single'; + if (!in_array($vote_type, ['single', 'multiple', 'pk'], true)) return self::fail('投票方式无效'); + $vote_title = sanitize_text_field((string) ($input['vote_title'] ?? ($current ? get_post_meta($id, '_jio_forum_vote_title', true) : ''))); + if (mb_strlen($vote_title) > 60) return self::fail('投票标题过长'); + $vote_days = absint($input['vote_days'] ?? 0); + if (!in_array($vote_days, [0, 1, 7, 30], true)) return self::fail('投票有效期无效'); + $raw = (string) ($input['vote_options'] ?? ''); + if ($raw === '' && $current) { + $votes = (array) get_post_meta($id, '_jio_forum_vote_options', true); + } else { + $votes = array_values(array_unique(array_filter(array_map('sanitize_text_field', preg_split('/\R/', $raw))))); + } + $max_votes = max(2, min(20, (int) jio_get_setting('jio_forum_vote_max', 10))); + if (count($votes) < 2 || count($votes) > $max_votes) { + return self::fail('投票选项数量无效'); + } + if ($vote_type === 'pk' && count($votes) !== 2) return self::fail('双选 PK 只能有两个选项'); + if ($current && get_post_meta($id, '_jio_forum_vote_total', true) && $votes !== (array) get_post_meta($id, '_jio_forum_vote_options', true)) { + return self::fail('已有投票后不能更改选项'); + } + if ($current && get_post_meta($id, '_jio_forum_vote_total', true) && $vote_type !== (string) get_post_meta($id, '_jio_forum_vote_type', true)) { + return self::fail('已有投票后不能更改投票方式'); + } + } + $view = sanitize_key((string) ($input['view_limit'] ?? ($current ? get_post_meta($id, '_jio_forum_view_limit', true) : 'all'))); + if (!in_array($view, ['all', 'login', 'follow', 'points', 'pay'], true)) { + return self::fail('访问权限无效'); + } + if (($view === 'points' && !jio_get_setting('jio_forum_points_enabled', true)) || ($view === 'pay' && !jio_get_setting('jio_forum_paid_content', true))) { + return self::fail('当前未启用此解锁方式'); + } + $price = (string) ($input['price'] ?? ($current ? get_post_meta($id, '_jio_forum_price', true) : '0')); + if (($view === 'points' && (!ctype_digit($price) || (int) $price <= 0)) || ($view === 'pay' && !preg_match('/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $price))) { + return self::fail('解锁价格无效'); + } + if (in_array($view, ['points', 'pay'], true)) { + $valid_price = self::validate_price($price, $view); + if (is_wp_error($valid_price)) return $valid_price; + foreach (['vip1', 'vip2'] as $vip) { + if (!isset($input['price_' . $vip]) || $input['price_' . $vip] === '') continue; + $valid_price = self::validate_price((string) $input['price_' . $vip], $view); + if (is_wp_error($valid_price)) return $valid_price; + } + } + if (isset($input['topics']) && !jio_get_setting('jio_forum_user_topics', true) && !Access::is_admin($user_id)) { + foreach (array_filter(array_map('sanitize_text_field', explode(',', (string) $input['topics']))) as $name) { + if (!term_exists($name, Core::TOPIC)) return self::fail('无权创建新话题', 403); + } + } + if ($current && (int) get_post_meta($id, '_jio_forum_vote_total', true) > 0 && $type !== 'vote') { + return self::fail('已有投票后不能更改帖子类型'); + } + if ($current && get_post_meta($id, '_jio_forum_adopted_comment', true) && $type !== 'question') { + return self::fail('已有采纳回复后不能更改帖子类型'); + } + $status = Access::is_moderator($user_id, $plate_id) || !jio_get_setting('jio_forum_post_review', true) ? 'publish' : 'pending'; + $post_data = [ + 'ID' => $id, 'post_type' => Core::POST, 'post_title' => $title, 'post_content' => $content, + 'post_status' => $status, 'post_author' => $current ? (int) $current->post_author : $user_id, + 'comment_status' => $current ? $current->comment_status : 'open', 'ping_status' => 'closed', + ]; + $result = $id ? wp_update_post($post_data, true) : wp_insert_post($post_data, true); + if (is_wp_error($result) || !$result) { + return self::fail('保存失败', 500); + } + $id = (int) $result; + if (class_exists('\\Jio\\Attachment\\PendingUploads')) { + \Jio\Attachment\PendingUploads::claim_text($content, $user_id); + } + update_post_meta($id, '_jio_forum_plate_id', $plate_id); + update_post_meta($id, '_jio_forum_type', $type); + update_post_meta($id, '_jio_forum_view_limit', $view); + update_post_meta($id, '_jio_forum_price', $price); + foreach (['vip1', 'vip2'] as $vip) { + if (!isset($input['price_' . $vip])) continue; + $vip_price = (string) $input['price_' . $vip]; + if ($vip_price === '' || preg_match($view === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $vip_price)) { + update_post_meta($id, '_jio_forum_price_' . $vip, $vip_price); + } + } + if ($type === 'vote') { + update_post_meta($id, '_jio_forum_vote_options', $votes); + update_post_meta($id, '_jio_forum_vote_type', $vote_type); + update_post_meta($id, '_jio_forum_vote_title', $vote_title); + if (!$current || !get_post_meta($id, '_jio_forum_vote_started', true)) { + $started = current_time('timestamp'); + update_post_meta($id, '_jio_forum_vote_started', $started); + update_post_meta($id, '_jio_forum_vote_deadline', $vote_days ? $started + $vote_days * DAY_IN_SECONDS : 0); + } + } + foreach ([Core::TOPIC => 'topics', Core::TAG => 'tags'] as $taxonomy => $key) { + if (isset($input[$key])) { + $names = array_values(array_filter(array_map('sanitize_text_field', explode(',', (string) $input[$key])))); + wp_set_object_terms($id, $names, $taxonomy); + } + } + do_action($current ? 'jio_forum_post_updated' : 'jio_forum_post_created', $id, $user_id, $status); + return self::success(['id' => $id, 'url' => $status === 'pending' ? Core::edit_url($id) : get_permalink($id), 'status' => $status], $status === 'pending' ? '已提交审核' : '帖子已发布'); + } + + public static function create_plate(\WP_REST_Request $request) { + return self::save_plate($request, 0); + } + + public static function update_plate(\WP_REST_Request $request) { + return self::save_plate($request, absint($request['id'])); + } + + private static function save_plate(\WP_REST_Request $request, int $id) { + $user_id = self::actor(); + $current = $id > 0 ? self::plate($id) : null; + if ($id > 0 && (!$current || !Access::is_moderator($user_id, $id))) { + return self::fail('无权编辑版块', 403); + } + if ($id === 0 && !Access::is_admin($user_id) && !jio_get_setting('jio_forum_user_plates', true)) { + return self::fail('当前不允许创建版块', 403); + } + $input = self::input($request); + $title = sanitize_text_field((string) ($input['title'] ?? ($current->post_title ?? ''))); + if (mb_strlen($title) < 2 || mb_strlen($title) > 80) { + return self::fail('版块名称需为 2 至 80 个字符'); + } + $follow_mode = sanitize_key((string) ($input['follow_mode'] ?? ($current ? get_post_meta($id, '_jio_forum_follow_mode', true) : 'all'))); + $view_limit = sanitize_key((string) ($input['view_limit'] ?? ($current ? get_post_meta($id, '_jio_forum_view_limit', true) : 'all'))); + if (!in_array($follow_mode, ['all', 'points', 'pay'], true) || !in_array($view_limit, ['all', 'login', 'follow', 'points', 'pay'], true)) { + return self::fail('版块权限无效'); + } + if (in_array($follow_mode, ['points', 'pay'], true) && !jio_get_setting('jio_forum_paid_follow', true)) { + return self::fail('付费关注未启用'); + } + if (($follow_mode === 'points' || $view_limit === 'points') && !jio_get_setting('jio_forum_points_enabled', true)) { + return self::fail('积分解锁未启用'); + } + $follow_price = (string) ($input['follow_price'] ?? ($current ? get_post_meta($id, '_jio_forum_follow_price', true) : '0')); + if ($follow_price === '') $follow_price = '0'; + if (!preg_match($follow_mode === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $follow_price)) { + return self::fail('关注价格无效'); + } + if (in_array($follow_mode, ['points', 'pay'], true)) { + $valid_price = self::validate_price($follow_price, $follow_mode); + if (is_wp_error($valid_price)) return $valid_price; + foreach (['vip1', 'vip2'] as $vip) { + if (!isset($input['follow_price_' . $vip]) || $input['follow_price_' . $vip] === '') continue; + $valid_price = self::validate_price((string) $input['follow_price_' . $vip], $follow_mode); + if (is_wp_error($valid_price)) return $valid_price; + } + } + $status = Access::is_admin($user_id) || !jio_get_setting('jio_forum_plate_review', true) ? 'publish' : 'pending'; + $result = $id ? wp_update_post([ + 'ID' => $id, 'post_title' => $title, + 'post_content' => wp_kses_post((string) ($input['content'] ?? $current->post_content)), + ], true) : wp_insert_post([ + 'post_type' => Core::PLATE, 'post_title' => $title, 'post_status' => $status, + 'post_author' => $user_id, 'post_content' => wp_kses_post((string) ($input['content'] ?? '')), + ], true); + if (is_wp_error($result) || !$result) { + return self::fail('版块保存失败', 500); + } + $id = (int) $result; + if (isset($input['category_id'])) { + $term_id = absint($input['category_id']); + if ($term_id && term_exists($term_id, Core::CATEGORY)) { + wp_set_object_terms($id, [$term_id], Core::CATEGORY); + } + } + if (Access::is_moderator($user_id, $id) && isset($input['publish_limit'])) { + $limit = sanitize_key((string) $input['publish_limit']); + if (in_array($limit, ['all', 'follow'], true)) { + update_post_meta($id, '_jio_forum_publish_limit', $limit); + } + } + update_post_meta($id, '_jio_forum_follow_mode', $follow_mode); + update_post_meta($id, '_jio_forum_follow_price', $follow_price); + update_post_meta($id, '_jio_forum_view_limit', $view_limit); + foreach (['vip1', 'vip2'] as $vip) { + if (!isset($input['follow_price_' . $vip])) continue; + $vip_price = (string) $input['follow_price_' . $vip]; + if ($vip_price === '' || preg_match($follow_mode === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $vip_price)) { + update_post_meta($id, '_jio_forum_follow_price_' . $vip, $vip_price); + } + } + return self::success(['id' => $id, 'url' => get_post_status($id) === 'publish' ? get_permalink($id) : Core::home_url(), 'status' => get_post_status($id)], get_post_status($id) === 'pending' ? '版块已提交审核' : '版块已保存'); + } + + private static function validate_price(string $value, string $mode) { + if ($mode === 'points') { + $max = (string) PHP_INT_MAX; + return preg_match('/^(0|[1-9]\d*)$/', $value) && (strlen($value) < strlen($max) || strlen($value) === strlen($max) && strcmp($value, $max) <= 0) + ? true : self::fail('积分价格超出范围'); + } + try { + \Jio\Ledger\AssetAmount::balance($value); + return true; + } catch (\InvalidArgumentException $exception) { + return self::fail('付费价格超出范围'); + } + } +} diff --git a/src/Forum/RestReplies.php b/src/Forum/RestReplies.php new file mode 100644 index 00000000..a52c446d --- /dev/null +++ b/src/Forum/RestReplies.php @@ -0,0 +1,132 @@ +post_status !== 'publish' || $post->comment_status !== 'open' || !Access::can_view(self::actor(), $id)) { + return self::fail('当前无法回复', 403); + } + $input = self::input($request); + $content = trim(wp_kses_post((string) ($input['content'] ?? ''))); + if (mb_strlen(wp_strip_all_tags($content)) < 2 || mb_strlen($content) > 10000) { + return self::fail('回复长度需为 2 至 10000 个字符'); + } + $parent = absint($input['parent_id'] ?? 0); + if ($parent) { + $parent_comment = get_comment($parent); + if (!$parent_comment instanceof \WP_Comment || (int) $parent_comment->comment_post_ID !== $id || $parent_comment->comment_approved !== '1') { + return self::fail('被回复的内容不存在'); + } + } + $user = wp_get_current_user(); + self::$posting_reply = true; + try { + $result = wp_new_comment([ + 'comment_post_ID' => $id, + 'comment_content' => $content, + 'comment_parent' => $parent, + 'user_id' => (int) $user->ID, + 'comment_author' => $user->display_name, + 'comment_author_email' => $user->user_email, + 'comment_author_url' => $user->user_url, + ], true); + } finally { + self::$posting_reply = false; + } + if (is_wp_error($result) || !$result) { + return self::fail(is_wp_error($result) ? $result->get_error_message() : '回复失败'); + } + $comment = get_comment((int) $result); + if (class_exists('\\Jio\\Attachment\\PendingUploads')) { + \Jio\Attachment\PendingUploads::claim_text($content, (int) $user->ID); + } + return self::success(['id' => (int) $result, 'status' => $comment->comment_approved === '1' ? 'publish' : 'pending'], $comment->comment_approved === '1' ? '回复成功' : '回复已提交审核'); + } + + public static function like_reply(\WP_REST_Request $request) { + $id = absint($request['id']); + $comment = get_comment($id); + if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || $comment->comment_approved !== '1') { + return self::fail('回复不存在', 404); + } + $key = '_jio_forum_reply_like_' . $id; + $user = self::actor(); + return self::with_lock('reply-like', $id, static function () use ($id, $user, $key) { + $active = !get_user_meta($user, $key, true); + if ($active) add_user_meta($user, $key, 1, true); + else delete_user_meta($user, $key); + $count = max(0, (int) get_comment_meta($id, 'jio_comment_likes', true) + ($active ? 1 : -1)); + update_comment_meta($id, 'jio_comment_likes', $count); + do_action('jio_comment_liked', $id, $user, $active); + return self::success(['active' => $active, 'count' => $count, 'label' => '赞 ' . $count], $active ? '已点赞' : '已取消点赞'); + }); + } + + public static function hot_reply(\WP_REST_Request $request) { + $id = absint($request['id']); + $comment = get_comment($id); + $post_id = $comment instanceof \WP_Comment ? (int) $comment->comment_post_ID : 0; + $plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true); + if (!$comment instanceof \WP_Comment || get_post_type($post_id) !== Core::POST || !Access::is_moderator(self::actor(), $plate)) { + return self::fail('无权设置热门回复', 403); + } + $active = !get_comment_meta($id, '_jio_forum_hot', true); + update_comment_meta($id, '_jio_forum_hot', $active ? 1 : 0); + return self::success(['active' => $active, 'label' => $active ? '取消热门' : '设为热门'], $active ? '已设为热门' : '已取消热门'); + } + + private static function can_moderate_reply(int $id): bool { + $comment = get_comment($id); + if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST) return false; + $plate = (int) get_post_meta((int) $comment->comment_post_ID, '_jio_forum_plate_id', true); + return Access::is_moderator(self::actor(), $plate); + } + + public static function audit_reply(\WP_REST_Request $request) { + $id = absint($request['id']); + if (!self::can_moderate_reply($id)) return self::fail('无权审核回复', 403); + $approved = sanitize_key((string) (self::input($request)['status'] ?? 'approve')); + if (!in_array($approved, ['approve', 'hold'], true)) return self::fail('审核状态无效'); + if (!wp_set_comment_status($id, $approved)) return self::fail('回复审核失败', 500); + if ($approved === 'hold') self::clear_adoption($id); + return self::success(['status' => $approved], $approved === 'approve' ? '回复已通过' : '回复已隐藏'); + } + + public static function delete_reply(\WP_REST_Request $request) { + $id = absint($request['id']); + $comment = get_comment($id); + if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || ((int) $comment->user_id !== self::actor() && !self::can_moderate_reply($id))) return self::fail('无权删除回复', 403); + if (!wp_trash_comment($id)) return self::fail('删除失败', 500); + self::clear_adoption($id); + return self::success([], '回复已删除'); + } + + public static function restore_reply(\WP_REST_Request $request) { + $id = absint($request['id']); + $comment = get_comment($id); + if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || $comment->comment_approved !== 'trash' || ((int) $comment->user_id !== self::actor() && !self::can_moderate_reply($id))) return self::fail('无权恢复回复', 403); + if (!wp_untrash_comment($id)) return self::fail('恢复失败', 500); + return self::success([], '回复已恢复'); + } + + private static function clear_adoption(int $comment_id): void { + $comment = get_comment($comment_id); + if (!$comment instanceof \WP_Comment) return; + $post_id = (int) $comment->comment_post_ID; + if ((int) get_post_meta($post_id, '_jio_forum_adopted_comment', true) === $comment_id) { + delete_post_meta($post_id, '_jio_forum_adopted_comment'); + delete_comment_meta($comment_id, '_jio_forum_adopted'); + } + } +} diff --git a/src/Forum/View.php b/src/Forum/View.php new file mode 100644 index 00000000..ca6a71c7 --- /dev/null +++ b/src/Forum/View.php @@ -0,0 +1,387 @@ +
'; + echo ''; + } + + public static function close(): void { + echo '
'; + get_footer(); + } + + public static function sidebar(string $context): void { + echo ''; + } + + public static function home(): void { + self::open('论坛', 'home'); + \Jio\WidgetSystem\WidgetAreas::render('forum_home_top_fluid'); + echo '
'; + $name = (string) jio_get_setting('jio_forum_forum_name', '论坛'); + echo '

' . esc_html($name) . '

'; + if (Access::is_admin(get_current_user_id())) { + $pending = get_posts(['post_type' => Core::PLATE, 'post_status' => 'pending', 'posts_per_page' => 20]); + if ($pending) { + echo '

待审核版块

'; + foreach ($pending as $plate) { + echo '
' . esc_html($plate->post_title) . '
'; + } + echo '
'; + } + self::term_management(); + } + self::tabs(); + $categories = get_terms(['taxonomy' => Core::CATEGORY, 'hide_empty' => false]); + if (is_array($categories) && $categories) { + echo '
'; + foreach ($categories as $category) { + echo '' . esc_html($category->name) . ''; + } + echo '
'; + } + self::posts(['post_type' => Core::POST, 'post_status' => 'publish'], 'home'); + echo '
'; + self::sidebar('home'); + echo '
'; + \Jio\WidgetSystem\WidgetAreas::render('forum_home_bottom_fluid'); + self::close(); + } + + public static function listing(): void { + $obj = get_queried_object(); + $plate = $obj instanceof \WP_Post && $obj->post_type === Core::PLATE ? $obj : null; + $term = $obj instanceof \WP_Term ? $obj : null; + $title = $plate ? $plate->post_title : ($term ? $term->name : '论坛'); + $context = $plate ? 'plate' : 'term'; + self::open($title, $context); + \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_top_fluid'); + echo '
'; + echo '

' . esc_html($title) . '

'; + if ($plate) { + echo '
' . wp_kses_post(wpautop($plate->post_excerpt ?: $plate->post_content)) . '
'; + echo '

' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注

'; + self::action('follow', $plate->ID, Access::follows(get_current_user_id(), $plate->ID) ? '已关注' : '关注版块'); + if (Access::is_moderator(get_current_user_id(), $plate->ID)) { + self::plate_editor($plate); + } + if (is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate->ID) && jio_get_setting('jio_forum_moderator_apply', true)) { + self::action('apply', $plate->ID, '申请版主'); + } + if (Access::is_admin(get_current_user_id()) || (int) $plate->post_author === get_current_user_id()) { + self::applications($plate->ID); + } + } elseif ($term) { + echo '

' . esc_html($term->description) . '

'; + } + echo '
'; + self::tabs(); + if ($term && $term->taxonomy === Core::CATEGORY) { + self::plates(['tax_query' => [['taxonomy' => Core::CATEGORY, 'terms' => [$term->term_id]]]]); + } else { + $args = ['post_type' => Core::POST, 'post_status' => 'publish']; + if ($plate) { + $args['meta_query'] = [['key' => '_jio_forum_plate_id', 'value' => $plate->ID]]; + } elseif ($term) { + $args['tax_query'] = [['taxonomy' => $term->taxonomy, 'terms' => [$term->term_id]]]; + } + self::posts($args, $context); + if ($plate && Access::is_moderator(get_current_user_id(), $plate->ID)) { + echo '

待审核帖子

'; + $args['post_status'] = 'pending'; + self::posts($args, $context); + } + } + echo '
'; + self::sidebar($context); + echo '
'; + \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_fluid'); + self::close(); + } + + public static function tabs(): void { + $tabs = ['latest' => '最新', 'popular' => '热门', 'essence' => '精华', 'question' => '问答', 'vote' => '投票', 'plates' => '版块', 'follow' => '已关注']; + $enabled = (array) jio_get_setting('jio_forum_home_tabs', array_keys($tabs)); + $base_url = is_singular(Core::PLATE) ? get_permalink(get_queried_object_id()) : (is_tax([Core::CATEGORY, Core::TOPIC, Core::TAG]) ? get_term_link(get_queried_object()) : Core::home_url()); + echo ''; + } + + public static function posts(array $args, string $context = 'home'): void { + $view = sanitize_key((string) ($_GET['forum_view'] ?? 'latest')); + if ($view === 'plates') { + self::plates([]); + return; + } + if ($view === 'follow') { + $ids = self::user_marked_ids(get_current_user_id(), '_jio_forum_follow_'); + $args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $ids ?: [0], 'compare' => 'IN']; + } + if (in_array($view, ['question', 'vote'], true)) { + $args['meta_query'][] = ['key' => '_jio_forum_type', 'value' => $view]; + } + if ($view === 'essence') { + $args['meta_query'][] = ['key' => '_jio_forum_essence', 'value' => 1]; + } + $args['posts_per_page'] = max(1, min(50, (int) jio_get_setting('jio_forum_page_size', 20))); + $args['paged'] = max(1, (int) get_query_var('paged', 1)); + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + $args['jio_forum_list'] = in_array($view, ['latest', 'popular', 'question', 'vote', 'essence', 'follow'], true) ? $view : 'latest'; + $query = new \WP_Query($args); + \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_top_content'); + echo '
'; + if (!$query->have_posts()) { + echo '

暂无帖子

'; + } + while ($query->have_posts()) { + $query->the_post(); + self::post_card(get_post()); + } + wp_reset_postdata(); + echo '
'; + self::pagination($query); + \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_content'); + } + + public static function plates(array $args): void { + $query = new \WP_Query(array_merge(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 20], $args)); + echo '
'; + while ($query->have_posts()) { + $query->the_post(); + $plate = get_post(); + self::plate_card($plate); + } + wp_reset_postdata(); + echo '
'; + } + + public static function plate_card(\WP_Post $plate): void { + $url = get_permalink($plate); + echo ''; + } + + public static function post_card(\WP_Post $post): void { + $plate_id = (int) get_post_meta($post->ID, '_jio_forum_plate_id', true); + $type = (string) get_post_meta($post->ID, '_jio_forum_type', true); + echo '
' . get_avatar((int) $post->post_author, 44) . '
'; + echo '
' . esc_html(get_the_author_meta('display_name', (int) $post->post_author)) . '
'; + $url = $post->post_status === 'publish' ? get_permalink($post) : Core::edit_url($post->ID); + echo '

' . esc_html($post->post_title) . '

'; + $excerpt = Access::can_view(get_current_user_id(), $post->ID) ? wp_trim_words(wp_strip_all_tags($post->post_excerpt ?: $post->post_content), 30) : '内容已隐藏'; + echo '

' . esc_html($excerpt) . '

'; + echo '
'; + $hot = get_comments(['post_id' => $post->ID, 'status' => 'approve', 'number' => 1, 'meta_key' => '_jio_forum_hot', 'meta_value' => '1']); + if ($hot) { + echo '
热门回复:' . esc_html(wp_trim_words(wp_strip_all_tags($hot[0]->comment_content), 25)) . '
'; + } + } + + public static function replies(int $post_id): void { + $page = max(1, (int) ($_GET['cpage'] ?? 1)); + $limit = max(1, min(100, (int) jio_get_setting('jio_forum_reply_page_size', 20))); + $comments = get_comments(['post_id' => $post_id, 'status' => 'approve', 'number' => $limit, 'offset' => ($page - 1) * $limit, 'orderby' => 'comment_date_gmt', 'order' => 'ASC']); + $total = (int) get_comments(['post_id' => $post_id, 'status' => 'approve', 'count' => true]); + echo '

回复 ' . $total . '

'; + if (!$comments) echo '

暂无回复

'; + foreach ($comments as $comment) { + $adopted = (bool) get_comment_meta($comment->comment_ID, '_jio_forum_adopted', true); + $hot = (bool) get_comment_meta($comment->comment_ID, '_jio_forum_hot', true); + echo '
' . get_avatar((int) $comment->user_id, 40) . '
' . esc_html(get_comment_author($comment)) . '' . ($adopted ? '已采纳' : '') . '
'; + if ($hot) echo '热门回复'; + if ((int) $comment->comment_parent > 0) { + $parent = get_comment((int) $comment->comment_parent); + if ($parent instanceof \WP_Comment) echo '

回复 @' . esc_html(get_comment_author($parent)) . '

'; + } + echo '
' . wp_kses_post(wpautop($comment->comment_content)) . '
'; + if (is_user_logged_in()) echo ''; + if (is_user_logged_in() && ((int) $comment->user_id === get_current_user_id() || Access::is_moderator(get_current_user_id(), (int) get_post_meta($post_id, '_jio_forum_plate_id', true)))) echo ''; + if (Access::is_moderator(get_current_user_id(), (int) get_post_meta($post_id, '_jio_forum_plate_id', true))) echo ''; + if (is_user_logged_in()) echo ''; + if (get_post_meta($post_id, '_jio_forum_type', true) === 'question' && !get_post_meta($post_id, '_jio_forum_adopted_comment', true) && Access::can_manage_post(get_current_user_id(), $post_id)) { + echo ''; + } + echo '
'; + } + if ($total > $limit) { + $links = paginate_links(['base' => add_query_arg('cpage', '%#%', get_permalink($post_id)), 'current' => $page, 'total' => (int) ceil($total / $limit), 'type' => 'list']); + echo ''; + } + if (get_post_field('comment_status', $post_id) === 'open') { + if (is_user_logged_in()) { + echo '

发表回复

'; + } else { + echo '

登录后回复

'; + } + } else { + echo '

回复已关闭

'; + } + echo '
'; + } + + private static function term_management(): void { + echo '

分类与话题

'; + foreach (['category' => Core::CATEGORY, 'topic' => Core::TOPIC, 'tag' => Core::TAG] as $label => $taxonomy) { + $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 30]); + if (!is_array($terms)) continue; + foreach ($terms as $term) { + echo '
' . esc_html($term->name) . '
'; + } + } + echo '
'; + } + + private static function plate_editor(\WP_Post $plate): void { + $id = $plate->ID; + echo ''; + echo ''; + echo '

编辑版块

'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '

'; + } + + private static function applications(int $plate_id): void { + $users = get_users(['meta_key' => '_jio_forum_application_' . $plate_id, 'meta_value' => 'pending', 'number' => 20]); + echo '

版主管理

'; + $moderators = array_map('absint', (array) get_post_meta($plate_id, '_jio_forum_moderators', true)); + foreach ($moderators as $user_id) { + $user = get_userdata($user_id); + if ($user) echo '
' . esc_html($user->display_name) . '
'; + } + echo '

'; + if ($users) echo '

待处理申请

'; + foreach ($users as $user) { + echo '
' . esc_html($user->display_name) . '
'; + } + echo '
'; + } + + public static function pagination(\WP_Query $query): void { + if ($query->max_num_pages <= 1) { + return; + } + echo ''; + } + + public static function action(string $action, int $id, string $label): void { + echo ''; + } + + public static function user_marked_ids(int $user_id, string $prefix): array { + if ($user_id <= 0) return []; + $ids = []; + foreach (get_user_meta($user_id) as $key => $values) { + if (str_starts_with((string) $key, $prefix) && !empty($values[0])) { + $id = absint(substr((string) $key, strlen($prefix))); + if ($id) $ids[] = $id; + } + } + return array_values(array_unique($ids)); + } + + public static function user_panel(int $user_id, bool $owner): void { + $tab = sanitize_key((string) ($_GET['forum_tab'] ?? 'posts')); + $tabs = ['posts' => '帖子', 'plates' => '版块']; + if ($owner) $tabs += ['favorites' => '收藏', 'follow' => '关注']; + if (!isset($tabs[$tab])) $tab = 'posts'; + echo '
'; + if ($owner) echo '

发布帖子

'; + $args = ['post_status' => $owner ? ['publish', 'pending'] : 'publish', 'posts_per_page' => 20]; + if ($tab === 'posts' || $tab === 'plates') { + $args['post_type'] = $tab === 'posts' ? Core::POST : Core::PLATE; + $args['author'] = $user_id; + } else { + $args['post_type'] = $tab === 'favorites' ? Core::POST : Core::PLATE; + $ids = self::user_marked_ids($user_id, $tab === 'favorites' ? '_jio_forum_favorite_' : '_jio_forum_follow_'); + $args['post__in'] = $ids ?: [0]; + } + $query = new \WP_Query($args); + echo '
'; + if (!$query->have_posts()) echo '

暂无内容

'; + while ($query->have_posts()) { + $query->the_post(); + $post = get_post(); + if ($post->post_type === Core::POST) self::post_card($post); + else { + $url = $post->post_status === 'publish' ? get_permalink($post) : ''; + echo '
' . ($url ? '' : '') . esc_html($post->post_title) . ($url ? '' : '') . ($url ? '' : '待审核') . '
'; + if ($owner && !$url) self::plate_editor($post); + } + } + wp_reset_postdata(); + echo '
'; + } +} diff --git a/src/Forum/Widget.php b/src/Forum/Widget.php new file mode 100644 index 00000000..17b9b925 --- /dev/null +++ b/src/Forum/Widget.php @@ -0,0 +1,58 @@ + '显示论坛帖子、版块或话题']); + } + + public function widget($args, $instance): void { + if (!Core::enabled()) { + return; + } + $title = sanitize_text_field((string) ($instance['title'] ?? '论坛动态')); + $type = sanitize_key((string) ($instance['type'] ?? 'posts')); + $count = max(1, min(20, (int) ($instance['count'] ?? 6))); + echo $args['before_widget'] ?? ''; + echo ($args['before_title'] ?? '

') . esc_html($title) . ($args['after_title'] ?? '

'); + if ($type === 'topics') { + $terms = get_terms(['taxonomy' => Core::TOPIC, 'hide_empty' => true, 'number' => $count, 'orderby' => 'count', 'order' => 'DESC']); + if (is_array($terms)) { + foreach ($terms as $term) { + echo '' . esc_html($term->name) . '' . (int) $term->count . ''; + } + } + } else { + $posts = get_posts(['post_type' => $type === 'plates' ? Core::PLATE : Core::POST, 'post_status' => 'publish', 'posts_per_page' => $count, 'orderby' => $type === 'hot' ? 'comment_count' : 'date']); + foreach ($posts as $post) { + echo '' . esc_html($post->post_title) . ''; + } + } + echo $args['after_widget'] ?? ''; + } + + public function form($instance): void { + $title = (string) ($instance['title'] ?? '论坛动态'); + $type = (string) ($instance['type'] ?? 'posts'); + $count = (int) ($instance['count'] ?? 6); + echo '

'; + echo '

'; + echo '

'; + } + + public function update($new_instance, $old_instance): array { + $type = sanitize_key((string) ($new_instance['type'] ?? 'posts')); + return [ + 'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')), + 'type' => in_array($type, ['posts', 'hot', 'plates', 'topics'], true) ? $type : 'posts', + 'count' => max(1, min(20, (int) ($new_instance['count'] ?? 6))), + ]; + } +} diff --git a/src/User/Home.php b/src/User/Home.php index 4d658b05..c4e61c71 100644 --- a/src/User/Home.php +++ b/src/User/Home.php @@ -187,6 +187,9 @@ class Home { $visible[$key] = $this->tabs[$key]; } + if (class_exists('\\Jio\\Forum\\Core') && \Jio\Forum\Core::enabled() && isset($this->tabs['forum'])) { + $visible['forum'] = $this->tabs['forum']; + } if (empty($visible)) { foreach (['posts', 'comments', 'favorites'] as $fallback) { diff --git a/src/WidgetSystem/WidgetAreas.php b/src/WidgetSystem/WidgetAreas.php index 8496db00..28e69a81 100644 --- a/src/WidgetSystem/WidgetAreas.php +++ b/src/WidgetSystem/WidgetAreas.php @@ -50,6 +50,13 @@ final class WidgetAreas { $areas['newposts_sidebar_bottom'] = ['前台投稿 - 侧栏底部', '显示在前台投稿侧栏底部,并在移动端保留。', 'sidebar']; $areas['all_footer'] = ['页脚内部', '显示在站点页脚内部。', 'content']; $areas['mobile_nav_fluid'] = ['移动菜单 - 底部', '显示在移动端抽屉菜单底部。', 'sidebar']; + foreach (['home' => '论坛首页', 'plate' => '论坛版块', 'term' => '论坛分类', 'post' => '论坛帖子'] as $key => $name) { + foreach (['top_fluid' => '顶部全宽', 'top_content' => '主内容上方', 'bottom_content' => '主内容下方', 'bottom_fluid' => '底部全宽', 'sidebar' => '侧栏'] as $position => $label) { + $kind = str_contains($position, 'fluid') ? 'fluid' : ($position === 'sidebar' ? 'sidebar' : 'content'); + $areas['forum_' . $key . '_' . $position] = [$name . ' - ' . $label, '显示在' . $name . '的' . $label, $kind]; + } + } + $areas['forum_edit_sidebar'] = ['论坛发帖 - 侧栏', '显示在论坛发帖页面侧栏', 'sidebar']; return $areas; } diff --git a/templates/pages/page-forum-edit.php b/templates/pages/page-forum-edit.php new file mode 100644 index 00000000..67e30329 --- /dev/null +++ b/templates/pages/page-forum-edit.php @@ -0,0 +1,57 @@ + 0 ? get_post($edit_id) : null; +if (!is_user_logged_in()) { + wp_safe_redirect(jio_get_login_url(Core::edit_url($edit_id))); + exit; +} +if ($edit_id > 0 && (!$post instanceof WP_Post || !Access::can_manage_post(get_current_user_id(), $edit_id))) { + status_header(403); + wp_die(esc_html__('无权编辑此帖子', 'jio')); +} + +wp_enqueue_editor(); + +$plate_id = $post ? (int) get_post_meta($post->ID, '_jio_forum_plate_id', true) : absint($_GET['plate'] ?? 0); +$vote_started = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_started', true) : 0; +$vote_deadline = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_deadline', true) : 0; +$vote_days = $vote_started && $vote_deadline ? (int) round(($vote_deadline - $vote_started) / DAY_IN_SECONDS) : 0; +$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC']); +View::open($post ? '编辑帖子' : '发布帖子', 'edit'); +?> +
+

+
+ + + + + post_content : '', 'jio_forum_content', ['textarea_name' => 'content', 'textarea_rows' => 16, 'media_buttons' => false, 'quicktags' => true, 'tinymce' => ['toolbar1' => 'formatselect,bold,italic,bullist,numlist,blockquote,link,unlink,alignleft,aligncenter,alignright,undo,redo', 'toolbar2' => 'forecolor,removeformat']]); ?> +
+ '插入图片', 'video' => '插入视频', 'resource' => '插入附件'] as $kind => $label) : ?> + + + +
+ + +
+ + + + +
+ + + + +

+
+
+ diff --git a/templates/pages/page-forum-list.php b/templates/pages/page-forum-list.php new file mode 100644 index 00000000..01ca8ad5 --- /dev/null +++ b/templates/pages/page-forum-list.php @@ -0,0 +1,3 @@ +ID; +$plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true); +$type = (string) get_post_meta($id, '_jio_forum_type', true); +$user_id = get_current_user_id(); +$can_view = Access::can_view($user_id, $id); +$can_manage = Access::can_manage_post($user_id, $id); +$can_moderate = Access::is_moderator($user_id, $plate_id); +View::open($post->post_title, 'post'); +\Jio\WidgetSystem\WidgetAreas::render('forum_post_top_fluid'); +?> +
+
+
+
post_author, 40); ?>post_author)); ?>
+

post_title); ?>

+
+ 置顶 + 问答投票 + 已解决 + 精华 + 0) : ?> + #name); ?> +
+
+ +
post_content); ?>
+ + 0 && current_time('timestamp') > $deadline; + $vote_display = (string) jio_get_setting('jio_forum_vote_number_type', 'percentage'); + $already_voted = $user_id > 0 && get_user_meta($user_id, '_jio_forum_vote_' . $id, true) !== ''; + ?> +

+ $option) : ?> + + +

已投票

投票已截止

+
+ + +

内容已隐藏

请完成访问条件后查看。

+ 0) : ?> + 关注版块'; + } elseif (in_array($post_rule, ['points', 'pay'], true)) { + View::action('unlock', $id, '解锁帖子'); + } + ?> + 登录查看 +
+ +
+ + + + + 编辑 + comment_status === 'open' ? '关闭回复' : '开放回复'); View::action('move', $id, '移动版块'); endif; ?> +
+
+ +

已采纳回复 #

+ + +
+ diff --git a/templates/pages/page-forums.php b/templates/pages/page-forums.php new file mode 100644 index 00000000..e941872f --- /dev/null +++ b/templates/pages/page-forums.php @@ -0,0 +1,12 @@ + 0) { + \Jio\Forum\View::user_panel($user_id, $user_id === get_current_user_id()); +} diff --git a/tests/forum/browser-smoke.cjs b/tests/forum/browser-smoke.cjs new file mode 100644 index 00000000..f7baa48c --- /dev/null +++ b/tests/forum/browser-smoke.cjs @@ -0,0 +1,207 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawn } = require('node:child_process') + +const edge = process.env.JIO_FORUM_BROWSER || 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' +const base = process.env.JIO_FORUM_BASE_URL || 'http://localhost:9081' +const lockedUrl = process.env.JIO_FORUM_LOCKED_URL || '' +const multiVoteUrl = process.env.JIO_FORUM_MULTI_VOTE_URL || '' +const browserPassword = process.env.JIO_FORUM_TEST_PASSWORD || '' +const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'jio-forum-browser-')) +const browser = spawn(edge, [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank', +], { stdio: 'ignore', windowsHide: true }) + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const until = async (fn, timeout = 15000) => { + const end = Date.now() + timeout + while (Date.now() < end) { + const result = await fn().catch(() => null) + if (result) return result + await sleep(100) + } + throw new Error('browser timeout') +} + +async function run() { + const portFile = path.join(profile, 'DevToolsActivePort') + const port = await until(async () => fs.existsSync(portFile) && Number(fs.readFileSync(portFile, 'utf8').split('\n')[0])) + const target = await until(async () => { + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json() + return list.find((item) => item.type === 'page')?.webSocketDebuggerUrl + }) + const socket = new WebSocket(target) + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }) + socket.addEventListener('error', reject, { once: true }) + }) + let nextId = 0 + const pending = new Map() + const problems = [] + socket.addEventListener('message', ({ data }) => { + const item = JSON.parse(data) + if (item.id && pending.has(item.id)) { + const { resolve, reject } = pending.get(item.id) + pending.delete(item.id) + item.error ? reject(new Error(item.error.message)) : resolve(item.result) + } + if (item.method === 'Runtime.exceptionThrown') problems.push(item.params.exceptionDetails.text) + if (item.method === 'Runtime.consoleAPICalled' && item.params.type === 'error') { + problems.push(item.params.args.map((arg) => arg.value || arg.description || '').join(' ')) + } + if (item.method === 'Network.loadingFailed' && !item.params.canceled) problems.push(item.params.errorText) + }) + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++nextId + pending.set(id, { resolve, reject }) + socket.send(JSON.stringify({ id, method, params })) + }) + await Promise.all([call('Page.enable'), call('Runtime.enable'), call('Network.enable')]) + + const check = async (width, height, url, label) => { + await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 }) + await call('Page.navigate', { url: base + url }) + await until(async () => { + const result = await call('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true }) + return result.result.value === 'complete' + }) + await sleep(800) + const result = await call('Runtime.evaluate', { + expression: `({forum:!!document.getElementById('forum'),posts:document.querySelectorAll('.jio-forum__post').length,locked:document.body.textContent.includes('内容已隐藏'),leaked:document.documentElement.innerHTML.includes('需要积分查看的正文'),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,width:innerWidth})`, + returnByValue: true, + }) + const state = result.result.value + if (!state.forum || state.overflow || (url === '/forums/' && state.posts < 1) || (url === lockedUrl && (!state.locked || state.leaked))) { + throw new Error(`${label}: ${JSON.stringify(state)}`) + } + console.log(`${label}: ${JSON.stringify(state)}`) + } + + try { + await check(1440, 900, '/forums/', 'desktop home') + await check(390, 844, '/forums/', 'mobile home') + if (lockedUrl) { + await check(390, 844, lockedUrl, 'mobile locked post') + await call('Runtime.evaluate', { expression: `document.body.classList.remove('dark-theme');document.documentElement.classList.remove('dark')` }) + await sleep(650) + const before = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true }) + await call('Runtime.evaluate', { expression: `document.body.classList.add('dark-theme');document.documentElement.classList.add('dark')` }) + await sleep(650) + const after = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true }) + if (before.result.value === after.result.value) throw new Error(`forum dark mode does not change locked surface: ${before.result.value}`) + console.log(`dark mode: ${JSON.stringify({ light: before.result.value, dark: after.result.value })}`) + } + if (browserPassword) { + await call('Page.navigate', { url: base + '/sign/?tab=signin' }) + await until(async () => { + const state = await call('Runtime.evaluate', { expression: 'document.readyState === "complete" && !!window.jioData?.restNonce', returnByValue: true }) + return state.result.value + }) + const login = await call('Runtime.evaluate', { + expression: `(async () => { const response=await fetch('/wp-json/jio/v1/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-WP-Nonce':window.jioData.restNonce},body:JSON.stringify({target:'forum_admin',password:${JSON.stringify(browserPassword)},redirect_to:${JSON.stringify(base + '/wp-admin/')}})});return {status:response.status,body:await response.json()}})()`, + awaitPromise: true, + returnByValue: true, + }) + if (login.result.value?.status !== 200) throw new Error(`login failed: ${JSON.stringify(login.result.value)}`) + for (const [width, height, label] of [[1440, 900, 'desktop admin'], [390, 844, 'mobile admin']]) { + await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 }) + await call('Page.navigate', { url: base + '/wp-admin/admin.php?page=jio-settings#/forum/general' }) + const state = await until(async () => { + const result = await call('Runtime.evaluate', { expression: `({app:!!document.querySelector('#jio-admin-app'),ready:document.querySelector('#jio-admin-app')?.textContent.includes('启用论坛'),width:innerWidth,overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth})`, returnByValue: true }) + return result.result.value?.ready && result.result.value + }, 20000) + if (!state.app || state.overflow) throw new Error(`${label}: ${JSON.stringify(state)}`) + console.log(`${label}: ${JSON.stringify(state)}`) + } + if (multiVoteUrl) { + await call('Page.navigate', { url: base + multiVoteUrl }) + await until(async () => { + const result = await call('Runtime.evaluate', { expression: `document.querySelectorAll('.jio-forum__vote input[type=checkbox]').length >= 2`, returnByValue: true }) + return result.result.value + }) + await call('Runtime.evaluate', { expression: `(() => {const form=document.querySelector('[data-forum-form=vote]');const boxes=form.querySelectorAll('input[type=checkbox]');boxes[0].checked=true;boxes[1].checked=true;form.requestSubmit();return true})()`, returnByValue: true }) + const voted = await until(async () => { + const result = await call('Runtime.evaluate', { expression: `({done:document.querySelector('.jio-forum__vote')?.textContent.includes('已投票'),error:document.querySelector('.jio-forum__vote .jio-forum__message')?.textContent||''})`, returnByValue: true }) + return result.result.value?.done && result.result.value + }, 12000) + if (voted.error) throw new Error(`multiple vote browser error: ${voted.error}`) + console.log(`multiple vote browser: ${JSON.stringify(voted)}`) + } + await call('Page.navigate', { url: base + '/posts-edit/' }) + const editorState = await until(async () => { + const result = await call('Runtime.evaluate', { expression: `({form:!!document.querySelector('[data-forum-form=post]'),editor:!!document.querySelector('#jio_forum_content'),upload:!!document.querySelector('[data-forum-upload=image]')})`, returnByValue: true }) + return result.result.value?.form && result.result.value + }) + if (!editorState.editor || !editorState.upload) throw new Error(`forum editor unavailable: ${JSON.stringify(editorState)}`) + console.log(`forum editor: ${JSON.stringify(editorState)}`) + const editorLayout = await call('Runtime.evaluate', { expression: `({overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true }) + if (editorLayout.result.value?.overflow || !editorLayout.result.value?.css) throw new Error(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`) + console.log(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`) + const upload = await call('Runtime.evaluate', { + expression: `(async()=>{const source=await fetch('/wp-content/themes/jio/assets/img/topic.jpg');const blob=await source.blob();const body=new FormData();body.append('file',new File([blob],'topic.jpg',{type:'image/jpeg'}));const response=await fetch('/wp-json/jio/v1/forum/upload/image',{method:'POST',credentials:'same-origin',headers:{'X-WP-Nonce':window.jioForum.nonce},body});const payload=await response.json();return {status:response.status,id:payload.data?.id,url:payload.data?.url,message:payload.message}})()`, + awaitPromise: true, + returnByValue: true, + }) + if (upload.result.value?.status !== 200 || !upload.result.value?.url) throw new Error(`forum upload failed: ${JSON.stringify(upload.result.value)}`) + console.log(`forum upload: ${JSON.stringify({ status: upload.result.value.status, id: upload.result.value.id })}`) + const submitted = await call('Runtime.evaluate', { + expression: `(() => { const form=document.querySelector('[data-forum-form=post]');form.querySelector('[name=title]').value='浏览器表单测试';const plate=form.querySelector('[name=plate_id]');plate.value=plate.querySelector('option[value]:not([value=""])')?.value||'';const editor=window.tinymce?.get('jio_forum_content');if(editor&&!editor.isHidden())editor.setContent('

浏览器表单正文

');else form.querySelector('[name=content]').value='浏览器表单正文';form.requestSubmit();return {plate:plate.value} })()`, + returnByValue: true, + }) + if (!submitted.result.value?.plate) throw new Error('no publishable plate in editor') + const saved = await until(async () => { + const result = await call('Runtime.evaluate', { expression: `({url:location.pathname,error:document.querySelector('.jio-forum__form .jio-forum__message')?.textContent||''})`, returnByValue: true }) + return result.result.value?.url.startsWith('/forum-post/') && result.result.value + }, 12000) + console.log(`browser form submit: ${JSON.stringify(saved)}`) + await call('Page.navigate', { url: base + '/user/forum/' }) + const center = await until(async () => { + const result = await call('Runtime.evaluate', { expression: `({panel:!!document.querySelector('#panel-forum'),posts:document.querySelector('#panel-forum')?.textContent.includes('帖子'),plates:document.querySelector('#panel-forum')?.textContent.includes('版块'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true }) + return result.result.value?.panel && result.result.value + }) + if (!center.posts || !center.plates || !center.css) throw new Error(`forum user panel unavailable: ${JSON.stringify(center)}`) + console.log(`user center: ${JSON.stringify(center)}`) + await call('Page.navigate', { url: base + '/author/forum_admin/' }) + const publicHome = await until(async () => { + const result = await call('Runtime.evaluate', { expression: `({forum:!!document.querySelector('#panel-forum'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true }) + return result.result.value?.forum && result.result.value + }) + if (!publicHome.css) throw new Error(`public user home CSS missing: ${JSON.stringify(publicHome)}`) + console.log(`public user home: ${JSON.stringify(publicHome)}`) + } + if (problems.length) throw new Error(`browser errors: ${problems.join('; ')}`) + } finally { + call('Browser.close').catch(() => {}) + await sleep(150) + socket.close() + } +} + +const cleanup = async () => { + const target = path.resolve(profile) + const tempRoot = path.resolve(os.tmpdir()) + path.sep + if (target.startsWith(tempRoot) && path.basename(target).startsWith('jio-forum-browser-')) { + for (let attempt = 0; attempt < 20; attempt++) { + try { + fs.rmSync(target, { recursive: true, force: true }) + return + } catch (error) { + if (error.code !== 'EBUSY' && error.code !== 'EPERM') throw error + await sleep(250) + } + } + throw new Error('browser profile is still in use') + } +} + +run().then(async () => { + browser.kill() + await cleanup() +}).catch(async (error) => { + console.error(error) + browser.kill() + await cleanup().catch((cleanupError) => console.error(cleanupError)) + process.exitCode = 1 +}) diff --git a/tests/forum/docker-compose.yml b/tests/forum/docker-compose.yml new file mode 100644 index 00000000..017d7947 --- /dev/null +++ b/tests/forum/docker-compose.yml @@ -0,0 +1,40 @@ +name: jio-forum-check + +services: + db: + image: mysql:8.0 + environment: + MYSQL_DATABASE: jioforum + MYSQL_USER: jioforum + MYSQL_PASSWORD: jioforum + MYSQL_RANDOM_ROOT_PASSWORD: "1" + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u jioforum -pjioforum"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - db_data:/var/lib/mysql + + wordpress: + image: wordpress:latest + depends_on: + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: jioforum + WORDPRESS_DB_PASSWORD: jioforum + WORDPRESS_DB_NAME: jioforum + ports: + - "9081:80" + volumes: + - wp_data:/var/www/html + - type: bind + source: ../.. + target: /var/www/html/wp-content/themes/jio + read_only: true + +volumes: + db_data: + wp_data: diff --git a/tests/forum/forum-contract.test.cjs b/tests/forum/forum-contract.test.cjs new file mode 100644 index 00000000..2270cc64 --- /dev/null +++ b/tests/forum/forum-contract.test.cjs @@ -0,0 +1,47 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '../..') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') + +test('every forum page mounts the dedicated runtime and the public write routes require a nonce', () => { + const core = read('src/Forum/Core.php') + const rest = read('src/Forum/Rest.php') + const runtime = read('assets/js/forum-runtime.js') + for (const template of ['page-forums.php', 'page-forum-list.php', 'page-forum-post.php', 'page-forum-edit.php']) { + assert.ok(fs.existsSync(path.join(root, 'templates/pages', template))) + } + assert.match(core, /wp_enqueue_script\('jio-forum'/) + assert.match(core, /wp_create_nonce\('wp_rest'\)/) + assert.match(rest, /'permission_callback' => \$auth/) + assert.match(rest, /wp_verify_nonce\(\$nonce, 'wp_rest'\)/) + assert.match(runtime, /'X-WP-Nonce'/) +}) + +test('paid content has server-side presentation guards beyond its page body', () => { + const access = read('src/Forum/Access.php') + const integration = read('src/Forum/Integration.php') + const view = read('src/Forum/View.php') + assert.match(access, /function can_view\(/) + assert.match(integration, /add_filter\('the_content'/) + assert.match(integration, /add_filter\('get_the_excerpt'/) + assert.match(integration, /add_filter\('jio_seo_head_payload'/) + assert.match(view, /Access::can_view\(get_current_user_id\(\), \$post->ID\)/) +}) + +test('forum management, account, search and responsive styles remain connected', () => { + const admin = read('assets/admin/src/constants/sections/forum.js') + const schema = read('src/Admin/Schema/SchemaForum.php') + const search = read('search.php') + const css = read('assets/css/routes/forum.css') + for (const section of ['general', 'home', 'publishing', 'access', 'replies', 'payment', 'urls']) { + assert.match(admin, new RegExp(`'${section}'`)) + assert.match(schema, new RegExp(`'${section}'`)) + } + assert.match(search, /search_type === 'forum'/) + assert.match(css, /@media\(max-width:800px\)/) + assert.match(css, /@media\(max-width:480px\)/) + assert.match(read('src/Forum/View.php'), /function user_panel\(/) +}) diff --git a/tests/forum/smoke.php b/tests/forum/smoke.php new file mode 100644 index 00000000..07078251 --- /dev/null +++ b/tests/forum/smoke.php @@ -0,0 +1,275 @@ +ID); + echo "browser login ready\n"; + exit; +} + +if (($argv[1] ?? '') === 'install') { + if (!is_blog_installed()) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + wp_install('Forum check', 'forum_admin', 'forum@example.test', false, '', wp_generate_password(24)); + } + update_option('home', 'http://localhost:9081'); + update_option('siteurl', 'http://localhost:9081'); + switch_theme('jio'); + echo "installed\n"; + exit; +} + +function forum_check(bool $condition, string $label): void { + if (!$condition) { + throw new RuntimeException($label); + } + echo "OK: {$label}\n"; +} + +function forum_request(string $method, string $route, int $user_id, array $params = [], string $key = ''): WP_REST_Response { + wp_set_current_user($user_id); + $request = new WP_REST_Request($method, '/jio/v1/forum/' . ltrim($route, '/')); + $request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest')); + if ($key !== '') $request->set_header('Idempotency-Key', $key); + $request->set_body_params($params); + $response = rest_do_request($request); + forum_check($response instanceof WP_REST_Response, "REST response {$route}"); + return $response; +} + +forum_check(post_type_exists(\Jio\Forum\Core::PLATE), 'plate registered'); +forum_check(post_type_exists(\Jio\Forum\Core::POST), 'post registered'); +forum_check(taxonomy_exists(\Jio\Forum\Core::TOPIC), 'topic registered'); +forum_check(get_page_by_path('forums') instanceof WP_Post, 'forum home created'); + +$schema = \Jio\Admin\SchemaProvider::get_instance()->get_schema('forum'); +forum_check(is_array($schema) && count($schema['sections'] ?? []) === 7, 'forum settings schema'); +$invalid_urls = \Jio\Admin\SchemaProvider::get_instance()->validate('forum', ['jio_forum_slug_plate' => 'forum-post'], 'urls'); +forum_check(!$invalid_urls['valid'], 'duplicate URL slugs rejected'); + +$admin = get_user_by('login', 'forum_admin'); +forum_check($admin instanceof WP_User, 'admin exists'); +wp_set_current_user((int) $admin->ID); +$admin_schema_request = new WP_REST_Request('GET', '/jio/v1/settings/schema/forum/general'); +$admin_schema_request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest')); +$admin_schema_response = rest_do_request($admin_schema_request); +forum_check($admin_schema_response->get_status() === 200, 'admin forum settings route'); +$member_login = 'forum_member_' . substr(str_replace('-', '', wp_generate_uuid4()), 0, 8); +$member_id = wp_create_user($member_login, wp_generate_password(24), $member_login . '@example.test'); +forum_check(!is_wp_error($member_id), 'member created'); +$member = get_userdata((int) $member_id); + +wp_set_current_user((int) $admin->ID); +$plate = wp_insert_post(['post_type' => \Jio\Forum\Core::PLATE, 'post_status' => 'publish', 'post_title' => '测试版块', 'post_author' => (int) $admin->ID], true); +forum_check(!is_wp_error($plate) && $plate > 0, 'plate created'); + +$guest = forum_request('POST', 'posts', 0, ['title' => '测试帖子', 'content' => '正文内容', 'plate_id' => $plate]); +forum_check($guest->get_status() === 401, 'guest cannot publish'); + +$created = forum_request('POST', 'posts', (int) $member->ID, ['title' => '测试帖子', 'content' => '正文内容', 'plate_id' => $plate, 'type' => 'question']); +forum_check($created->get_status() === 200, 'member can submit'); +$post_id = (int) ($created->get_data()['data']['id'] ?? 0); +forum_check($post_id > 0 && get_post_status($post_id) === 'pending', 'post awaits review'); + +$blocked = forum_request('POST', "posts/{$post_id}/audit", (int) $member->ID); +forum_check($blocked->get_status() === 403, 'member cannot audit'); +$approved = forum_request('POST', "posts/{$post_id}/audit", (int) $admin->ID); +forum_check($approved->get_status() === 200 && get_post_status($post_id) === 'publish', 'moderator approves post'); + +$follow = forum_request('POST', "plates/{$plate}/follow", (int) $member->ID); +forum_check($follow->get_status() === 200 && \Jio\Forum\Access::follows((int) $member->ID, $plate), 'plate follow'); + +$reply = forum_request('POST', "posts/{$post_id}/replies", (int) $admin->ID, ['content' => '这是测试回复']); +forum_check($reply->get_status() === 200, 'reply submitted'); +$comment_id = (int) ($reply->get_data()['data']['id'] ?? 0); +$adopt = forum_request('POST', "posts/{$post_id}/adopt", (int) $member->ID, ['comment_id' => $comment_id]); +forum_check($adopt->get_status() === 200, 'answer adopted'); +$hot_reply = forum_request('POST', "replies/{$comment_id}/hot", (int) $admin->ID); +forum_check($hot_reply->get_status() === 200 && get_comment_meta($comment_id, '_jio_forum_hot', true), 'reply marked hot'); +$reply_deleted = forum_request('POST', "replies/{$comment_id}/delete", (int) $member->ID); +forum_check($reply_deleted->get_status() === 403, 'other member cannot delete reply'); +$reply_deleted = forum_request('POST', "replies/{$comment_id}/delete", (int) $admin->ID); +forum_check($reply_deleted->get_status() === 200 && get_comment($comment_id)->comment_approved === 'trash', 'moderator deletes reply'); +forum_check(!get_post_meta($post_id, '_jio_forum_adopted_comment', true), 'deleted answer clears adoption'); +$reply_restored = forum_request('POST', "replies/{$comment_id}/restore", (int) $admin->ID); +forum_check($reply_restored->get_status() === 200 && get_comment($comment_id)->comment_approved === '1', 'moderator restores reply'); +$readopt = forum_request('POST', "posts/{$post_id}/adopt", (int) $member->ID, ['comment_id' => $comment_id]); +forum_check($readopt->get_status() === 200, 'restored answer may be adopted again'); +$article_id = wp_insert_post(['post_type' => 'post', 'post_status' => 'publish', 'post_title' => '普通文章', 'post_author' => (int) $admin->ID]); +$article_comment = wp_insert_comment(['comment_post_ID' => $article_id, 'comment_content' => '普通文章评论', 'user_id' => (int) $member->ID, 'comment_approved' => 1]); +$wrong_comment_delete = forum_request('POST', "replies/{$article_comment}/delete", (int) $member->ID); +forum_check($wrong_comment_delete->get_status() === 403 && get_comment($article_comment)->comment_approved === '1', 'forum route cannot delete article comment'); + +$term_created = forum_request('POST', 'terms', (int) $admin->ID, ['type' => 'topic', 'name' => '测试话题']); +forum_check($term_created->get_status() === 200, 'forum topic created'); +$term_id = (int) ($term_created->get_data()['data']['id'] ?? 0); +$term_updated = forum_request('PATCH', "terms/{$term_id}", (int) $admin->ID, ['name' => '新测试话题']); +forum_check($term_updated->get_status() === 200 && get_term($term_id)->name === '新测试话题', 'forum topic edited'); +$term_deleted = forum_request('DELETE', "terms/{$term_id}", (int) $admin->ID); +forum_check($term_deleted->get_status() === 200, 'forum topic deleted'); + +$vote = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '测试投票', 'content' => '请选择选项', 'plate_id' => $plate, 'type' => 'vote', 'vote_options' => "选项一\n选项二"]); +forum_check($vote->get_status() === 200, 'vote post created'); +$vote_id = (int) ($vote->get_data()['data']['id'] ?? 0); +$cast = forum_request('POST', "posts/{$vote_id}/vote", (int) $member->ID, ['option' => 0]); +forum_check($cast->get_status() === 200, 'vote cast'); +$repeat = forum_request('POST', "posts/{$vote_id}/vote", (int) $member->ID, ['option' => 1]); +forum_check($repeat->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'vote idempotent'); +$multi = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '多选投票', 'content' => '选择多个选项', 'plate_id' => $plate, 'type' => 'vote', 'vote_type' => 'multiple', 'vote_title' => '你喜欢什么', 'vote_options' => "一\n二\n三"]); +forum_check($multi->get_status() === 200, 'multiple vote created'); +$multi_id = (int) ($multi->get_data()['data']['id'] ?? 0); +$multi_cast = forum_request('POST', "posts/{$multi_id}/vote", (int) $member->ID, ['options' => [0, 2]]); +$multi_counts = (array) get_post_meta($multi_id, '_jio_forum_vote_counts', true); +forum_check($multi_cast->get_status() === 200 && (int) get_post_meta($multi_id, '_jio_forum_vote_total', true) === 1 && (int) ($multi_counts[0] ?? 0) === 1 && (int) ($multi_counts[2] ?? 0) === 1, 'multiple vote counts one voter'); +$expired_vote = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '过期投票', 'content' => '已过期', 'plate_id' => $plate, 'type' => 'vote', 'vote_days' => 1, 'vote_options' => "甲\n乙"]); +forum_check($expired_vote->get_status() === 200, 'limited vote created'); +$expired_id = (int) ($expired_vote->get_data()['data']['id'] ?? 0); +update_post_meta($expired_id, '_jio_forum_vote_deadline', current_time('timestamp') - 10); +$late_cast = forum_request('POST', "posts/{$expired_id}/vote", (int) $member->ID, ['option' => 0]); +forum_check($late_cast->get_status() === 409, 'expired vote rejects ballot'); +$locked_vote = \Jio\Forum\Lock::acquire('vote', (int) $admin->ID, $vote_id); +forum_check($locked_vote !== '', 'vote lock acquired'); +$overlap_vote = forum_request('POST', "posts/{$vote_id}/vote", (int) $admin->ID, ['option' => 0]); +forum_check($overlap_vote->get_status() === 409 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'overlapping vote rejected'); +\Jio\Forum\Lock::release('vote', (int) $admin->ID, $vote_id, $locked_vote); +$object_lock = \Jio\Forum\Lock::acquire('vote-object', 0, $vote_id); +forum_check($object_lock !== '', 'vote object lock acquired'); +$cross_user_vote = forum_request('POST', "posts/{$vote_id}/vote", (int) $admin->ID, ['option' => 0]); +forum_check($cross_user_vote->get_status() === 409 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'cross-user overlapping vote rejected'); +\Jio\Forum\Lock::release('vote-object', 0, $vote_id, $object_lock); +$pinned = forum_request('POST', "posts/{$vote_id}/pin", (int) $admin->ID); +forum_check($pinned->get_status() === 200, 'post pinned'); +$ordered = new WP_Query(['post_type' => \Jio\Forum\Core::POST, 'post_status' => 'publish', 'posts_per_page' => 1, 'jio_forum_list' => 'latest']); +forum_check((int) ($ordered->posts[0]->ID ?? 0) === $vote_id, 'pinned post leads list'); +$favorite = forum_request('POST', "posts/{$vote_id}/favorite", (int) $member->ID); +forum_check($favorite->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_favorite_count', true) === 1, 'favorite count updated'); +$scored = forum_request('POST', "posts/{$vote_id}/score", (int) $member->ID, ['direction' => 'up']); +forum_check($scored->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_score', true) === 1, 'score count updated'); + +$apply = forum_request('POST', "plates/{$plate}/apply", (int) $member->ID, ['reason' => '愿意维护版块']); +forum_check($apply->get_status() === 200, 'moderator application submitted'); +$process = forum_request('POST', "plates/{$plate}/applications/{$member->ID}", (int) $admin->ID, ['status' => 'approved']); +forum_check($process->get_status() === 200 && \Jio\Forum\Access::is_moderator((int) $member->ID, $plate), 'moderator application approved'); + +$member_plate = forum_request('POST', 'plates', (int) $member->ID, ['title' => '用户创建版块']); +forum_check($member_plate->get_status() === 200, 'member plate submitted'); +$member_plate_id = (int) ($member_plate->get_data()['data']['id'] ?? 0); +forum_check(get_post_status($member_plate_id) === 'pending', 'member plate awaits review'); +$member_plate_approved = forum_request('POST', "plates/{$member_plate_id}/audit", (int) $admin->ID); +forum_check($member_plate_approved->get_status() === 200, 'member plate approved'); +$member_plate_post = forum_request('POST', 'posts', (int) $member->ID, ['title' => '用户版块帖子', 'content' => '删除版块后应该一起入回收站', 'plate_id' => $member_plate_id]); +forum_check($member_plate_post->get_status() === 200, 'member plate post created'); +$member_plate_post_id = (int) ($member_plate_post->get_data()['data']['id'] ?? 0); +$deleted_plate = forum_request('POST', "plates/{$member_plate_id}/delete", (int) $member->ID, ['posts_action' => 'trash']); +forum_check($deleted_plate->get_status() === 200 && get_post_status($member_plate_post_id) === 'trash', 'plate deletion cascades to posts'); +$restored_plate = forum_request('POST', "plates/{$member_plate_id}/restore", (int) $member->ID); +forum_check($restored_plate->get_status() === 200 && get_post_status($member_plate_post_id) === 'publish', 'plate restore recovers posts (' . get_post_status($member_plate_post_id) . ')'); + +wp_set_current_user((int) $admin->ID); +$pay_test_plate = wp_insert_post(['post_type' => \Jio\Forum\Core::PLATE, 'post_status' => 'publish', 'post_title' => '付费测试版块', 'post_author' => (int) $admin->ID], true); +forum_check(!is_wp_error($pay_test_plate) && $pay_test_plate > 0, 'payment test plate created'); +$pending_post = forum_request('POST', 'posts', (int) $member->ID, ['title' => '待审核恢复测试', 'content' => '恢复后不能绕过审核', 'plate_id' => $pay_test_plate]); +$pending_id = (int) ($pending_post->get_data()['data']['id'] ?? 0); +forum_check($pending_post->get_status() === 200 && get_post_status($pending_id) === 'pending', 'pending post created'); +$pending_deleted = forum_request('POST', "posts/{$pending_id}/delete", (int) $member->ID); +forum_check($pending_deleted->get_status() === 200 && get_post_status($pending_id) === 'trash', 'pending post trashed'); +$pending_restored = forum_request('POST', "posts/{$pending_id}/restore", (int) $member->ID); +forum_check($pending_restored->get_status() === 200 && get_post_status($pending_id) === 'pending', 'pending status preserved on restore'); + +\Jio\Core\DBMigrator::maybe_migrate(); +$ledger = \Jio\Ledger\LedgerService::get_instance(); +$credit = $ledger->credit((int) $member->ID, \Jio\Ledger\AssetType::POINTS, 30, \Jio\Ledger\OperationKey::unique('forum-smoke-credit'), 'forum_test_credit'); +forum_check(!is_wp_error($credit), 'points credited'); +$before = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS); +$locked = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '积分帖子', 'content' => '需要积分查看的正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'points', 'price' => '5']); +forum_check($locked->get_status() === 200, 'paid post created'); +$locked_id = (int) ($locked->get_data()['data']['id'] ?? 0); +forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $locked_id), 'unpaid content locked'); +$preview = forum_request('GET', "posts/{$locked_id}/checkout", (int) $member->ID); +forum_check($preview->get_status() === 200 && (string) ($preview->get_data()['data']['price'] ?? '') === '5', 'points preview'); +$checkout_key = wp_generate_uuid4(); +$checkout = forum_request('POST', "posts/{$locked_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], $checkout_key); +forum_check($checkout->get_status() === 200, 'points checkout'); +forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $locked_id), 'paid content unlocked'); +$after = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS); +forum_check($after === $before - 5, 'points debited once'); +$checkout_repeat = forum_request('POST', "posts/{$locked_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], $checkout_key); +forum_check($checkout_repeat->get_status() === 200 && $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS) === $after, 'checkout idempotent'); + +$paid_plate = forum_request('POST', 'plates', (int) $admin->ID, ['title' => '积分版块', 'follow_mode' => 'points', 'follow_price' => '3', 'view_limit' => 'points']); +forum_check($paid_plate->get_status() === 200, 'paid plate created'); +$paid_plate_id = (int) ($paid_plate->get_data()['data']['id'] ?? 0); +$plate_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '版块内帖子', 'content' => '付费版块内的内容', 'plate_id' => $paid_plate_id]); +forum_check($plate_post->get_status() === 200, 'paid plate post created'); +$plate_post_id = (int) ($plate_post->get_data()['data']['id'] ?? 0); +forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'paid plate content locked'); +$nested_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '双层解锁帖子', 'content' => '双层付费正文', 'plate_id' => $paid_plate_id, 'view_limit' => 'points', 'price' => '1']); +forum_check($nested_post->get_status() === 200, 'nested paid post created'); +$nested_post_id = (int) ($nested_post->get_data()['data']['id'] ?? 0); +$premature_checkout = forum_request('POST', "posts/{$nested_post_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4()); +forum_check($premature_checkout->get_status() === 402, 'post checkout requires parent plate access'); +$denied_follow = forum_request('POST', "plates/{$paid_plate_id}/follow", (int) $member->ID); +forum_check($denied_follow->get_status() === 402, 'paid plate follow requires payment'); +$follow_paid = forum_request('POST', "plates/{$paid_plate_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4()); +forum_check($follow_paid->get_status() === 200 && \Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'paid plate entitlement'); +forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'paid plate content unlocked'); +$unfollow = forum_request('POST', "plates/{$paid_plate_id}/follow", (int) $member->ID); +forum_check($unfollow->get_status() === 200 && !\Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'unfollow revokes entitlement'); +forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'unfollow relocks content'); +$points_before_refollow = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS); +$repurchase = forum_request('POST', "plates/{$paid_plate_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4()); +forum_check($repurchase->get_status() === 200 && \Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'paid follow can be purchased again'); +forum_check($ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS) === $points_before_refollow - 3, 'second follow charged once'); + +$cash_credit = $ledger->credit((int) $member->ID, \Jio\Ledger\AssetType::BALANCE, 500, \Jio\Ledger\OperationKey::unique('forum-smoke-balance'), 'forum_test_credit'); +forum_check(!is_wp_error($cash_credit), 'balance credited'); +$cash_before = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::BALANCE); +$cash_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '余额帖子', 'content' => '需要余额查看的正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '2.00']); +forum_check($cash_post->get_status() === 200, 'cash post created'); +$cash_post_id = (int) ($cash_post->get_data()['data']['id'] ?? 0); +$cash_paid = forum_request('POST', "posts/{$cash_post_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4()); +forum_check($cash_paid->get_status() === 200, 'balance checkout'); +forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $cash_post_id), 'balance content unlocked'); +forum_check($ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::BALANCE) === $cash_before - 200, 'balance debited once'); + +$lock = \Jio\Forum\Lock::acquire('checkout-forum_post', (int) $member->ID, $vote_id); +forum_check($lock !== '' && \Jio\Forum\Lock::acquire('checkout-forum_post', (int) $member->ID, $vote_id) === '', 'checkout lock excludes overlap'); +\Jio\Forum\Lock::release('checkout-forum_post', (int) $member->ID, $vote_id, $lock); +$expensive = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '余额不足测试', 'content' => '订单复用正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '1000.00']); +$expensive_id = (int) ($expensive->get_data()['data']['id'] ?? 0); +forum_check($expensive->get_status() === 200, 'expensive post created'); +$first_attempt = forum_request('POST', "posts/{$expensive_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4()); +forum_check($first_attempt->get_status() === 402, 'insufficient balance rejected'); +$second_attempt = forum_request('POST', "posts/{$expensive_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4()); +forum_check($second_attempt->get_status() === 402, 'retry still insufficient (' . $second_attempt->get_status() . ': ' . wp_json_encode($second_attempt->get_data()) . ')'); +$pending_orders = array_filter(\Jio\Payment\OrderRepository::get_instance()->get_user_orders((int) $member->ID, ['product_type' => 'forum_post', 'status' => 'pending', 'limit' => 100]), static fn(array $order): bool => (int) $order['product_id'] === $expensive_id); +forum_check(count($pending_orders) === 1, 'checkout retry reuses pending order'); +$too_large = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '无效价格测试', 'content' => '异常金额不应入库', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '9999999999999999999999999999999.00']); +forum_check($too_large->get_status() === 400, 'oversized price rejected before save'); +update_post_meta($expensive_id, '_jio_forum_price', '9999999999999999999999999999999.00'); +$invalid_preview = forum_request('GET', "posts/{$expensive_id}/checkout", (int) $member->ID); +forum_check($invalid_preview->get_status() === 400, 'invalid stored price cannot become free'); +update_post_meta($expensive_id, '_jio_forum_price', '1000.00'); + +$settings = get_option('jio_settings', []); +$settings['jio_forum_enabled'] = false; +update_option('jio_settings', $settings); +\Jio\Settings\Settings::flush_settings_cache(); +$disabled = forum_request('POST', 'posts', (int) $member->ID, ['title' => '已关闭论坛', 'content' => '不能发帖', 'plate_id' => $plate]); +forum_check($disabled->get_status() === 404, 'disabled forum rejects writes'); +$settings['jio_forum_enabled'] = true; +update_option('jio_settings', $settings); +\Jio\Settings\Settings::flush_settings_cache(); + +echo "forum smoke passed\n";