为 WordPress 插件接入个人数据导出

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 编码、邮箱验证。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容