修复:平台后台显示用户地址和坐标

仅在已授权的用户地址列表与详情接口恢复真实地址、经度和纬度。前端增加六位小数、未填写提示及编辑回填,并同步安全规范、项目文档和操作日志。
This commit is contained in:
czl231
2026-08-11 20:49:28 +08:00
parent e1e8579a2b
commit e375e9851b
8 changed files with 219 additions and 7 deletions

View File

@@ -1,3 +1,5 @@
// 功能描述:实现平台总后台的用户地址与服务关系管理。
// 版本v1.1
package user
import (
@@ -20,8 +22,69 @@ type addressRequest struct {
IsDefault bool `json:"is_default"`
}
func ListUserAddress(ctx *gin.Context) { common.ListPage[models.UserAddress](ctx) }
func GetUserAddress(ctx *gin.Context) { common.GetByIdentity[models.UserAddress](ctx) }
// ListUserAddress 查询用户地址分页列表,并为已授权的平台页面恢复地址与坐标。
func ListUserAddress(ctx *gin.Context) {
page, size := common.PageSize(ctx)
var list []models.UserAddress
var total int64
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.UserAddress{})), &models.UserAddress{})
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
response, err := common.PublicResourceResponse(list)
if err != nil {
infra.Response.Error(ctx, err)
return
}
protected := common.ProtectPreciseLocation(ctx, &models.UserAddress{}, response)
infra.Response.Success(ctx, gin.H{"total": total, "list": restoreUserAddressLocations(protected, list)})
}
// GetUserAddress 查询用户地址详情,并返回详情与编辑页需要的地址和坐标。
func GetUserAddress(ctx *gin.Context) {
var address models.UserAddress
if err := common.ActiveRecords(impl.DBService).Where("identity = ?", ctx.Param("identity")).First(&address).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
response, err := common.PublicResourceResponse(address)
if err != nil {
infra.Response.Error(ctx, err)
return
}
protected := common.ProtectPreciseLocation(ctx, &models.UserAddress{}, response)
infra.Response.Success(ctx, restoreUserAddressLocations(protected, []models.UserAddress{address}))
}
// restoreUserAddressLocations 仅在用户地址受控接口中恢复完整地址和精确坐标。
func restoreUserAddressLocations(response any, addresses []models.UserAddress) any {
restore := func(record map[string]any, address models.UserAddress) {
record["address"] = address.Address
record["longitude"] = address.Longitude
record["latitude"] = address.Latitude
}
switch data := response.(type) {
case []any:
for index, item := range data {
if index >= len(addresses) {
break
}
if record, ok := item.(map[string]any); ok {
restore(record, addresses[index])
}
}
case map[string]any:
if len(addresses) > 0 {
restore(data, addresses[0])
}
}
return response
}
func CreateUserAddress(ctx *gin.Context) {
var request addressRequest
if err := ctx.ShouldBindJSON(&request); err != nil {

View File

@@ -1,8 +1,45 @@
// 功能描述:验证平台用户地址写入参数与 GORM 的兼容性。
// 版本v1.0
// 版本v1.1
package user
import "testing"
import (
"testing"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
// TestRestoreUserAddressLocations 验证用户地址列表按原顺序恢复地址与坐标。
func TestRestoreUserAddressLocations(t *testing.T) {
response := []any{
map[string]any{"identity": "address-1"},
map[string]any{"identity": "address-2"},
}
addresses := []models.UserAddress{
{Address: "广州市越秀区测试路 1 号", Longitude: "113.264385", Latitude: "23.129112"},
{Address: "", Longitude: "", Latitude: ""},
}
restored := restoreUserAddressLocations(response, addresses).([]any)
first := restored[0].(map[string]any)
if first["address"] != addresses[0].Address || first["longitude"] != addresses[0].Longitude || first["latitude"] != addresses[0].Latitude {
t.Fatalf("第一条用户地址位置未正确恢复:%#v", first)
}
second := restored[1].(map[string]any)
if second["address"] != "" || second["longitude"] != "" || second["latitude"] != "" {
t.Fatalf("空位置字段应保持为空:%#v", second)
}
}
// TestRestoreUserAddressLocation 验证用户地址详情恢复编辑所需字段。
func TestRestoreUserAddressLocation(t *testing.T) {
response := map[string]any{"identity": "address-1"}
address := models.UserAddress{Address: "深圳市南山区测试路 8 号", Longitude: "113.930478", Latitude: "22.533191"}
restored := restoreUserAddressLocations(response, []models.UserAddress{address}).(map[string]any)
if restored["address"] != address.Address || restored["longitude"] != address.Longitude || restored["latitude"] != address.Latitude {
t.Fatalf("用户地址详情位置未正确恢复:%#v", restored)
}
}
// TestUserAddressUpdateValuesUsesSupportedMap 验证更新参数使用标准映射并保留布尔零值。
func TestUserAddressUpdateValuesUsesSupportedMap(t *testing.T) {

View File

@@ -95,7 +95,7 @@
- 登录令牌短期有效,刷新令牌可撤销;后台高权限账号启用 MFA、IP/设备策略。平台后台管理的平台、气站、配送、员工和业主账号密码按当前实施口径仅要求不少于 6 个字符,不附加复杂度校验。
- 权限校验在服务端执行,前端菜单隐藏不构成权限控制。按角色、站点、区域、对象归属联合鉴权。
- 手机号、地址、身份证明、收款账户、定位、视频为敏感数据:传输 TLS、存储加密/字段加密、访问留痕、最小化留存。默认响应继续脱敏;仅已通过平台总后台 JWT 和对应菜单权限校验的管理员,可在用户、工作人员和平台账户的列表与详情中查看姓名、显示名称和主手机号明文。例外不适用于其他终端,也不放宽身份证明、地址、头像、资质编号、订单联系人或收款账户的脱敏规则。
- 手机号、地址、身份证明、收款账户、定位、视频为敏感数据:传输 TLS、存储加密/字段加密、访问留痕、最小化留存。默认响应继续脱敏;仅已通过平台总后台 JWT 和对应菜单权限校验的管理员,可在用户、工作人员和平台账户的列表与详情中查看姓名、显示名称和主手机号明文。用户地址资源另设最小范围例外:仅 `/user_address` 的受控列表与详情接口可向具备对应菜单权限的平台管理员返回完整地址及经纬度,用于地址维护和编辑回填。上述例外不适用于其他终端,也不放宽身份证明、头像、资质编号、订单联系人或收款账户的脱敏规则。
- 所有支付回调验证签名与金额、订单、商户号一致性;合同文件使用可信第三方原文与哈希存证。渠道回调入口为 `/heqi/payment-return/v1/{alipay|wechat}/notify`,不使用用户 JWT必须完成渠道证书验签、商户/appid、平台支付单号、金额、币种和状态校验后才可在数据库事务中推进业务。重复通知必须幂等原始敏感报文只保存摘要。
- `payment_order` 是统一支付尝试事实;`payment_refund``payment_refund_item` 保存用户退款申请及明细。审批通过与钱包入账必须同事务完成。
- 图片/视频上传做文件类型、大小、病毒/恶意内容检测;访问采用短期授权,不使用公开桶。

View File

@@ -0,0 +1,48 @@
# 用户地址受控展示操作日志
操作时间2026-08-11
操作类型:扩展
影响模块:平台总后台用户地址列表、详情、编辑页及平台 API
## 操作前状态
用户地址已写入数据库,但通用敏感字段投影会从列表与详情响应中删除地址和经纬度。端口 5173 的列表显示 `-`,详情与编辑页也无法读取已保存的位置字段。
## 具体操作
1. 保留全局敏感字段保护规则。
2. 在已受平台 JWT、角色和菜单权限保护的 `user_address` 列表与详情接口中恢复当前记录的地址和坐标。
3. 列表地址沿用单行省略和悬停全文;空字段显示“未填写”。
4. 列表与详情中的经纬度统一显示六位小数。
5. 新增列表、详情及空位置字段回归测试,并同步安全与项目文档。
## 操作后状态
端口 5173 的用户地址列表、详情和编辑页能够读取数据库中真实保存的地址及坐标。历史空数据不补充、不伪造,页面明确显示“未填写”。
## 代码变更
- `backend/api/internal/logic/platform/user/relation.go`:新增受控列表、详情与位置字段恢复。
- `backend/api/internal/logic/platform/user/relation_test.go`:新增恢复与空值回归测试。
- `frontend/platform_admin/src/api/resources.ts`:新增用户地址空值及坐标精度规则。
- `frontend/platform_admin/src/api/resource-display.ts`:支持字段级小数精度展示。
- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:支持文本字段自定义占位文本。
- `docs/11-数据接口与安全.md`:记录用户地址资源级安全例外。
- `docs/项目文档_用户地址受控展示_v1.0.md`:新增项目说明。
## 验证结果
- `go test ./internal/logic/platform/user ./internal/logic/common`:通过;测试使用工作区 Go 缓存,避免系统缓存目录权限限制。
- `pnpm.cmd type:check`:通过。
- 项目内 Biome 对本次 3 个前端修改文件执行定向检查:通过;仅报告 7 条既有模板识别告警,无新增错误。
- `pnpm.cmd contract:check`:通过,共校验 48 个资源。
- `pnpm.cmd build`通过Vite 成功构建 2608 个模块。
- `git diff --check`:通过。
- 浏览器访问 `http://localhost:5173/`:服务正常,重定向到登录页,控制台无错误;因无可用登录会话,未在自动化浏览器中读取真实用户地址数据。
- 重启平台开发后端后12426 端口正常监听;`GET /heqi/platform/v1/ping/hello` 返回 `platform-api``ok`
## 风险评估
完整地址和坐标属于敏感数据。风险通过资源级最小放行控制,不修改全局脱敏函数;其他终端和其他资源不受影响。若后续需要导出或扩大可见角色,必须另行增加用途审计与审批规则。

View File

@@ -0,0 +1,57 @@
# 用户地址受控展示项目文档 v1.0
## 1. 项目概述
- 项目名称:平台总后台用户地址受控展示。
- 实施范围:平台 API 的 `user_address` 资源,以及端口 5173 的平台总后台列表、详情和编辑页。
- 主要功能:向已通过平台后台鉴权和用户地址菜单权限校验的管理员展示真实地址、经度和纬度;空值明确显示“未填写”。
- 技术栈Go、Gin、GORM、Vue 3、TypeScript、Arco Design。
- 运行环境:后端 API 与 `frontend/platform_admin` 本地开发环境。
## 2. 目录结构
```text
platforms/
├── backend/api/internal/logic/platform/user/
│ ├── relation.go # 用户地址列表、详情及受控位置字段恢复
│ └── relation_test.go # 地址与坐标恢复回归测试
├── frontend/platform_admin/src/
│ ├── api/resources.ts # 用户地址字段展示规则
│ ├── api/resource-display.ts # 六位小数展示格式
│ └── views/resource/
│ └── ResourceFieldForm.vue # 表单字段占位文本
└── docs/
└── 11-数据接口与安全.md # 用户地址资源级安全例外
```
## 3. 核心文件说明
### 3.1 后端用户地址逻辑
`backend/api/internal/logic/platform/user/relation.go` 继续先执行通用敏感字段保护,再仅在 `user_address` 的受控列表和详情响应中恢复当前查询记录的地址、经度和纬度。创建、更新、归档、默认地址事务以及公共路由均保持不变。
### 3.2 前端展示规则
- 列表复用共享表格的单行省略和悬停全文能力。
- 地址、经度、纬度为空时显示“未填写”。
- 经度和纬度在列表、详情中统一保留六位小数。
- 编辑页从详情接口回填真实字段;空字段使用“未填写”占位文本,不伪造数据。
## 4. 安全与兼容性
- 资源例外只作用于平台总后台 `/user_address` 列表和详情。
- 请求仍必须通过平台 JWT、角色、菜单及资源权限中间件。
- 用户端、工作人员端、气站端、配送点端及其他平台资源继续沿用原脱敏规则。
- 不修改数据库结构,不补充历史数据,不改变公开请求字段或返回字段名称。
## 5. 变更记录
- 新增用户地址受控位置字段恢复。
- 新增空值文案和坐标六位小数展示。
- 同步安全基线和回归测试。
## 6. 维护指南
- 新增其他敏感字段展示例外时,不得修改全局脱敏字段表,应采用资源级恢复并补充权限、测试和安全文档。
- 后端验证执行 `go test ./internal/logic/platform/user ./internal/logic/common`
- 前端验证执行 `pnpm type:check``pnpm lint``pnpm contract:check``pnpm build`

View File

@@ -189,6 +189,12 @@ export function displayResourceField(
if (hasOptionSource && field.unknownValueLabel) {
return `${field.unknownValueLabel}${String(value)}`;
}
if (field.displayPrecision != null) {
const numericValue = Number(value);
return Number.isFinite(numericValue)
? numericValue.toFixed(field.displayPrecision)
: String(value);
}
if (field.type === 'identity' && typeof value === 'string') {
return relationLabel(field, value, relationOptions);
}

View File

@@ -34,6 +34,7 @@ export type ResourceField = {
required?: boolean;
relation?: string;
displayRelationLabel?: boolean;
displayPrecision?: number;
emptyText?: string;
placeholder?: string;
options?: Array<{ label: string; value: string | number }>;
@@ -357,7 +358,7 @@ export const resources: ResourceUiDefinition[] = [
{ ...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' }] }), f('gas_basic_identity', { label: '所属气站', type: 'identity', relation: '/gas_basic' }), f('delivery_basic_identity', { label: '所属配送点', type: 'identity', relation: '/delivery_basic', relationLinkage: { parentKey: 'gas_basic_identity', optionParentKey: 'gas_basic_identity', filterKey: 'gas_basic_identities', backfillParent: true } }), 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, { staffRelation: { roles: 'context', lockPrefilled: true, showIdentityCopy: true } }), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]),
{ ...define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name')]), walletOwnerType: 'user' },
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]),
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true, emptyText: '未填写', placeholder: '未填写' }), f('longitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('latitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('is_default')]),
define('user_service_relation', '用户服务关系', 'writable', [relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('staff_account_identity', '/staff_account', false, { staffRelation: { roles: ['installer', 'delivery', 'operations'], enabledOnly: true } })]),
define('producer_account', '生产商管理', 'writable', [f('producer_code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('phone'), f('address'), f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), f('remark')]),

View File

@@ -99,7 +99,7 @@
v-else
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:placeholder="disabledSet.has(field.key) ? '' : `请输入${field.label}`"
:placeholder="disabledSet.has(field.key) ? '' : (field.placeholder ?? `请输入${field.label}`)"
/>
</a-form-item>
</div>