diff --git a/assets/admin/src/pages/users/UsersWechatTemplate.vue b/assets/admin/src/pages/users/UsersWechatTemplate.vue index ffbd17cc..4f73fff8 100644 --- a/assets/admin/src/pages/users/UsersWechatTemplate.vue +++ b/assets/admin/src/pages/users/UsersWechatTemplate.vue @@ -1,257 +1,198 @@ - diff --git a/inc/modules/user-notifications.php b/inc/modules/user-notifications.php index 09e9d58e..0673da1a 100644 --- a/inc/modules/user-notifications.php +++ b/inc/modules/user-notifications.php @@ -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 { diff --git a/inc/modules/user-parts/account-ajax.php b/inc/modules/user-parts/account-ajax.php index 2aa17d45..48883fb4 100644 --- a/inc/modules/user-parts/account-ajax.php +++ b/inc/modules/user-parts/account-ajax.php @@ -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, diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index 7ced0e11..e34b6a1a 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -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 { diff --git a/src/Admin/UserControllerReportOperations.php b/src/Admin/UserControllerReportOperations.php index 17002d20..15aeb9f1 100644 --- a/src/Admin/UserControllerReportOperations.php +++ b/src/Admin/UserControllerReportOperations.php @@ -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'), ]); diff --git a/src/Auth/WeixinGzh.php b/src/Auth/WeixinGzh.php index 3cf454bc..2bd6bfa7 100644 --- a/src/Auth/WeixinGzh.php +++ b/src/Auth/WeixinGzh.php @@ -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) { diff --git a/src/Identity/ApplicationService.php b/src/Identity/ApplicationService.php index 1e806f5a..7ad3c895 100644 --- a/src/Identity/ApplicationService.php +++ b/src/Identity/ApplicationService.php @@ -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); } diff --git a/src/Messages/ReportCenter.php b/src/Messages/ReportCenter.php index cb1abfac..26800e34 100644 --- a/src/Messages/ReportCenter.php +++ b/src/Messages/ReportCenter.php @@ -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; diff --git a/src/Payment/WeChatNotify.php b/src/Payment/WeChatNotify.php index 4532d733..231f9422 100644 --- a/src/Payment/WeChatNotify.php +++ b/src/Payment/WeChatNotify.php @@ -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; + } } diff --git a/src/Payment/WeChatNotifyEvents.php b/src/Payment/WeChatNotifyEvents.php new file mode 100644 index 00000000..d1ae8fd9 --- /dev/null +++ b/src/Payment/WeChatNotifyEvents.php @@ -0,0 +1,246 @@ +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)); + } +} diff --git a/src/Settings/Integration.php b/src/Settings/Integration.php index ea7ef4d8..63f9982b 100644 --- a/src/Settings/Integration.php +++ b/src/Settings/Integration.php @@ -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)); } } diff --git a/src/Settings/WechatTemplates.php b/src/Settings/WechatTemplates.php new file mode 100644 index 00000000..c017156f --- /dev/null +++ b/src/Settings/WechatTemplates.php @@ -0,0 +1,53 @@ +}> */ + 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}>} */ + 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]; + } +} diff --git a/src/User/AuthApply.php b/src/User/AuthApply.php index cea576eb..367c2a1a 100644 --- a/src/User/AuthApply.php +++ b/src/User/AuthApply.php @@ -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]; } diff --git a/tests/admin/wechat-events-contract.test.cjs b/tests/admin/wechat-events-contract.test.cjs new file mode 100644 index 00000000..ff06830c --- /dev/null +++ b/tests/admin/wechat-events-contract.test.cjs @@ -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'/); +}); diff --git a/tests/admin/wechat-events-harness.php b/tests/admin/wechat-events-harness.php new file mode 100644 index 00000000..72a1add7 --- /dev/null +++ b/tests/admin/wechat-events-harness.php @@ -0,0 +1,154 @@ +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"; +} diff --git a/tests/admin/wechat-settings-harness.php b/tests/admin/wechat-settings-harness.php index 4828f07b..5da69227 100644 --- a/tests/admin/wechat-settings-harness.php +++ b/tests/admin/wechat-settings-harness.php @@ -1,151 +1,141 @@ 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' => 'wxXSS', - '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"; } diff --git a/tests/admin/wechat-settings-rest-contract.test.cjs b/tests/admin/wechat-settings-rest-contract.test.cjs index 5f07b7ba..9778bf12 100644 --- a/tests/admin/wechat-settings-rest-contract.test.cjs +++ b/tests/admin/wechat-settings-rest-contract.test.cjs @@ -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/) +}) diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs index d97f13b1..391cb4df 100644 --- a/tests/widget-system/report-ban-contract.test.cjs +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -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'/) })