WordPress 4.9.6 增加了一组工具,帮助站点处理欧盟《通用数据保护条例》(GDPR)等要求。其中的个人数据导出工具,可以把指定用户的个人数据导出为 ZIP 文件。
除了 WordPress 评论等功能保存的个人数据,插件也可以接入导出器,把自己收集的信息纳入导出。数据可以位于文章元数据(postmeta)中,也可以来自插件新建的自定义文章类型(CPT)。
导出过程与回调接口
导出使用用户的电子邮件地址作为检索依据。这样既能处理已经注册的用户,也能处理没有登录或没有账号的用户,例如以访客身份发表评论的人。
整理导出文件可能耗费资源,而且其中通常包含敏感数据,因此后台不会仅凭请求就直接生成文件并发给申请人。管理员先在界面中输入申请人的用户名或邮箱,系统再发送一个供申请人确认请求的链接。
确认请求之后,管理员可以生成并下载导出的 ZIP,也可以把它直接发送给用户;原手册同时说明,管理员在必要时仍可以执行导出。用户收到的 ZIP 包含一个小型网页,其中的 HTML 索引页按组整理个人数据,例如评论组。
无论管理员选择下载还是发送文件,整理导出的机制都是相同的:导出器回调负责收集数据。点击相应链接后,后台通过 AJAX 循环依次调用系统中注册的导出器。插件除了使用核心已有的导出器,也能注册自己的回调。
回调接口接收目标邮箱和页码。页码从 1 开始,用于避免一次处理全部数据而造成超时。插件应限制每页处理的数据量,例如每次处理 100 篇文章或 200 条评论。这里讨论的是导出流程,不是删除数据。
回调返回当前邮箱、当前页的数据,并说明是否已经处理完毕。若尚未完成,系统会在另一次请求中把页码加 1,再次调用同一回调。
导出数据由一组项目组成。每个项目包括所属组的标识(例如 comments、posts、orders)、可选的已翻译组名称、项目标识(例如 comment-133),以及若干名称和值构成的数据对。如果一个值是媒体路径,导出的 HTML 索引中会加入指向该媒体文件的链接。
所有导出器完成后,WordPress 整理作为导出报告主体的 HTML 索引。同一个项目如果由 WordPress 核心或其他插件提供了补充数据,这些数据会合并展示。
原手册写明,导出文件在服务器上缓存 3 天后删除。这是手册中的默认行为描述;真实站点的版本、配置或扩展可能改变保存期限,应以目标环境为准。
原手册的评论坐标示例
插件可以注册多个导出器,但通常一个就够了。原手册用一个假想插件举例:它为评论者保存位置信息,通过 add_comment_meta 写入 latitude 与 longitude 两个元数据键。
首先编写接受邮箱与页码的导出器函数。以下两段保留手册原代码,用于核对接口;其中存在的问题在后面的补充部分列明,不能跳过审查直接部署。
/**
* Export user meta for a user using the supplied email.
*
* @param string $email_address email address to manipulate
* @param int $page pagination
*
* @return array
*/
function wporg_export_user_data_by_email( $email_address, $page = 1 ) {
$number = 500; // Limit us to avoid timing out
$page = (int) $page;
$export_items = array();
$comments = get_comments(
array(
'author_email' => $email_address,
'number' => $number,
'paged' => $page,
'order_by' => 'comment_ID',
'order' => 'ASC',
)
);
foreach ( (array) $comments as $comment ) {
$latitude = get_comment_meta( $comment->comment_ID, 'latitude', true );
$longitude = get_comment_meta( $comment->comment_ID, 'longitude', true );
// Only add location data to the export if it is not empty.
if ( ! empty( $latitude ) ) {
// Most item IDs should look like postType-postID. If you don't have a post, comment or other ID to work with,
// use a unique value to avoid having this item's export combined in the final report with other items
// of the same id.
$item_id = "comment-{$comment->comment_ID}";
// Core group IDs include 'comments', 'posts', etc. But you can add your own group IDs as needed
$group_id = 'comments';
// Optional group label. Core provides these for core groups. If you define your own group, the first
// exporter to include a label will be used as the group label in the final exported report.
$group_label = __( 'Comments', 'text-domain' );
// Plugins can add as many items in the item data array as they want.
$data = array(
array(
'name' => __( 'Commenter Latitude', 'text-domain' ),
'value' => $latitude,
),
array(
'name' => __( 'Commenter Longitude', 'text-domain' ),
'value' => $longitude,
),
);
$export_items[] = array(
'group_id' => $group_id,
'group_label' => $group_label,
'item_id' => $item_id,
'data' => $data,
);
}
}
// Tell core if we have more comments to work on still.
$done = count( $comments ) > $number;
return array(
'data' => $export_items,
'done' => $done,
);
}
接着通过 wp_privacy_personal_data_exporters 过滤器,把回调加入导出器数组。注册时提供一个便于调试的名称和回调;原手册说明,这个名称当时不向用户展示。
/**
* Registers all data exporters.
*
* @param array $exporters
*
* @return mixed
*/
function wporg_register_user_data_exporters( $exporters ) {
$exporters['my-plugin-slug'] = array(
'exporter_friendly_name' => __( 'Comment Location Plugin', 'text-domain' ),
'callback' => 'my_plugin_exporter',
);
return $exporters;
}
add_filter( 'wp_privacy_personal_data_exporters', 'wporg_register_user_data_exporters' );
回调与注册完成之后,插件就能向导出流程提供自己的数据。
补充:先识别原示例的四个问题
原查询使用 order_by,而 WP_Comment_Query 的排序参数是 orderby。原函数名为 wporg_export_user_data_by_email,注册却指向 my_plugin_exporter,二者没有对应。分页结束条件是 count( $comments ) > $number;查询已经把结果限制在 $number 以内时,这个条件无法按预期结束导出。
另外,! empty( $latitude ) 会跳过数值 0 或字符串 "0",而纬度 0 是合法坐标。判断字段是否存在与判断值是否非零是两件事。下面的独立教学实现改用两个虚构的评论附加字段,演示正确的回调名称、分页结束条件和零值处理;它没有把坐标示例悄悄换写成“逐字保留”的代码。
补充:为评论附加信息写一个教学导出器
假设一个学习反馈插件在评论元数据中保存跟进次数和联系偏好。导出器只读取 _demo_follow_up_count 与 _demo_contact_preference 这两个白名单键,不遍历整个元数据表。
把下面的文件放在隔离测试站点的插件目录,例如 wp-content/plugins/feedback-exporter-demo/feedback-exporter-demo.php。示例没有提供收集表单、公开下载接口或邮件功能;本文也没有在站点安装或运行它。
<?php
/**
* Plugin Name: Feedback Exporter Demo
* Description: Export two illustrative comment metadata fields.
* Version: 0.1.0
* Text Domain: feedback-exporter-demo
* License: GPL-2.0-or-later
*/
defined( 'ABSPATH' ) || exit;
function fedemo_format_export_value( $value ) {
if ( is_string( $value ) ) {
return $value;
}
$encoded = wp_json_encode( $value );
if ( false === $encoded ) {
return new WP_Error( 'fedemo_encoding', 'Unable to encode an export value.' );
}
return $encoded;
}
function fedemo_export_comment_details( $email_address, $page = 1 ) {
$email = is_email( $email_address );
$page = (int) $page;
if ( false === $email || $page < 1 ) {
return new WP_Error( 'fedemo_bad_request', 'Invalid email address or page.' );
}
$limit = 50;
$comments = get_comments(
array(
'author_email' => $email,
'status' => 'all',
'number' => $limit,
'paged' => $page,
'orderby' => 'comment_ID',
'order' => 'ASC',
)
);
$fields = array(
'_demo_follow_up_count' => __( 'Follow-up count', 'feedback-exporter-demo' ),
'_demo_contact_preference' => __( 'Contact preference', 'feedback-exporter-demo' ),
);
$items = array();
foreach ( $comments as $comment ) {
$data = array();
foreach ( $fields as $key => $label ) {
if ( ! metadata_exists( 'comment', $comment->comment_ID, $key ) ) {
continue;
}
$values = get_comment_meta( $comment->comment_ID, $key, false );
foreach ( $values as $position => $value ) {
$formatted = fedemo_format_export_value( $value );
if ( is_wp_error( $formatted ) ) {
return $formatted;
}
$data[] = array(
'name' => $label . ' (' . ( $position + 1 ) . ')',
'value' => $formatted,
);
}
}
if ( $data ) {
$items[] = array(
'group_id' => 'fedemo-comment-details',
'group_label' => __( 'Feedback details', 'feedback-exporter-demo' ),
'item_id' => 'fedemo-comment-' . $comment->comment_ID,
'data' => $data,
);
}
}
return array(
'data' => $items,
'done' => count( $comments ) < $limit,
);
}
function fedemo_register_exporter( $exporters ) {
$exporters['fedemo-comment-details'] = array(
'exporter_friendly_name' => __( 'Feedback details', 'feedback-exporter-demo' ),
'callback' => 'fedemo_export_comment_details',
);
return $exporters;
}
add_filter( 'wp_privacy_personal_data_exporters', 'fedemo_register_exporter' );
is_email() 验证输入邮箱,页码必须至少为 1。查询按评论 ID 升序分页,并限制为该邮箱对应的评论。两个导出字段属于虚构教学场景,不是原坐标示例的字段。
metadata_exists() 判断元数据记录是否存在,因此值为 0 或空字符串仍会被保留。get_comment_meta() 的第三个参数为 false,取得同一个键的全部值;名称中的序号区分重复记录。字符串直接输出,其他值通过 wp_json_encode() 转换;编码失败时返回错误,不把缺失的数据伪装成已成功导出。
组标识和项目标识包含插件前缀,避免与其他插件的项目意外合并。回调返回普通数据值,不自行拼接 HTML,也不绕过核心的请求确认与导出权限流程。
补充:明确分页为什么会结束
结束条件依据查询到的评论数,而不是最终导出的项目数。一页查到 50 条评论,即使这些评论没有白名单字段,也要继续下一页;不足 50 条才表明扫描已经到达末尾。
如果最后一页正好有 50 条,下一次查询将得到空数组,并返回 done = true。这允许多一次空查询,避免把仍有数据的情况误判为结束。
这是一种按页查询,不是事务快照。导出期间如果评论被添加或删除,记录可能在页之间移动;不能据此保证对持续变化的数据获得严格一致的快照。
补充:先用虚构数据验收
在隔离测试站点准备同一个虚构邮箱对应的评论:一个字段值为 0,一个为 1,一个没有白名单字段。再准备另一个虚构邮箱的数据,确认不会混入导出。为同一字段保存多个元数据记录时,核对它们均能进入报告。
分别准备 49、50、51 条目标邮箱评论,观察分页是否结束、是否漏项。通过 WordPress 正常的后台个人数据导出流程创建和确认测试请求,再生成结果;同时确认组名称与项目归属。上述步骤是验收方案,不是已经执行的测试结果。
插件只负责提供自己保存的字段,核心负责请求确认、权限控制、导出文件和报告的整理。实际插件还需按自身的数据结构明确导出范围;这份教学文件并未覆盖所有插件、所有字段或所有数据保存策略。
来源与许可
原文:Adding the Personal Data Exporter to Your Plugin,WordPress Plugin Handbook。首次发布于 2018 年 5 月 17 日,最后更新于 2022 年 11 月 17 日。本文完整汉化原文正文,并保留两个原代码块;所有“补充”章节及第三个代码块为独立编写的审查和教学内容,示例均未运行。
依据 WordPress 官方文档许可,包含 Developer Documentation 的正文、图片等一般材料采用 CC0,代码片段采用 GPLv2 或更高版本。本文保留来源,原代码与新增教学插件代码按 GPL-2.0-or-later 提供。
补充核对来源:导出器注册过滤器、核心导出处理函数、评论查询参数、元数据存在性、读取评论元数据、JSON 编码、邮箱验证。











暂无评论内容