feat: 企业计费服务与超管/管理端设置及定价规则文档

1、修复了财务、Analyze/Test 等与定价、账务相关的接口行为。

2、新增 EnterpriseBillingService 与定价企业账务规则说明文档。

3、优化超管 Settings、PricingConfig 及小程序 app 入口逻辑。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-13 10:38:56 +08:00
parent 6fec2965aa
commit 95e4effcd9
9 changed files with 430 additions and 143 deletions

View File

@@ -0,0 +1,125 @@
<?php
namespace app\common\service;
use app\model\PricingConfig as PricingConfigModel;
use think\facade\Db;
/**
* 企业平台费账务服务
*
* 口径:
* - 仅当 test_results 归属企业时才扣费
* - 扣费金额读取超管全局 enterprise 定价
* - 每条 test_results 仅扣一次,使用 finance_records 描述做幂等标记
*/
class EnterpriseBillingService
{
/**
* 测评完成后,按超管全局企业版单价扣企业平台费。
*/
public static function chargePlatformFeeForTestResult(int $testResultId, string $testType, ?int $enterpriseId): void
{
$enterpriseId = (int) ($enterpriseId ?? 0);
if ($testResultId <= 0 || $enterpriseId <= 0) {
return;
}
$amountFen = self::getPlatformFeeFen($testType);
if ($amountFen <= 0) {
return;
}
$now = time();
$description = self::buildPlatformFeeDescription($testType, $testResultId);
Db::startTrans();
try {
$enterprise = Db::name('enterprises')
->where('id', $enterpriseId)
->field('id, balance')
->lock(true)
->find();
if (!$enterprise) {
Db::rollback();
return;
}
$exists = Db::name('finance_records')
->where('enterpriseId', $enterpriseId)
->where('type', 'consume')
->whereNull('orderId')
->where('description', $description)
->find();
if ($exists) {
Db::commit();
return;
}
$beforeFen = (int) ($enterprise['balance'] ?? 0);
// 平台费独立记账,允许与企业测试收入分列后形成净额。
$afterFen = $beforeFen - $amountFen;
Db::name('enterprises')
->where('id', $enterpriseId)
->update([
'balance' => $afterFen,
'updatedAt' => $now,
]);
Db::name('finance_records')->insert([
'enterpriseId' => $enterpriseId,
'type' => 'consume',
'amount' => $amountFen,
'balanceBefore' => $beforeFen,
'balanceAfter' => $afterFen,
'description' => $description,
'orderId' => null,
'createdAt' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
}
}
/**
* 从超管全局 enterprise 定价读取平台费(分)。
*/
public static function getPlatformFeeFen(string $testType): int
{
$config = PricingConfigModel::where('type', 'enterprise')->whereNull('enterpriseId')->find();
if (!$config || empty($config->config)) {
return 0;
}
$raw = $config->config;
$pricing = is_array($raw) ? $raw : (array) $raw;
$key = $testType === 'team_analysis' ? 'teamAnalysis' : $testType;
if (!isset($pricing[$key])) {
return 0;
}
$yuan = (float) $pricing[$key];
return $yuan > 0 ? (int) round($yuan * 100) : 0;
}
private static function buildPlatformFeeDescription(string $testType, int $testResultId): string
{
return '平台扣费:' . self::getTestTypeLabel($testType) . '测试testResultId=' . $testResultId . '';
}
private static function getTestTypeLabel(string $testType): string
{
$map = [
'face' => '人脸',
'mbti' => 'MBTI',
'sbti' => 'SBTI',
'disc' => 'DISC',
'pdp' => 'PDP',
'resume' => '简历',
];
return $map[$testType] ?? strtoupper($testType);
}
}

View File

@@ -101,7 +101,7 @@ class Finance extends BaseController
$direction = $type === 'consume' ? 'out' : 'in';
$description = (string) ($row['description'] ?? '');
$typeLabel = $type === 'consume'
? '佣金扣减'
? (strpos($description, '平台扣费:') === 0 ? '平台扣费' : '佣金扣减')
: (strpos($description, '企业余额充值') !== false ? '余额充值' : ($orderId > 0 ? '测试收入' : '余额充值'));
return [

View File

@@ -2,7 +2,7 @@
namespace app\controller\api;
use app\BaseController;
use app\common\SystemDefaultEnterprise;
use app\common\service\EnterpriseBillingService;
use app\controller\api\Test as TestController;
use app\model\AiProvider as AiProviderModel;
use app\model\SystemConfig as SystemConfigModel;
@@ -51,7 +51,7 @@ class Analyze extends BaseController
// 三个变量各司其职(同 Test::submit 逻辑):
// $enterpriseId —— 仅企业测试(请求体传入)才非 null决定走 admin_enterprise 定价
// $pricingEnterpriseId —— 个人测试时从 wechat_users 取,走 admin_personal + eid 定价
// $writeEnterpriseId —— 写入 test_results.enterpriseId企业测试 or 绑定企业记录)
// $writeEnterpriseId —— 写入 test_results.enterpriseId企业测试或真实绑定企业记录)
$pricingEnterpriseId = $enterpriseId;
$writeEnterpriseId = $enterpriseId;
if ($enterpriseId === null && $earlyUserId > 0) {
@@ -59,12 +59,6 @@ class Analyze extends BaseController
if (!empty($boundEid)) {
$pricingEnterpriseId = (int) $boundEid; // admin_personal + eid
$writeEnterpriseId = (int) $boundEid; // 历史记录展示企业名
} else {
$defEid = SystemDefaultEnterprise::getId();
if ($defEid !== null) {
$pricingEnterpriseId = $defEid;
$writeEnterpriseId = $defEid;
}
}
}
@@ -189,6 +183,11 @@ class Analyze extends BaseController
]);
}
}
try {
EnterpriseBillingService::chargePlatformFeeForTestResult($testResultId, 'face', $writeEnterpriseId);
} catch (\Throwable $e) {
// 平台费扣款失败不阻断主流程
}
// 面相分析由本接口直接写入 test_results也要补触发测试完成佣金
try {
\app\controller\api\Distribution::settleTestCommission($testResultId, $userId, 'face');
@@ -376,6 +375,11 @@ class Analyze extends BaseController
]);
}
}
try {
EnterpriseBillingService::chargePlatformFeeForTestResult($testResultId, 'resume', $enterpriseId > 0 ? $enterpriseId : null);
} catch (\Throwable $e) {
// 平台费扣款失败不阻断主流程
}
}
} catch (\Throwable $e) {
Log::error('resumeAnalysis: save test_result failed ' . $e->getMessage());

View File

@@ -2,8 +2,8 @@
namespace app\controller\api;
use app\BaseController;
use app\common\SystemDefaultEnterprise;
use app\common\PdpDiscResultText;
use app\common\service\EnterpriseBillingService;
use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel;
use app\model\Question as QuestionModel;
@@ -668,7 +668,7 @@ class Test extends BaseController
// 三个变量各司其职:
// $enterpriseId —— 仅企业测试(请求体传入)才非 null决定走 admin_enterprise 定价
// $pricingEnterpriseId —— 个人测试时从 wechat_users 取,走 admin_personal + eid 定价
// $writeEnterpriseId —— 写入 test_results.enterpriseId企业测试 or 绑定企业记录)
// $writeEnterpriseId —— 写入 test_results.enterpriseId企业测试或真实绑定企业记录)
$pricingEnterpriseId = $enterpriseId;
$writeEnterpriseId = $enterpriseId;
if ($enterpriseId === null) {
@@ -676,13 +676,6 @@ class Test extends BaseController
if (!empty($boundEid)) {
$pricingEnterpriseId = (int) $boundEid; // admin_personal + eid
$writeEnterpriseId = (int) $boundEid; // 历史记录展示企业名
} else {
// 主入口无参数且未绑定企业:回落超管配置的默认企业(与个人版 getEnterpriseIdForApiPayload 不传参一致,由服务端统一落库)
$defEid = SystemDefaultEnterprise::getId();
if ($defEid !== null) {
$pricingEnterpriseId = $defEid;
$writeEnterpriseId = $defEid;
}
}
}
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
@@ -715,6 +708,12 @@ class Test extends BaseController
]);
}
}
// 已绑定企业时,独立按超管企业版单价扣平台费,与用户实付分列记账。
try {
EnterpriseBillingService::chargePlatformFeeForTestResult($id, $testType, $writeEnterpriseId);
} catch (\Throwable $e) {
// 平台费扣款失败不阻断主流程
}
// 测试完成佣金结算(无需付款,异步不影响主流程)
try {
\app\controller\api\Distribution::settleTestCommission($id, $userId, $testType);

View File

@@ -61,19 +61,19 @@ class Settings extends BaseController
'defaultEnterpriseId' => null,
];
$systemOut = $systemDefault;
// 仅当 system 行「未包含」maintenanceMode 键时,才用旧表 review_mode 回退(否则关闭审核后会被 review_mode.enabled 再次顶成「已开启」)
$hasMaintenanceKeyInSystem = false;
if ($systemConfig && $systemConfig->value !== null && $systemConfig->value !== '') {
$raw = $systemConfig->value;
$decoded = is_array($raw) ? $raw : (is_string($raw) ? json_decode($raw, true) : null);
if (is_array($decoded)) {
$hasMaintenanceKeyInSystem = array_key_exists('maintenanceMode', $decoded);
$systemOut = array_merge($systemDefault, $decoded);
}
}
// 审核模式唯一口径system.maintenanceMode(布尔)。兼容旧版 review_mode.enabled
$maint = false;
if (isset($systemOut['maintenanceMode'])) {
$maint = (bool) $systemOut['maintenanceMode'];
}
if (!$maint && $reviewModeConfig && !empty($reviewModeConfig->value)) {
// 审核模式system.maintenanceMode 为唯一口径;兼容旧数据仅在没有该键时读 review_mode.enabled
$maint = (bool) ($systemOut['maintenanceMode'] ?? false);
if (!$hasMaintenanceKeyInSystem && !$maint && $reviewModeConfig && !empty($reviewModeConfig->value)) {
$rv = $reviewModeConfig->value;
if (is_string($rv)) {
$rv = json_decode($rv, true);
@@ -187,6 +187,21 @@ class Settings extends BaseController
$config->value = array_merge($oldArr, $data);
$config->save();
// 与旧版 review_mode 行同步,避免库内 enabled=true 与 system.maintenanceMode=false 并存
if (array_key_exists('maintenanceMode', $data)) {
$rmRow = SystemConfigModel::where('key', 'review_mode')->where('enterprise_id', 0)->find();
if ($rmRow) {
$rv = $rmRow->value;
$rv = is_array($rv) ? $rv : (is_string($rv) ? (json_decode($rv, true) ?: []) : []);
if (!is_array($rv)) {
$rv = [];
}
$rv['enabled'] = !empty($data['maintenanceMode']);
$rmRow->value = $rv;
$rmRow->save();
}
}
// 更新站点信息
$this->updateSiteInfo($data);
@@ -528,6 +543,18 @@ class Settings extends BaseController
$config->value = $oldArr;
$config->save();
$rmRow = SystemConfigModel::where('key', 'review_mode')->where('enterprise_id', 0)->find();
if ($rmRow) {
$rv = $rmRow->value;
$rv = is_array($rv) ? $rv : (is_string($rv) ? (json_decode($rv, true) ?: []) : []);
if (!is_array($rv)) {
$rv = [];
}
$rv['enabled'] = (bool) $on;
$rmRow->value = $rv;
$rmRow->save();
}
return success([
'maintenanceMode' => (bool) $on,
'enabled' => (bool) $on,

View File

@@ -1,117 +1,101 @@
<?php
namespace app\model;
use think\Model;
/**
* 定价配置模型
* 实际表名 = 数据库前缀 + pricing_config例如 .env 中 DATABASE_PREFIX=mbti_ 时为 mbti_pricing_config
*/
class PricingConfig extends Model
{
// 表名(不含前缀);最终访问表 = config(database.prefix) + pricing_config
protected $name = 'pricing_config';
// 设置字段信息(匹配数据库字段命名:驼峰命名)
protected $schema = [
'id' => 'int',
'type' => 'string',
'enterpriseId' => 'int',
'config' => 'string',
'createdAt' => 'int',
'updatedAt' => 'int',
];
// 自动时间戳(使用驼峰命名,时间戳格式)
protected $autoWriteTimestamp = 'int';
// 时间戳字段名(驼峰命名,匹配数据库)
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
// 时间字段类型(时间戳格式)
protected $type = [
'createdAt' => 'integer',
'updatedAt' => 'integer',
];
// JSON字段自动转换
protected $json = ['config'];
/**
* 配置修改器自动转换为JSON
*/
public function setConfigAttr($value)
{
if (is_array($value)) {
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
return $value;
}
/**
* 配置获取器自动解析JSON
*/
public function getConfigAttr($value)
{
if (is_string($value)) {
return json_decode($value, true);
}
return $value;
}
/**
* 按类型与可选企业ID取定价配置
*
* personal个人版优先级
* 1. admin_personal + enterpriseId企业专属管理端配置有 eid 时)
* 2. admin_personal + null通用管理端配置
* 3. 任意一条 admin_personal兜底只要管理端配过就不走超管
* 4. personal + null超管全局仅在管理端完全未配置时使用
*
* enterprise(企业版)优先级:
* 1. admin_enterprise + enterpriseId有 eid 时)
* 2. admin_enterprise + null通用管理端企业配置
* 3. 任意一条 admin_enterprise
* 4. enterprise + null超管全局兜底
*
* @param string $type personal|enterprise|deep
* @param int|null $enterpriseId 有则优先读该企业专属配置
* @return \app\model\PricingConfig|null
*/
public static function getByTypeAndEnterprise(string $type, ?int $enterpriseId = null): ?self
{
if ($type === 'personal') {
if (!empty($enterpriseId)) {
$row = self::where('type', 'admin_personal')->where('enterpriseId', $enterpriseId)->find();
if ($row) return $row;
}
// 通用管理端个人配置admin_personal + null
$row = self::where('type', 'admin_personal')->whereNull('enterpriseId')->find();
if ($row) return $row;
// 任意管理端个人配置(兜底:管理端配过就不走超管)
$row = self::where('type', 'admin_personal')->order('id', 'asc')->find();
if ($row) return $row;
// 超管全局个人定价(最后兜底,仅管理端完全未配置时使用)
return self::where('type', 'personal')->whereNull('enterpriseId')->find();
}
if ($type === 'enterprise') {
if (!empty($enterpriseId)) {
$row = self::where('type', 'admin_enterprise')->where('enterpriseId', $enterpriseId)->find();
if ($row) return $row;
}
// 通用管理端企业配置admin_enterprise + null
$row = self::where('type', 'admin_enterprise')->whereNull('enterpriseId')->find();
if ($row) return $row;
// 任意管理端企业配置(兜底)
$row = self::where('type', 'admin_enterprise')->order('id', 'asc')->find();
if ($row) return $row;
return self::where('type', 'enterprise')->whereNull('enterpriseId')->find();
}
if ($type === 'deep') {
return self::where('type', 'deep')->whereNull('enterpriseId')->find();
}
return self::where('type', $type)->whereNull('enterpriseId')->find();
}
}
<?php
namespace app\model;
use think\Model;
/**
* 定价配置模型
* 实际表名 = 数据库前缀 + pricing_config例如 .env 中 DATABASE_PREFIX=mbti_ 时为 mbti_pricing_config
*/
class PricingConfig extends Model
{
// 表名(不含前缀);最终访问表 = config(database.prefix) + pricing_config
protected $name = 'pricing_config';
// 设置字段信息(匹配数据库字段命名:驼峰命名)
protected $schema = [
'id' => 'int',
'type' => 'string',
'enterpriseId' => 'int',
'config' => 'string',
'createdAt' => 'int',
'updatedAt' => 'int',
];
// 自动时间戳(使用驼峰命名,时间戳格式)
protected $autoWriteTimestamp = 'int';
// 时间戳字段名(驼峰命名,匹配数据库)
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
// 时间字段类型(时间戳格式)
protected $type = [
'createdAt' => 'integer',
'updatedAt' => 'integer',
];
// JSON字段自动转换
protected $json = ['config'];
/**
* 配置修改器自动转换为JSON
*/
public function setConfigAttr($value)
{
if (is_array($value)) {
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
return $value;
}
/**
* 配置获取器自动解析JSON
*/
public function getConfigAttr($value)
{
if (is_string($value)) {
return json_decode($value, true);
}
return $value;
}
/**
* 按类型与可选企业ID取定价配置
*
* personal个人版优先级
* 1. admin_personal + enterpriseId企业专属管理端配置有 eid 时)
* 2. personal + null超管全局,仅未绑定企业或该企业未配置时使用
*
* enterprise企业版优先级
* 1. admin_enterprise + enterpriseId有 eid 时)
* 2. enterprise + null超管全局兜底
*
* @param string $type personal|enterprise|deep
* @param int|null $enterpriseId 有则优先读该企业专属配置
* @return \app\model\PricingConfig|null
*/
public static function getByTypeAndEnterprise(string $type, ?int $enterpriseId = null): ?self
{
if ($type === 'personal') {
if (!empty($enterpriseId)) {
$row = self::where('type', 'admin_personal')->where('enterpriseId', $enterpriseId)->find();
if ($row) return $row;
}
// 超管全局个人定价(最后兜底,仅管理端完全未配置时使用)
return self::where('type', 'personal')->whereNull('enterpriseId')->find();
}
if ($type === 'enterprise') {
if (!empty($enterpriseId)) {
$row = self::where('type', 'admin_enterprise')->where('enterpriseId', $enterpriseId)->find();
if ($row) return $row;
}
return self::where('type', 'enterprise')->whereNull('enterpriseId')->find();
}
if ($type === 'deep') {
return self::where('type', 'deep')->whereNull('enterpriseId')->find();
}
return self::where('type', $type)->whereNull('enterpriseId')->find();
}
}