788 lines
39 KiB
PHP
788 lines
39 KiB
PHP
<?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/uploads;OSS 模式写入阿里云对象存储。',
|
||
'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, '/');
|
||
}
|
||
}
|