修复配送端合同与用户管理问题

This commit is contained in:
czl231
2026-08-31 20:49:51 +08:00
parent 9bbe25fa79
commit 9a23ab2f5a
13 changed files with 290 additions and 12 deletions

View File

@@ -1,5 +1,5 @@
// 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。
// 版本v1.4.0
// 版本v1.4.1
package delivery
import (
@@ -74,7 +74,8 @@ func ListContract(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
var list []platformgasorder.ContractPartyDisplay
// 空结果也初始化为空切片,确保公开响应保持 JSON 数组而不是 null。
list := make([]platformgasorder.ContractPartyDisplay, 0)
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return

View File

@@ -1,5 +1,5 @@
// 功能描述:验证配送合同附件列表脱敏与公开状态投影。
// 版本v1.0.0。
// 版本v1.1.0。
package delivery
import (
@@ -44,3 +44,22 @@ func TestProtectDeliveryContractListResponse(t *testing.T) {
t.Fatal("无附件的合同必须返回 has_attachment=false")
}
}
// TestProtectDeliveryContractListEmptyResponse 验证没有可用合同时仍返回空数组。
func TestProtectDeliveryContractListEmptyResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("GET", "/gasorder_contract?candidate=order", nil)
contracts := make([]models.GasorderContract, 0)
response, err := common.PublicResourceResponse(contracts)
if err != nil {
t.Fatalf("构造空合同公开响应失败: %v", err)
}
protected, err := protectDeliveryContractListResponse(ctx, response, contracts)
if err != nil {
t.Fatalf("空合同列表不应返回结构错误: %v", err)
}
if protected == nil || len(protected) != 0 {
t.Fatalf("空合同列表必须返回非 nil 空数组,实际值: %#v", protected)
}
}

View File

@@ -1,5 +1,5 @@
// 功能描述:实现配送点范围内的用户、服务关系和收货地址管理。
// 版本v1.1.0。
// 版本v1.2.0。
package delivery
import (
@@ -12,9 +12,25 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
)
const deliveryUserUsernameConstraint = "idx_user_account_username"
var errDeliveryUserUsernameExists = errors.New("用户名已存在,请更换用户名")
// deliveryUserCreateError 仅转换配送点新建用户时的用户名唯一约束冲突。
// 参数err 为用户和服务关系事务返回的错误。
// 返回值:用户名冲突返回中文提示,其他错误保持原样。
func deliveryUserCreateError(err error) error {
var postgresError *pgconn.PgError
if errors.As(err, &postgresError) && postgresError.Code == "23505" && postgresError.ConstraintName == deliveryUserUsernameConstraint {
return errDeliveryUserUsernameExists
}
return err
}
func userQuery(pointID uint64) *gorm.DB {
return common.ActiveRecords(db().Model(&models.UserAccount{})).
Joins("JOIN user_service_relation ON user_service_relation.user_account_id = user_account.id AND user_service_relation.status <> ?",
@@ -116,7 +132,7 @@ func CreateUser(ctx *gin.Context) {
relation.UserAccountID = user.ID
return tx.Create(&relation).Error
}); err != nil {
infra.Response.Error(ctx, err)
infra.Response.Error(ctx, deliveryUserCreateError(err))
return
}
common.RespondCreatedResource(ctx, user)

View File

@@ -0,0 +1,33 @@
// 功能描述:验证配送点新建用户的用户名冲突提示转换。
// 版本v1.0.0。
package delivery
import (
"errors"
"fmt"
"testing"
"github.com/jackc/pgx/v5/pgconn"
)
// TestDeliveryUserCreateErrorUsernameConflict 验证用户名唯一约束冲突返回指定中文提示。
// 参数t 为 Go 测试上下文。
// 返回值:无。
func TestDeliveryUserCreateErrorUsernameConflict(t *testing.T) {
databaseError := &pgconn.PgError{Code: "23505", ConstraintName: deliveryUserUsernameConstraint}
wrapped := fmt.Errorf("create delivery user: %w", databaseError)
if actual := deliveryUserCreateError(wrapped).Error(); actual != "用户名已存在,请更换用户名" {
t.Fatalf("用户名冲突提示不正确,实际为 %q", actual)
}
}
// TestDeliveryUserCreateErrorKeepsOtherErrors 验证其他错误不被本次局部映射改变。
// 参数t 为 Go 测试上下文。
// 返回值:无。
func TestDeliveryUserCreateErrorKeepsOtherErrors(t *testing.T) {
original := errors.New("other error")
if actual := deliveryUserCreateError(original); !errors.Is(actual, original) {
t.Fatalf("其他错误应保持原样,实际为 %v", actual)
}
}

View File

@@ -0,0 +1,29 @@
# 操作日志:配送点合同用户名称显示
操作时间2026-08-31
操作类型:修改
影响模块:配送点合同列表
## 操作前状态
“用户账户”列直接显示系统唯一标识片段,与气站管理端的用户名称展示不一致。
## 具体操作
1. 关系列表字段优先读取服务端派生业务名称。
2. 复用名称、关联详情和唯一标识复制的组合展示。
3. 增加静态契约检查,防止回退为裸唯一标识。
## 操作后状态
配送点合同列表按气站端样式显示用户业务名称,并保留完整唯一标识的复制能力。
## 验证结果
- `npm.cmd run type:check`:通过。
- `npm.cmd run resource-pages:check`:通过,详情 17、新建 9、编辑 5。
- `npm.cmd run build`通过2648 个模块完成生产构建。
## 风险评估
仅调整前端关系列表字段渲染,提交字段、后端响应和权限范围均不改变。

View File

@@ -0,0 +1,29 @@
# 操作日志:配送点用户名重复提示
操作时间2026-08-31
操作类型:修改
影响模块:配送点用户管理—新建用户
## 操作前状态
重复用户名直接向页面返回 PostgreSQL 唯一约束错误及 SQLSTATE。
## 具体操作
1. 识别用户名约束 `idx_user_account_username``23505` 冲突。
2. 将该冲突转换为“用户名已存在,请更换用户名”。
3. 其他数据库错误保持原有处理,避免扩大本次变更范围。
## 操作后状态
重复创建同名用户时页面显示明确中文提示,不再显示该约束的数据库英文错误。
## 验证结果
- `go test ./api/internal/logic/delivery`:通过。
- 已覆盖包装后的 PostgreSQL `23505` 用户名约束冲突。
- 已验证其他错误保持原样。
## 风险评估
仅改变一个已知唯一约束冲突的提示文本,不影响成功创建路径与其他错误类型。

View File

@@ -0,0 +1,30 @@
# 操作日志:配送点订单合同空列表修复
操作时间2026-08-31
操作类型:修改
影响模块:配送点订单创建、配送合同候选列表
## 操作前状态
没有可履约合同时,合同查询结果为 nil 切片,公开响应序列化成 `null`,随后被列表结构校验拒绝。
## 具体操作
1. 将合同查询接收容器初始化为空切片。
2. 增加零条合同的回归测试,要求返回非 nil 空数组。
3. 保持接口路径、筛选条件和响应字段不变。
## 操作后状态
无可用合同时接口正常返回 `total: 0``list: []`,页面只显示无可用合同提示。
## 验证结果
- `go test ./api/internal/logic/delivery`:通过。
- 新增空合同列表用例,确认公开响应为非 nil 空数组。
- 后端已重新构建并重启12426 端口监听正常。
- 浏览器会话的原登录令牌已失效并跳转登录页,登录后页面验证待执行。
## 风险评估
变更仅影响零条记录的序列化形式;有数据列表和合同筛选逻辑不变,兼容风险低。

View File

@@ -0,0 +1,24 @@
# 项目文档:配送点合同用户名称显示 v1.0
## 项目概述
配送点合同列表的“用户账户”列与气站管理端保持一致,以用户业务名称为主展示,不再把系统唯一标识作为主文本。
## 核心文件说明
- `frontend/delivery_admin/src/views/shared/ResourceListPage.vue`:渲染服务端返回的 `user_account_display_name`,并保留关联详情入口和唯一标识复制能力。
- `frontend/delivery_admin/src/views/shared/RelationNameText.vue`:支持可点击的关系名称展示。
- `frontend/delivery_admin/scripts/check-resource-pages.mjs`:增加关系名称展示的静态契约检查。
## 行为变化
- 修改前:合同列表显示用户账户唯一标识片段。
- 修改后:合同列表显示用户业务名称,例如 `ccc``xc``cyy`;名称可进入用户详情,旁边按钮可复制完整唯一标识。
## 接口兼容性
复用现有合同列表响应中的 `user_account_display_name`,不修改后端接口和数据库。
## 测试方法
`frontend/delivery_admin` 目录执行类型检查、资源页面契约检查和生产构建。

View File

@@ -0,0 +1,22 @@
# 项目文档:配送点用户名重复提示 v1.0
## 项目概述
配送点管理端新建用户时,将用户名唯一约束冲突转换为可理解的中文提示。
## 核心文件说明
- `backend/api/internal/logic/delivery/user.go`:识别 `idx_user_account_username` 唯一约束冲突并返回“用户名已存在,请更换用户名”。
- `backend/api/internal/logic/delivery/user_error_test.go`:验证已知冲突提示与其他错误保持原样。
## 变更范围
仅修改配送点管理端新建用户的重复用户名提示,不修改数据库约束、用户创建流程、字段校验或其他错误处理。
## 测试方法
`backend` 目录执行:
```text
go test ./api/internal/logic/delivery
```

View File

@@ -0,0 +1,27 @@
# 项目文档:配送点订单合同空列表修复 v1.0
## 项目概述
修复配送点管理端创建配送订单时,当前没有可履约合同时错误提示“配送合同列表响应结构无效”的问题。
## 核心文件说明
- `backend/api/internal/logic/delivery/order.go`:查询配送合同候选项;空结果初始化为非 nil 空切片,保证接口输出 `list: []`
- `backend/api/internal/logic/delivery/order_contract_attachment_test.go`:覆盖合同空列表公开响应的回归测试。
## 行为变化
- 修复前:零条合同被序列化为 `null`,后端响应结构校验失败。
- 修复后:零条合同返回空数组,前端正常显示“暂无可下单的生效合同”。
## 接口兼容性
接口地址和字段保持不变,仅将异常的 `list: null` 规范为 `list: []`,兼容现有调用方。
## 测试方法
`backend` 目录执行:
```text
go test ./api/internal/logic/delivery
```

View File

@@ -74,6 +74,9 @@ const listPage = read('src/views/shared/ResourceListPage.vue');
assert(!listPage.includes('title="ID"'), '标准列表仍暴露数据库自增 ID');
assert(listPage.includes('ProtectedAvatarThumbnail'), '账户列表尚未接入受控头像缩略图');
assert(listPage.includes('avatarLoader.reset()'), '列表刷新未清理头像缓存和请求');
assert(listPage.includes('field.listRelationNameOnly && fieldValue(field, record)'), '关系列表字段尚未按气站端显示业务名称');
assert(listPage.includes('relationListName(field, record)'), '关系列表字段缺少服务端派生名称读取');
assert(listPage.includes('@open="openRelation(field, record)"'), '关系列表名称缺少关联详情入口');
const avatarLoader = read('src/views/shared/protected-list-avatar-loader.ts');
assert(avatarLoader.includes('MAX_CONCURRENT_REQUESTS = 6'), '头像请求并发上限未与 5173 对齐');
assert(avatarLoader.includes("new Set(['staff_account', 'user_account'])"), '头像列表资源白名单不正确');

View File

@@ -1,8 +1,14 @@
<!-- 功能描述业务名称展示关联记录并保留唯一标识的查看与复制能力版本v1.0.0 -->
<!-- 功能描述关系名称为主展示支持进入关联详情并复制完整唯一标识版本v1.1.0 -->
<template>
<div class="relation-name-text">
<a-tooltip :content="`${identityLabel}${identity}`">
<span class="relation-name">{{ displayName }}</span>
<button
v-if="clickable"
class="relation-name relation-link"
type="button"
@click.stop="emit('open')"
>{{ displayName }}</button>
<span v-else class="relation-name">{{ displayName }}</span>
</a-tooltip>
<a-tooltip :content="`复制${identityLabel}`">
<button class="copy-button" type="button" :aria-label="`复制${identityLabel} ${identity}`" @click.stop="copyIdentity">
@@ -17,14 +23,20 @@ import { Message } from '@arco-design/web-vue';
import { IconCopy } from '@arco-design/web-vue/es/icon';
import { computed } from 'vue';
const props = defineProps<{ name: string; identity: string; identityLabel: string }>();
const props = defineProps<{
name: string;
identity: string;
identityLabel?: string;
clickable?: boolean;
}>();
const emit = defineEmits<{ open: [] }>();
const displayName = computed(() => props.name || props.identity);
/** 复制完整唯一标识,并给出明确的操作反馈。 */
async function copyIdentity() {
try {
await navigator.clipboard.writeText(props.identity);
Message.success(`${props.identityLabel}已复制`);
Message.success(`${props.identityLabel ?? '唯一标识'}已复制`);
} catch {
Message.error('复制失败,请从悬浮提示中复制');
}
@@ -34,6 +46,7 @@ async function copyIdentity() {
<style scoped>
.relation-name-text { display: flex; gap: 6px; align-items: center; min-width: 0; }
.relation-name { min-width: 0; overflow: hidden; color: var(--color-text-1); text-overflow: ellipsis; white-space: nowrap; }
.relation-link { padding: 0; color: rgb(var(--primary-6)); font: inherit; text-align: left; background: transparent; border: 0; cursor: pointer; }
.copy-button { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; color: rgb(var(--primary-6)); font-size: 14px; background: transparent; border: 0; border-radius: 4px; cursor: pointer; }
.copy-button:hover, .copy-button:focus-visible { background: var(--color-fill-2); outline: none; }
</style>

View File

@@ -1,4 +1,4 @@
<!-- 功能描述展示配送点标准资源列表并导航到独立记录页版本v1.2.0 -->
<!-- 功能描述展示配送点标准资源列表并导航到独立记录页版本v1.3.0 -->
<template>
<a-card :title="listTitle" :bordered="false">
<template v-if="isCredentialList" #extra>
@@ -56,8 +56,8 @@
:title="field.listLabel ?? field.label"
:width="columnWidth(field)"
:align="isProtectedListAvatarField(definition.name, field.key) ? 'center' : 'left'"
:ellipsis="!isProtectedListAvatarField(definition.name, field.key)"
:tooltip="!isProtectedListAvatarField(definition.name, field.key)"
:ellipsis="!isProtectedListAvatarField(definition.name, field.key) && !field.listRelationNameOnly"
:tooltip="!isProtectedListAvatarField(definition.name, field.key) && !field.listRelationNameOnly"
>
<template #cell="{ record }">
<ProtectedAvatarThumbnail
@@ -68,6 +68,14 @@
:refresh-key="avatarRefreshKey"
:loader="avatarLoader"
/>
<RelationNameText
v-else-if="field.listRelationNameOnly && fieldValue(field, record)"
:name="relationListName(field, record)"
:identity="fieldValue(field, record)"
:identity-label="field.listLabel ?? field.label"
:clickable="Boolean(field.relation)"
@open="openRelation(field, record)"
/>
<a-button
v-else-if="field.type === 'contract-file' && record.has_attachment"
size="mini"
@@ -147,6 +155,7 @@ import type { ResourceRow } from '@/api/resource-record-form';
import { resourceFieldLabel, type ResourceField, type ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
import RelationNameText from './RelationNameText.vue';
import {
createProtectedListAvatarLoader,
isProtectedListAvatarField,
@@ -216,6 +225,7 @@ async function ensureCredentialContext() {
/** 返回列表列宽。 */
function columnWidth(field: ResourceField) {
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
if (field.listRelationNameOnly) return 240;
if (field.type === 'contract-file') return 120;
if (field.type === 'datetime' || field.type === 'date') return 180;
if (field.type === 'money') return 160;
@@ -249,6 +259,28 @@ function fieldValue(field: ResourceField, row: ResourceRow) {
return String(row[field.key] ?? row[`${field.key}_masked`] ?? '');
}
/** 优先读取服务端在当前合同范围内返回的关联业务名称。 */
function relationListName(field: ResourceField, row: ResourceRow) {
const displayKey = field.key.endsWith('_identity')
? `${field.key.slice(0, -9)}_display_name`
: '';
return displayKey ? String(row[displayKey] ?? '').trim() : '';
}
/** 打开关系字段对应的独立详情页。 */
function openRelation(field: ResourceField, row: ResourceRow) {
if (!field.relation) return;
const target = router.getRoutes().find(
(item) => item.meta.resource === field.relation && item.meta.recordMode === 'detail',
);
if (!target?.name) return;
return router.push({
name: target.name,
params: { identity: fieldValue(field, row) },
query: { return_to: route.fullPath },
});
}
/** 优先使用后端受控派生字段展示列表摘要,空值使用字段业务文案。 */
function displayListField(field: ResourceField, row: ResourceRow) {
if (field.listDisplayKey) {