diff --git a/backend/api/internal/logic/common/resource.go b/backend/api/internal/logic/common/resource.go index 1b5db2f..b1724b2 100644 --- a/backend/api/internal/logic/common/resource.go +++ b/backend/api/internal/logic/common/resource.go @@ -135,13 +135,20 @@ func isCreatedResponseField(key string) bool { } func ProtectPreciseLocation(ctx *gin.Context, model, value any) any { - maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) || - reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{}) - maskDisplayName := reflect.TypeOf(model) == reflect.TypeOf(&models.PlatformAccount{}) - ProtectPublicFields(value, maskPersonalName, maskDisplayName, HasPreciseLocationScope(ctx)) + retainPlatformPersonalData := canViewPlatformPersonalData(ctx) + maskPersonalName := !retainPlatformPersonalData && (reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) || + reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{})) + maskDisplayName := !retainPlatformPersonalData && reflect.TypeOf(model) == reflect.TypeOf(&models.PlatformAccount{}) + protectPublicFields(value, maskPersonalName, maskDisplayName, HasPreciseLocationScope(ctx), retainPlatformPersonalData) return value } +// canViewPlatformPersonalData 仅允许已通过平台总后台鉴权和菜单校验的请求查看姓名与主手机号明文。 +func canViewPlatformPersonalData(ctx *gin.Context) bool { + claims, err := middleware.ParseAuth(ctx) + return err == nil && claims.Client == "platform_admin" +} + var sensitiveResponseFields = map[string]bool{ "avatar": true, "address": true, "credential_no": true, "evidence_uri": true, "evidence_url": true, "file_uri": true, @@ -152,12 +159,18 @@ var sensitiveResponseFields = map[string]bool{ } func ProtectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates bool) { + protectPublicFields(value, maskPersonalName, maskDisplayName, retainCoordinates, false) +} + +func protectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates, retainPrimaryPhone bool) { switch data := value.(type) { case map[string]any: - if phone, ok := data["phone"].(string); ok && phone != "" { - data["phone_masked"] = MaskPhone(phone) + if !retainPrimaryPhone { + if phone, ok := data["phone"].(string); ok && phone != "" { + data["phone_masked"] = MaskPhone(phone) + } + delete(data, "phone") } - delete(data, "phone") for _, key := range []string{"contact_phone", "recipient_phone"} { if phone, ok := data[key].(string); ok && phone != "" { data[key+"_masked"] = MaskPhone(phone) @@ -194,11 +207,11 @@ func ProtectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoo delete(data, "latitude") } for _, item := range data { - ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) + protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates, retainPrimaryPhone) } case []any: for _, item := range data { - ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) + protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates, retainPrimaryPhone) } } } diff --git a/backend/api/internal/logic/common/resource_test.go b/backend/api/internal/logic/common/resource_test.go index 2be115f..9e68caf 100644 --- a/backend/api/internal/logic/common/resource_test.go +++ b/backend/api/internal/logic/common/resource_test.go @@ -5,8 +5,10 @@ import ( "strings" "testing" + "git.apinb.com/bsm-sdk/core/types" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -143,6 +145,55 @@ func TestPublicFieldProtectionMasksGasorderContacts(t *testing.T) { } } +func TestPlatformAdminResourceResponseRetainsNamesAndPrimaryPhone(t *testing.T) { + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("Auth", &types.JwtClaims{Client: "platform_admin"}) + + user := map[string]any{ + "name": "张三", "real_name": "张三", "phone": "13800138000", + "address": "敏感地址", "contact_phone": "13900139000", + } + ProtectPreciseLocation(ctx, &models.UserAccount{}, user) + if user["name"] != "张三" || user["real_name"] != "张三" || user["phone"] != "13800138000" { + t.Fatalf("platform personal data was masked: %#v", user) + } + if _, exists := user["phone_masked"]; exists { + t.Fatalf("platform response contains an unexpected phone mask: %#v", user) + } + if _, exists := user["address"]; exists { + t.Fatalf("platform exception exposed an address: %#v", user) + } + if user["contact_phone_masked"] != "139****9000" { + t.Fatalf("order contact phone was not kept masked: %#v", user) + } + + account := map[string]any{"display_name": "平台主管", "phone": "13700137000"} + ProtectPreciseLocation(ctx, &models.PlatformAccount{}, account) + if account["display_name"] != "平台主管" || account["phone"] != "13700137000" { + t.Fatalf("platform account data was masked: %#v", account) + } +} + +func TestNonPlatformResourceResponseStillMasksNamesAndPhone(t *testing.T) { + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("Auth", &types.JwtClaims{Client: "user_app"}) + value := map[string]any{"name": "张三", "real_name": "张三", "phone": "13800138000"} + + ProtectPreciseLocation(ctx, &models.UserAccount{}, value) + if _, exists := value["name"]; exists { + t.Fatalf("non-platform response exposed a name: %#v", value) + } + if _, exists := value["real_name"]; exists { + t.Fatalf("non-platform response exposed a real name: %#v", value) + } + if _, exists := value["phone"]; exists { + t.Fatalf("non-platform response exposed a phone: %#v", value) + } + if value["name_masked"] != "张*" || value["phone_masked"] != "138****8000" { + t.Fatalf("non-platform response masks are incorrect: %#v", value) + } +} + func TestResourceRelationOptionalEmptyIdentityClearsRelation(t *testing.T) { values, err := ResolveResourceRelations( map[string]any{"warehouse_identity": ""}, diff --git a/docs/05-平台总后台需求.md b/docs/05-平台总后台需求.md index b18aee1..a1c92c7 100644 --- a/docs/05-平台总后台需求.md +++ b/docs/05-平台总后台需求.md @@ -90,6 +90,7 @@ - 气站、配送点、人员和用户菜单可读取其详情内的钱包摘要;提现菜单可读取钱包信息用于审核。 - 角色的定位范围只允许 `standard`(脱敏坐标)或 `precise`(精确坐标)。root 默认使用精确坐标。 - 前端菜单隐藏只负责展示,最终权限由后端中间件执行。 +- 已通过平台总后台 JWT 和对应菜单权限校验的管理员,在用户、工作人员和平台账户的列表与详情中可查看姓名、显示名称和主手机号明文;该例外不扩展到其他终端,也不放宽身份证明、地址、头像、资质编号、订单联系人或收款账户的保护规则。 ## 5. 首页与报表 @@ -220,7 +221,7 @@ | 退款 | `/wallet_refund` | 只读 | 查询退款记录 | | 提现 | `/wallet_apply_cash` | 只读 + 动作 | 审核通过、审核驳回、标记处理完成 | -钱包拥有者类型限定为气站、配送点、工作人员或用户。账户号等敏感字段在列表和非精确权限下脱敏。 +钱包拥有者类型限定为气站、配送点、工作人员或用户。除平台总后台已授权管理员可查看用户、工作人员和平台账户的姓名与主手机号外,账户号等其他敏感字段在列表和非精确权限下继续脱敏。 后台充值必须携带唯一请求号、金额、是否进入可提现余额、原因和备注;后端在事务中更新余额并追加钱包流水,重复请求不得重复入账。 diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index e8c373b..d4ba438 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -95,7 +95,7 @@ - 登录令牌短期有效,刷新令牌可撤销;后台高权限账号启用 MFA、IP/设备策略。平台后台管理的平台、气站、配送、员工和业主账号密码按当前实施口径仅要求不少于 6 个字符,不附加复杂度校验。 - 权限校验在服务端执行,前端菜单隐藏不构成权限控制。按角色、站点、区域、对象归属联合鉴权。 -- 手机号、地址、身份证明、收款账户、定位、视频为敏感数据:传输 TLS、存储加密/字段加密、显示脱敏、访问留痕、最小化留存。 +- 手机号、地址、身份证明、收款账户、定位、视频为敏感数据:传输 TLS、存储加密/字段加密、访问留痕、最小化留存。默认响应继续脱敏;仅已通过平台总后台 JWT 和对应菜单权限校验的管理员,可在用户、工作人员和平台账户的列表与详情中查看姓名、显示名称和主手机号明文。该例外不适用于其他终端,也不放宽身份证明、地址、头像、资质编号、订单联系人或收款账户的脱敏规则。 - 所有支付回调验证签名与金额、订单、商户号一致性;合同文件使用可信第三方原文与哈希存证。渠道回调入口为 `/heqi/payment-return/v1/{alipay|wechat}/notify`,不使用用户 JWT;必须完成渠道证书验签、商户/appid、平台支付单号、金额、币种和状态校验后才可在数据库事务中推进业务。重复通知必须幂等,原始敏感报文只保存摘要。 - `payment_order` 是统一支付尝试事实;`payment_refund` 与 `payment_refund_item` 保存用户退款申请及明细。审批通过与钱包入账必须同事务完成。 - 图片/视频上传做文件类型、大小、病毒/恶意内容检测;访问采用短期授权,不使用公开桶。 diff --git a/docs/12-验收与迭代规划.md b/docs/12-验收与迭代规划.md index f1da0a4..2aca843 100644 --- a/docs/12-验收与迭代规划.md +++ b/docs/12-验收与迭代规划.md @@ -46,6 +46,7 @@ - 可用性:安全告警、设备控制、支付回调等关键链路定义 SLO 和降级方案;上线前完成故障演练。 - 性能:以目标设备数量、每设备上报频率、峰值下单量、消息量为基准压测;容量指标须在立项时量化。 - 安全:完成权限越权、支付回调伪造、设备身份伪造、文件上传、敏感信息泄漏和常见 Web/App 攻击测试。 +- 隐私展示:平台总后台通过 JWT 和菜单鉴权后,用户、工作人员和平台账户列表与详情可显示姓名、显示名称和主手机号明文;其他终端、未授权请求及身份证明、地址、头像、资质编号、订单联系人和收款账户仍保持脱敏或不返回。 - 可观测性:每个关键请求可用 `request_id` 串联;每个设备命令、告警和订单状态可定位日志、指标和事件。 - 可用性与无障碍:移动端具备弱网提示、重要操作反馈、可读的风险颜色与文字;后台关键表格可键盘操作。 diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 79cbcd4..b55630a 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -21,9 +21,13 @@ export type ResourceFieldType = export type ResourceField = { key: string; label: string; + listLabel?: string; type?: ResourceFieldType; required?: boolean; relation?: string; + displayRelationLabel?: boolean; + emptyText?: string; + placeholder?: string; options?: Array<{ label: string; value: string | number }>; }; @@ -316,7 +320,7 @@ const reason = [f('reason', { required: true })]; export const resources: 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'), f('role_code'), relation('gas_basic_identity', '/gas_basic', true)]), - { ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), relation('gas_basic_identity', '/gas_basic'), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' }, walletOwnerType: 'delivery' }, + { ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), f('gas_basic_identity', { label: '气站', listLabel: '气站名称', type: 'identity', relation: '/gas_basic', displayRelationLabel: true, emptyText: '平台直属', placeholder: '请选择气站,留空表示平台直属' }), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' }, walletOwnerType: 'delivery' }, define('delivery_account', '配送站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('delivery_basic_identity', '/delivery_basic', true)]), { ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' }, define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 6bb46d7..659b562 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -34,9 +34,9 @@ - + @@ -169,7 +169,7 @@ {{ option.label }} - + {{ optionLabel(option) }} @@ -186,7 +186,7 @@
{{ formatValue(value) }}
- +
@@ -1027,11 +1027,15 @@ function relationLabel(field: ResourceField, identity: string) { const match = (relationOptions[field.relation ?? ''] ?? []).find( (option) => String(option.identity) === identity, ); - return match ? `${optionLabel(match)} · ${identity}` : identity; + if (!match) return identity; + return field.displayRelationLabel + ? optionLabel(match) + : `${optionLabel(match)} · ${identity}`; } function displayFieldValue(field: ResourceField, row: Row) { const value = row[field.key] ?? row[`${field.key}_masked`]; + if (value == null || value === '') return field.emptyText ?? '-'; if (field.options) { const option = field.options.find((item) => item.value === value); if (option) return option.label; @@ -1044,6 +1048,19 @@ function displayFieldValue(field: ResourceField, row: Row) { return displayValue(field.key, value); } +function displayDetailValue(key: string, value: unknown) { + const normalizedKey = key.endsWith('_masked') + ? key.slice(0, -'_masked'.length) + : key; + const field = props.definition.fields.find( + (item) => item.key === normalizedKey, + ); + if (field?.displayRelationLabel) { + return displayFieldValue(field, { [field.key]: value }); + } + return displayValue(key, value); +} + function displayValue(key: string, value: unknown) { if (value == null || value === '') return '-'; if (typeof value === 'boolean') return value ? '是' : '否';