Files
anxinyan/server-api/app/controller/admin/SystemConfigsController.php
2026-06-05 16:12:56 +08:00

788 lines
39 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\controller\admin;
use app\support\FileStorageConfigService;
use support\Request;
use support\think\Db;
class SystemConfigsController
{
private const H5_OAUTH_REDIRECT_HASH_PATH = '/#/pages/auth/login';
private const SHOUQIANBA_NOTIFY_PATH = '/api/open/shouqianba/payment/notify';
public function index(Request $request)
{
$this->bootstrapDefaults();
$configs = Db::name('system_configs')
->whereIn('config_group', array_keys($this->definitions()))
->order('config_group', 'asc')
->order('config_key', 'asc')
->select()
->toArray();
$configMap = [];
foreach ($configs as $item) {
$configMap[$item['config_group'] . '.' . $item['config_key']] = $item['config_value'] ?? '';
}
$this->applyDerivedConfigValues($configMap);
$groups = [];
foreach ($this->definitions() as $groupCode => $group) {
$groups[] = [
'group_code' => $groupCode,
'group_name' => $group['group_name'],
'group_desc' => $group['group_desc'],
'items' => array_map(function (array $item) use ($groupCode, $configMap) {
return [
'config_key' => $item['config_key'],
'title' => $item['title'],
'field_type' => $item['field_type'],
'placeholder' => $item['placeholder'],
'remark' => $item['remark'],
'is_secret' => (bool)$item['is_secret'],
'read_only' => (bool)($item['read_only'] ?? false),
'options' => $item['options'] ?? [],
'visible_when' => $item['visible_when'] ?? null,
'value' => $configMap[$groupCode . '.' . $item['config_key']] ?? '',
];
}, $group['items']),
];
}
return api_success(['groups' => $groups]);
}
public function save(Request $request)
{
$this->bootstrapDefaults();
$items = $request->input('items', []);
if (!is_array($items) || !$items) {
return api_error('配置项不能为空', 422);
}
$definitions = $this->definitions();
$allowedMap = [];
foreach ($definitions as $groupCode => $group) {
foreach ($group['items'] as $item) {
$allowedMap[$groupCode . '.' . $item['config_key']] = true;
}
}
$configValueMap = [];
foreach ($definitions as $groupCode => $group) {
foreach ($group['items'] as $item) {
$configValueMap[$groupCode . '.' . $item['config_key']] = (string)Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $item['config_key'])
->value('config_value');
}
}
$submittedConfigKeys = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$groupCode = trim((string)($item['config_group'] ?? ''));
$configKey = trim((string)($item['config_key'] ?? ''));
$mapKey = $groupCode . '.' . $configKey;
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
continue;
}
$configValueMap[$mapKey] = (string)($item['config_value'] ?? '');
$submittedConfigKeys[$mapKey] = [
'config_group' => $groupCode,
'config_key' => $configKey,
];
}
$this->applyDerivedConfigValues($configValueMap);
if (isset($submittedConfigKeys['h5.page_base_url']) || isset($submittedConfigKeys['h5.oauth_redirect_url'])) {
$submittedConfigKeys['h5.oauth_redirect_url'] = [
'config_group' => 'h5',
'config_key' => 'oauth_redirect_url',
];
}
try {
$this->validateConfigValues($configValueMap);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
foreach ($submittedConfigKeys as $mapKey => $configMeta) {
$groupCode = $configMeta['config_group'];
$configKey = $configMeta['config_key'];
$configValue = (string)($configValueMap[$mapKey] ?? '');
$mapKey = $groupCode . '.' . $configKey;
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
continue;
}
$exists = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
$payload = [
'config_group' => $groupCode,
'config_key' => $configKey,
'config_value' => $configValue,
'remark' => '后台系统配置',
'updated_at' => $now,
];
if ($exists) {
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
} else {
$payload['created_at'] = $now;
Db::name('system_configs')->insert($payload);
}
}
Db::commit();
(new FileStorageConfigService())->clearCache();
} catch (\Throwable $e) {
Db::rollback();
return api_error('系统配置保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([], '系统配置已保存');
}
public function uploadFile(Request $request)
{
$groupCode = trim((string)$request->input('config_group', ''));
$configKey = trim((string)$request->input('config_key', ''));
if ($groupCode === '' || $configKey === '') {
return api_error('配置分组和配置项不能为空', 422);
}
$allowed = $this->uploadableConfigMap();
$mapKey = $groupCode . '.' . $configKey;
if (!isset($allowed[$mapKey])) {
return api_error('当前配置项不支持文件上传', 422);
}
$file = $request->file('file');
if (!$file || !$file->isValid()) {
return api_error('上传文件无效', 422);
}
$originalName = (string)$file->getUploadName();
$extension = strtolower((string)$file->getUploadExtension());
if ($extension !== 'pem') {
return api_error('仅支持上传 .pem 文件', 422);
}
$content = file_get_contents($file->getRealPath());
if (!is_string($content) || !str_contains($content, '-----BEGIN')) {
return api_error('PEM 文件内容格式不正确', 422);
}
$storageDir = base_path() . '/storage/payment-certs';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0775, true);
}
$targetFilename = $allowed[$mapKey]['filename'];
$targetPath = $storageDir . '/' . $targetFilename;
file_put_contents($targetPath, $content);
@chmod($targetPath, 0600);
$now = date('Y-m-d H:i:s');
$exists = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
$payload = [
'config_group' => $groupCode,
'config_key' => $configKey,
'config_value' => $targetPath,
'remark' => '后台系统配置',
'updated_at' => $now,
];
if ($exists) {
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
} else {
$payload['created_at'] = $now;
Db::name('system_configs')->insert($payload);
}
return api_success([
'config_group' => $groupCode,
'config_key' => $configKey,
'config_value' => $targetPath,
'file_name' => $targetFilename,
'original_name' => $originalName,
], '文件已上传');
}
private function bootstrapDefaults(): void
{
$now = date('Y-m-d H:i:s');
foreach ($this->definitions() as $groupCode => $group) {
foreach ($group['items'] as $item) {
$exists = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $item['config_key'])
->find();
if ($exists) {
continue;
}
Db::name('system_configs')->insert([
'config_group' => $groupCode,
'config_key' => $item['config_key'],
'config_value' => (string)($item['default_value'] ?? ''),
'remark' => '后台系统配置',
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
private function definitions(): array
{
return [
'file_storage' => [
'group_name' => '文件存储',
'group_desc' => '配置业务文件存储方式。支持本地磁盘或阿里云 OSS切换为 OSS 后需填写对应 Bucket 与密钥资料。',
'items' => [
[
'config_key' => 'driver',
'title' => '存储驱动',
'field_type' => 'select',
'placeholder' => '请选择文件存储方式',
'remark' => '本地模式写入服务器 public/uploadsOSS 模式写入阿里云对象存储。',
'is_secret' => false,
'default_value' => 'local',
'options' => [
['label' => '本地存储', 'value' => 'local'],
['label' => '阿里云 OSS', 'value' => 'oss'],
['label' => '七牛云 Kodo', 'value' => 'qiniu'],
],
],
[
'config_key' => 'public_base_url',
'title' => '公开访问域名',
'field_type' => 'text',
'placeholder' => '例如 https://api.anxinjianyan.com 或 https://static.example.com',
'remark' => '用于生成文件公网访问地址;本地可填 API 域名OSS 可填自定义 CDN/回源域名,不填则按驱动自动推导。',
'is_secret' => false,
],
[
'config_key' => 'oss_endpoint',
'title' => 'OSS Endpoint',
'field_type' => 'text',
'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com',
'remark' => '后台服务端 SDK 使用的 Endpoint。可填公网 Endpoint如服务器在同地域内网也可填内网 Endpoint。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_upload_endpoint',
'title' => 'OSS 直传 Endpoint',
'field_type' => 'text',
'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com',
'remark' => '前端直传 OSS 使用的公网 Endpoint。为空时沿用 OSS Endpoint如 OSS Endpoint 填了内网地址,这里必须填写公网地址。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_bucket',
'title' => 'OSS Bucket',
'field_type' => 'text',
'placeholder' => '请输入 Bucket 名称',
'remark' => '将作为所有业务文件的目标 Bucket。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_access_key_id',
'title' => 'OSS AccessKey ID',
'field_type' => 'text',
'placeholder' => '请输入 OSS AccessKey ID',
'remark' => '用于 OSS 文件上传、删除和存在性校验。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_access_key_secret',
'title' => 'OSS AccessKey Secret',
'field_type' => 'password',
'placeholder' => '请输入 OSS AccessKey Secret',
'remark' => '请妥善保管,仅后台可见。',
'is_secret' => true,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_bucket_domain',
'title' => 'OSS 绑定域名',
'field_type' => 'text',
'placeholder' => '例如 https://static.anxinjianyan.com',
'remark' => '如 Bucket 已绑定自定义域名,可填写;不填则默认使用 https://bucket.endpoint。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_path_prefix',
'title' => 'OSS 路径前缀',
'field_type' => 'text',
'placeholder' => '例如 anxinyan-prod',
'remark' => '可选。填写后 OSS 对象会统一写入此前缀目录下。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'direct_upload_max_size_mb',
'title' => '直传文件大小上限 MB',
'field_type' => 'text',
'placeholder' => '默认 200',
'remark' => '前端直传 OSS 的单文件最大大小,单位 MB。建议按业务网络环境设置允许范围 1-2048。',
'is_secret' => false,
'default_value' => '200',
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'qiniu_bucket',
'title' => '七牛 Bucket',
'field_type' => 'text',
'placeholder' => '请输入七牛 Kodo Bucket 名称',
'remark' => '将作为七牛云对象存储的目标 Bucket。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_access_key',
'title' => '七牛 AccessKey',
'field_type' => 'text',
'placeholder' => '请输入七牛 AccessKey',
'remark' => '用于七牛文件上传、删除和存在性校验。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_secret_key',
'title' => '七牛 SecretKey',
'field_type' => 'password',
'placeholder' => '请输入七牛 SecretKey',
'remark' => '请妥善保管,仅后台可见。',
'is_secret' => true,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_bucket_domain',
'title' => '七牛公网访问域名',
'field_type' => 'text',
'placeholder' => '例如 https://static.example.com 或 https://xxx.clouddn.com',
'remark' => '用于生成七牛文件公网访问地址。建议填写已绑定并可公开访问的域名。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_path_prefix',
'title' => '七牛路径前缀',
'field_type' => 'text',
'placeholder' => '例如 anxinyan-prod',
'remark' => '可选。填写后七牛对象会统一写入此前缀目录下。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
],
],
'mini_program' => [
'group_name' => '小程序配置',
'group_desc' => '配置微信小程序 AppID、密钥及消息通知相关参数。',
'items' => [
['config_key' => 'app_id', 'title' => '小程序 AppID', 'field_type' => 'text', 'placeholder' => '请输入小程序 AppID', 'remark' => '用于小程序登录、消息与支付能力接入', 'is_secret' => false],
['config_key' => 'app_secret', 'title' => '小程序 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入小程序 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
['config_key' => 'original_id', 'title' => '原始 ID', 'field_type' => 'text', 'placeholder' => '请输入原始 ID', 'remark' => '用于公众号/小程序主体识别', 'is_secret' => false],
],
],
'h5' => [
'group_name' => 'H5 配置',
'group_desc' => '配置 H5 接入、开放平台、回调地址以及公开页面域名。',
'items' => [
['config_key' => 'app_id', 'title' => 'H5 AppID', 'field_type' => 'text', 'placeholder' => '请输入 H5 AppID', 'remark' => '用于 H5 登录与开放平台接入', 'is_secret' => false],
['config_key' => 'app_secret', 'title' => 'H5 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入 H5 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '保存 H5 页面根地址后自动生成', 'remark' => '由 H5 页面根地址自动拼接,无需手动填写。', 'is_secret' => false, 'read_only' => true],
['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinjianyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false],
],
],
'payment' => [
'group_name' => '收钱吧支付',
'group_desc' => '配置收钱吧轻 POS 远程收款和小程序收银插件参数,用于用户端 H5 与微信小程序下单付款。',
'items' => [
[
'config_key' => 'enabled',
'title' => '收钱吧支付开关',
'field_type' => 'select',
'placeholder' => '请选择是否启用',
'remark' => '参数未齐时请先保持停用保存草稿;启用后会校验全部必填参数并正式接管用户端支付。',
'is_secret' => false,
'default_value' => 'disabled',
'options' => [
['label' => '停用', 'value' => 'disabled'],
['label' => '启用', 'value' => 'enabled'],
],
],
['config_key' => 'api_domain', 'title' => 'API 域名', 'field_type' => 'text', 'placeholder' => '例如 https://xxx.shouqianba.com', 'remark' => '收钱吧提供的接口域名,不需要填写末尾斜杠。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'appid', 'title' => '收钱吧 AppID', 'field_type' => 'text', 'placeholder' => '请输入收钱吧 AppID', 'remark' => '请求头 head.appid 使用的应用编号。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'brand_code', 'title' => '品牌编号 brand_code', 'field_type' => 'text', 'placeholder' => '请输入收钱吧分配的品牌编号', 'remark' => '收钱吧系统对接前分配并提供。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'store_sn', 'title' => '门店编号 store_sn', 'field_type' => 'text', 'placeholder' => '请输入门店编号', 'remark' => '商户内部使用的门店编号。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'order_expire_minutes', 'title' => '订单有效期(分钟)', 'field_type' => 'text', 'placeholder' => '默认 1440', 'remark' => '收钱吧订单有效时间,允许 1-43200 分钟。', 'is_secret' => false, 'default_value' => '1440', 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'merchant_private_key', 'title' => '商户 RSA 私钥', 'field_type' => 'textarea', 'placeholder' => '可填 PEM 内容或服务器可读取的 PEM 文件路径', 'remark' => '我们自己生成的私钥,用于请求签名和通知响应签名,请勿提供给收钱吧。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'shouqianba_public_key', 'title' => '收钱吧 RSA 公钥', 'field_type' => 'textarea', 'placeholder' => '请输入收钱吧提供的 RSA 公钥 PEM 内容', 'remark' => '这是收钱吧回传给我们的公钥,不是我们生成并提交给收钱吧的商户公钥。用于验签接口返回和支付通知。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'notify_url', 'title' => '支付通知地址', 'field_type' => 'text', 'placeholder' => '由 API 公开域名自动生成', 'remark' => '由后端 API 公开域名自动拼接生成,仅展示无需手动填写。', 'is_secret' => false, 'read_only' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'mini_program_plugin_version', 'title' => '小程序插件版本号', 'field_type' => 'text', 'placeholder' => '例如 2.3.xx', 'remark' => '构建微信小程序前同步到 manifest用于声明收钱吧轻 POS 插件。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
],
],
'sms' => [
'group_name' => '短信配置',
'group_desc' => '配置阿里云短信服务 AccessKey、签名和登录验证码模板用于手机号验证码登录。',
'items' => [
['config_key' => 'access_key_id', 'title' => 'AccessKey ID', 'field_type' => 'text', 'placeholder' => '请输入阿里云 AccessKey ID', 'remark' => '用于调用阿里云短信 SendSms 接口', 'is_secret' => false],
['config_key' => 'access_key_secret', 'title' => 'AccessKey Secret', 'field_type' => 'password', 'placeholder' => '请输入阿里云 AccessKey Secret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
['config_key' => 'sign_name', 'title' => '短信签名', 'field_type' => 'text', 'placeholder' => '请输入短信签名', 'remark' => '需与阿里云短信服务已审核通过的签名一致', 'is_secret' => false],
['config_key' => 'login_template_code', 'title' => '登录模板 Code', 'field_type' => 'text', 'placeholder' => '例如 SMS_123456789', 'remark' => '模板中需包含 code 变量', 'is_secret' => false],
['config_key' => 'region_id', 'title' => 'Region ID', 'field_type' => 'text', 'placeholder' => '默认 cn-hangzhou', 'remark' => '通常填写 cn-hangzhou', 'is_secret' => false],
['config_key' => 'endpoint', 'title' => '短信 Endpoint', 'field_type' => 'text', 'placeholder' => '默认可留空', 'remark' => '如不填写则按 SDK 默认规则解析', 'is_secret' => false],
],
],
'kuaidi100' => [
'group_name' => '快递100',
'group_desc' => '配置快递100实时查询与物流订阅推送用于订单寄送和回寄物流轨迹同步。',
'items' => [
[
'config_key' => 'enabled',
'title' => '同步开关',
'field_type' => 'select',
'placeholder' => '请选择是否启用',
'remark' => '启用后新提交的运单会尝试订阅快递100推送后台进程会定时补查轨迹。',
'is_secret' => false,
'default_value' => 'disabled',
'options' => [
['label' => '停用', 'value' => 'disabled'],
['label' => '启用', 'value' => 'enabled'],
],
],
['config_key' => 'customer', 'title' => 'Customer', 'field_type' => 'text', 'placeholder' => '请输入快递100 Customer', 'remark' => '实时查询接口签名使用的 Customer。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'key', 'title' => 'Key', 'field_type' => 'password', 'placeholder' => '请输入快递100 Key', 'remark' => '用于实时查询签名和订阅推送。请妥善保管。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'callback_url', 'title' => '推送回调地址', 'field_type' => 'text', 'placeholder' => '例如 https://api.example.com/api/open/kuaidi100/callback', 'remark' => '需公网可访问;生产建议填本系统 /api/open/kuaidi100/callback 的完整地址。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'callback_salt', 'title' => '回调 Salt', 'field_type' => 'password', 'placeholder' => '可选需与快递100订阅参数保持一致', 'remark' => '用于快递100推送签名增强如账号未配置可留空。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'query_min_interval_minutes', 'title' => '最小查询间隔(分钟)', 'field_type' => 'text', 'placeholder' => '默认 30', 'remark' => '定时补查同一运单的最小间隔,允许 5-1440。', 'is_secret' => false, 'default_value' => '30', 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
],
],
];
}
private function uploadableConfigMap(): array
{
return [];
}
private function validateConfigValues(array $configValueMap): void
{
$driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
$this->validatePaymentConfig($configValueMap);
if ($driver === 'local') {
$this->validateKuaidi100Config($configValueMap);
return;
}
if ($driver === 'oss') {
$required = [
'file_storage.oss_endpoint' => 'OSS Endpoint',
'file_storage.oss_bucket' => 'OSS Bucket',
'file_storage.oss_access_key_id' => 'OSS AccessKey ID',
'file_storage.oss_access_key_secret' => 'OSS AccessKey Secret',
];
foreach ($required as $key => $label) {
if (trim((string)($configValueMap[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('当前已切换为 OSS 存储,请先填写 %s', $label));
}
}
$directUploadMaxSizeMb = trim((string)($configValueMap['file_storage.direct_upload_max_size_mb'] ?? '200'));
if ($directUploadMaxSizeMb !== '' && (!ctype_digit($directUploadMaxSizeMb) || (int)$directUploadMaxSizeMb < 1 || (int)$directUploadMaxSizeMb > 2048)) {
throw new \RuntimeException('直传文件大小上限需填写 1-2048 之间的整数');
}
$this->validateKuaidi100Config($configValueMap);
return;
}
if ($driver !== 'qiniu') {
$this->validateKuaidi100Config($configValueMap);
return;
}
$required = [
'file_storage.qiniu_bucket' => '七牛 Bucket',
'file_storage.qiniu_access_key' => '七牛 AccessKey',
'file_storage.qiniu_secret_key' => '七牛 SecretKey',
];
foreach ($required as $key => $label) {
if (trim((string)($configValueMap[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('当前已切换为七牛云存储,请先填写 %s', $label));
}
}
$publicBaseUrl = trim((string)($configValueMap['file_storage.public_base_url'] ?? ''));
$bucketDomain = trim((string)($configValueMap['file_storage.qiniu_bucket_domain'] ?? ''));
if ($publicBaseUrl === '' && $bucketDomain === '') {
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
}
$this->validateKuaidi100Config($configValueMap);
}
private function validatePaymentConfig(array $configValueMap): void
{
$enabled = (string)($configValueMap['payment.enabled'] ?? 'disabled');
if (!in_array($enabled, ['enabled', 'disabled'], true)) {
throw new \RuntimeException('收钱吧支付开关配置无效');
}
if ($enabled !== 'enabled') {
return;
}
$required = [
'payment.api_domain' => '收钱吧 API 域名',
'payment.appid' => '收钱吧 AppID',
'payment.brand_code' => '品牌编号',
'payment.store_sn' => '门店编号',
'payment.order_expire_minutes' => '订单有效分钟数',
'payment.merchant_private_key' => '商户 RSA 私钥',
'payment.shouqianba_public_key' => '收钱吧 RSA 公钥',
'payment.notify_url' => '支付通知地址',
];
foreach ($required as $key => $label) {
if (trim((string)($configValueMap[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('当前已启用收钱吧支付,请先填写 %s如需先保存草稿请先将收钱吧支付开关设为停用', $label));
}
}
foreach (['payment.api_domain' => '收钱吧 API 域名', 'payment.notify_url' => '支付通知地址'] as $key => $label) {
if (!preg_match('/^https?:\/\//i', trim((string)$configValueMap[$key]))) {
throw new \RuntimeException(sprintf('%s需以 http:// 或 https:// 开头', $label));
}
}
$expireMinutes = trim((string)($configValueMap['payment.order_expire_minutes'] ?? ''));
if (!ctype_digit($expireMinutes) || (int)$expireMinutes < 1 || (int)$expireMinutes > 43200) {
throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数');
}
if (!$this->isPrivateKeyContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) {
throw new \RuntimeException('商户 RSA 私钥需填写可被 OpenSSL 解析的 PEM 内容,或填写服务器可读取的 PEM 文件路径');
}
if (!$this->isPublicKeyContentOrReadablePath((string)$configValueMap['payment.shouqianba_public_key'])) {
throw new \RuntimeException('收钱吧 RSA 公钥需填写可被 OpenSSL 解析的 PEM 内容、纯公钥文本,或填写服务器可读取的 PEM 文件路径');
}
}
private function isPrivateKeyContentOrReadablePath(string $value): bool
{
$content = $this->pemContentOrReadablePath($value);
if ($content === '') {
return false;
}
$key = openssl_pkey_get_private($content);
$ok = $key !== false;
$this->clearOpenSslErrors();
return $ok;
}
private function isPublicKeyContentOrReadablePath(string $value): bool
{
$content = $this->pemContentOrReadablePath($value);
if ($content !== '' && $this->canOpenPublicKey($content)) {
return true;
}
if (!$this->looksLikeBase64KeyBody($value)) {
return false;
}
return $this->canOpenPublicKey($this->wrapPemKey($value, 'PUBLIC KEY'));
}
private function pemContentOrReadablePath(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (str_contains($value, '-----BEGIN')) {
return $this->normalizePemNewlines($value);
}
if (!is_file($value) || !is_readable($value)) {
return '';
}
$content = file_get_contents($value);
if (!is_string($content) || !str_contains($content, '-----BEGIN')) {
return '';
}
return $this->normalizePemNewlines($content);
}
private function canOpenPublicKey(string $content): bool
{
$key = openssl_pkey_get_public($content);
$ok = $key !== false;
$this->clearOpenSslErrors();
return $ok;
}
private function wrapPemKey(string $value, string $pemLabel): string
{
$body = preg_replace('/\s+/', '', trim($value)) ?: '';
return sprintf(
"-----BEGIN %s-----\n%s\n-----END %s-----",
$pemLabel,
rtrim(chunk_split($body, 64, "\n")),
$pemLabel
);
}
private function normalizePemNewlines(string $value): string
{
return str_replace(["\\r\\n", "\\n", "\\r"], ["\n", "\n", "\r"], $value);
}
private function clearOpenSslErrors(): void
{
while (openssl_error_string() !== false) {
}
}
private function looksLikeBase64KeyBody(string $value): bool
{
$body = preg_replace('/\s+/', '', trim($value));
if (!is_string($body) || strlen($body) < 64) {
return false;
}
return preg_match('/^[A-Za-z0-9+\/=]+$/', $body) === 1 && base64_decode($body, true) !== false;
}
private function validateKuaidi100Config(array $configValueMap): void
{
$enabled = (string)($configValueMap['kuaidi100.enabled'] ?? 'disabled');
if (!in_array($enabled, ['enabled', 'disabled'], true)) {
throw new \RuntimeException('快递100同步开关配置无效');
}
if ($enabled !== 'enabled') {
return;
}
$required = [
'kuaidi100.customer' => '快递100 Customer',
'kuaidi100.key' => '快递100 Key',
'kuaidi100.callback_url' => '快递100推送回调地址',
];
foreach ($required as $key => $label) {
if (trim((string)($configValueMap[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('当前已启用快递100请先填写 %s', $label));
}
}
$interval = trim((string)($configValueMap['kuaidi100.query_min_interval_minutes'] ?? '30'));
if ($interval !== '' && (!ctype_digit($interval) || (int)$interval < 5 || (int)$interval > 1440)) {
throw new \RuntimeException('快递100最小查询间隔需填写 5-1440 之间的整数');
}
}
private function applyDerivedConfigValues(array &$configValueMap): void
{
$configValueMap['h5.oauth_redirect_url'] = $this->buildH5OAuthRedirectUrl((string)($configValueMap['h5.page_base_url'] ?? ''));
$configValueMap['payment.notify_url'] = $this->buildShouqianbaNotifyUrl((string)($configValueMap['file_storage.public_base_url'] ?? ''));
}
private function buildH5OAuthRedirectUrl(string $pageBaseUrl): string
{
$baseUrl = $this->normalizeH5PageBaseUrl($pageBaseUrl);
if ($baseUrl === '') {
return '';
}
return $baseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH;
}
private function buildShouqianbaNotifyUrl(string $publicBaseUrl): string
{
$baseUrl = $this->normalizePublicApiBaseUrl($publicBaseUrl);
if ($baseUrl === '') {
return '';
}
return $baseUrl . self::SHOUQIANBA_NOTIFY_PATH;
}
private function normalizePublicApiBaseUrl(string $publicBaseUrl): string
{
$baseUrl = trim((string)($_ENV['PUBLIC_FILE_BASE_URL'] ?? ''));
if ($baseUrl === '') {
$baseUrl = trim($publicBaseUrl);
}
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function normalizeH5PageBaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
}