fix: audit platform contracts and relation identities
This commit is contained in:
44
backend/api/cmd/resource-contract/main.go
Normal file
44
backend/api/cmd/resource-contract/main.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/routers"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type route struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type contract struct {
|
||||
Domain string `json:"domain"`
|
||||
Name string `json:"name"`
|
||||
PageKind string `json:"pageKind"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
type manifest struct {
|
||||
Resources []contract `json:"resources"`
|
||||
Routes []route `json:"routes"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
routers.RegisterPlatform("heqi", engine)
|
||||
routes := make([]route, 0, len(engine.Routes()))
|
||||
for _, item := range engine.Routes() {
|
||||
routes = append(routes, route{Method: item.Method, Path: strings.TrimPrefix(item.Path, "/heqi/platform/v1")})
|
||||
}
|
||||
expected := platform.ExpectedResources()
|
||||
contracts := make([]contract, 0, len(expected))
|
||||
for _, item := range expected {
|
||||
contracts = append(contracts, contract{Domain: item.Domain, Name: item.Name, PageKind: item.PageKind, Mode: string(item.Mode)})
|
||||
}
|
||||
if err := json.NewEncoder(os.Stdout).Encode(manifest{Resources: contracts, Routes: routes}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func CreateGasAccount(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, account)
|
||||
infra.Response.Success(ctx, resourceResponse(account))
|
||||
}
|
||||
|
||||
func UpdateGasAccount(ctx *gin.Context) {
|
||||
@@ -95,7 +95,7 @@ func CreateDeliveryAccount(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, account)
|
||||
infra.Response.Success(ctx, resourceResponse(account))
|
||||
}
|
||||
|
||||
func UpdateDeliveryAccount(ctx *gin.Context) {
|
||||
|
||||
@@ -47,7 +47,7 @@ func listPage[T any](ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": list})
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list)})
|
||||
}
|
||||
|
||||
func getByIdentity[T any](ctx *gin.Context) {
|
||||
@@ -56,7 +56,7 @@ func getByIdentity[T any](ctx *gin.Context) {
|
||||
respondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, data)
|
||||
infra.Response.Success(ctx, publicResourceResponse(data))
|
||||
}
|
||||
|
||||
func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {
|
||||
|
||||
@@ -261,6 +261,29 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" ORDER BY created_at desc LIMIT $1`)).
|
||||
WithArgs(20).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}).
|
||||
AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator", "Operator", "hash", "admin"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "identity" FROM "gas_basic" WHERE id = $1 ORDER BY "gas_basic"."id" LIMIT $2`)).
|
||||
WithArgs(uint64(7), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity"}).AddRow("gas-a"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil)
|
||||
ListGasAccount(ctx)
|
||||
|
||||
assertResponseCode(t, recorder, 0)
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"gas_basic_identity":"gas-a"`) || strings.Contains(body, `"gas_basic_id"`) || strings.Contains(body, `"id":`) {
|
||||
t.Fatalf("account list did not return the public relation shape: %s", body)
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
@@ -35,7 +35,7 @@ func CreateStaffCredential(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, credential)
|
||||
infra.Response.Success(ctx, resourceResponse(credential))
|
||||
}
|
||||
func UpdateStaffCredential(ctx *gin.Context) {
|
||||
var request staffCredentialRequest
|
||||
@@ -77,7 +77,7 @@ func CreateUserAddress(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, address)
|
||||
infra.Response.Success(ctx, resourceResponse(address))
|
||||
}
|
||||
func UpdateUserAddress(ctx *gin.Context) {
|
||||
var request userAddressRequest
|
||||
@@ -133,7 +133,7 @@ func CreateUserServiceRelation(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, relation)
|
||||
infra.Response.Success(ctx, resourceResponse(relation))
|
||||
}
|
||||
func UpdateUserServiceRelation(ctx *gin.Context) {
|
||||
var request userServiceRelationRequest
|
||||
|
||||
@@ -102,7 +102,7 @@ func listResource(ctx *gin.Context, model any) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list.Elem().Interface())})
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list.Elem().Interface())})
|
||||
}
|
||||
|
||||
func getResource(ctx *gin.Context, model any) {
|
||||
@@ -111,7 +111,7 @@ func getResource(ctx *gin.Context, model any) {
|
||||
respondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, resourceResponse(data.Interface()))
|
||||
infra.Response.Success(ctx, publicResourceResponse(data.Interface()))
|
||||
}
|
||||
|
||||
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
|
||||
@@ -230,6 +230,113 @@ func resourceResponse(value any) any {
|
||||
return stripInternalIDs(decoded)
|
||||
}
|
||||
|
||||
// publicResourceResponse additionally resolves persisted relation keys into
|
||||
// their public identities. It is used by list/detail endpoints so an edit form
|
||||
// can round-trip the relation without ever receiving a surrogate database ID.
|
||||
func publicResourceResponse(value any) any {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
return value
|
||||
}
|
||||
return projectRelationIdentities(decoded)
|
||||
}
|
||||
|
||||
var relationIdentityModels = map[string]any{
|
||||
"gas_basic_id": &models.GasBasic{},
|
||||
"gas_station_id": &models.GasBasic{},
|
||||
"delivery_basic_id": &models.DeliveryBasic{},
|
||||
"delivery_point_id": &models.DeliveryBasic{},
|
||||
"user_account_id": &models.UserAccount{},
|
||||
"staff_account_id": &models.StaffAccount{},
|
||||
"smart_cylinder_valve_id": &models.DevSmartCylinderValve{},
|
||||
"ec_category_id": &models.EcCategory{},
|
||||
"ec_product_id": &models.EcProduct{},
|
||||
"ec_order_id": &models.EcOrder{},
|
||||
"delivery_task_id": &models.DeliveryTask{},
|
||||
"delivery_track_id": &models.DeliveryTrack{},
|
||||
"platform_role_id": &models.PlatformRole{},
|
||||
"platform_menu_id": &models.PlatformMenu{},
|
||||
"report_id": &models.Report{},
|
||||
"wallet_id": &models.Wallet{},
|
||||
}
|
||||
|
||||
var relationIdentityKeys = map[string]string{
|
||||
"gas_station_id": "gas_basic_identity",
|
||||
"delivery_point_id": "delivery_basic_identity",
|
||||
}
|
||||
|
||||
func projectRelationIdentities(value any) any {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range data {
|
||||
if key == "id" {
|
||||
delete(data, key)
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(key, "_id") {
|
||||
identityKey := strings.TrimSuffix(key, "_id") + "_identity"
|
||||
if alias := relationIdentityKeys[key]; alias != "" {
|
||||
identityKey = alias
|
||||
}
|
||||
model := relationIdentityModels[key]
|
||||
if key == "subject_id" {
|
||||
model = settlementSubjectModel(data["subject_type"])
|
||||
identityKey = "subject_identity"
|
||||
}
|
||||
if model != nil {
|
||||
data[identityKey] = relationIdentity(item, model)
|
||||
}
|
||||
delete(data, key)
|
||||
continue
|
||||
}
|
||||
data[key] = projectRelationIdentities(item)
|
||||
}
|
||||
case []any:
|
||||
for index := range data {
|
||||
data[index] = projectRelationIdentities(data[index])
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func relationIdentity(value any, model any) string {
|
||||
var id uint64
|
||||
switch raw := value.(type) {
|
||||
case float64:
|
||||
id = uint64(raw)
|
||||
case uint64:
|
||||
id = raw
|
||||
case int:
|
||||
id = uint64(raw)
|
||||
}
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
var related struct{ Identity string }
|
||||
if err := impl.DBService.Model(model).Select("identity").Where("id = ?", id).First(&related).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return related.Identity
|
||||
}
|
||||
|
||||
func settlementSubjectModel(value any) any {
|
||||
subjectType, _ := value.(string)
|
||||
switch subjectType {
|
||||
case "gas", "gas_basic":
|
||||
return &models.GasBasic{}
|
||||
case "delivery", "delivery_basic":
|
||||
return &models.DeliveryBasic{}
|
||||
case "staff", "staff_account":
|
||||
return &models.StaffAccount{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stripInternalIDs(value any) any {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
@@ -311,7 +418,7 @@ func GetEcOrder(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, resourceResponse(gin.H{"order": order, "items": items}))
|
||||
infra.Response.Success(ctx, publicResourceResponse(gin.H{"order": order, "items": items}))
|
||||
}
|
||||
|
||||
// GetDeliveryTrack returns time-ordered points. Precise coordinates are only
|
||||
@@ -337,7 +444,7 @@ func GetDeliveryTrack(ctx *gin.Context) {
|
||||
points[index].Latitude = ""
|
||||
}
|
||||
}
|
||||
infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points}))
|
||||
infra.Response.Success(ctx, publicResourceResponse(gin.H{"track": track, "points": points}))
|
||||
}
|
||||
|
||||
type ecCategoryView struct {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ export type ResourceField = { key: string; label: string; required?: boolean };
|
||||
export type DetailAction = { name: string; resource: string; fields: ResourceField[] };
|
||||
export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[]; requiredIdentities: string[]; detailActions?: DetailAction[] };
|
||||
|
||||
const field = (value: string): ResourceField => ({ key: value.replace(/!$/, ''), label: value.replace(/!$/, ''), required: value.endsWith('!') || undefined });
|
||||
const labels: Record<string, string> = { code: '编码', name: '名称', credit_code: '统一信用代码', principal: '负责人', address: '地址', longitude: '经度', latitude: '纬度', username: '用户名', password: '密码', display_name: '显示名称', role_code: '角色编码', delivery_code: '配送编码', phone: '联系电话', avatar: '头像', work_status: '工作状态', credential_type: '资质类型', credential_no: '资质编号', expired_at: '到期时间', real_name: '实名姓名', is_default: '默认地址', device_no: '设备编号', model: '设备型号', online_status: '在线状态', effective_at: '生效时间', recorded_at: '采集时间', payload: '遥测数据', rule_code: '规则编码', version_no: '版本号', threshold: '阈值', action: '处置动作', gray_scope: '灰度范围', event_code: '事件编码', level: '事件等级', title: '标题', sla_at: '处置时限', result: '检查结果', evidence_uri: '凭证地址', reason: '处置原因', sort_no: '排序号', product_code: '商品编码', price_amount: '售价', stock_quantity: '库存', value: '属性值', image_uri: '图片地址', is_cover: '封面图', quantity: '数量', selected: '是否选中', order_no: '订单号', total_amount: '订单金额', product_snapshot: '商品快照', sale_amount: '成交金额', score: '评分', channel: '渠道', amount: '金额', paid_at: '支付时间', settlement_no: '结算单号', subject_type: '结算对象类型', period_start: '结算开始时间', period_end: '结算结束时间', bill_date: '账单日期', difference_amount: '差异金额', content_type: '内容类型', body: '正文', publish_status: '发布状态', template_code: '模板编码', content: '内容', ticket_no: '工单号', category: '分类', priority: '优先级', platform_role_code: '平台角色', data_scope: '数据范围', menu_code: '菜单编码', icon: '图标', path: '路径', balance_amount: '余额', change_amount: '变动金额', balance_after: '变动后余额', report_code: '报表编码', metric_code: '指标编码', captured_at: '采集时间', operator_identity: '操作人标识', resource_type: '资源类型', handled_at: '处理时间', status: '状态' };
|
||||
const field = (value: string): ResourceField => { const key = value.replace(/!$/, ''); return { key, label: labels[key] ?? (key.endsWith('_identity') ? '关联业务标识' : '业务字段'), required: value.endsWith('!') || undefined }; };
|
||||
const titles: Record<string, string> = { gas_basic: '气站管理', gas_account: '气站账户', delivery_basic: '配送点管理', delivery_account: '配送账户', delivery_task: '配送任务', delivery_track: '配送轨迹', delivery_track_point: '轨迹点', staff_account: '服务人员', staff_credential: '人员资质', user_account: '用户账户', user_address: '用户地址', user_service_relation: '用户服务关系', dev_smart_cylinder_valve: '智能钢瓶阀', dev_device_binding: '设备绑定', dev_telemetry: '设备遥测', saf_rule: '安全规则', saf_event: '安全事件', saf_inspection: '安全检查', saf_event_disposal: '事件处置', ec_category: '商品分类', ec_product: '商品管理', ec_product_attribute: '商品属性', ec_product_image: '商品图片', ec_cart: '购物车', ec_order: '订单管理', ec_order_item: '订单明细', ec_review: '商品评价', fin_payment: '支付记录', fin_settlement: '财务结算', fin_reconciliation: '财务对账', cnt_content: '内容管理', ntf_template: '通知模板', cs_ticket: '客服工单', platfrom_account: '平台账户', platform_role: '平台角色', platform_menu: '平台菜单', wallet: '钱包', wallet_ledger: '钱包流水', wallet_recharge: '钱包充值', wallet_withdrawal: '钱包提现', report: '报表', report_item: '报表项目', report_metric_snapshot: '指标快照', aud_operation_log: '操作审计', aud_export_log: '导出审计', aud_approval: '审批审计' };
|
||||
const define = (name: string, resource: string, mode: ResourceMode, pageKind: ResourcePageKind, keys: string[], detailActions?: DetailAction[]): ResourceUiDefinition => {
|
||||
const fields = keys.map(field);
|
||||
return { key: name.replace(/_/g, '-'), name, resource, title: name, mode, pageKind, fields, requiredIdentities: fields.filter((item) => item.required && item.key.endsWith('_identity')).map((item) => item.key), ...(detailActions ? { detailActions } : {}) };
|
||||
return { key: name.replace(/_/g, '-'), name, resource, title: titles[name] ?? '业务资源', mode, pageKind, fields, requiredIdentities: fields.filter((item) => item.required && item.key.endsWith('_identity')).map((item) => item.key), ...(detailActions ? { detailActions } : {}) };
|
||||
};
|
||||
const action = (name: string, resource: string, keys: string[]): DetailAction => ({ name, resource, fields: keys.map(field) });
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<template><a-card :title="definition.title" :bordered="false"><template #extra><a-button @click="load">刷新</a-button></template><a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }" /></a-card></template>
|
||||
<template><a-card :title="definition.title" :bordered="false"><template #extra><a-space><a-button @click="load">刷新</a-button><a-button v-if="canWrite" type="primary" @click="openCreate">新增</a-button></a-space></template><a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }"><template #title="node"><a-space>{{ node.title }}<a-button v-if="canWrite" size="mini" @click.stop="openEdit(node)">编辑</a-button></a-space></template></a-tree></a-card><a-drawer :visible="formVisible" :title="editingIdentity ? '编辑' + definition.title : '新增' + definition.title" :width="480" @cancel="formVisible = false" @ok="save"><a-form :model="form" layout="vertical"><a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required"><a-input v-model="form[field.key]" /></a-form-item></a-form></a-drawer></template>
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import type { ResourceUiDefinition } from '@/api/resources';
|
||||
type Node = Record<string, unknown> & { identity: string; parent_identity?: string; children: Node[] };
|
||||
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
||||
const loading = ref(false); const list = ref<Node[]>([]);
|
||||
const props = defineProps<{ definition: ResourceUiDefinition }>(); const loading = ref(false); const list = ref<Node[]>([]); const formVisible = ref(false); const editingIdentity = ref(''); const form = reactive<Record<string, string>>({});
|
||||
const canWrite = computed(() => props.definition.mode === 'writable');
|
||||
const tree = computed(() => { const byIdentity = new Map<string, Node>(); const roots: Node[] = []; list.value.forEach((item) => byIdentity.set(item.identity, { ...item, children: [] })); byIdentity.forEach((item) => { const parent = item.parent_identity ? byIdentity.get(item.parent_identity) : undefined; if (parent) parent.children.push(item); else roots.push(item); }); return roots; });
|
||||
function reset(data?: Node) { props.definition.fields.forEach((field) => { form[field.key] = data?.[field.key] == null ? '' : String(data[field.key]); }); }
|
||||
function openCreate() { editingIdentity.value = ''; reset(); formVisible.value = true; }
|
||||
function openEdit(node: Node) { editingIdentity.value = node.identity; reset(node); formVisible.value = true; }
|
||||
async function save() { if (props.definition.fields.some((field) => field.required && !form[field.key])) { Message.warning('请填写必填字段'); return; } try { if (editingIdentity.value) await resourceApi.update(props.definition.resource, editingIdentity.value, form); else await resourceApi.create(props.definition.resource, form); formVisible.value = false; await load(); } catch (error) { Message.error((error as Error).message); } }
|
||||
async function load() { loading.value = true; try { list.value = (await resourceApi.list<Node>(props.definition.resource, 1, 500)).list; } catch (error) { Message.error((error as Error).message); } finally { loading.value = false; } }
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user