106 lines
3.6 KiB
PHP
106 lines
3.6 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
use think\Env;
|
||
|
||
/**
|
||
* 退订工具:生成/校验签名 token,构造退订 URL(支持 expert / user 两类受众)。
|
||
*
|
||
* .env 配置([unsubscribe] 段):
|
||
* UNSUBSCRIBE_SECRET 用于签名的密钥(必须自行设置一个长随机字符串)
|
||
* UNSUBSCRIBE_BASE_URL 退订入口 URL,指向 Unsubscribe 控制器的 index 方法,例如:
|
||
* https://api.tmrjournals.com/api/Unsubscribe/index
|
||
*
|
||
* URL 格式:
|
||
* {UNSUBSCRIBE_BASE_URL}?k={kind}&id={audience_id}&t={sha256(kind|id|email_lower|secret)}
|
||
*
|
||
* kind:
|
||
* expert → 外部专家库 t_expert.expert_id
|
||
* user → 系统内部用户 t_user.user_id(编委 / 主编 / 青年编委等)
|
||
*
|
||
* 设计要点:
|
||
* - 不依赖 session:纯 HMAC 风格签名,token 永久有效(直到密钥更换或邮箱变更)。
|
||
* - 收到请求后服务端按 kind+id 查邮箱,重新计算签名比对,hash_equals 防时序攻击。
|
||
* - 邮箱大小写敏感性:统一 strtolower 后再签名/校验。
|
||
*/
|
||
class UnsubscribeService
|
||
{
|
||
const KIND_EXPERT = 'expert';
|
||
const KIND_USER = 'user';
|
||
|
||
/**
|
||
* 读取签名密钥;未配置时使用一个固定默认值(仅供本地测试,生产必须覆盖)。
|
||
*/
|
||
public static function getSecret()
|
||
{
|
||
$secret = trim((string)Env::get('unsubscribe.unsubscribe_secret', ''));
|
||
if ($secret === '') {
|
||
$secret = 'tmrjournals-default-unsubscribe-secret-change-me';
|
||
}
|
||
return $secret;
|
||
}
|
||
|
||
/**
|
||
* 读取退订入口 URL(指向 Unsubscribe/index);未配置返回空串。
|
||
*/
|
||
public static function getBaseUrl()
|
||
{
|
||
return rtrim(trim((string)Env::get('unsubscribe.unsubscribe_base_url', '')), '/');
|
||
}
|
||
|
||
/**
|
||
* 规范化 kind;未知值降级为 expert(保持向后兼容)。
|
||
*/
|
||
public static function normalizeKind($kind)
|
||
{
|
||
$kind = strtolower(trim((string)$kind));
|
||
return $kind === self::KIND_USER ? self::KIND_USER : self::KIND_EXPERT;
|
||
}
|
||
|
||
/**
|
||
* 生成签名 token。
|
||
*/
|
||
public static function buildToken($kind, $audienceId, $email)
|
||
{
|
||
$kind = self::normalizeKind($kind);
|
||
$audienceId = intval($audienceId);
|
||
$email = strtolower(trim((string)$email));
|
||
return hash('sha256', $kind . '|' . $audienceId . '|' . $email . '|' . self::getSecret());
|
||
}
|
||
|
||
/**
|
||
* 校验签名 token;恒定时间比较,防时序攻击。
|
||
*/
|
||
public static function verifyToken($kind, $audienceId, $email, $token)
|
||
{
|
||
$kind = self::normalizeKind($kind);
|
||
$audienceId = intval($audienceId);
|
||
$email = strtolower(trim((string)$email));
|
||
$token = strtolower(trim((string)$token));
|
||
if ($audienceId <= 0 || $email === '' || $token === '') {
|
||
return false;
|
||
}
|
||
$expected = self::buildToken($kind, $audienceId, $email);
|
||
return hash_equals($expected, $token);
|
||
}
|
||
|
||
/**
|
||
* 构造完整退订 URL(用于邮件模板变量 {{unsubscribe_url}})。
|
||
* BASE_URL 未配置或参数无效时返回空串。
|
||
*/
|
||
public static function buildUrl($kind, $audienceId, $email)
|
||
{
|
||
$kind = self::normalizeKind($kind);
|
||
$audienceId = intval($audienceId);
|
||
$email = trim((string)$email);
|
||
if ($audienceId <= 0 || $email === '') return '';
|
||
$base = self::getBaseUrl();
|
||
if ($base === '') return '';
|
||
return $base
|
||
. '?k=' . $kind
|
||
. '&id=' . $audienceId
|
||
. '&t=' . self::buildToken($kind, $audienceId, $email);
|
||
}
|
||
}
|