Files
MBTI_wang/api/app/controller/admin/Settings.php
卡若 ce55c48c64 feat: 同步今日小程序与后台迭代版本
集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。

Made-with: Cursor
2026-04-20 11:07:55 +08:00

782 lines
28 KiB
PHP
Raw 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\BaseController;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\OutboundPushHookService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use think\facade\Request;
use think\facade\Db;
/**
* 系统设置控制器(普通管理员)
*/
class Settings extends BaseController
{
/**
* 获取系统配置
* @return \think\response\Json
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
// 获取当前管理员用户名
$jwtUsername = $user['username'] ?? null;
$username = 'admin';
if ($jwtUsername) {
$currentUser = UserModel::where('username', $jwtUsername)
->whereIn('role', ['admin', 'enterprise_admin'])
->find();
if ($currentUser) {
$username = $currentUser->username;
} else {
$username = $jwtUsername;
}
}
return success([
'username' => $username
]);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
/**
* 获取可用字体列表
* GET /api/v1/admin/settings/fonts
*/
public function getFonts()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$fonts = \app\common\service\PosterService::getAvailableFonts();
return success([
'fonts' => $fonts,
'fontDir' => root_path() . 'public/fonts/',
'dirExist' => is_dir(root_path() . 'public/fonts/'),
]);
}
/**
* 获取海报配置
* GET /api/v1/admin/settings/poster
* 有 enterpriseId 则读企业专属行否则读全局enterprise_id=0
*/
public function getPosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$eid = (int)($user['enterpriseId'] ?? 0);
$row = self::getConfig('poster_config', $eid);
$poster = $row ?: ['bgColor' => '#ffffff', 'bgImage' => '', 'elements' => []];
return success(['poster' => $poster]);
}
/**
* 保存海报配置
* PUT /api/v1/admin/settings/poster
*/
public function updatePosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$input = json_decode($this->request->getContent(), true);
if (!is_array($input)) {
$input = [];
}
$data = [
'bgColor' => $input['bgColor'] ?? '#ffffff',
'bgImage' => $input['bgImage'] ?? '',
'elements' => $input['elements'] ?? []
];
$eid = (int)($user['enterpriseId'] ?? 0);
try {
self::saveConfig('poster_config', $data, $eid, '分销海报可视化配置');
return success(null, '海报配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 读取配置key + enterprise_id有企业专属则取否则降级到 enterprise_id=0
*/
private static function getConfig(string $key, int $enterpriseId = 0, bool $fallbackGlobal = false): ?array
{
$row = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->find();
if ($row && !empty($row['value'])) {
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (is_array($val)) return $val;
}
if ($fallbackGlobal && $enterpriseId > 0) {
$row = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->find();
if ($row && !empty($row['value'])) {
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (is_array($val)) return $val;
}
}
return null;
}
/**
* 保存配置key + enterprise_id存在则 update否则 insert
*/
private static function saveConfig(string $key, array $value, int $enterpriseId = 0, string $description = ''): void
{
$now = time();
$json = json_encode($value, JSON_UNESCAPED_UNICODE);
$exists = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => $enterpriseId,
'value' => $json,
'description' => $description,
'createdAt' => $now,
'updatedAt' => $now,
]);
}
}
/**
* 存客宝默认结构(测评线索共用一个 Key + 上报时机)
*/
private static function defaultCunkebaoKeysStructure(): array
{
return [
'apiKey' => '',
'reportTiming' => 'after_paid',
];
}
/**
* @param mixed $raw 已解码的配置或 null单 Key兼容旧版 enterprise/personal 及按题型分栏)
*/
private static function normalizeCunkebaoKeysPayload($raw): array
{
$out = self::defaultCunkebaoKeysStructure();
if (!is_array($raw)) {
return $out;
}
$rtIn = static function ($v) use (&$out): void {
$rt = (string) ($v ?? '');
if (in_array($rt, ['after_paid', 'after_test'], true)) {
$out['reportTiming'] = $rt;
}
};
// 新版apiKey + reportTiming
if (array_key_exists('apiKey', $raw)) {
$out['apiKey'] = isset($raw['apiKey']) ? trim((string) $raw['apiKey']) : '';
$rtIn($raw['reportTiming'] ?? '');
return $out;
}
// 上一版enterprise / personal 合二为一(优先企业栏)
if (array_key_exists('enterprise', $raw) || array_key_exists('personal', $raw)) {
$e = isset($raw['enterprise']) ? trim((string) $raw['enterprise']) : '';
$p = isset($raw['personal']) ? trim((string) $raw['personal']) : '';
$out['apiKey'] = $e !== '' ? $e : $p;
$rtIn($raw['reportTiming'] ?? '');
return $out;
}
// 旧版:按题型分栏
$types = ['face', 'pdp', 'disc', 'mbti'];
foreach ($types as $type) {
if (!isset($raw[$type]) || !is_array($raw[$type])) {
continue;
}
$row = $raw[$type];
if ($out['apiKey'] === '' && isset($row['enterprise'])) {
$x = trim((string) $row['enterprise']);
if ($x !== '') {
$out['apiKey'] = $x;
}
}
if ($out['apiKey'] === '' && isset($row['personal'])) {
$x = trim((string) $row['personal']);
if ($x !== '') {
$out['apiKey'] = $x;
}
}
if ((string) ($row['reportTiming'] ?? '') === 'after_test') {
$out['reportTiming'] = 'after_test';
}
}
return $out;
}
/**
* JWT 中的企业 ID企业管理员必有关联企业
*/
private function adminBoundEnterpriseId($user): int
{
if (!is_array($user)) {
return 0;
}
$eid = (int) ($user['enterpriseId'] ?? $user['enterprise_id'] ?? 0);
return $eid > 0 ? $eid : 0;
}
/**
* GET /api/v1/admin/settings/cunkebao-keys
* 按当前管理员所属企业读取system_config.enterprise_id = 企业 ID
*/
public function getCunkebaoKeys()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$eid = $this->adminBoundEnterpriseId($user);
if ($eid <= 0) {
return error('当前账号未关联企业,无法配置存客宝 Key', 403);
}
$row = self::getConfig('cunkebao_keys', $eid, false);
return success([
'cunkebaoKeys' => self::normalizeCunkebaoKeysPayload($row),
]);
}
/**
* PUT /api/v1/admin/settings/cunkebao-keys
*/
public function updateCunkebaoKeys()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$eid = $this->adminBoundEnterpriseId($user);
if ($eid <= 0) {
return error('当前账号未关联企业,无法配置存客宝 Key', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$payload = $input['cunkebaoKeys'] ?? [];
if (!is_array($payload)) {
return error('存客宝 Key 格式错误', 400);
}
$sanitized = self::normalizeCunkebaoKeysPayload($payload);
try {
self::saveConfig('cunkebao_keys', $sanitized, $eid, '存客宝 Key本企业 · 测评类共用)');
return success($sanitized, '存客宝 Key 已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 安全解码 JSON处理可能的多重编码
*/
private static function decodeJsonSafe($raw): ?array
{
if (!$raw) return null;
$val = $raw;
for ($i = 0; $i < 5 && is_string($val); $i++) {
$decoded = json_decode($val, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) break;
$val = $decoded;
}
return is_array($val) ? $val : null;
}
/**
* 获取小程序配置
* 读取全局 text_configenterprise_id=0作为默认值再用企业专属行覆盖
* GET /api/v1/admin/settings/miniprogram
*/
public function getMiniprogramConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
$eid = (int)($user['enterpriseId'] ?? 0);
// 全局小程序名称(仅超管可改,此处只读)
$miniprogramName = '神仙团队AI性格测试';
$siteInfo = Db::name('system_config')
->where('key', 'site_info')
->where('enterprise_id', 0)
->find();
if ($siteInfo && !empty($siteInfo['value'])) {
$val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
$miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? $miniprogramName);
}
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '30秒测出你的性格',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',
];
// 全局文案enterprise_id=0作为基础
$globalTc = self::getConfig('text_config', 0);
$textConfigData = $globalTc
? array_merge($tcDefaults, array_intersect_key($globalTc, $tcDefaults))
: $tcDefaults;
// 企业专属文案 + 小程序名称 覆盖
if ($eid > 0) {
$eidTc = self::getConfig('text_config', $eid);
if ($eidTc) {
$textConfigData = array_merge($textConfigData, array_intersect_key($eidTc, $tcDefaults));
if (!empty($eidTc['miniprogramName'])) {
$miniprogramName = (string) $eidTc['miniprogramName'];
}
}
}
return success([
'miniprogramName' => $miniprogramName,
'textConfig' => $textConfigData,
]);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
/**
* 飞书获客 Webhook全局 enterprise_id=0
* GET /api/v1/admin/settings/feishu-lead
*/
public function getFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$cfg = FeishuLeadWebhookService::getConfig();
return success([
'enabled' => !empty($cfg['enabled']),
'webhookUrl' => (string) ($cfg['webhookUrl'] ?? ''),
'contactPerson' => (string) ($cfg['contactPerson'] ?? '运营'),
]);
}
/**
* PUT /api/v1/admin/settings/feishu-lead
*/
public function updateFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$enabled = !empty($input['enabled']);
$webhookUrl = trim((string) ($input['webhookUrl'] ?? ''));
$contactPerson = trim((string) ($input['contactPerson'] ?? '运营'));
if ($contactPerson === '') {
$contactPerson = '运营';
}
if ($enabled && $webhookUrl !== '' && stripos($webhookUrl, 'http') !== 0) {
return error('Webhook 须以 http(s) 开头', 400);
}
$json = json_encode([
'enabled' => $enabled,
'webhookUrl' => $webhookUrl,
'contactPerson' => $contactPerson,
], JSON_UNESCAPED_UNICODE);
$now = time();
$key = FeishuLeadWebhookService::CONFIG_KEY;
$exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => 0,
'value' => $json,
'description' => '飞书获客 Webhook',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '已保存');
}
/**
* 当前管理员可编辑的作用域:有企业则读写 enterprise_id=本企业否则读写全平台默认0与超管维护同一条
* GET /api/v1/admin/settings/push-hook
*/
public function getPushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$eid = $this->resolveAdminPushHookEnterpriseId();
$cfg = OutboundPushHookService::getConfig($eid);
$events = $cfg['events'] ?? [];
if (!is_array($events)) {
$events = [];
}
$enterpriseName = null;
if ($eid > 0) {
$n = Db::name('enterprises')->where('id', $eid)->value('name');
$enterpriseName = ($n !== null && $n !== '') ? (string) $n : null;
}
return success([
'scope' => $eid > 0 ? 'enterprise' : 'platform',
'configEnterpriseId' => $eid,
'enterpriseName' => $enterpriseName,
'enabled' => !empty($cfg['enabled']),
'url' => (string) ($cfg['url'] ?? ''),
'secret' => (string) ($cfg['secret'] ?? ''),
'events' => $events,
]);
}
/**
* PUT /api/v1/admin/settings/push-hook
*/
public function updatePushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$enabled = !empty($input['enabled']);
$url = trim((string) ($input['url'] ?? ''));
$secret = (string) ($input['secret'] ?? '');
$eventsRaw = $input['events'] ?? null;
$events = [];
if (is_array($eventsRaw)) {
foreach ($eventsRaw as $ev) {
$ev = trim((string) $ev);
if ($ev !== '') {
$events[] = $ev;
}
}
}
if ($enabled && $url !== '' && stripos($url, 'http') !== 0) {
return error('URL 须以 http(s) 开头', 400);
}
$json = json_encode([
'enabled' => $enabled,
'url' => $url,
'secret' => $secret,
'events' => $events,
], JSON_UNESCAPED_UNICODE);
$now = time();
$key = OutboundPushHookService::CONFIG_KEY;
$eid = $this->resolveAdminPushHookEnterpriseId();
$desc = $eid > 0 ? '通用 HTTP 出站推送 Hook企业专属' : '通用 HTTP 出站推送 Hook全平台默认';
$exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', $eid)->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $eid)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => $eid,
'value' => $json,
'description' => $desc,
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '已保存');
}
/**
* POST /api/v1/admin/settings/push-hook/test
* 向当前解析到的 URL 发送 hook.ping不写去重表
*/
public function testPushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$ctx = $this->resolveAdminPushHookEnterpriseId();
$r = OutboundPushHookService::sendTestPing($ctx);
return success($r, $r['ok'] ? '测试推送已发出' : ($r['message'] ?? '测试失败'));
}
/**
* POST /api/v1/admin/settings/push-hook/test-result
* 按真实业务数据重放一条 test.result_completed支持 force=1 强制清去重。
*/
public function testPushHookTestResult()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$testResultId = (int) Request::param('testResultId', 0);
$force = (int) Request::param('force', 0) === 1;
$r = OutboundPushHookService::replayTestResultForDebug($testResultId, $force);
return success($r, $r['message'] ?? ($r['ok'] ? '已重放推送' : '重放失败'));
}
/**
* 出站 Hook 可编辑行JWT 或 users 表解析到的本企业 ID无企业则为 0与超管共用全平台默认行
*/
private function resolveAdminPushHookEnterpriseId(): int
{
$user = $this->request->user ?? null;
if (!$user) {
return 0;
}
$eid = (int) ($user['enterpriseId'] ?? 0);
if ($eid > 0) {
return $eid;
}
$adminId = (int) ($user['userId'] ?? 0);
if ($adminId > 0) {
$v = Db::name('users')->where('id', $adminId)->value('enterpriseId');
if ($v !== null && $v !== '') {
$x = (int) $v;
if ($x > 0) {
return $x;
}
}
}
return 0;
}
/**
* 更新小程序配置
* 写入 text_config 行enterprise_id={eid}(有企业)或 0无企业
* PUT /api/v1/admin/settings/miniprogram
*/
public function updateMiniprogramConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$rawBody = $this->request->getContent();
if (empty($rawBody)) {
$rawBody = file_get_contents('php://input');
}
$input = $rawBody ? json_decode($rawBody, true) : null;
if (!is_array($input)) {
$input = [
'miniprogramName' => Request::param('miniprogramName', ''),
'textConfig' => Request::param('textConfig', []),
];
}
$miniprogramName = trim((string) ($input['miniprogramName'] ?? ''));
$textConfig = $input['textConfig'] ?? [];
if ($miniprogramName === '') {
return error('小程序名称不能为空', 400);
}
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '30秒测出你的性格',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',
];
$tcData = is_array($textConfig) ? array_intersect_key($textConfig, array_flip($tcKeys)) : [];
$tcMerge = array_merge($tcDefaults, $tcData);
$eid = (int)($user['enterpriseId'] ?? 0);
try {
// eid=0更新 site_info 的小程序名称(全局)
if ($eid === 0) {
$siteRow = Db::name('system_config')->where('key', 'site_info')->where('enterprise_id', 0)->find();
$siteInfo = $siteRow && !empty($siteRow['value'])
? (is_string($siteRow['value']) ? json_decode($siteRow['value'], true) : $siteRow['value'])
: [];
$siteInfo = is_array($siteInfo) ? $siteInfo : [];
$siteInfo['miniprogramName'] = $miniprogramName;
$siteInfo['siteName'] = $siteInfo['siteName'] ?? $miniprogramName;
$siteInfo['updatedAt'] = time();
self::saveConfig('site_info', $siteInfo, 0, '站点信息');
} else {
// 企业专属:把 miniprogramName 一并写入 text_config
$tcMerge['miniprogramName'] = $miniprogramName;
}
// 统一写到 text_config企业行已含 miniprogramName全局行不含
self::saveConfig('text_config', $tcMerge, $eid, $eid > 0 ? "小程序文案配置(企业{$eid}" : '小程序文案配置(全局)');
return success([
'miniprogramName' => $miniprogramName,
'textConfig' => $tcMerge,
], '小程序配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 更新管理员账户信息
* @return \think\response\Json
*/
public function updateCredentials()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
// 兼容 axios JSON PUT 与表单提交
$rawBody = $this->request->getContent();
if (empty($rawBody)) {
$rawBody = file_get_contents('php://input');
}
$input = $rawBody ? json_decode($rawBody, true) : null;
if (!is_array($input)) {
$input = [];
}
$username = trim((string)($input['username'] ?? Request::param('username', '')));
$currentPassword = (string)($input['currentPassword'] ?? Request::param('currentPassword', ''));
$newPassword = (string)($input['newPassword'] ?? Request::param('newPassword', ''));
$confirmPassword = (string)($input['confirmPassword'] ?? Request::param('confirmPassword', ''));
if (empty($username)) {
return error('用户名不能为空', 400);
}
try {
// 优先使用JWT中的username来查找用户
$jwtUsername = $user['username'] ?? null;
if (empty($jwtUsername)) {
return error('无法获取用户信息,请重新登录', 400);
}
// 直接通过username查找用户
$userModel = UserModel::where('username', $jwtUsername)
->whereIn('role', ['admin', 'enterprise_admin'])
->find();
if (!$userModel) {
return error('用户不存在,请检查登录状态', 404);
}
// 如果要修改密码,需要验证当前密码
if (!empty($newPassword)) {
if (empty($currentPassword)) {
return error('请输入当前密码', 400);
}
if ($newPassword !== $confirmPassword) {
return error('两次输入的密码不一致', 400);
}
// 验证当前密码User 模型已有原始加密密码)
if (!password_verify($currentPassword, $userModel->password)) {
return error('当前密码错误', 400);
}
// 更新密码:传入明文,交由 User 模型的 setPasswordAttr 自动加密
$userModel->password = $newPassword;
}
// 更新用户名
if ($username !== $userModel->username) {
// 检查用户名是否已存在(排除当前用户)
$exists = UserModel::where('username', $username)
->where('id', '<>', $userModel->id)
->find();
if ($exists) {
return error('用户名已存在', 400);
}
$userModel->username = $username;
}
$userModel->save();
return success([
'username' => $userModel->username
], '账户信息已更新');
} catch (\Exception $e) {
return error('更新失败:' . $e->getMessage(), 500);
}
}
}