481 lines
24 KiB
PHP
481 lines
24 KiB
PHP
<?php
|
||
|
||
namespace app\controller\admin;
|
||
|
||
use app\support\FileStorageConfigService;
|
||
use support\Request;
|
||
use support\think\Db;
|
||
|
||
class SystemConfigsController
|
||
{
|
||
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'] ?? '';
|
||
}
|
||
|
||
$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'],
|
||
'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)
|
||
{
|
||
$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');
|
||
}
|
||
}
|
||
|
||
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'] ?? '');
|
||
}
|
||
|
||
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 ($items as $item) {
|
||
if (!is_array($item)) {
|
||
continue;
|
||
}
|
||
|
||
$groupCode = trim((string)($item['config_group'] ?? ''));
|
||
$configKey = trim((string)($item['config_key'] ?? ''));
|
||
$configValue = (string)($item['config_value'] ?? '');
|
||
$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' => '填写 Bucket 所在地域的公网 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' => '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],
|
||
['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false],
|
||
],
|
||
],
|
||
'payment' => [
|
||
'group_name' => '支付与商户平台',
|
||
'group_desc' => '配置微信支付商户号、API 密钥、证书序列号等上线必要参数。',
|
||
'items' => [
|
||
['config_key' => 'mch_id', 'title' => '商户号 MchID', 'field_type' => 'text', 'placeholder' => '请输入商户号', 'remark' => '微信支付商户平台分配的商户号', 'is_secret' => false],
|
||
['config_key' => 'api_v3_key', 'title' => 'APIv3 Key', 'field_type' => 'password', 'placeholder' => '请输入 APIv3 Key', 'remark' => '用于微信支付接口验签与解密', 'is_secret' => true],
|
||
['config_key' => 'merchant_serial_no', 'title' => '商户证书序列号', 'field_type' => 'text', 'placeholder' => '请输入商户证书序列号', 'remark' => '与商户 API 证书匹配', 'is_secret' => false],
|
||
['config_key' => 'apiclient_key_path', 'title' => 'apiclient_key.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_key.pem', 'remark' => '上传微信支付商户私钥文件,系统将保存到后端非公开目录', 'is_secret' => true],
|
||
['config_key' => 'apiclient_cert_path', 'title' => 'apiclient_cert.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_cert.pem', 'remark' => '上传微信支付商户证书文件,系统将保存到后端非公开目录', 'is_secret' => false],
|
||
['config_key' => 'merchant_private_key', 'title' => '商户私钥', 'field_type' => 'textarea', 'placeholder' => '请输入商户私钥内容', 'remark' => '用于支付签名,请妥善保管', 'is_secret' => true],
|
||
['config_key' => 'platform_certificate_serial', 'title' => '平台证书序列号', 'field_type' => 'text', 'placeholder' => '请输入微信支付平台证书序列号', 'remark' => '用于平台证书校验', 'is_secret' => false],
|
||
['config_key' => 'notify_url', 'title' => '支付回调地址', 'field_type' => 'text', 'placeholder' => '请输入支付回调通知地址', 'remark' => '支付成功后用于回调业务系统', 'is_secret' => false],
|
||
],
|
||
],
|
||
'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],
|
||
],
|
||
],
|
||
];
|
||
}
|
||
|
||
private function uploadableConfigMap(): array
|
||
{
|
||
return [
|
||
'payment.apiclient_key_path' => [
|
||
'filename' => 'apiclient_key.pem',
|
||
],
|
||
'payment.apiclient_cert_path' => [
|
||
'filename' => 'apiclient_cert.pem',
|
||
],
|
||
];
|
||
}
|
||
|
||
private function validateConfigValues(array $configValueMap): void
|
||
{
|
||
$driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
|
||
if ($driver === 'local') {
|
||
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));
|
||
}
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if ($driver !== 'qiniu') {
|
||
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('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
|
||
}
|
||
}
|
||
}
|