fix(users): restore WeChat template notification flow
This commit is contained in:
1 parent
30ceab4615
commit
7fe26d3fff
18 files changed
+1051
-763
No files matched your search
@@ -1,257 +1,198 @@
|
||||
<template>
|
||||
<div class="jio-page-container">
|
||||
<PageHeader title="微信模板消息" desc="配置微信公众号模板消息推送。" />
|
||||
<PageHeader title="微信模板消息" desc="按通知事件配置公众号模板及参数。" />
|
||||
|
||||
<!-- 状态卡片 -->
|
||||
<div class="jio-wechat__status">
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon" :class="status.configured ? 'jio-wechat__status-icon--ok' : 'jio-wechat__status-icon--warn'">
|
||||
<i :class="status.configured ? 'fa fas fa-check' : 'fa fas fa-exclamation'"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">配置状态</div>
|
||||
<div class="jio-wechat__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon" :class="status.has_token ? 'jio-wechat__status-icon--ok' : 'jio-wechat__status-icon--warn'">
|
||||
<i :class="status.has_token ? 'fa fas fa-key' : 'fa fas fa-key'"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">Access Token</div>
|
||||
<div class="jio-wechat__status-value">{{ status.has_token ? '已获取' : '未获取' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon" :class="status.has_template ? 'jio-wechat__status-icon--ok' : 'jio-wechat__status-icon--warn'">
|
||||
<i :class="status.has_template ? 'fa fas fa-file-alt' : 'fa fas fa-file-alt'"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">模板 ID</div>
|
||||
<div class="jio-wechat__status-value">{{ status.has_template ? '已配置' : '未配置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon jio-wechat__status-icon--info">
|
||||
<i class="fa fas fa-users"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">绑定用户</div>
|
||||
<div class="jio-wechat__status-value">{{ status.bound_users || 0 }} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 240px">
|
||||
<n-alert v-if="loadError" type="error" class="wechat-notice">
|
||||
{{ loadError }}
|
||||
<template #action><n-button size="small" @click="load">重试</n-button></template>
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-alert :type="oauthReady ? 'info' : 'warning'" class="wechat-notice">
|
||||
<div>模板消息依赖已启用的微信公众号登录服务号,以及公众号的模板消息权限。</div>
|
||||
<div>只有绑定并关注该公众号的用户才能收到通知。当前账号:{{ currentUserBound ? '已绑定' : '未绑定' }};已绑定用户:{{ boundUsers }} 人。</div>
|
||||
<template #action>
|
||||
<router-link to="/users/oauth">公众号登录设置</router-link>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="jio-wechat__form-card">
|
||||
<h3>基本配置</h3>
|
||||
<div class="jio-form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.enabled"> 启用微信模板消息
|
||||
</label>
|
||||
</div>
|
||||
<div class="jio-form-group">
|
||||
<label>AppID</label>
|
||||
<input v-model="form.app_id" type="text" class="jio-input" placeholder="微信公众号 AppID">
|
||||
</div>
|
||||
<div class="jio-form-group">
|
||||
<label>AppSecret</label>
|
||||
<input v-model="form.app_secret" type="text" class="jio-input jio-secret-input" placeholder="微信公众号 AppSecret">
|
||||
<p class="jio-form-hint">保存后不会回显完整密钥</p>
|
||||
</div>
|
||||
<div class="jio-form-group">
|
||||
<label>模板 ID</label>
|
||||
<input v-model="form.template_id" type="text" class="jio-input" placeholder="微信模板消息模板 ID">
|
||||
</div>
|
||||
<div class="jio-form-actions">
|
||||
<button class="jio-button jio-button--primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="wechat-section">
|
||||
<div class="wechat-section__head">
|
||||
<h3>消息推送</h3>
|
||||
<n-switch v-model:value="form.enabled" :disabled="loading" aria-label="启用微信模板消息" />
|
||||
</div>
|
||||
<p class="wechat-muted">通过微信公众号将网站通知推送给绑定用户。配置模板后保存,测试会发送到当前管理员账号。</p>
|
||||
</section>
|
||||
|
||||
<!-- 通知类型 -->
|
||||
<div class="jio-wechat__form-card">
|
||||
<h3>支持的通知类型</h3>
|
||||
<p class="jio-form-hint">以下事件类型可触发微信模板消息推送(需用户绑定微信且开启对应订阅):</p>
|
||||
<table class="jio-table jio-table--sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事件类型</th>
|
||||
<th>说明</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="evt in eventTypes" :key="evt.key">
|
||||
<td><code>{{ evt.key }}</code></td>
|
||||
<td>{{ evt.desc }}</td>
|
||||
<td>
|
||||
<span class="jio-badge" :class="evt.ready ? 'jio-badge--success' : 'jio-badge--warning'">
|
||||
{{ evt.ready ? '可用' : '待接入' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<section v-for="group in groups" :key="group.name" class="wechat-section">
|
||||
<h3>{{ group.name }}</h3>
|
||||
<div v-for="type in group.types" :key="type.key" class="wechat-template">
|
||||
<div class="wechat-template__head">
|
||||
<div>
|
||||
<strong>{{ type.label }}</strong>
|
||||
<span class="wechat-template__key">{{ type.key }}</span>
|
||||
</div>
|
||||
<n-switch v-model:value="form.templates[type.key].enabled" :disabled="!form.enabled" :aria-label="`启用${type.label}`" />
|
||||
</div>
|
||||
<div v-if="form.enabled && form.templates[type.key].enabled" class="wechat-template__body">
|
||||
<label class="wechat-field">
|
||||
<span>模板 ID</span>
|
||||
<n-input v-model:value="form.templates[type.key].id" placeholder="公众号模板消息 ID" maxlength="128" clearable />
|
||||
</label>
|
||||
<div class="wechat-field">
|
||||
<span>模板参数</span>
|
||||
<div class="wechat-params">
|
||||
<label v-for="field in type.fields" :key="field.key" class="wechat-param">
|
||||
<span>{{ field.label }} <code>{{ field.key }}</code></span>
|
||||
<n-input v-model:value="form.templates[type.key].keys[field.key]" :placeholder="`留空使用 keyword${field.index}`" maxlength="64" clearable />
|
||||
</label>
|
||||
</div>
|
||||
<small>填写公众号模板中的参数名,例如 thing1、time2;留空时依次使用 keyword1、keyword2。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="jio-form-actions">
|
||||
<n-button :disabled="!dirty || saving" @click="load">放弃更改</n-button>
|
||||
<n-button type="primary" :disabled="!dirty || loading" :loading="saving" @click="save">保存设置</n-button>
|
||||
</div>
|
||||
|
||||
<section class="wechat-section wechat-test">
|
||||
<h3>发送测试</h3>
|
||||
<p class="wechat-muted">先保存配置,再选择已启用的模板。测试消息会发送到当前管理员绑定的公众号账号。</p>
|
||||
<div class="wechat-test__controls">
|
||||
<n-select v-model:value="testType" :options="testOptions" placeholder="选择需要测试的模板" :disabled="!testOptions.length" />
|
||||
<n-button type="primary" :loading="testing" :disabled="dirty || !testType || !currentUserBound || !oauthReady || !form.enabled" @click="sendTest">发送测试</n-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import api from '@/api';
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'UsersWechatTemplate',
|
||||
components: { PageHeader },
|
||||
setup() {
|
||||
const status = ref({
|
||||
enabled: false,
|
||||
configured: false,
|
||||
has_token: false,
|
||||
has_template: false,
|
||||
bound_users: 0,
|
||||
});
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const loadError = ref('')
|
||||
const oauthReady = ref(false)
|
||||
const currentUserBound = ref(false)
|
||||
const boundUsers = ref(0)
|
||||
const definitions = ref({})
|
||||
const form = reactive({ enabled: false, templates: {} })
|
||||
const initial = ref('')
|
||||
const testType = ref(null)
|
||||
const snapshot = computed(() => JSON.stringify(form))
|
||||
const dirty = computed(() => initial.value !== '' && initial.value !== snapshot.value)
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
template_id: '',
|
||||
});
|
||||
const groups = computed(() => {
|
||||
const grouped = new Map()
|
||||
for (const [key, definition] of Object.entries(definitions.value)) {
|
||||
const name = definition.group || '其他'
|
||||
if (!grouped.has(name)) grouped.set(name, [])
|
||||
grouped.get(name).push({
|
||||
key,
|
||||
label: definition.label,
|
||||
fields: Object.entries(definition.fields || {}).map(([fieldKey, label], index) => ({ key: fieldKey, label, index: index + 1 })),
|
||||
})
|
||||
}
|
||||
return Array.from(grouped, ([name, types]) => ({ name, types }))
|
||||
})
|
||||
|
||||
const saving = ref(false);
|
||||
const testOptions = computed(() => Object.entries(definitions.value)
|
||||
.filter(([key]) => form.templates[key]?.enabled && form.templates[key]?.id)
|
||||
.map(([key, definition]) => ({ label: definition.label, value: key })))
|
||||
|
||||
// Event type definitions — readiness driven by backend allowed_types
|
||||
const eventTypeDefs = [
|
||||
{ key: 'comment_reply', desc: '评论回复通知' },
|
||||
{ key: 'system_notice', desc: '系统公告通知' },
|
||||
{ key: 'order_paid', desc: '订单支付通知' },
|
||||
{ key: 'credits_change', desc: '积分变动通知' },
|
||||
];
|
||||
|
||||
const allowedTypes = ref([]);
|
||||
|
||||
const eventTypes = computed(() =>
|
||||
eventTypeDefs.map(et => ({
|
||||
...et,
|
||||
ready: allowedTypes.value.length === 0 || allowedTypes.value.includes(et.key),
|
||||
}))
|
||||
);
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const json = await api.request('/wechat/status');
|
||||
if (json.success) {
|
||||
status.value = json.data;
|
||||
form.enabled = json.data.enabled || false;
|
||||
form.app_id = json.data.appid || '';
|
||||
form.app_secret = json.data.app_secret === '***' ? '' : '';
|
||||
form.template_id = json.data.template_id || '';
|
||||
// Use backend allowed_types for readiness (empty = all allowed)
|
||||
allowedTypes.value = json.data.allowed_types || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load WeChat status failed:', e);
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const response = await api.request('/wechat/status')
|
||||
const data = response.data || {}
|
||||
definitions.value = data.types || {}
|
||||
form.enabled = Boolean(data.config?.enabled)
|
||||
form.templates = structuredClone(data.config?.templates || {})
|
||||
oauthReady.value = Boolean(data.oauth_ready)
|
||||
currentUserBound.value = Boolean(data.current_user_bound)
|
||||
boundUsers.value = Number(data.bound_users || 0)
|
||||
if (!testOptions.value.some(option => option.value === testType.value)) {
|
||||
testType.value = testOptions.value[0]?.value || null
|
||||
}
|
||||
initial.value = snapshot.value
|
||||
} catch (error) {
|
||||
loadError.value = error.message || '加载微信模板消息设置失败'
|
||||
message.error(loadError.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
// 如果密码为空且状态显示已配置,发送掩码保留原值
|
||||
if (!payload.app_secret && status.value.configured) {
|
||||
payload.app_secret = '***';
|
||||
}
|
||||
const json = await api.request('/wechat/settings', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
});
|
||||
if (json.success) {
|
||||
alert('设置已保存');
|
||||
loadStatus();
|
||||
} else {
|
||||
alert(json.message || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
alert('保存失败: ' + (e.message || '未知错误'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await api.request('/wechat/settings', { method: 'POST', data: JSON.parse(snapshot.value) })
|
||||
const config = response.data || {}
|
||||
form.enabled = Boolean(config.enabled)
|
||||
form.templates = structuredClone(config.templates || {})
|
||||
if (!testOptions.value.some(option => option.value === testType.value)) {
|
||||
testType.value = testOptions.value[0]?.value || null
|
||||
}
|
||||
initial.value = snapshot.value
|
||||
message.success('微信模板消息设置已保存')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadStatus());
|
||||
async function sendTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
await api.request('/wechat/test', { method: 'POST', data: { type: testType.value } })
|
||||
message.success('测试消息已发送')
|
||||
} catch (error) {
|
||||
message.error(error.message || '测试发送失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { status, form, saving, eventTypes, saveSettings };
|
||||
},
|
||||
};
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jio-wechat__status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.jio-wechat__status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
.jio-wechat__status-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.jio-wechat__status-icon--ok {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.jio-wechat__status-icon--warn {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.jio-wechat__status-icon--info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.jio-wechat__status-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--jio-color-text);
|
||||
}
|
||||
.jio-wechat__status-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.jio-wechat__form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.jio-wechat__form-card h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.jio-form-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
.wechat-notice { margin-bottom: 20px; }
|
||||
.wechat-section { border-top: 1px solid #e6e9ed; padding: 20px 0; }
|
||||
.wechat-section h3 { margin: 0 0 12px; font-size: 16px; font-weight: 600; }
|
||||
.wechat-section__head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.wechat-section__head h3 { margin: 0; }
|
||||
.wechat-muted { margin: 8px 0 0; color: #64707d; font-size: 13px; line-height: 1.6; }
|
||||
.wechat-template { border-bottom: 1px solid #edf0f2; padding: 14px 0; }
|
||||
.wechat-template:last-child { border-bottom: 0; }
|
||||
.wechat-template__head { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 32px; }
|
||||
.wechat-template__head strong { font-size: 14px; font-weight: 600; }
|
||||
.wechat-template__key { display: block; color: #7b8590; font-size: 11px; margin-top: 3px; }
|
||||
.wechat-template__body { display: grid; grid-template-columns: minmax(200px, 320px) minmax(0, 1fr); gap: 24px; padding: 16px 0 4px; }
|
||||
.wechat-field { display: flex; flex-direction: column; gap: 7px; min-width: 0; font-size: 13px; }
|
||||
.wechat-field > span { font-weight: 600; }
|
||||
.wechat-field small { color: #64707d; line-height: 1.5; }
|
||||
.wechat-params { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.wechat-param { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.wechat-param span { color: #4c5966; font-size: 12px; }
|
||||
.wechat-param code { color: #7b8590; font-size: 11px; }
|
||||
.wechat-test__controls { display: flex; gap: 10px; margin-top: 14px; max-width: 520px; }
|
||||
.wechat-test__controls :deep(.n-select) { flex: 1; min-width: 0; }
|
||||
@media (max-width: 780px) {
|
||||
.wechat-template__body { grid-template-columns: 1fr; gap: 16px; }
|
||||
.wechat-test__controls { flex-wrap: wrap; }
|
||||
.wechat-test__controls :deep(.n-select) { flex-basis: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -422,6 +422,7 @@ function jio_notify_post_author_of_comment(WP_Comment $comment): void {
|
||||
'url' => get_comment_link($comment),
|
||||
]);
|
||||
update_comment_meta($comment->comment_ID, 'jio_post_comment_notified', 1);
|
||||
do_action('jio_wechat_comment_postauthor', $owner_id, $comment);
|
||||
}
|
||||
|
||||
add_action('wp_insert_comment', function ($comment_id, $comment) {
|
||||
@@ -445,6 +446,7 @@ add_action('wp_insert_comment', function ($comment_id, $comment) {
|
||||
'url' => $url ?: get_permalink($post_id),
|
||||
]);
|
||||
update_comment_meta($comment_id, 'jio_reply_notified', 1);
|
||||
do_action('jio_wechat_comment_parent', $target_user_id, $comment);
|
||||
}, 20, 2);
|
||||
|
||||
add_action('transition_comment_status', function ($new_status, $old_status, $comment) {
|
||||
@@ -478,6 +480,7 @@ add_action('transition_comment_status', function ($new_status, $old_status, $com
|
||||
'url' => get_comment_link($comment),
|
||||
]);
|
||||
update_comment_meta($comment->comment_ID, 'jio_reply_notified', 1);
|
||||
do_action('jio_wechat_comment_parent', $target_user_id, $comment);
|
||||
}, 20, 3);
|
||||
|
||||
add_action('jio_post_favorited', function (int $post_id, int $actor_id, bool $active): void {
|
||||
|
||||
@@ -353,6 +353,7 @@ add_action('wp_ajax_jio_change_email', function (): void {
|
||||
\Jio\Extensions\SecurityHardening::error_frequency_clear();
|
||||
}
|
||||
update_user_meta($user_id, 'jio_email_verified', 1);
|
||||
do_action('jio_user_email_changed', $user_id, $new_email, $old_email);
|
||||
if ($old_email !== '' && is_email($old_email) && class_exists('\Jio\Settings\Settings')) {
|
||||
\Jio\Settings\Settings::send_event_email('change_notice', $old_email, [
|
||||
'{username}' => $user->display_name,
|
||||
|
||||
@@ -256,6 +256,12 @@ class UserController extends ControllerBase {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/wechat/test', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'test_wechat_template'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// ── User Restrictions / Ban ──
|
||||
|
||||
// List restrictions for a user (admin)
|
||||
@@ -324,11 +330,7 @@ class UserController extends ControllerBase {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* GET /wechat/status — 微信模板消息配置状态。
|
||||
*
|
||||
* 状态形状与 WeChatNotify::get_status() 对齐(该方法的既定用途即 REST 与 AJAX 复用)。
|
||||
*/
|
||||
/** GET /wechat/status */
|
||||
public function get_wechat_status(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\Jio\Payment\WeChatNotify')) {
|
||||
return $this->error(__('微信通知组件未加载', 'jio'), 500);
|
||||
@@ -337,39 +339,59 @@ class UserController extends ControllerBase {
|
||||
return $this->success(['data' => \Jio\Payment\WeChatNotify::get_instance()->get_status()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /wechat/settings — 更新微信模板消息配置。
|
||||
*
|
||||
* 前端在密钥留空且已配置时发送掩码 ***,此时保留已存密钥;
|
||||
* 未提供的字段沿用当前值。键白名单由 Integration::update_wechat_settings() 收敛。
|
||||
*/
|
||||
/** POST /wechat/settings */
|
||||
public function update_wechat_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$current = \Jio\Settings\Settings::get_wechat_settings();
|
||||
|
||||
$enabled = array_key_exists('enabled', $params) ? !empty($params['enabled']) : !empty($current['enabled']);
|
||||
$app_id = array_key_exists('app_id', $params)
|
||||
? sanitize_text_field((string) $params['app_id'])
|
||||
: (string) ($current['app_id'] ?? '');
|
||||
$template_id = array_key_exists('template_id', $params)
|
||||
? sanitize_text_field((string) $params['template_id'])
|
||||
: (string) ($current['template_id'] ?? '');
|
||||
|
||||
// 掩码 *** 表示「不修改密钥」;仅在显式传入其他值时才覆盖。
|
||||
if (array_key_exists('app_secret', $params) && (string) $params['app_secret'] !== '***') {
|
||||
$app_secret = sanitize_text_field((string) $params['app_secret']);
|
||||
} else {
|
||||
$app_secret = (string) ($current['app_secret'] ?? '');
|
||||
$params = $this->get_json_params($request);
|
||||
if (!isset($params['templates']) || !is_array($params['templates'])) {
|
||||
return $this->error(__('模板配置格式无效', 'jio'), 400);
|
||||
}
|
||||
foreach ($params['templates'] as $type => $item) {
|
||||
if (!isset(\Jio\Settings\WechatTemplates::definitions()[$type]) || !is_array($item)) {
|
||||
return $this->error(__('通知类型无效', 'jio'), 400);
|
||||
}
|
||||
if (isset($item['id']) && !is_string($item['id'])) {
|
||||
return $this->error(__('模板 ID 格式无效', 'jio'), 400);
|
||||
}
|
||||
if (isset($item['keys']) && !is_array($item['keys'])) {
|
||||
return $this->error(__('模板参数格式无效', 'jio'), 400);
|
||||
}
|
||||
foreach (($item['keys'] ?? []) as $field => $key) {
|
||||
if (!isset(\Jio\Settings\WechatTemplates::definitions()[$type]['fields'][$field])
|
||||
|| !is_string($key)
|
||||
|| ($key !== '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $key))) {
|
||||
return $this->error(__('模板参数名无效', 'jio'), 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
$config = \Jio\Settings\WechatTemplates::normalize($params);
|
||||
foreach ($config['templates'] as $item) {
|
||||
$used_keys = [];
|
||||
$index = 1;
|
||||
foreach ($item['keys'] as $key) {
|
||||
$effective_key = $key !== '' ? $key : 'keyword' . $index;
|
||||
if (isset($used_keys[$effective_key])) {
|
||||
return $this->error(__('同一模板的参数名不能重复', 'jio'), 400);
|
||||
}
|
||||
$used_keys[$effective_key] = true;
|
||||
$index++;
|
||||
}
|
||||
}
|
||||
\Jio\Settings\Settings::update_wechat_settings($config);
|
||||
return $this->success(['data' => $config, 'message' => __('微信模板消息设置已保存', 'jio')]);
|
||||
}
|
||||
|
||||
\Jio\Settings\Settings::update_wechat_settings([
|
||||
'enabled' => $enabled,
|
||||
'app_id' => $app_id,
|
||||
'app_secret' => $app_secret,
|
||||
'template_id' => $template_id,
|
||||
]);
|
||||
|
||||
return $this->success(['message' => __('微信通知配置已保存', 'jio')]);
|
||||
/** POST /wechat/test */
|
||||
public function test_wechat_template(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$type = sanitize_key((string) ($params['type'] ?? ''));
|
||||
if (!isset(\Jio\Settings\WechatTemplates::definitions()[$type])) {
|
||||
return $this->error(__('请选择需要测试的模板', 'jio'), 400);
|
||||
}
|
||||
$result = \Jio\Payment\WeChatNotify::get_instance()->test_send(get_current_user_id(), $type);
|
||||
if (is_wp_error($result)) {
|
||||
return $this->error($result->get_error_message(), (int) ($result->get_error_data()['status'] ?? 400));
|
||||
}
|
||||
return $this->success(['message' => __('测试消息已发送', 'jio')]);
|
||||
}
|
||||
|
||||
public function rest_handle_credit_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||||
|
||||
@@ -229,6 +229,14 @@ trait UserControllerReportOperations {
|
||||
return $this->error(__('处理失败', 'jio'));
|
||||
}
|
||||
|
||||
if ($updated > 0 && $status !== (string) ($row['status'] ?? '')) {
|
||||
do_action('jio_report_status_changed', $report_id, (int) ($row['reporter_id'] ?? 0), $status, $note, [
|
||||
'category' => (string) ($row['category'] ?? ''),
|
||||
'reason' => (string) ($row['reason'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('举报工单已处理', 'jio'),
|
||||
]);
|
||||
|
||||
@@ -90,6 +90,16 @@ final class WeixinGzh {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function access_token(): string {
|
||||
$config = self::settings();
|
||||
$appid = trim((string) ($config['appid'] ?? ''));
|
||||
$secret = trim((string) ($config['appsecret'] ?? ''));
|
||||
if ($appid === '' || $secret === '') {
|
||||
return '';
|
||||
}
|
||||
return $this->get_access_token($appid, $secret);
|
||||
}
|
||||
|
||||
public function rest_qrcode_login(\WP_REST_Request $request) {
|
||||
$limited = $this->rate_limit('qr-create', 12, MINUTE_IN_SECONDS, 2);
|
||||
if ($limited) {
|
||||
|
||||
@@ -77,6 +77,7 @@ final class ApplicationService {
|
||||
'actor_user_id' => $user_id,
|
||||
'summary' => ['name' => $name],
|
||||
]);
|
||||
do_action('jio_auth_application_submitted', $user_id, $created);
|
||||
if (class_exists('Jio\\Settings\\MailNotifications')) {
|
||||
\Jio\Settings\MailNotifications::auth_submitted($user_id, $created);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ class ReportCenter {
|
||||
global $wpdb;
|
||||
|
||||
$existing = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id, reporter_id, notes_json FROM {$table} WHERE id = %d",
|
||||
"SELECT id, reporter_id, notes_json, category, reason, created_at FROM {$table} WHERE id = %d",
|
||||
$report_id
|
||||
));
|
||||
|
||||
@@ -205,9 +205,13 @@ class ReportCenter {
|
||||
}
|
||||
|
||||
$result = $wpdb->update($table, $update_data, ['id' => $report_id], $format, ['%d']);
|
||||
if ($result !== false) {
|
||||
if ($result > 0) {
|
||||
// 第 4 参携带处理说明:邮件通知(report_result)与站内信均需反馈给举报人。
|
||||
do_action('jio_report_status_changed', $report_id, (int) ($existing->reporter_id ?? 0), $new_status, $note_clean);
|
||||
do_action('jio_report_status_changed', $report_id, (int) ($existing->reporter_id ?? 0), $new_status, $note_clean, [
|
||||
'category' => (string) ($existing->category ?? ''),
|
||||
'reason' => (string) ($existing->reason ?? ''),
|
||||
'created_at' => (string) ($existing->created_at ?? ''),
|
||||
]);
|
||||
$this->notify_reporter_status_changed((int) ($existing->reporter_id ?? 0), $new_status, $note_clean);
|
||||
}
|
||||
return $result !== false;
|
||||
|
||||
+137
-262
@@ -5,278 +5,153 @@
|
||||
|
||||
namespace Jio\Payment;
|
||||
|
||||
use Jio\Auth\WeixinGzh;
|
||||
use Jio\Settings\Settings;
|
||||
use Jio\Settings\WechatTemplates;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class WeChatNotify {
|
||||
final class WeChatNotify {
|
||||
use WeChatNotifyEvents;
|
||||
|
||||
private static $instance = null;
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* 可联动微信模板消息的站内通知类型。
|
||||
* report:举报处理结果;system:禁封/限制/解除等账号限制通知。
|
||||
*/
|
||||
const NOTIFY_TYPES = ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'report', 'system'];
|
||||
public static function get_instance(): self {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
public function init(): void {
|
||||
$this->register_event_hooks();
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('jio_notification_sent', [$this, 'maybe_send_wechat'], 10, 3);
|
||||
add_action('wp_ajax_jio_wechat_status', [$this, 'ajax_status']);
|
||||
}
|
||||
public function get_status(): array {
|
||||
$config = Settings::get_wechat_settings();
|
||||
global $wpdb;
|
||||
$bound_users = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM {$wpdb->usermeta} WHERE meta_key = 'jio_weixingzh_id' AND meta_value != ''"
|
||||
);
|
||||
return [
|
||||
'config' => $config,
|
||||
'types' => WechatTemplates::definitions(),
|
||||
'oauth_ready' => $this->oauth_ready(),
|
||||
'bound_users' => $bound_users,
|
||||
'current_user_bound' => trim((string) get_user_meta(get_current_user_id(), 'jio_weixingzh_id', true)) !== '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信通知配置
|
||||
*/
|
||||
public function get_config(): array {
|
||||
if (class_exists('\Jio\Settings\Settings')) {
|
||||
return \Jio\Settings\Settings::get_wechat_settings();
|
||||
}
|
||||
return ['enabled' => false];
|
||||
}
|
||||
public function test_send(int $user_id, string $type) {
|
||||
$definition = WechatTemplates::definitions()[$type] ?? null;
|
||||
if ($definition === null) {
|
||||
return new \WP_Error('jio_wechat_type', __('通知类型无效', 'jio'), ['status' => 400]);
|
||||
}
|
||||
$values = [];
|
||||
foreach ($definition['fields'] as $field => $label) {
|
||||
$values[$field] = $this->sample_value($field, $label);
|
||||
}
|
||||
return $this->deliver($user_id, $type, $values, home_url('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送微信模板消息
|
||||
*
|
||||
* @param int $user_id 用户 ID
|
||||
* @param string $template 模板标识
|
||||
* @param array $data 模板数据
|
||||
* @return bool
|
||||
*/
|
||||
public function send(int $user_id, string $template, array $data = []): bool {
|
||||
$config = $this->get_config();
|
||||
public function send_event(int $user_id, string $type, array $values, string $url = ''): bool {
|
||||
return $this->deliver($user_id, $type, $values, $url) === true;
|
||||
}
|
||||
|
||||
if (empty($config['enabled'])) {
|
||||
return false;
|
||||
}
|
||||
private function deliver(int $user_id, string $type, array $values, string $url) {
|
||||
$config = Settings::get_wechat_settings();
|
||||
if (empty($config['enabled'])) {
|
||||
return new \WP_Error('jio_wechat_disabled', __('请先启用微信模板消息并保存设置', 'jio'), ['status' => 400]);
|
||||
}
|
||||
$template = $config['templates'][$type] ?? null;
|
||||
if (!is_array($template) || empty($template['enabled']) || empty($template['id'])) {
|
||||
return new \WP_Error('jio_wechat_template', __('该通知类型未启用或未填写模板 ID', 'jio'), ['status' => 400]);
|
||||
}
|
||||
if (!$this->oauth_ready()) {
|
||||
return new \WP_Error('jio_wechat_oauth', __('请先启用并配置微信公众号登录服务号', 'jio'), ['status' => 400]);
|
||||
}
|
||||
$openid = trim((string) get_user_meta($user_id, 'jio_weixingzh_id', true));
|
||||
if ($openid === '') {
|
||||
return new \WP_Error('jio_wechat_unbound', __('接收账号尚未绑定微信公众号', 'jio'), ['status' => 400]);
|
||||
}
|
||||
try {
|
||||
$token = WeixinGzh::get_instance()->access_token();
|
||||
} catch (\Throwable $error) {
|
||||
return new \WP_Error('jio_wechat_token', __('获取公众号访问令牌失败:', 'jio') . $error->getMessage(), ['status' => 502]);
|
||||
}
|
||||
if ($token === '') {
|
||||
return new \WP_Error('jio_wechat_token', __('无法获取公众号访问令牌', 'jio'), ['status' => 502]);
|
||||
}
|
||||
$data = $this->template_data($type, $values, $template['keys']);
|
||||
if ($data === []) {
|
||||
return new \WP_Error('jio_wechat_data', __('模板消息缺少参数', 'jio'), ['status' => 400]);
|
||||
}
|
||||
$response = wp_remote_post('https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=' . rawurlencode($token), [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => wp_json_encode([
|
||||
'touser' => $openid,
|
||||
'template_id' => $template['id'],
|
||||
'url' => esc_url_raw($url !== '' ? $url : home_url('/')),
|
||||
'data' => $data,
|
||||
]),
|
||||
'timeout' => 10,
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return new \WP_Error('jio_wechat_http', $response->get_error_message(), ['status' => 502]);
|
||||
}
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (wp_remote_retrieve_response_code($response) !== 200 || !is_array($body) || !array_key_exists('errcode', $body)) {
|
||||
return new \WP_Error('jio_wechat_response', __('公众号返回了无效响应', 'jio'), ['status' => 502]);
|
||||
}
|
||||
if ((int) $body['errcode'] !== 0) {
|
||||
return new \WP_Error('jio_wechat_api', sprintf(__('公众号发送失败(%1$s):%2$s', 'jio'), (string) $body['errcode'], (string) ($body['errmsg'] ?? '')), ['status' => 502]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取用户的微信 openid
|
||||
$openid = get_user_meta($user_id, 'jio_wechat_openid', true);
|
||||
if (empty($openid)) {
|
||||
return false;
|
||||
}
|
||||
private function oauth_ready(): bool {
|
||||
$config = Settings::get_weixingzh_settings();
|
||||
return WeixinGzh::is_enabled()
|
||||
&& (string) ($config['gzh_type'] ?? '') === 'service'
|
||||
&& trim((string) ($config['appid'] ?? '')) !== ''
|
||||
&& trim((string) ($config['appsecret'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
// 获取 access token
|
||||
$access_token = $this->get_access_token($config);
|
||||
if (empty($access_token)) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[Jio WeChat] Failed to get access token');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private function template_data(string $type, array $values, array $keys): array {
|
||||
$definition = WechatTemplates::definitions()[$type] ?? null;
|
||||
if ($definition === null) {
|
||||
return [];
|
||||
}
|
||||
$data = [];
|
||||
$index = 1;
|
||||
foreach ($definition['fields'] as $field => $label) {
|
||||
$value = sanitize_text_field((string) ($values[$field] ?? ''));
|
||||
$key = (string) ($keys[$field] ?? '');
|
||||
if ($key === '') {
|
||||
$key = 'keyword' . $index;
|
||||
}
|
||||
if (str_contains($key, 'thing') && mb_strlen($value, 'UTF-8') > 19) {
|
||||
$value = mb_substr($value, 0, 16, 'UTF-8') . '...';
|
||||
}
|
||||
$data[$key] = ['value' => $value];
|
||||
$index++;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
// 构建模板消息
|
||||
$template_id = $config['template_id'] ?? '';
|
||||
if (empty($template_id)) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[Jio WeChat] No template ID configured');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = $this->build_template_message($template, $data, $openid, $template_id);
|
||||
|
||||
// 调用微信模板消息 API
|
||||
$response = wp_remote_post('https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=' . $access_token, [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => wp_json_encode($message),
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[Jio WeChat] API error: ' . $response->get_error_message());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
$errcode = (int) ($body['errcode'] ?? 0);
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log(sprintf('[Jio WeChat] User: %d, Template: %s, Result: %s', $user_id, $template, wp_json_encode($body)));
|
||||
}
|
||||
|
||||
return $errcode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信 access token(带缓存)
|
||||
*/
|
||||
private function get_access_token(array $config): string {
|
||||
$cache_key = 'jio_wechat_token';
|
||||
$cached = get_transient($cache_key);
|
||||
if (!empty($cached)) return (string) $cached;
|
||||
|
||||
$app_id = $config['app_id'] ?? '';
|
||||
$app_secret = $config['app_secret'] ?? '';
|
||||
if (empty($app_id) || empty($app_secret)) return '';
|
||||
|
||||
$response = wp_remote_get(sprintf(
|
||||
'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s',
|
||||
urlencode($app_id),
|
||||
urlencode($app_secret)
|
||||
));
|
||||
|
||||
if (is_wp_error($response)) return '';
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
$token = $body['access_token'] ?? '';
|
||||
$expires = max(600, (int) ($body['expires_in'] ?? 7200) - 300);
|
||||
|
||||
if (!empty($token)) {
|
||||
set_transient($cache_key, $token, $expires);
|
||||
}
|
||||
|
||||
return (string) $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模板消息体
|
||||
*/
|
||||
private function build_template_message(string $template, array $data, string $openid, string $template_id): array {
|
||||
$url = $data['url'] ?? home_url('/');
|
||||
$values = $data['values'] ?? [];
|
||||
|
||||
// 通用兜底:站内通知未带模板字段时,用标题/内容合成。
|
||||
if (empty($values)) {
|
||||
$values = array_filter([
|
||||
'first' => (string) ($data['title'] ?? ''),
|
||||
'keyword1' => (string) ($data['description'] ?? ''),
|
||||
], static function ($v) { return $v !== ''; });
|
||||
}
|
||||
|
||||
$template_data = [];
|
||||
foreach ($values as $key => $value) {
|
||||
$template_data[$key] = [
|
||||
'value' => (string) $value,
|
||||
'color' => '#333333',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'touser' => $openid,
|
||||
'template_id' => $template_id,
|
||||
'url' => esc_url($url),
|
||||
'data' => $template_data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定用户微信 openid
|
||||
*/
|
||||
public function bind_user(int $user_id, string $openid): bool {
|
||||
if ($user_id <= 0 || empty($openid)) return false;
|
||||
return (bool) update_user_meta($user_id, 'jio_wechat_openid', sanitize_text_field($openid));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑用户微信
|
||||
*/
|
||||
public function unbind_user(int $user_id): bool {
|
||||
return (bool) delete_user_meta($user_id, 'jio_wechat_openid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听站内通知,联动微信通知
|
||||
*/
|
||||
public function maybe_send_wechat(int $user_id, string $type, array $data): void {
|
||||
$config = $this->get_config();
|
||||
if (empty($config['enabled'])) return;
|
||||
|
||||
// 仅特定类型通知发送微信(含举报处理结果与账号限制通知)
|
||||
if (!in_array($type, self::NOTIFY_TYPES, true)) return;
|
||||
|
||||
$this->send($user_id, $type, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Get WeChat notification status (admin only).
|
||||
*/
|
||||
public function ajax_status(): void {
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => __('权限不足', 'jio')]);
|
||||
}
|
||||
|
||||
wp_send_json_success($this->get_status());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WeChat service status (used by REST and AJAX).
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$has_token = false;
|
||||
|
||||
if (!empty($config['app_id']) && !empty($config['app_secret'])) {
|
||||
$cached = get_transient('jio_wechat_token');
|
||||
$has_token = !empty($cached);
|
||||
}
|
||||
|
||||
// Count bound users
|
||||
global $wpdb;
|
||||
$bound_count = 0;
|
||||
if ($wpdb) {
|
||||
$bound_count = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'jio_wechat_openid' AND meta_value != ''"
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !empty($config['enabled']),
|
||||
'appid' => $config['app_id'] ?? '',
|
||||
'configured' => !empty($config['app_id']) && !empty($config['app_secret']),
|
||||
'has_token' => $has_token,
|
||||
'has_template' => !empty($config['template_id']),
|
||||
'bound_users' => $bound_count,
|
||||
'app_secret' => !empty($config['app_secret']) ? '***' : '',
|
||||
'allowed_types' => self::NOTIFY_TYPES,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification subscriptions for a user.
|
||||
*/
|
||||
public function get_user_subscriptions(int $user_id): array {
|
||||
$subs = get_user_meta($user_id, 'jio_wechat_subscriptions', true);
|
||||
if (!is_array($subs)) {
|
||||
$subs = [
|
||||
'comment_reply' => true,
|
||||
'system_notice' => true,
|
||||
'order_paid' => true,
|
||||
'credits_change' => false,
|
||||
'report' => true,
|
||||
'system' => true,
|
||||
];
|
||||
}
|
||||
return $subs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification subscriptions for a user.
|
||||
*/
|
||||
public function update_user_subscriptions(int $user_id, array $subscriptions): bool {
|
||||
$allowed = self::NOTIFY_TYPES;
|
||||
$clean = [];
|
||||
foreach ($allowed as $key) {
|
||||
$clean[$key] = !empty($subscriptions[$key]);
|
||||
}
|
||||
return (bool) update_user_meta($user_id, 'jio_wechat_subscriptions', $clean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should receive a specific notification type via WeChat.
|
||||
*/
|
||||
public function should_notify(int $user_id, string $type): bool {
|
||||
$config = $this->get_config();
|
||||
if (empty($config['enabled'])) return false;
|
||||
|
||||
$openid = get_user_meta($user_id, 'jio_wechat_openid', true);
|
||||
if (empty($openid)) return false;
|
||||
|
||||
$subs = $this->get_user_subscriptions($user_id);
|
||||
return !empty($subs[$type]);
|
||||
}
|
||||
private function sample_value(string $field, string $label): string {
|
||||
if (str_contains($field, 'time')) {
|
||||
return current_time('Y-m-d H:i:s');
|
||||
}
|
||||
if (str_contains($field, 'price')) {
|
||||
return '88.50';
|
||||
}
|
||||
if ($field === 'num') {
|
||||
return '2026010100000000000001';
|
||||
}
|
||||
if ($field === 'status') {
|
||||
return '已完成';
|
||||
}
|
||||
return '测试' . $label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait WeChatNotifyEvents {
|
||||
private bool $event_hooks_registered = false;
|
||||
|
||||
private function register_event_hooks(): void {
|
||||
if ($this->event_hooks_registered) {
|
||||
return;
|
||||
}
|
||||
$this->event_hooks_registered = true;
|
||||
|
||||
add_action('jio_order_paid', [$this, 'on_wechat_order_paid'], 20, 2);
|
||||
add_action('jio_asset_transaction_committed', [$this, 'on_wechat_asset_committed'], 20, 2);
|
||||
add_action('jio_withdrawal_submitted', [$this, 'on_wechat_withdrawal_submitted'], 20, 2);
|
||||
add_action('jio_withdrawal_status_changed', [$this, 'on_wechat_withdrawal_processed'], 20, 4);
|
||||
add_action('jio_auth_application_submitted', [$this, 'on_wechat_auth_submitted'], 20, 2);
|
||||
add_action('jio_auth_application_reviewed', [$this, 'on_wechat_auth_reviewed'], 20, 4);
|
||||
add_action('jio_report_submitted', [$this, 'on_wechat_report_submitted'], 20, 2);
|
||||
add_action('jio_report_status_changed', [$this, 'on_wechat_report_processed'], 20, 5);
|
||||
add_action('jio_user_phone_changed', [$this, 'on_wechat_phone_changed'], 20, 3);
|
||||
add_action('jio_user_email_changed', [$this, 'on_wechat_email_changed'], 20, 3);
|
||||
add_action('jio_wechat_comment_postauthor', [$this, 'on_wechat_comment_postauthor'], 20, 2);
|
||||
add_action('jio_wechat_comment_parent', [$this, 'on_wechat_comment_parent'], 20, 2);
|
||||
}
|
||||
|
||||
public function on_wechat_order_paid(string $order_number, array $order): void {
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
$product = $product_id > 0 ? (string) get_the_title($product_id) : '';
|
||||
if ($product === '') {
|
||||
$product = (string) ($order['product_type'] ?? __('订单', 'jio'));
|
||||
}
|
||||
$currency = (string) ($order['currency'] ?? 'CNY');
|
||||
$price = (string) ($order['amount'] ?? '0');
|
||||
if ($currency === 'points') {
|
||||
$price .= ' ' . __('积分', 'jio');
|
||||
} else {
|
||||
$price .= ' ' . $currency;
|
||||
}
|
||||
$time = (string) ($order['paid_at'] ?? current_time('mysql'));
|
||||
$user_url = $this->wechat_user_url('orders');
|
||||
$this->send_event($user_id, 'payment_order', [
|
||||
'name' => $product,
|
||||
'price' => $price,
|
||||
'time' => $time,
|
||||
'num' => $order_number,
|
||||
], $user_url);
|
||||
|
||||
$this->send_wechat_admins('payment_order_admin', [
|
||||
'name' => $product,
|
||||
'price' => $price,
|
||||
'user' => $this->wechat_user_name($user_id),
|
||||
'time' => $time,
|
||||
'num' => $order_number,
|
||||
], admin_url('admin.php?page=jio_orders&order=' . rawurlencode($order_number)));
|
||||
}
|
||||
|
||||
public function on_wechat_asset_committed($result, $command): void {
|
||||
if (!$result instanceof \Jio\Ledger\LedgerResult || !$command instanceof \Jio\Ledger\LedgerCommand || $result->replayed) {
|
||||
return;
|
||||
}
|
||||
$type = match ($command->reason) {
|
||||
'creator_income' => 'payment_order_to_income',
|
||||
'affiliate_commission' => 'payment_order_to_referrer',
|
||||
default => '',
|
||||
};
|
||||
if ($type === '') {
|
||||
return;
|
||||
}
|
||||
$order_number = $this->wechat_ledger_order_number($command);
|
||||
if ($order_number === '') {
|
||||
return;
|
||||
}
|
||||
$sent = [];
|
||||
foreach ($result->entries as $entry) {
|
||||
$user_id = (int) ($entry['user_id'] ?? 0);
|
||||
$minor = (int) ($entry['delta_minor'] ?? 0);
|
||||
if ($user_id <= 0 || $minor <= 0 || isset($sent[$user_id])) {
|
||||
continue;
|
||||
}
|
||||
$sent[$user_id] = true;
|
||||
$asset = (string) ($entry['asset'] ?? 'balance');
|
||||
try {
|
||||
$price = \Jio\Ledger\AssetAmount::from_minor($asset, $minor)->decimal();
|
||||
} catch (\InvalidArgumentException | \ValueError $e) {
|
||||
continue;
|
||||
}
|
||||
$price .= $asset === 'points' ? ' ' . __('积分', 'jio') : ' CNY';
|
||||
$this->send_event($user_id, $type, [
|
||||
'name' => $type === 'payment_order_to_income' ? __('创作收益', 'jio') : __('推荐佣金', 'jio'),
|
||||
'price' => $price,
|
||||
'time' => current_time('mysql'),
|
||||
'num' => $order_number,
|
||||
], $this->wechat_user_url('credits'));
|
||||
}
|
||||
}
|
||||
|
||||
public function on_wechat_withdrawal_submitted(int $user_id, array $row): void {
|
||||
$this->send_wechat_admins('apply_withdraw_admin', [
|
||||
'user' => $this->wechat_user_name($user_id),
|
||||
'price' => (string) ($row['gross_amount'] ?? '0') . ' CNY',
|
||||
'time' => (string) ($row['created_at'] ?? current_time('mysql')),
|
||||
], admin_url('admin.php?page=jio_withdraw&status=pending'));
|
||||
}
|
||||
|
||||
public function on_wechat_withdrawal_processed(int $user_id, array $row, string $status, string $note): void {
|
||||
if (!in_array($status, [Withdrawal::STATUS_COMPLETED, Withdrawal::STATUS_REJECTED], true)) {
|
||||
return;
|
||||
}
|
||||
$gross = (string) ($row['gross_amount'] ?? '0');
|
||||
$received = $status === Withdrawal::STATUS_COMPLETED ? (string) ($row['amount'] ?? '0') : '0.00';
|
||||
$fee = '0.00';
|
||||
try {
|
||||
$gross_minor = \Jio\Ledger\AssetAmount::balance($gross)->minor();
|
||||
$received_minor = \Jio\Ledger\AssetAmount::balance((string) ($row['amount'] ?? '0'))->minor();
|
||||
$fee = \Jio\Ledger\AssetAmount::from_minor('balance', max(0, $gross_minor - $received_minor))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Keep the displayed amount available if a historical row is malformed.
|
||||
}
|
||||
$this->send_event($user_id, 'withdraw_process', [
|
||||
'create_time' => (string) ($row['created_at'] ?? ''),
|
||||
'process_time' => current_time('mysql'),
|
||||
'status' => $status === Withdrawal::STATUS_COMPLETED ? __('已完成', 'jio') : __('已拒绝', 'jio'),
|
||||
'price' => $gross . ' CNY',
|
||||
'received_price' => $received . ' CNY',
|
||||
'service_price' => $fee . ' CNY',
|
||||
], $this->wechat_user_url('credits'));
|
||||
}
|
||||
|
||||
public function on_wechat_auth_submitted(int $user_id, array $application): void {
|
||||
$this->send_wechat_admins('auth_apply_admin', [
|
||||
'name' => (string) ($application['public_name'] ?? ''),
|
||||
'time' => (string) ($application['created_at'] ?? current_time('mysql')),
|
||||
'user' => $this->wechat_user_name($user_id),
|
||||
], admin_url('users.php?page=jio_user_auth'));
|
||||
}
|
||||
|
||||
public function on_wechat_auth_reviewed(int $user_id, string $status, string $note, array $application): void {
|
||||
$this->send_event($user_id, 'auth_apply_process', [
|
||||
'status' => ($status === 'verified' ? __('已通过', 'jio') : __('未通过', 'jio')) . ($note !== '' ? ':' . $note : ''),
|
||||
'name' => (string) ($application['public_name'] ?? ''),
|
||||
'desc' => (string) ($application['description'] ?? ''),
|
||||
'time' => current_time('mysql'),
|
||||
], function_exists('jio_get_user_center_tab_url') ? jio_get_user_center_tab_url('auth') : $this->wechat_user_url('settings'));
|
||||
}
|
||||
|
||||
public function on_wechat_report_submitted(int $report_id, array $report): void {
|
||||
$target_id = (int) ($report['target_id'] ?? 0);
|
||||
$target_type = (string) ($report['target_type'] ?? '');
|
||||
$user_id = match ($target_type) {
|
||||
'user' => $target_id,
|
||||
'post' => (int) (get_post($target_id)->post_author ?? 0),
|
||||
'comment' => (int) (get_comment($target_id)->user_id ?? 0),
|
||||
default => 0,
|
||||
};
|
||||
$this->send_wechat_admins('report_user_admin', [
|
||||
'user' => $user_id > 0 ? $this->wechat_user_name($user_id) : __('未知用户', 'jio'),
|
||||
'time' => current_time('mysql'),
|
||||
'reason' => (string) ($report['category'] ?? ''),
|
||||
'desc' => (string) ($report['reason'] ?? ''),
|
||||
], admin_url('users.php?page=jio_user_ban&type=report&status=pending'));
|
||||
}
|
||||
|
||||
public function on_wechat_report_processed(int $report_id, int $reporter_id, string $status, string $note, array $report = []): void {
|
||||
if (!in_array($status, ['resolved', 'dismissed'], true)) {
|
||||
return;
|
||||
}
|
||||
$this->send_event($reporter_id, 'report_process', [
|
||||
'reason' => (string) ($report['category'] ?? $report['reason'] ?? ''),
|
||||
'time' => (string) ($report['created_at'] ?? ''),
|
||||
'desc' => $note !== '' ? $note : ($status === 'resolved' ? __('举报已处理', 'jio') : __('举报未通过', 'jio')),
|
||||
], function_exists('jio_get_messages_page_url') ? (jio_get_messages_page_url('system') ?: home_url('/')) : home_url('/'));
|
||||
}
|
||||
|
||||
public function on_wechat_phone_changed(int $user_id, string $phone, string $old_phone): void {
|
||||
$this->send_event($user_id, 'bind_phone', [
|
||||
'name' => $this->wechat_user_name($user_id),
|
||||
'time' => current_time('mysql'),
|
||||
'num' => $phone,
|
||||
], $this->wechat_user_url('settings'));
|
||||
}
|
||||
|
||||
public function on_wechat_email_changed(int $user_id, string $email, string $old_email): void {
|
||||
$this->send_event($user_id, 'bind_email', [
|
||||
'name' => $this->wechat_user_name($user_id),
|
||||
'time' => current_time('mysql'),
|
||||
'num' => $email,
|
||||
], $this->wechat_user_url('settings'));
|
||||
}
|
||||
|
||||
public function on_wechat_comment_postauthor(int $user_id, \WP_Comment $comment): void {
|
||||
$this->send_event($user_id, 'comment_to_postauthor', [
|
||||
'name' => get_comment_author($comment),
|
||||
'time' => (string) $comment->comment_date,
|
||||
'content' => (string) $comment->comment_content,
|
||||
'post' => get_the_title((int) $comment->comment_post_ID),
|
||||
], get_comment_link($comment));
|
||||
}
|
||||
|
||||
public function on_wechat_comment_parent(int $user_id, \WP_Comment $comment): void {
|
||||
$this->send_event($user_id, 'comment_to_parent', [
|
||||
'name' => get_comment_author($comment),
|
||||
'time' => (string) $comment->comment_date,
|
||||
'content' => (string) $comment->comment_content,
|
||||
], get_comment_link($comment));
|
||||
}
|
||||
|
||||
private function send_wechat_admins(string $type, array $values, string $url): void {
|
||||
foreach (get_users(['role' => 'administrator', 'fields' => 'ID']) as $user_id) {
|
||||
$this->send_event((int) $user_id, $type, $values, $url);
|
||||
}
|
||||
}
|
||||
|
||||
private function wechat_user_name(int $user_id): string {
|
||||
$user = get_userdata($user_id);
|
||||
return $user instanceof \WP_User ? (string) $user->display_name : __('用户', 'jio');
|
||||
}
|
||||
|
||||
private function wechat_user_url(string $tab): string {
|
||||
return function_exists('jio_get_user_center_url') ? jio_get_user_center_url($tab) : home_url('/');
|
||||
}
|
||||
|
||||
private function wechat_ledger_order_number(\Jio\Ledger\LedgerCommand $command): string {
|
||||
$order_number = (string) ($command->metadata['order_number'] ?? '');
|
||||
if ($order_number !== '' && $order_number !== $command->ref_key) {
|
||||
return $order_number;
|
||||
}
|
||||
$table_name = $command->reason === 'creator_income' ? 'revenue' : 'commission_ledger';
|
||||
if (!class_exists('\Jio\Core\DBMigrator') || !ctype_digit($command->ref_key)) {
|
||||
return '';
|
||||
}
|
||||
$table = \Jio\Core\DBMigrator::table($table_name);
|
||||
if ($table === '') {
|
||||
return '';
|
||||
}
|
||||
global $wpdb;
|
||||
return (string) $wpdb->get_var($wpdb->prepare("SELECT order_number FROM {$table} WHERE id = %d", (int) $command->ref_key));
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ class Integration {
|
||||
/**
|
||||
* 获取微信通知设置
|
||||
*
|
||||
* @return array{enabled:bool,app_id:string,app_secret:string,template_id:string}
|
||||
* @return array{enabled:bool,templates:array}
|
||||
*/
|
||||
public static function get_wechat_settings(): array {
|
||||
$stored = get_option('jio_wechat_settings', []);
|
||||
@@ -277,28 +277,16 @@ class Integration {
|
||||
$stored = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !empty($stored['enabled']),
|
||||
'app_id' => sanitize_text_field((string) ($stored['app_id'] ?? '')),
|
||||
'app_secret' => (string) ($stored['app_secret'] ?? ''),
|
||||
'template_id' => sanitize_text_field((string) ($stored['template_id'] ?? '')),
|
||||
];
|
||||
return WechatTemplates::normalize($stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新微信通知设置
|
||||
*
|
||||
* @param array $settings 合并后的完整微信配置
|
||||
* @param array $settings 完整微信模板配置
|
||||
*/
|
||||
public static function update_wechat_settings(array $settings): void {
|
||||
$allowed = ['enabled', 'app_id', 'app_secret', 'template_id'];
|
||||
$clean = [];
|
||||
foreach ($allowed as $key) {
|
||||
if (array_key_exists($key, $settings)) {
|
||||
$clean[$key] = $settings[$key];
|
||||
}
|
||||
}
|
||||
update_option('jio_wechat_settings', $clean);
|
||||
update_option('jio_wechat_settings', WechatTemplates::normalize($settings));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Settings;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class WechatTemplates {
|
||||
/** @return array<string,array{label:string,group:string,fields:array<string,string>}> */
|
||||
public static function definitions(): array {
|
||||
return [
|
||||
'payment_order' => ['label' => '新订单通知用户', 'group' => '订单与收益', 'fields' => ['name' => '商品名称', 'price' => '金额', 'time' => '付款时间', 'num' => '订单号']],
|
||||
'payment_order_admin' => ['label' => '新订单通知管理员', 'group' => '订单与收益', 'fields' => ['name' => '商品名称', 'price' => '金额', 'user' => '购买用户', 'time' => '时间', 'num' => '订单号']],
|
||||
'payment_order_to_income' => ['label' => '获得创作分成通知作者', 'group' => '订单与收益', 'fields' => ['name' => '收入内容', 'price' => '收入金额', 'time' => '时间', 'num' => '订单号']],
|
||||
'payment_order_to_referrer' => ['label' => '获得推荐佣金通知推荐人', 'group' => '订单与收益', 'fields' => ['name' => '推广内容', 'price' => '收入金额', 'time' => '时间', 'num' => '订单号']],
|
||||
'apply_withdraw_admin' => ['label' => '用户申请提现通知管理员', 'group' => '提现', 'fields' => ['user' => '申请用户', 'price' => '申请金额', 'time' => '时间']],
|
||||
'withdraw_process' => ['label' => '提现处理后通知用户', 'group' => '提现', 'fields' => ['create_time' => '申请时间', 'process_time' => '处理时间', 'status' => '处理结果', 'price' => '申请金额', 'received_price' => '到账金额', 'service_price' => '手续费']],
|
||||
'auth_apply_admin' => ['label' => '用户提交身份认证通知管理员', 'group' => '用户与互动', 'fields' => ['name' => '认证名称', 'time' => '认证时间', 'user' => '申请用户']],
|
||||
'auth_apply_process' => ['label' => '身份认证处理后通知用户', 'group' => '用户与互动', 'fields' => ['status' => '审核结果', 'name' => '认证名称', 'desc' => '认证简介', 'time' => '处理时间']],
|
||||
'report_user_admin' => ['label' => '收到举报后通知管理员', 'group' => '用户与互动', 'fields' => ['user' => '被举报用户', 'time' => '举报时间', 'reason' => '举报原因', 'desc' => '举报详情']],
|
||||
'report_process' => ['label' => '处理举报后通知举报人', 'group' => '用户与互动', 'fields' => ['reason' => '举报原因', 'time' => '举报时间', 'desc' => '处理说明']],
|
||||
'bind_phone' => ['label' => '绑定或修改手机号通知用户', 'group' => '用户与互动', 'fields' => ['name' => '用户昵称', 'time' => '操作时间', 'num' => '手机号']],
|
||||
'bind_email' => ['label' => '绑定或修改邮箱通知用户', 'group' => '用户与互动', 'fields' => ['name' => '用户昵称', 'time' => '操作时间', 'num' => '邮箱']],
|
||||
'comment_to_postauthor' => ['label' => '新评论通知文章作者', 'group' => '评论', 'fields' => ['name' => '评论用户', 'time' => '评论时间', 'content' => '评论内容', 'post' => '文章标题']],
|
||||
'comment_to_parent' => ['label' => '评论有新回复通知用户', 'group' => '评论', 'fields' => ['name' => '评论用户', 'time' => '评论时间', 'content' => '评论内容']],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{enabled:bool,templates:array<string,array{enabled:bool,id:string,keys:array<string,string>}>} */
|
||||
public static function normalize(array $input): array {
|
||||
$templates = [];
|
||||
$provided = is_array($input['templates'] ?? null) ? $input['templates'] : [];
|
||||
foreach (self::definitions() as $type => $definition) {
|
||||
$item = is_array($provided[$type] ?? null) ? $provided[$type] : [];
|
||||
$provided_keys = is_array($item['keys'] ?? null) ? $item['keys'] : [];
|
||||
$keys = [];
|
||||
foreach ($definition['fields'] as $field => $label) {
|
||||
$raw = $provided_keys[$field] ?? '';
|
||||
$value = is_string($raw) ? trim(sanitize_text_field($raw)) : '';
|
||||
$keys[$field] = preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $value) ? $value : '';
|
||||
}
|
||||
$id = $item['id'] ?? '';
|
||||
$templates[$type] = [
|
||||
'enabled' => !empty($item['enabled']),
|
||||
'id' => is_string($id) ? substr(trim(sanitize_text_field($id)), 0, 128) : '',
|
||||
'keys' => $keys,
|
||||
];
|
||||
}
|
||||
return ['enabled' => !empty($input['enabled']), 'templates' => $templates];
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ final class AuthApply {
|
||||
if (class_exists('Jio\\Settings\\MailNotifications')) {
|
||||
\Jio\Settings\MailNotifications::auth_reviewed($user_id, $status, $note, $reviewer_id);
|
||||
}
|
||||
do_action('jio_auth_application_reviewed', $user_id, $status, $note, $application);
|
||||
return ['status' => $status];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
|
||||
test('WeChat business event mappings and hook registration', () => {
|
||||
const harness = path.join(__dirname, 'wechat-events-harness.php');
|
||||
const output = execFileSync('php', [harness], { encoding: 'utf8' });
|
||||
assert.match(output, /WeChat events harness passed/);
|
||||
});
|
||||
|
||||
test('business actions are emitted by the owning workflows', () => {
|
||||
const cases = [
|
||||
['src/Identity/ApplicationService.php', 'jio_auth_application_submitted'],
|
||||
['src/User/AuthApply.php', 'jio_auth_application_reviewed'],
|
||||
['inc/modules/user-parts/account-ajax.php', 'jio_user_email_changed'],
|
||||
['inc/modules/user-notifications.php', 'jio_wechat_comment_postauthor'],
|
||||
['inc/modules/user-notifications.php', 'jio_wechat_comment_parent'],
|
||||
];
|
||||
const adapter = fs.readFileSync(path.join(root, 'src/Payment/WeChatNotifyEvents.php'), 'utf8');
|
||||
for (const [file, event] of cases) {
|
||||
const source = fs.readFileSync(path.join(root, file), 'utf8');
|
||||
assert.ok(source.includes(`do_action('${event}'`), `${file} does not emit ${event}`);
|
||||
assert.ok(adapter.includes(`add_action('${event}'`), `adapter does not register ${event}`);
|
||||
}
|
||||
const restReports = fs.readFileSync(path.join(root, 'src/Admin/UserControllerReportOperations.php'), 'utf8');
|
||||
assert.match(restReports, /\$status !== \(string\) \(\$row\['status'\] \?\? ''\)[\s\S]*?do_action\('jio_report_status_changed'/);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* Exercise the event adapter with a recording sender and minimal WordPress hooks.
|
||||
*/
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
$GLOBALS['wechat_event_hooks'] = [];
|
||||
|
||||
class WP_User {
|
||||
public function __construct(public int $ID, public string $display_name) {}
|
||||
}
|
||||
|
||||
class WP_Comment {
|
||||
public function __construct(
|
||||
public int $comment_ID,
|
||||
public int $comment_post_ID,
|
||||
public string $comment_date,
|
||||
public string $comment_content
|
||||
) {}
|
||||
}
|
||||
|
||||
function add_action(string $name, callable $callback, int $priority = 10, int $accepted_args = 1): void {
|
||||
$GLOBALS['wechat_event_hooks'][$name][] = [$callback, $accepted_args];
|
||||
}
|
||||
|
||||
function do_action(string $name, ...$args): void {
|
||||
foreach ($GLOBALS['wechat_event_hooks'][$name] ?? [] as [$callback, $accepted_args]) {
|
||||
$callback(...array_slice($args, 0, $accepted_args));
|
||||
}
|
||||
}
|
||||
|
||||
function __($message, $domain = ''): string { return (string) $message; }
|
||||
function current_time(string $format): string { return '2026-11-10 10:00:00'; }
|
||||
function home_url(string $path = '/'): string { return 'https://site.test' . $path; }
|
||||
function admin_url(string $path): string { return 'https://site.test/wp-admin/' . $path; }
|
||||
function jio_get_user_center_url(string $tab): string { return 'https://site.test/user/' . $tab . '/'; }
|
||||
function jio_get_user_center_tab_url(string $tab): string { return 'https://site.test/user/' . $tab . '/'; }
|
||||
function jio_get_messages_page_url(string $view): string { return 'https://site.test/messages/' . $view . '/'; }
|
||||
function get_users(array $args): array { return [2, 3]; }
|
||||
function get_userdata(int $id) { return new WP_User($id, 'User ' . $id); }
|
||||
function get_the_title(int $id): string { return 'Product ' . $id; }
|
||||
function get_comment_author(WP_Comment $comment): string { return 'Commenter ' . $comment->comment_ID; }
|
||||
function get_comment_link(WP_Comment $comment): string { return 'https://site.test/post/#comment-' . $comment->comment_ID; }
|
||||
$GLOBALS['wpdb'] = new class {
|
||||
public function prepare(string $query, int $id): string { return str_replace('%d', (string) $id, $query); }
|
||||
public function get_var(string $query): string { return str_contains($query, 'wp_revenue') ? 'O-3' : 'O-4'; }
|
||||
};
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/AssetType.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerCommand.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerResult.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Payment/Withdrawal.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Payment/WeChatNotifyEvents.php';
|
||||
}
|
||||
|
||||
namespace Jio\Core {
|
||||
final class DBMigrator {
|
||||
public static function table(string $name): string { return 'wp_' . $name; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace Jio\Payment {
|
||||
final class RecordingWechatSender {
|
||||
use WeChatNotifyEvents;
|
||||
|
||||
public array $messages = [];
|
||||
|
||||
public function init(): void {
|
||||
$this->register_event_hooks();
|
||||
}
|
||||
|
||||
public function send_event(int $user_id, string $type, array $values, string $url = ''): bool {
|
||||
$this->messages[] = compact('user_id', 'type', 'values', 'url');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
$check = static function (bool $condition, string $message): void {
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, $message . "\n");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
$sender = new \Jio\Payment\RecordingWechatSender();
|
||||
$sender->init();
|
||||
$sender->init();
|
||||
$check(count($GLOBALS['wechat_event_hooks']['jio_order_paid'] ?? []) === 1, 'order hook registered twice');
|
||||
$check(count($GLOBALS['wechat_event_hooks']['jio_wechat_comment_parent'] ?? []) === 1, 'comment hook registered twice');
|
||||
|
||||
do_action('jio_order_paid', 'O-1', [
|
||||
'user_id' => 1,
|
||||
'product_id' => 9,
|
||||
'amount' => '12.50',
|
||||
'currency' => 'CNY',
|
||||
'paid_at' => '2026-11-09 09:00:00',
|
||||
]);
|
||||
$check(count($sender->messages) === 3, 'order should notify buyer and two admins once');
|
||||
$check($sender->messages[0]['type'] === 'payment_order', 'buyer order type');
|
||||
$check($sender->messages[0]['values'] === [
|
||||
'name' => 'Product 9', 'price' => '12.50 CNY', 'time' => '2026-11-09 09:00:00', 'num' => 'O-1',
|
||||
], 'buyer order fields');
|
||||
$check($sender->messages[0]['url'] === 'https://site.test/user/orders/', 'buyer order URL');
|
||||
$check($sender->messages[1]['type'] === 'payment_order_admin', 'admin order type');
|
||||
$check($sender->messages[1]['values']['user'] === 'User 1', 'admin buyer field');
|
||||
$check($sender->messages[1]['url'] === 'https://site.test/wp-admin/admin.php?page=jio_orders&order=O-1', 'admin order URL');
|
||||
|
||||
$sender->messages = [];
|
||||
$row = ['gross_amount' => '20.00', 'amount' => '19.00', 'created_at' => '2026-11-08 08:00:00'];
|
||||
do_action('jio_withdrawal_submitted', 1, $row);
|
||||
$check(count($sender->messages) === 2, 'withdraw request should notify admins');
|
||||
$check($sender->messages[0]['type'] === 'apply_withdraw_admin', 'withdraw request type');
|
||||
$check($sender->messages[0]['values']['price'] === '20.00 CNY', 'withdraw request amount');
|
||||
$check(str_contains($sender->messages[0]['url'], 'page=jio_withdraw'), 'withdraw admin URL');
|
||||
$sender->messages = [];
|
||||
do_action('jio_withdrawal_status_changed', 1, $row, \Jio\Payment\Withdrawal::STATUS_PROCESSING, '');
|
||||
$check($sender->messages === [], 'intermediate withdrawal status should not send');
|
||||
do_action('jio_withdrawal_status_changed', 1, $row, \Jio\Payment\Withdrawal::STATUS_COMPLETED, '');
|
||||
$check(count($sender->messages) === 1, 'processed withdrawal should send once');
|
||||
$check($sender->messages[0]['type'] === 'withdraw_process', 'processed withdrawal type');
|
||||
$check($sender->messages[0]['values']['service_price'] === '1.00 CNY', 'withdraw fee field');
|
||||
$check($sender->messages[0]['values']['received_price'] === '19.00 CNY', 'withdraw received field');
|
||||
|
||||
$sender->messages = [];
|
||||
$comment = new WP_Comment(11, 9, '2026-11-10 09:00:00', 'Hello');
|
||||
do_action('jio_wechat_comment_postauthor', 1, $comment);
|
||||
do_action('jio_wechat_comment_parent', 2, $comment);
|
||||
$check(count($sender->messages) === 2, 'comment should produce two distinct event types');
|
||||
$check($sender->messages[0]['type'] === 'comment_to_postauthor', 'post author comment type');
|
||||
$check($sender->messages[0]['values']['post'] === 'Product 9', 'post title field');
|
||||
$check($sender->messages[1]['type'] === 'comment_to_parent', 'parent comment type');
|
||||
$check($sender->messages[1]['values']['content'] === 'Hello', 'reply content field');
|
||||
$check($sender->messages[1]['url'] === 'https://site.test/post/#comment-11', 'comment URL');
|
||||
|
||||
$sender->messages = [];
|
||||
$command = new \Jio\Ledger\LedgerCommand('income-1', 'creator_income', [], 'revenue', '12', 0, ['order_number' => 'O-2']);
|
||||
$entry = ['user_id' => 1, 'asset' => 'balance', 'delta_minor' => 250, 'entry_type' => 'credit'];
|
||||
do_action('jio_asset_transaction_committed', new \Jio\Ledger\LedgerResult(1, true, [$entry], []), $command);
|
||||
$check($sender->messages === [], 'replayed ledger result should not send');
|
||||
do_action('jio_asset_transaction_committed', new \Jio\Ledger\LedgerResult(2, false, [$entry], []), $command);
|
||||
$check(count($sender->messages) === 1, 'committed income should send once');
|
||||
$check($sender->messages[0]['type'] === 'payment_order_to_income', 'income type');
|
||||
$check($sender->messages[0]['values']['price'] === '2.50 CNY', 'income amount');
|
||||
$check($sender->messages[0]['values']['num'] === 'O-2', 'income order number');
|
||||
$sender->messages = [];
|
||||
$command = new \Jio\Ledger\LedgerCommand('income-2', 'creator_income', [], 'revenue', '12', 0, ['order_number' => '12']);
|
||||
do_action('jio_asset_transaction_committed', new \Jio\Ledger\LedgerResult(3, false, [$entry], []), $command);
|
||||
$check(count($sender->messages) === 1 && $sender->messages[0]['values']['num'] === 'O-3', 'ledger record id resolved to order number');
|
||||
|
||||
echo "WeChat events harness passed\n";
|
||||
}
|
||||
@@ -1,151 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* WeChat template-message settings harness.
|
||||
*
|
||||
* Drives the REAL UserController REST callbacks (get_wechat_status /
|
||||
* update_wechat_settings) against WP stubs so the previously-broken routes are
|
||||
* covered end to end:
|
||||
* - GET /wechat/status returns the WeChatNotify status shape (masked secret);
|
||||
* - POST /wechat/settings persists through Integration's key whitelist;
|
||||
* - the *** mask keeps the stored secret instead of overwriting it;
|
||||
* - omitted fields fall back to current values; unknown keys are dropped;
|
||||
* - inputs are sanitized before storage.
|
||||
*
|
||||
* Usage: php wechat-settings-harness.php -> prints "WeChat settings harness passed".
|
||||
*/
|
||||
/** @package Jio */
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
final class WP_Error {
|
||||
public function __construct(private string $code, private string $message, private array $data = []) {}
|
||||
public function get_error_message(): string { return $this->message; }
|
||||
public function get_error_data(): array { return $this->data; }
|
||||
}
|
||||
final class WP_REST_Response {
|
||||
public $data;
|
||||
public $status;
|
||||
public function __construct($data = null, int $status = 200) {
|
||||
$this->data = $data;
|
||||
$this->status = $status;
|
||||
}
|
||||
public function __construct(public $data = null, public int $status = 200) {}
|
||||
}
|
||||
|
||||
final class WP_REST_Request {
|
||||
private array $json;
|
||||
public function __construct(array $json = []) { $this->json = $json; }
|
||||
public function __construct(private array $json = []) {}
|
||||
public function get_json_params(): array { return $this->json; }
|
||||
public function get_method(): string { return 'POST'; }
|
||||
}
|
||||
|
||||
// ---------- WP function stubs ----------
|
||||
function __($s, $d = null) { return (string) $s; }
|
||||
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
|
||||
function current_user_can($c) { return true; }
|
||||
function wp_send_json_success($d) { echo (string) json_encode($d); }
|
||||
function get_transient($k) { return $GLOBALS['wechat_transient'] ?? false; }
|
||||
function get_option($k, $default = false) { return $GLOBALS['wechat_options'][$k] ?? $default; }
|
||||
function update_option($k, $v) { $GLOBALS['wechat_options'][$k] = $v; return true; }
|
||||
|
||||
$GLOBALS['wechat_options'] = ['jio_wechat_settings' => [
|
||||
'enabled' => true,
|
||||
'app_id' => 'wxAPPID',
|
||||
'app_secret' => 'SECRET123',
|
||||
'template_id' => 'TPL1',
|
||||
]];
|
||||
$GLOBALS['wechat_transient'] = 'ACCESS_TOKEN';
|
||||
function __($text, $domain = '') { return $text; }
|
||||
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
|
||||
function sanitize_key($value): string { return strtolower(preg_replace('/[^a-z0-9_-]/', '', (string) $value)); }
|
||||
function is_wp_error($value): bool { return $value instanceof WP_Error; }
|
||||
function get_option($key, $default = false) { return $GLOBALS['wechat_options'][$key] ?? $default; }
|
||||
function update_option($key, $value): bool { $GLOBALS['wechat_options'][$key] = $value; return true; }
|
||||
function get_current_user_id(): int { return 9; }
|
||||
function get_user_meta($user_id, $key, $single = false) { return $key === 'jio_weixingzh_id' && $user_id === 9 ? ($GLOBALS['wechat_openid'] ?? '') : ''; }
|
||||
function home_url($path = ''): string { return 'https://example.test' . $path; }
|
||||
function esc_url_raw($url): string { return (string) $url; }
|
||||
function current_time($format): string { return '2026-11-10 10:00:00'; }
|
||||
function wp_json_encode($value): string { return json_encode($value, JSON_UNESCAPED_UNICODE); }
|
||||
function wp_remote_post($url, $args) { $GLOBALS['wechat_last_post'] = [$url, $args]; return $GLOBALS['wechat_remote_response']; }
|
||||
function wp_remote_retrieve_body($response): string { return (string) ($response['body'] ?? ''); }
|
||||
function wp_remote_retrieve_response_code($response): int { return (int) ($response['response']['code'] ?? 0); }
|
||||
function get_userdata($user_id) { return null; }
|
||||
function get_users($args) { return []; }
|
||||
function add_action($hook, $callback, $priority = 10, $accepted_args = 1): void {}
|
||||
|
||||
$GLOBALS['wpdb'] = new class {
|
||||
public $usermeta = 'wp_usermeta';
|
||||
public function get_var($sql) { return 7; }
|
||||
public string $usermeta = 'wp_usermeta';
|
||||
public function get_var($query): int { return 1; }
|
||||
};
|
||||
$GLOBALS['wechat_options'] = ['jio_wechat_settings' => []];
|
||||
$GLOBALS['wechat_openid'] = 'OPENID9';
|
||||
$GLOBALS['wechat_remote_response'] = ['response' => ['code' => 200], 'body' => '{"errcode":0,"errmsg":"ok"}'];
|
||||
}
|
||||
|
||||
namespace Jio\Auth {
|
||||
final class WeixinGzh {
|
||||
public static function is_enabled(): bool { return true; }
|
||||
public static function get_instance(): self { return new self(); }
|
||||
public function access_token(): string { return 'ACCESS_TOKEN'; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace Jio\Settings {
|
||||
// Settings 层仅为薄委托,harness 直接对接真实 Integration。
|
||||
class Settings {
|
||||
public static function get_wechat_settings(): array { return Integration::get_wechat_settings(); }
|
||||
public static function update_wechat_settings(array $settings): void { Integration::update_wechat_settings($settings); }
|
||||
public static function update_wechat_settings(array $config): void { Integration::update_wechat_settings($config); }
|
||||
public static function get_weixingzh_settings(): array { return ['gzh_type' => 'service', 'appid' => 'wxAPP', 'appsecret' => 'secret']; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/ControllerBase.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerReportOperations.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerAuthPhoneOperations.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerRestrictionOperations.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerFollowOperations.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerProfileMediaOperations.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/UserController.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Settings/Integration.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Settings/DomainGroups.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Payment/WeChatNotify.php';
|
||||
|
||||
$failures = [];
|
||||
$check = static function (string $label, bool $ok) use (&$failures): void {
|
||||
if (!$ok) {
|
||||
$failures[] = $label;
|
||||
}
|
||||
};
|
||||
$stored = static function (): array {
|
||||
$v = get_option('jio_wechat_settings', []);
|
||||
return is_array($v) ? $v : [];
|
||||
};
|
||||
|
||||
$controller = \Jio\Admin\UserController::get_instance();
|
||||
|
||||
// 1) GET /wechat/status 返回完整状态形状(密钥掩码)。
|
||||
$response = $controller->get_wechat_status(new WP_REST_Request());
|
||||
$data = is_array($response->data) ? ($response->data['data'] ?? []) : [];
|
||||
$check('status 含 enabled', array_key_exists('enabled', $data) && !empty($data['enabled']));
|
||||
$check('status 含 appid', ($data['appid'] ?? '') === 'wxAPPID');
|
||||
$check('status configured', !empty($data['configured']));
|
||||
$check('status has_token', !empty($data['has_token']));
|
||||
$check('status has_template', !empty($data['has_template']));
|
||||
$check('status bound_users', ($data['bound_users'] ?? 0) === 7);
|
||||
$check('status 掩码 app_secret', ($data['app_secret'] ?? '') === '***');
|
||||
$check('status allowed_types 非空', !empty($data['allowed_types']));
|
||||
|
||||
// 2) 完整更新写入全部字段。
|
||||
$response = $controller->update_wechat_settings(new WP_REST_Request([
|
||||
'enabled' => true,
|
||||
'app_id' => 'wxNEW',
|
||||
'app_secret' => 'NEWSECRET',
|
||||
'template_id' => 'TPL2',
|
||||
]));
|
||||
$check('更新写入 app_id', ($stored()['app_id'] ?? '') === 'wxNEW');
|
||||
$check('更新写入 app_secret', ($stored()['app_secret'] ?? '') === 'NEWSECRET');
|
||||
$check('更新写入 template_id', ($stored()['template_id'] ?? '') === 'TPL2');
|
||||
$check('更新返回成功消息', str_contains((string) ($response->data['message'] ?? ''), '已保存'));
|
||||
|
||||
// 3) 掩码 *** 保留已存密钥。
|
||||
$controller->update_wechat_settings(new WP_REST_Request([
|
||||
'enabled' => true,
|
||||
'app_id' => 'wxNEW',
|
||||
'app_secret' => '***',
|
||||
]));
|
||||
$check('掩码保留原密钥', ($stored()['app_secret'] ?? '') === 'NEWSECRET');
|
||||
|
||||
// 4) 缺省字段沿用当前值,开关可关闭。
|
||||
$controller->update_wechat_settings(new WP_REST_Request(['enabled' => false]));
|
||||
$check('缺省字段沿用当前值', ($stored()['app_id'] ?? '') === 'wxNEW' && ($stored()['template_id'] ?? '') === 'TPL2');
|
||||
$check('enabled 可关闭', empty($stored()['enabled']));
|
||||
|
||||
// 5) 输入消毒 + 未知键被白名单丢弃。
|
||||
$controller->update_wechat_settings(new WP_REST_Request([
|
||||
'app_id' => '<b>wxXSS</b>',
|
||||
'evil' => 'x',
|
||||
]));
|
||||
$check('app_id 经消毒', ($stored()['app_id'] ?? '') === 'wxXSS');
|
||||
$check('未知键被忽略', !array_key_exists('evil', $stored()));
|
||||
|
||||
// 6) 未配置时空密钥可显式清空。
|
||||
update_option('jio_wechat_settings', ['enabled' => true, 'app_id' => 'a', 'app_secret' => '', 'template_id' => '']);
|
||||
$controller->update_wechat_settings(new WP_REST_Request(['app_secret' => '']));
|
||||
$check('空密钥可清存', ($stored()['app_secret'] ?? '') === '');
|
||||
|
||||
if ($failures) {
|
||||
echo "WeChat settings harness FAILED\n";
|
||||
foreach ($failures as $failure) {
|
||||
echo ' - ' . $failure . "\n";
|
||||
}
|
||||
exit(1);
|
||||
$root = dirname(__DIR__, 2);
|
||||
foreach ([
|
||||
'src/Admin/ControllerBase.php',
|
||||
'src/Admin/UserControllerReportOperations.php',
|
||||
'src/Admin/UserControllerAuthPhoneOperations.php',
|
||||
'src/Admin/UserControllerRestrictionOperations.php',
|
||||
'src/Admin/UserControllerFollowOperations.php',
|
||||
'src/Admin/UserControllerProfileMediaOperations.php',
|
||||
'src/Admin/UserController.php',
|
||||
'src/Settings/WechatTemplates.php',
|
||||
'src/Settings/Integration.php',
|
||||
'src/Payment/WeChatNotifyEvents.php',
|
||||
'src/Payment/WeChatNotify.php',
|
||||
] as $file) {
|
||||
require_once $root . '/' . $file;
|
||||
}
|
||||
|
||||
$check = static function (bool $condition, string $name): void {
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "FAILED: {$name}\n");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
$controller = \Jio\Admin\UserController::get_instance();
|
||||
$sender = \Jio\Payment\WeChatNotify::get_instance();
|
||||
$status = $controller->get_wechat_status(new WP_REST_Request())->data['data'];
|
||||
$check(count($status['types']) === 14, 'available types');
|
||||
$check($status['bound_users'] === 1 && $status['current_user_bound'], 'real binding key');
|
||||
$check($status['oauth_ready'], 'oauth status');
|
||||
|
||||
$config = ['enabled' => true, 'templates' => [
|
||||
'comment_to_parent' => ['enabled' => true, 'id' => 'TPL_REPLY', 'keys' => ['name' => 'thing1', 'time' => 'time2', 'content' => '']],
|
||||
]];
|
||||
$saved = $controller->update_wechat_settings(new WP_REST_Request($config));
|
||||
$check($saved->status === 200, 'save accepted');
|
||||
$stored = get_option('jio_wechat_settings');
|
||||
$check($stored['templates']['comment_to_parent']['id'] === 'TPL_REPLY', 'template persisted');
|
||||
$check(count($stored['templates']) === 14, 'all types normalized');
|
||||
$check(!isset($stored['app_id']) && !isset($stored['app_secret']) && !isset($stored['template_id']), 'obsolete settings removed');
|
||||
$check($controller->get_wechat_status(new WP_REST_Request())->data['data']['config'] === $stored, 'round trip');
|
||||
|
||||
$invalid = $controller->update_wechat_settings(new WP_REST_Request(['enabled' => true, 'templates' => [
|
||||
'comment_to_parent' => ['enabled' => true, 'id' => 'TPL', 'keys' => ['name' => 'bad key']],
|
||||
]]));
|
||||
$check($invalid->status === 400 && get_option('jio_wechat_settings') === $stored, 'invalid mapping rejected without write');
|
||||
$invalid = $controller->update_wechat_settings(new WP_REST_Request(['enabled' => true, 'templates' => ['unknown' => ['enabled' => true, 'id' => 'X']]]));
|
||||
$check($invalid->status === 400, 'unknown type rejected');
|
||||
$invalid = $controller->update_wechat_settings(new WP_REST_Request(['enabled' => true, 'templates' => [
|
||||
'comment_to_parent' => ['enabled' => true, 'id' => 'TPL', 'keys' => ['name' => 'keyword3']],
|
||||
]]));
|
||||
$check($invalid->status === 400 && get_option('jio_wechat_settings') === $stored, 'fallback mapping collision rejected');
|
||||
$draft = $controller->update_wechat_settings(new WP_REST_Request(['enabled' => true, 'templates' => [
|
||||
'comment_to_parent' => ['enabled' => true, 'id' => '', 'keys' => []],
|
||||
]]));
|
||||
$check($draft->status === 200, 'incomplete template can be saved as draft');
|
||||
$blocked = $controller->test_wechat_template(new WP_REST_Request(['type' => 'comment_to_parent']));
|
||||
$check($blocked->status === 400, 'incomplete template cannot be tested');
|
||||
$controller->update_wechat_settings(new WP_REST_Request($config));
|
||||
|
||||
$sent = $sender->send_event(9, 'comment_to_parent', ['name' => str_repeat('中', 25), 'time' => '2026-11-10', 'content' => '测试回复'], 'https://example.test/comment/1');
|
||||
$body = json_decode($GLOBALS['wechat_last_post'][1]['body'], true);
|
||||
$check($sent, 'send succeeds');
|
||||
$check($body['touser'] === 'OPENID9' && $body['template_id'] === 'TPL_REPLY', 'recipient and template');
|
||||
$check($body['url'] === 'https://example.test/comment/1', 'deep link');
|
||||
$check($body['data']['thing1']['value'] === str_repeat('中', 16) . '...', 'thing truncation');
|
||||
$check($body['data']['time2']['value'] === '2026-11-10' && $body['data']['keyword3']['value'] === '测试回复', 'mapping and fallback');
|
||||
|
||||
$tested = $controller->test_wechat_template(new WP_REST_Request(['type' => 'comment_to_parent']));
|
||||
$check($tested->status === 200, 'test endpoint sends');
|
||||
$GLOBALS['wechat_remote_response'] = ['response' => ['code' => 200], 'body' => '{"errcode":40013,"errmsg":"invalid appid"}'];
|
||||
$tested = $controller->test_wechat_template(new WP_REST_Request(['type' => 'comment_to_parent']));
|
||||
$check($tested->status === 502 && str_contains($tested->data['message'], '40013'), 'api failure exposed');
|
||||
$GLOBALS['wechat_openid'] = '';
|
||||
$tested = $controller->test_wechat_template(new WP_REST_Request(['type' => 'comment_to_parent']));
|
||||
$check($tested->status === 400, 'unbound user rejected');
|
||||
|
||||
echo "WeChat settings harness passed\n";
|
||||
}
|
||||
@@ -1,84 +1,44 @@
|
||||
'use strict';
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* 微信模板消息 REST 契约(修复存量审计错误):
|
||||
* 1) /wechat/status 与 /wechat/settings 的路由回调方法在 UserController 中真实存在;
|
||||
* 2) 状态读取委托 WeChatNotify::get_status(),写入委托 Settings::update_wechat_settings();
|
||||
* 3) app_secret 掩码 *** 不覆盖已存密钥,输入经 sanitize_text_field 消毒;
|
||||
* 4) PHP harness 驱动真实控制器验证上述行为;
|
||||
* 5) jio_settings 上的微信通知死键(wechat_notify_* 等)已彻底移除,
|
||||
* 活配置面仅剩 jio_wechat_settings(/users/wechat-template 自定义页)。
|
||||
*/
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const root = path.resolve(__dirname, '..', '..')
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
const controller = read('src/Admin/UserController.php')
|
||||
const sender = read('src/Payment/WeChatNotify.php')
|
||||
const settings = read('src/Settings/Integration.php')
|
||||
const page = read('assets/admin/src/pages/users/UsersWechatTemplate.vue')
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const controller = fs.readFileSync(path.join(root, 'src/Admin/UserController.php'), 'utf8');
|
||||
const schemaUsers = fs.readFileSync(path.join(root, 'src/Admin/Schema/SchemaUsers.php'), 'utf8');
|
||||
const usersSchemaIndex = fs.readFileSync(path.join(root, 'assets/admin/src/schema/sections/users/index.js'), 'utf8');
|
||||
const usersSections = fs.readFileSync(path.join(root, 'assets/admin/src/constants/sections/users.js'), 'utf8');
|
||||
|
||||
// 死双路面:写进 jio_settings、全仓无消费方的微信通知键。
|
||||
const DEAD_WECHAT_KEYS = [
|
||||
'wechat_notify_enabled',
|
||||
'wechat_app_id',
|
||||
'wechat_app_secret',
|
||||
'wechat_template_id',
|
||||
'wechat_token',
|
||||
'wechat_encoding_aes_key',
|
||||
'wechat_template_comment',
|
||||
'wechat_template_reply',
|
||||
'wechat_template_system',
|
||||
'wechat_template_order',
|
||||
'wechat_notify_comment',
|
||||
'wechat_notify_reply',
|
||||
'wechat_notify_system',
|
||||
'wechat_notify_order',
|
||||
'wechat_bind_entry',
|
||||
];
|
||||
|
||||
test('路由与回调方法配对存在', () => {
|
||||
assert.match(controller, /register_rest_route\(self::API_NAMESPACE, '\/wechat\/status'[\s\S]*?'callback'\s*=>\s*\[\$this, 'get_wechat_status'\]/);
|
||||
assert.match(controller, /register_rest_route\(self::API_NAMESPACE, '\/wechat\/settings'[\s\S]*?'callback'\s*=>\s*\[\$this, 'update_wechat_settings'\]/);
|
||||
assert.match(controller, /public function get_wechat_status\(\\WP_REST_Request \$request\)/);
|
||||
assert.match(controller, /public function update_wechat_settings\(\\WP_REST_Request \$request\)/);
|
||||
});
|
||||
|
||||
test('状态与写入委托既有服务层', () => {
|
||||
assert.match(controller, /WeChatNotify::get_instance\(\)->get_status\(\)/);
|
||||
assert.match(controller, /Settings::update_wechat_settings\(/);
|
||||
});
|
||||
|
||||
test('密钥掩码与输入消毒', () => {
|
||||
assert.match(controller, /\$params\['app_secret'\] !== '\*\*\*'/);
|
||||
assert.match(controller, /sanitize_text_field\(\(string\) \$params\['app_id'\]\)/);
|
||||
assert.match(controller, /sanitize_text_field\(\(string\) \$params\['app_secret'\]\)/);
|
||||
assert.match(controller, /sanitize_text_field\(\(string\) \$params\['template_id'\]\)/);
|
||||
});
|
||||
|
||||
test('微信设置行为 harness 通过', () => {
|
||||
const harness = path.join(root, 'tests', 'admin', 'wechat-settings-harness.php');
|
||||
const output = execFileSync('php', [harness], { encoding: 'utf8' });
|
||||
assert.match(output, /WeChat settings harness passed/);
|
||||
});
|
||||
|
||||
test('死双路面(jio_settings 微信通知键)已彻底移除', () => {
|
||||
for (const key of DEAD_WECHAT_KEYS) {
|
||||
assert.ok(!schemaUsers.includes(key), `SchemaUsers 残留: ${key}`);
|
||||
assert.ok(!usersSections.includes(key), `users 菜单常量残留: ${key}`);
|
||||
test('设置、状态和测试接口均受后台权限约束', () => {
|
||||
for (const route of ['/wechat/status', '/wechat/settings', '/wechat/test']) {
|
||||
assert.match(controller, new RegExp(`register_rest_route\\(self::API_NAMESPACE, '${route}'[\\s\\S]*?'permission_callback'\\s*=>\\s*\\[\\$this, 'check_permission'\\]`))
|
||||
}
|
||||
assert.ok(!usersSchemaIndex.includes('wechatSchema'), 'users schema index 残留 wechatSchema 注册');
|
||||
assert.ok(!usersSections.includes("route: '/users/wechat',"), '用户菜单残留微信通知 schema 页');
|
||||
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/schema/sections/users/wechat.js')), 'wechat.js 未删除');
|
||||
});
|
||||
})
|
||||
|
||||
test('活配置面保持唯一(jio_wechat_settings + 自定义页)', () => {
|
||||
assert.match(usersSections, /key: 'users-wechat-template'/);
|
||||
assert.match(usersSections, /route: '\/users\/wechat-template'/);
|
||||
assert.match(controller, /WeChatNotify::get_instance\(\)->get_status\(\)/);
|
||||
assert.match(controller, /Settings::update_wechat_settings\(/);
|
||||
});
|
||||
test('配置与发送共用公众号登录及已绑定账号', () => {
|
||||
assert.match(sender, /Settings::get_weixingzh_settings\(\)/)
|
||||
assert.match(sender, /WeixinGzh::get_instance\(\)->access_token\(\)/)
|
||||
assert.match(sender, /jio_weixingzh_id/)
|
||||
assert.doesNotMatch(sender, /jio_wechat_openid|jio_wechat_subscriptions|set_transient\('jio_wechat_token'/)
|
||||
assert.match(settings, /WechatTemplates::normalize\(\$stored\)/)
|
||||
assert.doesNotMatch(settings, /jio_wechat_settings'[\s\S]{0,350}'app_secret'/)
|
||||
})
|
||||
|
||||
test('页面提供分类型设置和发送测试', () => {
|
||||
assert.match(page, /form\.templates\[type\.key\]\.enabled/)
|
||||
assert.match(page, /form\.templates\[type\.key\]\.id/)
|
||||
assert.match(page, /form\.templates\[type\.key\]\.keys\[field\.key\]/)
|
||||
assert.match(page, /\/wechat\/test/)
|
||||
assert.match(page, /data: JSON\.parse\(snapshot\.value\)/)
|
||||
assert.doesNotMatch(page, /structuredClone\(form\)/)
|
||||
assert.doesNotMatch(page, /form\.app_secret|form\.template_id|alert\(/)
|
||||
})
|
||||
|
||||
test('PHP 行为验证', () => {
|
||||
const output = execFileSync('php', [path.join(root, 'tests/admin/wechat-settings-harness.php')], { encoding: 'utf8' })
|
||||
assert.match(output, /WeChat settings harness passed/)
|
||||
})
|
||||
@@ -544,12 +544,10 @@ test('report modal delegates its labelled title, close button and focus return t
|
||||
assert.doesNotMatch(reportJs, /class="close"|style="display:|setTimeout/)
|
||||
})
|
||||
|
||||
test('wechat template messages cover report and account restriction types', () => {
|
||||
const wechat = read('src/Payment/WeChatNotify.php')
|
||||
assert.match(wechat, /const NOTIFY_TYPES = \['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'report', 'system'\]/)
|
||||
assert.match(wechat, /in_array\(\$type, self::NOTIFY_TYPES, true\)/)
|
||||
// 订阅白名单与状态输出复用同一常量,不允许再出现硬编码子集。
|
||||
assert.ok(!wechat.includes("['comment_reply', 'system_notice', 'order_paid', 'credits_change']"), 'type list must come from NOTIFY_TYPES')
|
||||
// 站内通知缺省模板字段时用 title/description 兜底合成。
|
||||
assert.match(wechat, /\$data\['description'\] \?\? ''/)
|
||||
test('wechat report templates use distinct submission and processing events', () => {
|
||||
const events = read('src/Payment/WeChatNotifyEvents.php')
|
||||
assert.match(events, /add_action\('jio_report_submitted', \[\$this, 'on_wechat_report_submitted'\]/)
|
||||
assert.match(events, /add_action\('jio_report_status_changed', \[\$this, 'on_wechat_report_processed'\]/)
|
||||
assert.match(events, /send_wechat_admins\('report_user_admin'/)
|
||||
assert.match(events, /send_event\(\$reporter_id, 'report_process'/)
|
||||
})
|
||||
Reference in new issue
Block a user