修复配送端合同与用户管理问题
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
backend/api/internal/logic/delivery/user_error_test.go
Normal file
33
backend/api/internal/logic/delivery/user_error_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
29
docs/操作日志_配送点合同用户名称显示_20260831.md
Normal file
29
docs/操作日志_配送点合同用户名称显示_20260831.md
Normal 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 个模块完成生产构建。
|
||||
|
||||
## 风险评估
|
||||
|
||||
仅调整前端关系列表字段渲染,提交字段、后端响应和权限范围均不改变。
|
||||
29
docs/操作日志_配送点用户名重复提示_20260831.md
Normal file
29
docs/操作日志_配送点用户名重复提示_20260831.md
Normal 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` 用户名约束冲突。
|
||||
- 已验证其他错误保持原样。
|
||||
|
||||
## 风险评估
|
||||
|
||||
仅改变一个已知唯一约束冲突的提示文本,不影响成功创建路径与其他错误类型。
|
||||
30
docs/操作日志_配送点订单合同空列表修复_20260831.md
Normal file
30
docs/操作日志_配送点订单合同空列表修复_20260831.md
Normal 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 端口监听正常。
|
||||
- 浏览器会话的原登录令牌已失效并跳转登录页,登录后页面验证待执行。
|
||||
|
||||
## 风险评估
|
||||
|
||||
变更仅影响零条记录的序列化形式;有数据列表和合同筛选逻辑不变,兼容风险低。
|
||||
24
docs/项目文档_配送点合同用户名称显示_v1.0.md
Normal file
24
docs/项目文档_配送点合同用户名称显示_v1.0.md
Normal 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` 目录执行类型检查、资源页面契约检查和生产构建。
|
||||
22
docs/项目文档_配送点用户名重复提示_v1.0.md
Normal file
22
docs/项目文档_配送点用户名重复提示_v1.0.md
Normal 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
|
||||
```
|
||||
27
docs/项目文档_配送点订单合同空列表修复_v1.0.md
Normal file
27
docs/项目文档_配送点订单合同空列表修复_v1.0.md
Normal 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
|
||||
```
|
||||
@@ -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'])"), '头像列表资源白名单不正确');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user