---
name: free credits activity edit
overview: 在管理后台活动配置编辑表单中为活动类型 11(首充前免费余额定格与分档释放 / Free Credits)增加专属表单分支,并补齐字典与服务端校验;金额输入沿用 type==10 的「美元小数 → 千分位整数」模式,写入 ext_config 的 _qf 后缀键,与 slot_console `FreeCreditsLogic::configAmount()` 的读优先级一致。
todos:
- id: dict-activity-type-11
content: 字典 sm_system_dict_data 追加 code='activity_type' 的 value=11 / label='Free Credits(首充前免费余额)'(DB 或字典管理页)
status: completed
- id: edit-vue-type11-form
content: edit.vue 增加 v-if=type==11 的表单块(6 字段 + Banner)
status: completed
- id: edit-vue-skip-goods
content: edit.vue 让 type==11 跳过 goods 区块:扩展 onlyGift / 调整「添加赠送」与 goods 卡片的 v-if
status: completed
- id: edit-vue-submit-conv
content: edit.vue submit() 增加 type==11 的金额 ×1000 写入 _qf 键、清空 goods
status: completed
- id: edit-vue-setform-conv
content: edit.vue setFormData() 增加 type==11 的 _qf ÷1000 回填还原
status: completed
- id: validate-free-credits-ext
content: ActivityValidate 增加 checkFreeCreditsExt(extConfig) 方法,含必填、非负、max_unlock_per_recharge≥1、first_cash ≤ recharge_unlock 约束
status: completed
- id: controller-trigger-ext-check
content: ActivityController::save / update 在 checkData 后按 type==11 调用 checkFreeCreditsExt;update 兼容仅改状态请求
status: completed
- id: verify-end-to-end
content: 本地验证:新建/编辑/必填/业务约束/类型切换/C 端 FreeCreditsLogic 读取
status: completed
isProject: false
---
# Free Credits 活动后台编辑落地计划
## 目标
让运营在「活动管理」编辑弹窗里选活动类型 `11`(首充前免费余额)时,能直接编辑需求文档第 17 节列出的所有配置项;保存后落到 `s_recharge_gift_config.ext_config` JSON 字段,C 端 `FreeCreditsLogic` 立即生效。
## 现状要点
- `slot_console` 已实现 `RechargeGiftConfigModel::TYPE_FREE_CREDITS = 11`、`FreeCreditsLogic`、运行时配置读取(`ext_config.{key}_qf` 优先)。
- `slot_admin` 后端 [ActivityController](backend/slot_admin/app/game/controller/ActivityController.php) 走 `slotLib\services\ActivityService` 透传到 `slot_console innerapi/activity/*`,`ext_config` 已原样落库,**不需要改动 slot_console**。
- 管理前端 [edit.vue](backend/slot_admin_vue/src/views/game/activity/edit.vue) 现仅对 6 / 9 / 10 做了 `v-if` 分支,`type==11` 无任何 UI;字典 `activity_type` 也无 value=11 条目。
## 字段映射(type=11 专属 `ext_config`)
UI 输入用美元小数,提交时 ×1000 写入 `_qf` 键(与 type==10 模式一致;`FreeCreditsLogic::configAmount()` 优先读 `_qf`):
- `win_threshold_qf` ← 首笔赢取门槛(默认 $50)
- `recharge_unlock_amount_qf` ← 累计充值解锁第一档(默认 $50)
- `first_cash_amount_qf` ← 第一档免打码金额(默认 $20)
- `package_amount_qf` ← 后续每档拆分金额(默认 $10)
- `subsequent_min_recharge_qf` ← 解锁下一档单笔充值下限(默认 $10)
- `max_unlock_per_recharge` ← 整数,每笔充值最多解锁档数(默认 1,**不走 _qf**)
- `banner_image` ← Free Play to Go 弹窗 Banner 图片 URL(可空)
## 改动清单
### 1. 字典:追加 `activity_type` value=11
在系统管理「字典管理 → activity_type」以有,不需要再执行了
### 2. 前端:[backend/slot_admin_vue/src/views/game/activity/edit.vue](backend/slot_admin_vue/src/views/game/activity/edit.vue)
#### 2.1 增加 type==11 表单块
参考 [edit.vue line 108-123](backend/slot_admin_vue/src/views/game/activity/edit.vue) type==10 的写法,新增:
```vue
```
#### 2.2 让 type==11 跳过 goods 区块
- [edit.vue line 60](backend/slot_admin_vue/src/views/game/activity/edit.vue) `onlyGift` computed 追加 `|| formData.type == 11`:
```js
let onlyGift = computed(() => {
return formData.type == 7 || formData.type == 8 || formData.type == 9 || formData.type == 11;
})
```
- [edit.vue line 125](backend/slot_admin_vue/src/views/game/activity/edit.vue) 把「添加赠送」按钮与下方 `` 的 goods 卡片包成一组条件,排除 type==10 和 type==11:
```vue
添加赠送
...goods 卡片...
```
(保留原 type==9 的 exchange 表格 v-if 不变。)
#### 2.3 `submit()` 中追加 type==11 的金额 ×1000 转换
[edit.vue line 370](backend/slot_admin_vue/src/views/game/activity/edit.vue) 类似 type==10 的处理,把 6 个美元字段写到 `_qf` 键,整数字段原样保留:
```js
if (formData.type === 11) {
const e = data.ext_config || {};
const toQf = (v) => v === '' || v == null ? undefined : Math.round(Number(v) * 1000);
data.ext_config = {
win_threshold_qf: toQf(e.win_threshold),
recharge_unlock_amount_qf: toQf(e.recharge_unlock_amount),
first_cash_amount_qf: toQf(e.first_cash_amount),
package_amount_qf: toQf(e.package_amount),
subsequent_min_recharge_qf: toQf(e.subsequent_min_recharge),
max_unlock_per_recharge: e.max_unlock_per_recharge == null ? undefined : Math.round(Number(e.max_unlock_per_recharge)),
banner_image: e.banner_image || '',
};
data.goods = [];
}
```
#### 2.4 `setFormData()` 中追加 type==11 的回填还原
[edit.vue line 344-351](backend/slot_admin_vue/src/views/game/activity/edit.vue) 仿照 type==10:
```js
if (data.type === 11 && data.ext_config) {
const e = data.ext_config;
formData.ext_config = {
win_threshold: e.win_threshold_qf != null ? e.win_threshold_qf / 1000 : e.win_threshold,
recharge_unlock_amount: e.recharge_unlock_amount_qf != null ? e.recharge_unlock_amount_qf / 1000 : e.recharge_unlock_amount,
first_cash_amount: e.first_cash_amount_qf != null ? e.first_cash_amount_qf / 1000 : e.first_cash_amount,
package_amount: e.package_amount_qf != null ? e.package_amount_qf / 1000 : e.package_amount,
subsequent_min_recharge: e.subsequent_min_recharge_qf != null ? e.subsequent_min_recharge_qf / 1000 : e.subsequent_min_recharge,
max_unlock_per_recharge: e.max_unlock_per_recharge ?? 1,
banner_image: e.banner_image || '',
};
}
```
### 3. 后端校验:[backend/slot_admin/app/game/validate/ActivityValidate.php](backend/slot_admin/app/game/validate/ActivityValidate.php)
ThinkValidate 对嵌套 JSON 支持有限,采用「在 Controller 按 `type` 触发额外校验」+「Validate 提供专用方法」的模式,避免污染既有 scene。
#### 3.1 ActivityValidate 增加一个公开方法
```php
/**
* Free Credits(type=11)专用 ext_config 校验。
*
* @param array $extConfig 前端提交的 ext_config,金额已 ×1000 写入 _qf 键
* @throws \think\exception\ValidateException
*/
public function checkFreeCreditsExt(array $extConfig): void
{
$required = [
'win_threshold_qf' => '首笔赢取门槛',
'recharge_unlock_amount_qf' => '充值解锁门槛',
'first_cash_amount_qf' => '免打码提现额',
'package_amount_qf' => '解锁拆分金额',
'subsequent_min_recharge_qf' => '后续解锁最小充值',
'max_unlock_per_recharge' => '每笔最多解锁档数',
];
foreach ($required as $key => $label) {
if (!isset($extConfig[$key]) || $extConfig[$key] === '' || (int)$extConfig[$key] < 0) {
throw new \think\exception\ValidateException("{$label}必须填写且为非负数");
}
}
if ((int)$extConfig['max_unlock_per_recharge'] < 1) {
throw new \think\exception\ValidateException('每笔最多解锁档数必须 ≥ 1');
}
// 业务约束:免打码提现额 ≤ 累计充值解锁门槛
if ((int)$extConfig['first_cash_amount_qf'] > (int)$extConfig['recharge_unlock_amount_qf']) {
throw new \think\exception\ValidateException('免打码提现额不能超过充值解锁门槛');
}
}
```
#### 3.2 ActivityController save / update 触发
[ActivityController::save / update](backend/slot_admin/app/game/controller/ActivityController.php) 在 `checkData()` 之后追加:
```php
if ((int)input('type') === 11) {
$this->validate->checkFreeCreditsExt((array)input('ext_config', []));
}
```
`update` 里同样判断,且要兼容仅改状态的请求(无 `updateData` 时不校验 ext_config):
```php
if (!empty(input('updateData')) && (int)input('type') === 11) {
$this->validate->checkFreeCreditsExt((array)input('ext_config', []));
}
```
### 4. 不需要改的部分
- `slot_console` 侧 `ActivityConfigEntity::updateConfig` 已原样写入 `ext_config`,无变更。
- `Consts::ACTIVITY_TYPE_*` 不必新增 11;C 端已通过 `RechargeGiftConfigModel::TYPE_FREE_CREDITS` 引用。
- 不新增独立菜单页,复用通用「活动管理」编辑弹窗。
## 数据流
```mermaid
flowchart LR
edit[edit.vue type=11 form] -->|"美元×1000 → _qf"| save["POST /game/activity/save|update"]
save --> ctrl[ActivityController]
ctrl -->|"checkFreeCreditsExt"| validate[ActivityValidate]
ctrl --> svc[ActivityService HTTP]
svc --> console["slot_console innerapi/activity/update"]
console --> entity[ActivityConfigEntity::updateConfig]
entity --> db["s_recharge_gift_config.ext_config"]
db --> fc["FreeCreditsLogic::configAmount key_qf 优先"]
```
## 验收
- 新建 type=11 活动:填写 6 字段 + Banner,保存成功;DB `ext_config` 是 `*_qf` 整数 + `max_unlock_per_recharge` + `banner_image`。
- 编辑回填:再次打开同一条活动,美元字段显示为原值(如 50 / 20 / 10),整数字段为 1,Banner 显示已上传图。
- 必填校验:清空任一必填项保存,返回明确错误信息(如「首笔赢取门槛必须填写且为非负数」)。
- 业务约束:免打码提现额填 60、充值解锁门槛填 50,保存被拒。
- 类型切换:type 选 11→1,再切回 11,goods 区块不出现,formData 不污染;保存 type==1 时 `_qf` 字段不会被带到 ext_config。
- C 端:调 `FreeCreditsLogic::status($uid)`,门槛 / 第一档金额 / 解锁拆分金额能读到刚配的值。
- type 字典:管理后台编辑弹窗活动类型下拉出现「Free Credits(首充前免费余额)」选项。