规范气站与配送点提现渠道

This commit is contained in:
czl231
2026-08-19 23:43:02 +08:00
parent 2f5b934037
commit b93202274d
13 changed files with 148 additions and 12 deletions

View File

@@ -0,0 +1,11 @@
// Package common 提供跨管理端复用的提现渠道约束。
// 版本v1.0.0
package common
// OrganizationWithdrawalChannelBank 是当前组织钱包唯一开放的提现渠道。
const OrganizationWithdrawalChannelBank = "bank"
// IsSupportedOrganizationWithdrawalChannel 判断气站或配送点提交的渠道是否已开放。
func IsSupportedOrganizationWithdrawalChannel(channel string) bool {
return channel == OrganizationWithdrawalChannelBank
}

View File

@@ -0,0 +1,15 @@
package common
import "testing"
// TestIsSupportedOrganizationWithdrawalChannel 验证组织端只开放银行卡提现。
func TestIsSupportedOrganizationWithdrawalChannel(t *testing.T) {
if !IsSupportedOrganizationWithdrawalChannel("bank") {
t.Fatal("银行卡提现渠道应当可用")
}
for _, channel := range []string{"alipay", "wechat", "", " bank "} {
if IsSupportedOrganizationWithdrawalChannel(channel) {
t.Fatalf("未开放的提现渠道不应通过校验:%q", channel)
}
}
}

View File

@@ -206,7 +206,8 @@ func CreateApplyCash(ctx *gin.Context) {
Channel string `json:"channel" binding:"required,max=32"`
Remark string `json:"remark" binding:"max=2000"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || request.Amount <= 0 || strings.TrimSpace(request.RequestNo) == "" {
if err := ctx.ShouldBindJSON(&request); err != nil || request.Amount <= 0 || strings.TrimSpace(request.RequestNo) == "" ||
!common.IsSupportedOrganizationWithdrawalChannel(request.Channel) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}

View File

@@ -133,7 +133,8 @@ func CreateWalletApplyCash(ctx *gin.Context) {
Remark string `json:"remark" binding:"max=2000"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || request.Amount <= 0 ||
strings.TrimSpace(request.RequestNo) == "" {
strings.TrimSpace(request.RequestNo) == "" ||
!common.IsSupportedOrganizationWithdrawalChannel(request.Channel) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}

View File

@@ -127,6 +127,7 @@
- `payment_order` 是统一支付尝试事实,气站端必须通过 `business_type + business_identity` 关联燃气配送订单、商城订单或气站自有钱包充值单,并验证业务对象属于当前气站;不得假设支付单直接包含钱包主键。
- 未知业务类型、业务对象缺失以及用户或工作人员钱包充值无法证明属于当前气站,不得向气站端返回。钱包余额支付当前记录在 `wallet_record`,不伪装为统一支付单。
- 气站可以创建提现申请并查看处理结果。
- 提现申请保留渠道字段并使用下拉选择;当前默认且仅开放“银行卡提现”,支付宝提现和微信提现显示“暂未开通”且不可选择,服务端拒绝未开放渠道。
- 气站不能充值、冻结钱包,不能审批、驳回或标记提现完成。
- 所有金额使用最小货币单位整数,服务端校验余额和归属。
- `fin_reconciliation` 当前模型没有气站主体字段,为避免泄露平台全局渠道数据,气站端在补齐可验证归属前不返回全局记录。

View File

@@ -152,6 +152,7 @@ Global:
- `payment_order` 是统一支付尝试事实,配送点端必须通过 `business_type + business_identity` 关联燃气配送订单、商城订单或配送点自有钱包充值单,并验证业务对象属于当前配送点;不得假设支付单直接包含钱包主键。
- 未知业务类型、业务对象缺失以及用户或工作人员钱包充值无法证明属于当前配送点,不得向配送点端返回。钱包余额支付当前记录在 `wallet_record`,不伪装为统一支付单。
- 配送点可以创建提现申请,但不能审批、驳回或标记完成。
- 提现申请保留渠道字段并使用下拉选择;当前默认且仅开放“银行卡提现”,支付宝提现和微信提现显示“暂未开通”且不可选择,服务端拒绝未开放渠道。
- 配送点管理员可以给当前配送点钱包自主充值。
- 充值必须填写幂等号、金额、原因和备注。
- 单笔充值金额必须大于零且不超过 `Global.ManualRechargeMaxAmount`

View File

@@ -0,0 +1,50 @@
# 操作日志:组织提现渠道规范化
操作时间2026-08-19
操作类型:修改
影响模块:气站管理系统、配送点管理系统、组织钱包提现接口
## 操作前状态
- 气站端和配送点端的提现渠道使用必填文本框,申请人可以提交任意字符串。
- 渠道没有默认值,列表和详情会直接显示内部编码。
- 服务端只校验渠道非空,可能保存当前无法处理的渠道。
## 具体操作
- 两个管理端的渠道字段统一改为下拉框。
- 银行卡提现默认选中并可用;支付宝提现和微信提现保留展示,但标记“暂未开通”并禁用。
- 列表和详情将 `bank``alipay``wechat` 映射为对应中文名称。
- 服务端新增组织提现渠道白名单,当前只接受 `bank`
- 增加渠道白名单单元测试与前端静态回归检查。
## 操作后状态
- 用户无需填写内部渠道编码,新建申请默认使用银行卡提现。
- 未开通渠道无法在页面选择,也不能绕过页面直接提交。
- 历史记录按已知渠道显示中文名称,未知值保留“未知提现渠道(原值)”提示。
- 银行卡数据来源、提现余额预扣、审核及打款流程均未改变。
## 代码变更
- `frontend/gas_admin/src/api/resources.ts`:增加气站端提现渠道选项和默认值。
- `frontend/gas_admin/src/views/resource/ResourceFieldForm.vue`:支持禁用未开通选项。
- `frontend/delivery_admin/src/api/resources.ts`:增加配送点端提现渠道选项和默认值。
- `frontend/delivery_admin/src/views/shared/CrudListPage.vue`:支持禁用未开通选项。
- `backend/api/internal/logic/common/withdrawal_channel.go`:定义组织端提现渠道白名单。
- `backend/api/internal/logic/gas/finance.go`:校验气站提现渠道。
- `backend/api/internal/logic/delivery/finance.go`:校验配送点提现渠道。
- `docs/06-气站管理系统需求.md``docs/07-配送点管理系统需求.md`:同步提现渠道规则。
## 验证结果
- 后端全量测试 `go test ./...`:通过。
- 气站端契约检查通过20 个资源一致。
- 配送点端契约检查通过18 个资源一致。
- 气站端和配送点端类型检查及生产构建:通过。
## 风险评估
- 现有 `bank` 提现保持兼容;新限制只拒绝此前可任意填写的未开放渠道。
- 历史支付宝或微信记录仍可正常展示,不会被修改。
- 本次不处理组织银行卡绑定入口和银行卡空数据问题。

View File

@@ -35,6 +35,16 @@ for (const item of contract.resources) {
throw new Error(`缺少后端资源路由:${item.name}`);
}
if (!source.includes("defaultValue: 'bank'"))
throw new Error('提现申请必须默认选择银行卡提现');
for (const option of [
"{ label: '银行卡提现', value: 'bank' }",
"{ label: '支付宝提现', value: 'alipay', disabled: true }",
"{ label: '微信提现', value: 'wechat', disabled: true }",
]) {
if (!source.includes(option)) throw new Error(`提现渠道配置缺失:${option}`);
}
const forbidden = [
'/platform/platform_', '/gas/gas_', '/delivery/delivery_', '/staff/',
'/user/', '/ec/ec_', '/finance/fin_', '/wallet/wallet',

View File

@@ -32,7 +32,7 @@ export type ResourceField = {
readonlyRelationText?: boolean;
/** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */
listCopyable?: boolean;
options?: Array<{ label: string; value: string | number }>;
options?: Array<{ label: string; value: string | number; disabled?: boolean }>;
defaultValue?: string | number | boolean;
readonly?: boolean;
unknownValueLabel?: string;
@@ -336,6 +336,19 @@ function define(
const reason = [f('reason', { required: true })];
/** 提现渠道保留未来选项,但当前仅开放银行卡提现。 */
const withdrawalChannelField = f('channel', {
required: true,
type: 'select',
defaultValue: 'bank',
unknownValueLabel: '未知提现渠道',
options: [
{ label: '银行卡提现', value: 'bank' },
{ label: '支付宝提现', value: 'alipay', disabled: true },
{ label: '微信提现', value: 'wechat', disabled: true },
],
});
const platformResources: ResourceUiDefinition[] = [
{ ...define('gas_basic', '气站管理', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), accountManagement: { resource: '/gas_account', relationKey: 'gas_basic_identity', title: '气站账户' }, walletOwnerType: 'gas' },
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('气站管理员'), relation('gas_basic_identity', '/gas_basic', true)]),
@@ -476,7 +489,7 @@ const gasOverrides: ResourceUiDefinition[] = [
define('wallet_payment', '支付记录', 'readonly', []),
define('wallet_record', '钱包流水', 'readonly', []),
define('wallet_refund', '退款记录', 'readonly', []),
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), f('channel', { required: true }), f('remark')]),
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), withdrawalChannelField, f('remark')]),
define('fin_settlement', '财务结算', 'readonly', []),
define('fin_reconciliation', '财务对账', 'readonly', []),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), f('category', { required: true }), f('priority', { required: true })]),
@@ -516,7 +529,7 @@ const deliveryOverrides: ResourceUiDefinition[] = [
define('wallet_record', '钱包流水', 'readonly', []),
define('wallet_refund', '退款记录', 'readonly', []),
define('wallet_recharge', '钱包充值', 'append_only', [f('request_no', { required: true }), f('amount', { required: true }), ...reason, f('remark')]),
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), f('channel', { required: true }), f('remark')]),
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), withdrawalChannelField, f('remark')]),
define('fin_settlement', '结算结果', 'readonly', []),
];

View File

@@ -173,7 +173,7 @@
<a-textarea v-else-if="field.type === 'textarea'" v-model="form[field.key]" :auto-size="{ minRows: 3, maxRows: 8 }" />
<a-input-password v-else-if="field.type === 'password'" v-model="form[field.key]" />
<a-select v-else-if="field.type === 'select'" v-model="form[field.key]" :disabled="field.readonly" allow-clear>
<a-option v-for="option in selectFieldOptions(field)" :key="option.value" :value="option.value">{{ option.label }}</a-option>
<a-option v-for="option in selectFieldOptions(field)" :key="option.value" :value="option.value" :disabled="option.disabled">{{ option.label }}{{ option.disabled ? '暂未开通' : '' }}</a-option>
</a-select>
<a-select v-else-if="field.type === 'identity' || field.type === 'identity-list'" v-model="form[field.key]" :multiple="field.type === 'identity-list'" allow-clear allow-search :loading="relationLoading[field.relation ?? '']" @search="(value: string) => searchRelation(field.relation, value)">
<a-option v-for="option in relationOptions[field.relation ?? ''] ?? []" :key="String(option.identity)" :value="String(option.identity)">
@@ -243,7 +243,7 @@
<a-textarea v-else-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-date-picker v-else-if="field.type === 'datetime'" v-model="actionForm[field.key]" show-time value-format="YYYY-MM-DDTHH:mm:ssZ" />
<a-select v-else-if="field.type === 'select'" v-model="actionForm[field.key]">
<a-option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</a-option>
<a-option v-for="option in field.options" :key="option.value" :value="option.value" :disabled="option.disabled">{{ option.label }}{{ option.disabled ? '暂未开通' : '' }}</a-option>
</a-select>
<a-select v-else-if="field.type === 'identity' || field.type === 'identity-list'" v-model="actionForm[field.key]" :multiple="field.type === 'identity-list'" allow-search :loading="relationLoading[field.relation ?? '']" @search="(value: string) => searchRelation(field.relation, value)">
<a-option v-for="option in relationOptions[field.relation ?? ''] ?? []" :key="String(option.identity)" :value="String(option.identity)">{{ optionLabel(option) }}</a-option>

View File

@@ -13,6 +13,10 @@ const listPageSource = readFileSync(
resolve(root, 'src/views/shared/CrudListPage.vue'),
'utf8',
);
const fieldFormSource = readFileSync(
resolve(root, 'src/views/resource/ResourceFieldForm.vue'),
'utf8',
);
const contract = JSON.parse(
readFileSync(resolve(root, 'src/contracts/gas-resources.json'), 'utf8'),
);
@@ -54,6 +58,17 @@ if (searchSource.includes('关键字段模糊搜索'))
throw new Error('列表搜索不得使用含义不明确的兜底提示');
if (!listPageSource.includes('<a-form v-if="searchEnabled"'))
throw new Error('不支持搜索的资源必须隐藏完整搜索区域');
if (!source.includes("defaultValue: 'bank'"))
throw new Error('提现申请必须默认选择银行卡提现');
for (const option of [
"{ label: '银行卡提现', value: 'bank' }",
"{ label: '支付宝提现', value: 'alipay', disabled: true }",
"{ label: '微信提现', value: 'wechat', disabled: true }",
]) {
if (!source.includes(option)) throw new Error(`提现渠道配置缺失:${option}`);
}
if (!fieldFormSource.includes(':disabled="option.disabled"'))
throw new Error('未开通的提现渠道必须在下拉框中禁用');
const forbidden = [
'/platform/platform_', '/gas/gas_', '/delivery/delivery_', '/staff/',

View File

@@ -75,7 +75,7 @@ export type ResourceField = {
listDisplayIdentityCopy?: boolean;
listHidden?: boolean;
displayPrecision?: number;
options?: Array<{ label: string; value: string | number }>;
options?: Array<{ label: string; value: string | number; disabled?: boolean }>;
defaultValue?: string | number | boolean;
readonly?: boolean;
readonlyOnCreate?: boolean;
@@ -400,6 +400,19 @@ function define(
const reason = [f('reason', { required: true })];
/** 提现渠道保留未来选项,但当前仅开放银行卡提现。 */
const withdrawalChannelField = f('channel', {
required: true,
type: 'select',
defaultValue: 'bank',
unknownValueLabel: '未知提现渠道',
options: [
{ label: '银行卡提现', value: 'bank' },
{ label: '支付宝提现', value: 'alipay', disabled: true },
{ label: '微信提现', value: 'wechat', disabled: true },
],
});
const platformResources: ResourceUiDefinition[] = [
{ ...define('gas_basic', '气站管理', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), accountManagement: { resource: '/gas_account', relationKey: 'gas_basic_identity', title: '气站账户' }, walletOwnerType: 'gas' },
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('气站管理员'), relation('gas_basic_identity', '/gas_basic', true)]),
@@ -540,7 +553,7 @@ const gasOverrides: ResourceUiDefinition[] = [
define('payment_order', '支付记录', 'readonly', []),
define('wallet_record', '钱包流水', 'readonly', []),
define('payment_refund', '退款记录', 'readonly', []),
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), f('channel', { required: true }), f('remark')]),
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), withdrawalChannelField, f('remark')]),
define('fin_settlement', '财务结算', 'readonly', []),
define('fin_reconciliation', '财务对账', 'readonly', []),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), f('category', { required: true }), f('priority', { required: true })]),

View File

@@ -1,6 +1,6 @@
<!--
功能渲染标准资源的新建编辑和业务动作字段控件
版本v1.4.1
版本v1.4.2
-->
<template>
<div class="field-grid">
@@ -78,8 +78,13 @@
allow-clear
@change="(value: unknown) => emit('change-relation', field, value)"
>
<a-option v-for="option in selectOptions(field)" :key="option.value" :value="option.value">
{{ option.label }}
<a-option
v-for="option in selectOptions(field)"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}{{ option.disabled ? '(暂未开通)' : '' }}
</a-option>
</a-select>
<div v-else-if="field.type === 'identity' || field.type === 'identity-list'" class="relation-control">