字段解密授权态 + 统一脱敏渲染 + 按作用域控制(PHP版)

设计思路

核心上不要做成“前端自己解密”,而应由服务端统一判断字段权限并返回结果,因为列级权限/数据脱敏本质上应在服务端的数据集或字段访问层生效,这样更安全,也便于审计。[help.aliyun]

同时把你的两种场景拆成两类作用域:列表页用 scope = column,详情页用 scope = field,前者一次放开整列 A,后者只放开某个详情字段如 email,两者共用一套授权记录表和渲染规则即可。[cnblogs]

接口模型

建议至少有 4 类接口:列表查询、详情查询、隐私授权开关、隐私状态查询;其中“开关接口”只负责写入当前用户对某页面某字段的可见授权,不直接返回业务数据,这样就符合你说的“点眼睛后再刷新列表/详情才看到明文”的交互。[cnblogs]

接口方法用途
/api/customer/listGET返回列表数据,A 列按当前授权态决定脱敏或明文。
/api/customer/detailGET返回详情数据,email 等字段按字段级授权态决定脱敏或明文。
/api/privacy/grantPOST点击小眼睛后写入授权,如解锁某列表列或某详情字段。
/api/privacy/revokePOST再次点击小眼睛时取消授权,恢复脱敏显示。

推荐 grant 入参统一如下:

{
  "bizType": "customer",
  "pageCode": "customer_list",
  "scope": "column",
  "targetKey": "A",
  "recordId": null,
  "ttl": 300
}

详情页字段解锁则是:

{
  "bizType": "customer",
  "pageCode": "customer_detail",
  "scope": "field",
  "targetKey": "email",
  "recordId": 10001,
  "ttl": 300
}

这里的差异点是:列表列级通常 recordId 为空,表示对当前页面查询结果中的该列整体放开;详情字段级通常要绑定具体记录 recordId,避免用户先解锁一个详情字段,再串到别的记录上也能看明文。[help.aliyun]

数据表设计

建议至少有三张表:字段配置表、隐私授权表、审计日志表;这种做法接近“列级权限 + 脱敏规则”的通用实现,能把字段规则和用户临时可见状态解耦。[cloud.tencent]

1)字段隐私配置表 privacy_field_config

CREATE TABLE privacy_field_config (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_type VARCHAR(50) NOT NULL,
  page_code VARCHAR(50) NOT NULL,
  field_key VARCHAR(50) NOT NULL,
  field_name VARCHAR(100) NOT NULL,
  scope ENUM('column','field') NOT NULL,
  is_sensitive TINYINT NOT NULL DEFAULT 1,
  mask_type VARCHAR(30) NOT NULL,
  mask_rule JSON NULL,
  grant_mode ENUM('manual_click','role_direct') NOT NULL DEFAULT 'manual_click',
  status TINYINT NOT NULL DEFAULT 1,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_page_field (biz_type, page_code, field_key)
);

示例数据:

  • 列表页 customer_listA 字段:scope=column
  • 详情页 customer_detailemail 字段:scope=field

    这类字段可以配置成“默认脱敏,点击后放开”,也可以对部分角色配置成默认明文。[cnblogs]

2)用户隐私授权表 privacy_grant

CREATE TABLE privacy_grant (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  biz_type VARCHAR(50) NOT NULL,
  page_code VARCHAR(50) NOT NULL,
  scope ENUM('column','field') NOT NULL,
  target_key VARCHAR(50) NOT NULL,
  record_id BIGINT NULL,
  granted TINYINT NOT NULL DEFAULT 1,
  expire_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_grant (user_id, biz_type, page_code, scope, target_key, record_id)
);

这张表就是“小眼睛开关状态”。建议加 expire_at,比如 5 分钟或 30 分钟后自动失效,减少敏感数据长时间暴露的风险;很多数据脱敏实践都强调应结合访问规则而不是永久展示。[cnblogs]

3)访问审计表 privacy_audit_log

CREATE TABLE privacy_audit_log (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  biz_type VARCHAR(50) NOT NULL,
  page_code VARCHAR(50) NOT NULL,
  scope ENUM('column','field') NOT NULL,
  target_key VARCHAR(50) NOT NULL,
  record_id BIGINT NULL,
  action VARCHAR(30) NOT NULL,
  client_ip VARCHAR(64) NULL,
  user_agent VARCHAR(255) NULL,
  remark VARCHAR(255) NULL,
  created_at DATETIME NOT NULL
);

敏感字段查看最好留痕,尤其是邮箱、手机号、身份证这类字段;这样后续能追查谁在什么时候打开过明文显示。[blog.csdn]

服务端处理流程

后端不要在 Controller 里到处手写 if/else,建议统一成“查询原始数据 -> 走字段渲染器 -> 输出 DTO”。这种统一脱敏出口更适合后续扩展更多字段。[cloud.tencent]

列表页流程

  1. 查询列表原始数据。
  2. 读取当前页的隐私字段配置。
  3. 判断当前用户对 customer_list.A 是否已有 scope=column 的有效授权。
  4. 如果有授权,整列 A 返回明文;否则整列 A 统一返回脱敏值。
  5. 返回时额外带上字段可见状态,前端决定小眼睛是闭眼还是开眼。[cnblogs]

返回示例:

{
  "list": [
    { "id": 1, "name": "张三", "A": "138****5678" },
    { "id": 2, "name": "李四", "A": "139****4321" }
  ],
  "privacyMeta": {
    "columns": {
      "A": {
        "granted": false,
        "scope": "column",
        "canToggle": true
      }
    }
  }
}

点击列表头小眼睛后:

textPOST /api/privacy/grant

{
  "bizType": "customer",
  "pageCode": "customer_list",
  "scope": "column",
  "targetKey": "A",
  "ttl": 300
}

成功后前端重新调 /api/customer/list,此时 A 整列显示明文。[cnblogs]

详情页流程

  1. 查询详情原始记录。
  2. 读取详情页隐私字段配置。
  3. 判断当前用户对 customer_detail.email + recordId 是否已有 scope=field 的有效授权。
  4. 有授权则 email 返回明文,否则返回脱敏值。
  5. 返回 privacyMeta.fields.email.granted 供前端决定图标状态。[cnblogs]

返回示例:

{
  "id": 10001,
  "name": "张三",
  "email": "zh***@example.com",
  "mobile": "138****5678",
  "privacyMeta": {
    "fields": {
      "email": {
        "granted": false,
        "scope": "field",
        "canToggle": true,
        "recordId": 10001
      }
    }
  }
}

PHP 分层建议

比较稳妥的写法是 5 层:Controller、Application Service、Repository、PrivacyGrantService、MaskingService;数据脱敏应做成独立服务,便于列表和详情共用。[cnblogs]

1)脱敏服务

class MaskingService
{
    public function mask(string $type, ?string $value, array $rule = []): ?string
    {
        if ($value === null || $value === '') {
            return $value;
        }

        return match ($type) {
            'mobile' => substr($value, 0, 3) . '****' . substr($value, -4),
            'email'  => $this->maskEmail($value),
            'full'   => '******',
            'custom' => $this->maskCustom($value, $rule),
            default  => '******',
        };
    }

    private function maskEmail(string $email): string
    {
        if (!str_contains($email, '@')) {
            return '******';
        }

        [$name, $domain] = explode('@', $email, 2);
        $prefix = mb_substr($name, 0, 2);
        return $prefix . '***@' . $domain;
    }

    private function maskCustom(string $value, array $rule): string
    {
        $prefix = $rule['prefix'] ?? 0;
        $suffix = $rule['suffix'] ?? 0;
        $len = mb_strlen($value);
        if ($prefix + $suffix >= $len) {
            return str_repeat('*', $len);
        }
        return mb_substr($value, 0, $prefix)
            . str_repeat('*', $len - $prefix - $suffix)
            . mb_substr($value, $len - $suffix);
    }
}

邮箱、手机号做局部脱敏是常见方案,而不是一定返回全星号;列级权限文档里也常见“保留前后字符”“自定义脱敏规则”的做法。[cnblogs]

2)授权判断服务

class PrivacyGrantService
{
    public function hasGrant(
        int $userId,
        string $bizType,
        string $pageCode,
        string $scope,
        string $targetKey,
        ?int $recordId = null
    ): bool {
        // 查询 privacy_grant
        // 条件:user_id + biz_type + page_code + scope + target_key + record_id
        // granted = 1 且 (expire_at is null or expire_at > now)
        return true;
    }

    public function grant(
        int $userId,
        string $bizType,
        string $pageCode,
        string $scope,
        string $targetKey,
        ?int $recordId,
        int $ttl = 300
    ): void {
        // upsert 到 privacy_grant
        // expire_at = now + ttl
        // 写 privacy_audit_log
    }

    public function revoke(
        int $userId,
        string $bizType,
        string $pageCode,
        string $scope,
        string $targetKey,
        ?int $recordId
    ): void {
        // granted = 0 或直接删除
        // 写 privacy_audit_log
    }
}

3)统一字段渲染器

class PrivacyRenderService
{
    public function __construct(
        private PrivacyGrantService $grantService,
        private MaskingService $maskingService
    ) {}

    public function renderList(array $rows, array $fieldConfigs, int $userId): array
    {
        $privacyMeta = ['columns' => []];

        foreach ($fieldConfigs as $config) {
            if ($config['scope'] !== 'column') {
                continue;
            }

            $granted = $this->grantService->hasGrant(
                $userId,
                $config['biz_type'],
                $config['page_code'],
                'column',
                $config['field_key'],
                null
            );

            $privacyMeta['columns'][$config['field_key']] = [
                'granted' => $granted,
                'scope' => 'column',
                'canToggle' => true,
            ];

            if (!$granted) {
                foreach ($rows as &$row) {
                    if (array_key_exists($config['field_key'], $row)) {
                        $row[$config['field_key']] = $this->maskingService->mask(
                            $config['mask_type'],
                            (string)$row[$config['field_key']],
                            $config['mask_rule'] ?? []
                        );
                    }
                }
            }
        }

        return ['list' => $rows, 'privacyMeta' => $privacyMeta];
    }

    public function renderDetail(array $data, array $fieldConfigs, int $userId, int $recordId): array
    {
        $privacyMeta = ['fields' => []];

        foreach ($fieldConfigs as $config) {
            if ($config['scope'] !== 'field') {
                continue;
            }

            $granted = $this->grantService->hasGrant(
                $userId,
                $config['biz_type'],
                $config['page_code'],
                'field',
                $config['field_key'],
                $recordId
            );

            $privacyMeta['fields'][$config['field_key']] = [
                'granted' => $granted,
                'scope' => 'field',
                'canToggle' => true,
                'recordId' => $recordId,
            ];

            if (!$granted && array_key_exists($config['field_key'], $data)) {
                $data[$config['field_key']] = $this->maskingService->mask(
                    $config['mask_type'],
                    (string)$data[$config['field_key']],
                    $config['mask_rule'] ?? []
                );
            }
        }

        $data['privacyMeta'] = $privacyMeta;
        return $data;
    }
}

这个设计的重点是:列表按列批量处理,详情按单字段处理,但代码只在“是否带 recordId”和 scope 上有差异,整体模型一致。[cnblogs]

接口示例

1)列表接口

class CustomerController
{
    public function list()
    {
        $userId = auth()->id();
        $rows = $this->customerRepo->getList($_GET);
        $fieldConfigs = $this->fieldConfigRepo->getByPage('customer', 'customer_list');

        $result = $this->privacyRenderService->renderList($rows, $fieldConfigs, $userId);

        return $this->jsonSuccess($result);
    }
}

2)详情接口

public function detail()
{
    $userId = auth()->id();
    $id = (int)$_GET['id'];

    $data = $this->customerRepo->getDetail($id);
    $fieldConfigs = $this->fieldConfigRepo->getByPage('customer', 'customer_detail');

    $result = $this->privacyRenderService->renderDetail($data, $fieldConfigs, $userId, $id);

    return $this->jsonSuccess($result);
}

3)授权开关接口

class PrivacyController
{
    public function grant()
    {
        $userId = auth()->id();
        $body = json_decode(file_get_contents('php://input'), true);

        $this->validateGrantRequest($body);

        $this->privacyGrantService->grant(
            $userId,
            $body['bizType'],
            $body['pageCode'],
            $body['scope'],
            $body['targetKey'],
            $body['recordId'] ?? null,
            $body['ttl'] ?? 300
        );

        return $this->jsonSuccess(['granted' => true]);
    }

    public function revoke()
    {
        $userId = auth()->id();
        $body = json_decode(file_get_contents('php://input'), true);

        $this->privacyGrantService->revoke(
            $userId,
            $body['bizType'],
            $body['pageCode'],
            $body['scope'],
            $body['targetKey'],
            $body['recordId'] ?? null
        );

        return $this->jsonSuccess(['granted' => false]);
    }
}

前后端协作规则

前端只负责展示和触发,不负责判断是否可看明文;因为真正的列级/字段级权限应该由后端根据授权对象和规则生效。[cnblogs]

建议前端遵循下面规则:

  • 列表头小眼睛点击 -> POST /api/privacy/grant,参数 scope=column,targetKey=A
  • 成功后刷新列表 -> /api/customer/list
  • 详情字段旁小眼睛点击 -> POST /api/privacy/grant,参数 scope=field,targetKey=email,recordId=10001
  • 成功后刷新详情 -> /api/customer/detail?id=10001
  • 页面根据 privacyMeta 决定图标开闭状态,不靠本地缓存硬编码。

这样即便用户刷新页面、切分页、切路由,状态也能以服务端授权记录为准。[cnblogs]

安全细节

有几点建议最好一开始就加上:

  • 不要把明文和脱敏字段同时返回,比如不要返回 email_maskedemail_raw 给前端二选一,否则前端其实已经拿到明文了。[blog.csdn]
  • grant 接口除了登录校验,还应校验用户是否本来就有“查看敏感字段”的基础资格,比如角色、部门、数据范围,不应任何登录用户点一下就能看。[cnblogs]
  • 详情字段建议绑定 recordId,避免字段授权被横向复用到别的记录。[cnblogs]
  • 建议设置短期 TTL、二次验证或操作审计,尤其是邮箱、手机、身份证、银行卡等字段。[blog.csdn]
  • 如果安全要求更高,小眼睛点击前可增加短信验证码、密码确认或一次性口令;数据脱敏实践里常见“本人验证后再看明文”的模式。[cnblogs]

推荐落地方案

最适合你的不是“给每个页面单独写一套逻辑”,而是做成一个通用框架:

  • privacy_field_config 负责定义哪些字段敏感、按列还是按字段控制。[cnblogs]
  • privacy_grant 负责记录某用户当前是否已点开小眼睛。[cnblogs]
  • PrivacyRenderService 负责统一脱敏输出。[cnblogs]
  • 列表页和详情页都只调用各自查询接口,永远不直接请求“解密后的业务字段”。

    这样后续你再加手机号、身份证、地址、银行卡,只是加配置,不需要重新改一堆业务代码。[help.aliyun]

如果你要,我下一步可以直接给你一版可落地的 PHP 代码骨架,我可以按这 3 种风格任选一种继续写:

  1. 原生 PHP + PDO,
  2. Laravel 版本,
  3. ThinkPHP 版本。