feat: 完善平台总后台模块

This commit is contained in:
2026-07-27 00:18:42 +08:00
parent 073eb89762
commit 642980960e
197 changed files with 22356 additions and 1060 deletions

View File

@@ -7,6 +7,5 @@
| `api` | Gin HTTP API / BFF、同步事务、JWT 鉴权与统一响应 |
| `worker` | Redis Streams 消费、Outbox 投递、超时扫描与 Mock 外部适配 |
| `iot` | MQTT 协议适配边界、遥测/命令契约校验与 Mock 设备接入 |
| `migrations` | PostgreSQL 迁移、中文注释、回滚说明和初始化数据 |
`sample/server` 的工程机制被直接沿用;但由于项目规范强制要求 `identity` 为 UUID V7 主键,领域模型使用自定义 `models.Entity`,不使用样例中含自增 `ID``types.Std_IICUDS`

View File

@@ -1,4 +1,4 @@
.PHONY: build run cli migrate lint tidy
.PHONY: build run cli lint tidy
build:
go build -o build/platform-api ./cmd/main/main.go
@@ -10,9 +10,6 @@ run:
cli:
go run ./cmd/cli/main.go $(ARGS)
migrate:
go run ./cmd/cli/main.go migrate
lint:
go vet ./...
go fmt ./...

View File

@@ -12,8 +12,8 @@ $env:HEQI_PLATFORM_ROOT_PASSWORD="请设置不少于12位的root初始密码"
go run ./cmd/main/main.go
```
应用启动`go run ./cmd/cli/main.go migrate`会在事务内幂等创建平台 `root` 账号。账号名固定为 `root`;优先使用 `HEQI_PLATFORM_ROOT_PASSWORD`,未设置时仅使用开发环境默认值。root 首次登录后必须通过 `PUT /heqi/v1/auth/password` 修改密码。
应用启动会在事务内幂等创建平台 `root` 账号。账号名固定为 `root`;优先使用 `HEQI_PLATFORM_ROOT_PASSWORD`,未设置时仅使用开发环境默认值。已登录账户可通过 `PUT /heqi/v1/auth/password` 修改密码。
匿名接口为 `POST /heqi/v1/auth/login`;其余平台接口经 `middleware.JwtAuth(true)` 保护。请求头 `Authorization` 直接传递 JWT 原始值,不使用 `Bearer` 前缀。
UUID V7 主键、模型中文注释和 PostgreSQL 变更记录以 `../migrations` 为准
UUID V7 主键、模型中文注释与表结构以 `internal/models` 为准;应用启动时由 GORM 自动同步模型结构

View File

@@ -1,35 +1,19 @@
// 平台 API 的数据库迁移与版本命令行工具。
// 平台 API 的版本命令行工具。
package main
import (
"fmt"
"os"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
_ "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
const serviceKey = "heqi"
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: platform-cli <version|migrate>")
fmt.Println("usage: platform-cli <version>")
return
}
switch os.Args[1] {
case "version":
fmt.Println("platform-cli 0.1.0")
case "migrate":
config.New(serviceKey)
impl.NewImpl()
if err := initdb.New(impl.DBService); err != nil {
panic(err)
}
fmt.Println("platform database auto migrate completed")
default:
if os.Args[1] != "version" {
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
fmt.Println("platform-cli 0.1.0")
}

View File

@@ -6,6 +6,9 @@ import "gorm.io/gorm"
// New 在同一事务中初始化平台基础数据。
func New(database *gorm.DB) error {
return database.Transaction(func(tx *gorm.DB) error {
if err := InitPlatformAccess(tx); err != nil {
return err
}
return InitPlatformRoot(tx)
})
}

View File

@@ -15,12 +15,50 @@ const (
// PlatformRootPassword 是仅用于首次启动的初始密码,首次登录后必须修改。
PlatformRootPassword = "Heqi@Root2026"
// PlatformRootRoleCode 表示根账号的平台角色。
PlatformRootRoleCode = "platform_root"
PlatformRootRoleCode = "root"
)
// InitPlatformAccess 幂等初始化 root 角色、菜单和 root 的全菜单授权。
func InitPlatformAccess(database *gorm.DB) error {
rootRole := models.PlatformRole{
Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1},
RoleCode: PlatformRootRoleCode,
Name: "系统管理员",
DataScope: "global",
IsSystem: true,
}
if err := database.Where("role_code = ?", rootRole.RoleCode).FirstOrCreate(&rootRole).Error; err != nil {
return err
}
menus := []models.PlatformMenu{
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "dashboard", Name: "工作台", Icon: "icon-dashboard", Path: "/dashboard", SortNo: 10},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "gas", Name: "可燃气体站管理", Icon: "icon-fire", Path: "/gas/basic", SortNo: 20},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "delivery", Name: "配送管理", Icon: "icon-car", Path: "/delivery/basic", SortNo: 30},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "staff", Name: "服务人员", Icon: "icon-user", Path: "/staff/list", SortNo: 40},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "user", Name: "业主客户", Icon: "icon-user-group", Path: "/user/list", SortNo: 50},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "ec", Name: "电商管理", Icon: "icon-shopping", Path: "/ec/product", SortNo: 60},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "finance", Name: "财务管理", Icon: "icon-safe", Path: "/finance/payment", SortNo: 70},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "wallet", Name: "钱包中心", Icon: "icon-wallet", Path: "/wallet/list", SortNo: 80},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "report", Name: "统计报表", Icon: "icon-bar-chart", Path: "/report/list", SortNo: 90},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "platform", Name: "平台配置", Icon: "icon-settings", Path: "/platform/account", SortNo: 100},
}
for index := range menus {
menu := menus[index]
if err := database.Where("menu_code = ?", menu.MenuCode).FirstOrCreate(&menu).Error; err != nil {
return err
}
relation := models.PlatformRoleMenuRelation{PlatformRoleID: rootRole.ID, PlatformMenuID: menu.ID}
if err := database.Where("platform_role_id = ? AND platform_menu_id = ?", rootRole.ID, menu.ID).FirstOrCreate(&relation).Error; err != nil {
return err
}
}
return nil
}
// InitPlatformRoot 幂等创建平台总后台 root 账号。
func InitPlatformRoot(database *gorm.DB) error {
var account models.IdnAccount
var account models.PlatfromAccount
err := database.Where("username = ?", PlatformRootUsername).First(&account).Error
if err == nil {
return nil
@@ -34,16 +72,13 @@ func InitPlatformRoot(database *gorm.DB) error {
return err
}
account = models.IdnAccount{
account = models.PlatfromAccount{
Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled"},
Username: PlatformRootUsername,
DisplayName: "平台根管理员",
PasswordHash: string(passwordHash),
RoleCode: PlatformRootRoleCode,
MustChangePassword: true,
PlatformRoleCode: PlatformRootRoleCode,
Phone: "",
AccountType: "operator",
ServiceArea: "全国",
}
return database.Create(&account).Error
}

View File

@@ -28,7 +28,6 @@ type LoginReply struct {
Identity string `json:"identity"`
DisplayName string `json:"display_name"`
RoleCode string `json:"role_code"`
MustChangePassword bool `json:"must_change_password"`
}
// Login 校验平台账号密码并签发 BSM JWT。
@@ -39,7 +38,7 @@ func Login(ctx *gin.Context) {
return
}
var account models.IdnAccount
var account models.PlatfromAccount
err := impl.DBService.Where("username = ?", strings.TrimSpace(request.Username)).First(&account).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -60,11 +59,11 @@ func Login(ctx *gin.Context) {
accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(
0,
account.Identity.String(),
account.Identity,
"platform_admin",
account.RoleCode,
account.PlatformRoleCode,
map[string]string{"username": account.Username, "display_name": account.DisplayName},
map[string]string{"must_change_password": boolText(account.MustChangePassword)},
nil,
)
if err != nil {
infra.Response.Error(ctx, err)
@@ -73,10 +72,9 @@ func Login(ctx *gin.Context) {
infra.Response.Success(ctx, LoginReply{
AccessToken: accessToken,
TokenType: "JWT",
Identity: account.Identity.String(),
Identity: account.Identity,
DisplayName: account.DisplayName,
RoleCode: account.RoleCode,
MustChangePassword: account.MustChangePassword,
RoleCode: account.PlatformRoleCode,
})
}
@@ -87,14 +85,14 @@ func CurrentProfile(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
var account models.IdnAccount
var account models.PlatfromAccount
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, gin.H{
"identity": account.Identity.String(), "username": account.Username, "display_name": account.DisplayName,
"role_code": account.RoleCode, "must_change_password": account.MustChangePassword, "mfa_enabled": account.MFAEnabled,
"identity": account.Identity, "username": account.Username, "display_name": account.DisplayName,
"avatar": account.Avatar, "role_code": account.PlatformRoleCode,
})
}
@@ -116,7 +114,7 @@ func ChangePassword(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var account models.IdnAccount
var account models.PlatfromAccount
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
@@ -130,17 +128,9 @@ func ChangePassword(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
if err := impl.DBService.Model(&account).Updates(map[string]any{"password_hash": string(passwordHash), "must_change_password": false}).Error; err != nil {
if err := impl.DBService.Model(&account).Update("password_hash", string(passwordHash)).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"changed": true})
}
// boolText 将布尔值转换为 JWT 扩展字段约定的字符串。
func boolText(value bool) string {
if value {
return "true"
}
return "false"
}

View File

@@ -2,11 +2,15 @@
package platform
import (
"errors"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/bsm-sdk/core/utils"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PingHello 返回匿名健康状态。
@@ -14,7 +18,7 @@ func PingHello(ctx *gin.Context) {
infra.Response.Success(ctx, gin.H{"service": "platform-api", "status": "ok"})
}
// DashboardOverview 返回组织与安全运营的首期概览数据。
// DashboardOverview 返回平台总后台的运营概览数据。
func DashboardOverview(ctx *gin.Context) {
overview, err := models.GetDashboardOverview()
if err != nil {
@@ -24,131 +28,344 @@ func DashboardOverview(ctx *gin.Context) {
infra.Response.Success(ctx, overview)
}
// CreateOrgGasStationRequest 是创建 org_gas_station 的请求体
type CreateOrgGasStationRequest struct {
StationCode string `json:"station_code" binding:"required,max=32"`
// ListGasBasic 查询可燃气体站分页列表
func ListGasBasic(ctx *gin.Context) { listPage[models.GasBasic](ctx) }
// GetGasBasic 查询一个可燃气体站。
func GetGasBasic(ctx *gin.Context) { getByIdentity[models.GasBasic](ctx) }
// CreateGasBasic 创建可燃气体站档案。
func CreateGasBasic(ctx *gin.Context) {
var request models.GasBasic
if err := ctx.ShouldBindJSON(&request); err != nil || request.Code == "" || request.Name == "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
request.Entity = newEntity("draft")
if err := impl.DBService.Create(&request).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
}
// UpdateGasBasic 更新可燃气体站基础资料。
func UpdateGasBasic(ctx *gin.Context) {
var request struct {
Name string `json:"name" binding:"required,max=128"`
Principal string `json:"principal" binding:"required,max=64"`
ServiceArea string `json:"service_area" binding:"required,max=128"`
CreditCode string `json:"credit_code" binding:"max=64"`
Principal string `json:"principal" binding:"max=64"`
Address string `json:"address" binding:"max=255"`
Longitude string `json:"longitude" binding:"max=32"`
Latitude string `json:"latitude" binding:"max=32"`
}
// OrgGasStationListReply 是 org_gas_station 的标准分页响应。
type OrgGasStationListReply struct {
Total int64 `json:"total"`
List []models.OrgGasStation `json:"list"`
}
// CreateOrgGasStation 创建待审核气站并由数据库层保证唯一编码。
func CreateOrgGasStation(ctx *gin.Context) {
var request CreateOrgGasStationRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
data := models.OrgGasStation{StationCode: request.StationCode, Name: request.Name, Principal: request.Principal, ServiceArea: request.ServiceArea}
data.Identity = models.NewIdentity()
data.Status = "draft"
data.Version = 1
if err := models.CreateOrgGasStation(&data); err != nil {
updateByIdentity(ctx, &models.GasBasic{}, gin.H{"name": request.Name, "credit_code": request.CreditCode, "principal": request.Principal, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude})
}
// ListDeliveryBasic 查询配送点分页列表。
func ListDeliveryBasic(ctx *gin.Context) { listPage[models.DeliveryBasic](ctx) }
// GetDeliveryBasic 查询一个配送点。
func GetDeliveryBasic(ctx *gin.Context) { getByIdentity[models.DeliveryBasic](ctx) }
// CreateDeliveryBasic 创建配送点档案。
func CreateDeliveryBasic(ctx *gin.Context) {
var request models.DeliveryBasic
if err := ctx.ShouldBindJSON(&request); err != nil || request.DeliveryCode == "" || request.Name == "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
request.Entity = newEntity("draft")
if err := impl.DBService.Create(&request).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
}
// UpdateDeliveryBasic 更新配送点基础资料。
func UpdateDeliveryBasic(ctx *gin.Context) {
var request struct {
GasBasicID uint64 `json:"gas_basic_id"`
Name string `json:"name" binding:"required,max=128"`
Principal string `json:"principal" binding:"max=64"`
Address string `json:"address" binding:"max=255"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateByIdentity(ctx, &models.DeliveryBasic{}, gin.H{"gas_basic_id": request.GasBasicID, "name": request.Name, "principal": request.Principal, "address": request.Address})
}
// ListStaff 查询服务人员分页列表。
func ListStaff(ctx *gin.Context) { listPage[models.StaffAccount](ctx) }
// GetStaff 查询一个服务人员档案。
func GetStaff(ctx *gin.Context) { getByIdentity[models.StaffAccount](ctx) }
// CreateStaff 创建服务人员档案。
func CreateStaff(ctx *gin.Context) {
var request models.StaffAccount
if err := ctx.ShouldBindJSON(&request); err != nil || request.Name == "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
request.Entity = newEntity("draft")
if request.WorkStatus == "" {
request.WorkStatus = "off_duty"
}
if err := impl.DBService.Create(&request).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
}
// UpdateStaff 更新服务人员档案。
func UpdateStaff(ctx *gin.Context) {
var request struct {
Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"`
RoleCode string `json:"role_code" binding:"max=64"`
GasBasicID uint64 `json:"gas_basic_id"`
DeliveryBasicID uint64 `json:"delivery_basic_id"`
WorkStatus string `json:"work_status" binding:"max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": request.GasBasicID, "delivery_basic_id": request.DeliveryBasicID, "work_status": request.WorkStatus})
}
// ListUser 查询业主客户分页列表。
func ListUser(ctx *gin.Context) { listPage[models.UserAccount](ctx) }
// GetUser 查询一个业主客户档案。
func GetUser(ctx *gin.Context) { getByIdentity[models.UserAccount](ctx) }
// CreateUser 创建业主客户档案。
func CreateUser(ctx *gin.Context) {
var request models.UserAccount
if err := ctx.ShouldBindJSON(&request); err != nil || request.Name == "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
request.Entity = newEntity("enabled")
if err := impl.DBService.Create(&request).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
}
// UpdateUser 更新业主客户档案。
func UpdateUser(ctx *gin.Context) {
var request struct {
Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"`
RealName string `json:"real_name" binding:"max=64"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateByIdentity(ctx, &models.UserAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName})
}
// ListPlatformRole 查询平台角色分页列表。
func ListPlatformRole(ctx *gin.Context) { listPage[models.PlatformRole](ctx) }
// GetPlatformRole 查询一个平台角色。
func GetPlatformRole(ctx *gin.Context) { getByIdentity[models.PlatformRole](ctx) }
// CreatePlatformRole 创建非内置平台角色。
func CreatePlatformRole(ctx *gin.Context) {
var request models.PlatformRole
if err := ctx.ShouldBindJSON(&request); err != nil || request.RoleCode == "" || request.Name == "" || request.RoleCode == "root" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
request.Entity = newEntity("enabled")
request.IsSystem = false
if request.DataScope == "" {
request.DataScope = "global"
}
if err := impl.DBService.Create(&request).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
}
// UpdatePlatformRole 更新非内置平台角色。
func UpdatePlatformRole(ctx *gin.Context) {
var request struct {
Name string `json:"name" binding:"required,max=64"`
DataScope string `json:"data_scope" binding:"required,max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var role models.PlatformRole
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil {
respondRecordError(ctx, err)
return
}
if role.IsSystem {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := impl.DBService.Model(&role).Updates(gin.H{"name": request.Name, "data_scope": request.DataScope}).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, role)
}
// UpdatePlatformRoleStatus 更新非内置平台角色状态root 等系统角色始终受保护。
func UpdatePlatformRoleStatus(ctx *gin.Context) {
var request struct {
Status string `json:"status" binding:"required,max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var role models.PlatformRole
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil {
respondRecordError(ctx, err)
return
}
if role.IsSystem {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := impl.DBService.Model(&role).Update("status", request.Status).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
// ArchivePlatformRole 归档非内置平台角色root 等系统角色始终受保护。
func ArchivePlatformRole(ctx *gin.Context) {
var role models.PlatformRole
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil {
respondRecordError(ctx, err)
return
}
if role.IsSystem {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := impl.DBService.Model(&role).Update("status", "archived").Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
// ListPlatformMenu 返回菜单树构建所需的有序菜单列表。
func ListPlatformMenu(ctx *gin.Context) {
var list []models.PlatformMenu
if err := impl.DBService.Order("sort_no asc, id asc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": len(list), "list": list})
}
// UpdateRecordStatus 更新主表状态,停用和归档均保留历史记录。
func UpdateRecordStatus(ctx *gin.Context, model any) {
var request struct {
Status string `json:"status" binding:"required,max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateByIdentity(ctx, model, gin.H{"status": request.Status})
}
// ArchiveRecord 通过 archived 状态实现逻辑删除,不物理删除主数据。
func ArchiveRecord(ctx *gin.Context, model any) {
updateByIdentity(ctx, model, gin.H{"status": "archived"})
}
// ListPlatfromAccount 查询平台账号列表,手机号在展示层脱敏。
func ListPlatfromAccount(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListPlatfromAccount(page, size)
if err != nil {
infra.Response.Error(ctx, err)
return
}
views := make([]gin.H, 0, len(list))
for _, item := range list {
views = append(views, gin.H{"identity": item.Identity, "username": item.Username, "display_name": item.DisplayName, "avatar": item.Avatar, "phone_masked": maskPhone(item.Phone), "platform_role_code": item.PlatformRoleCode, "status": item.Status})
}
infra.Response.Success(ctx, gin.H{"total": total, "list": views})
}
func newEntity(status string) models.Entity {
return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1}
}
func listPage[T any](ctx *gin.Context) {
page, size := pageSize(ctx)
var list []T
var total int64
databaseQuery := impl.DBService.Model(new(T))
if err := databaseQuery.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := databaseQuery.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": list})
}
func getByIdentity[T any](ctx *gin.Context) {
var data T
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil {
respondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, data)
}
// ListOrgGasStation 查询气站分页列表。
func ListOrgGasStation(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListOrgGasStation(page, size)
if err != nil {
infra.Response.Error(ctx, err)
func updateByIdentity(ctx *gin.Context, model any, values map[string]any) {
result := impl.DBService.Model(model).Where("identity = ?", ctx.Param("identity")).Updates(values)
if result.Error != nil {
infra.Response.Error(ctx, result.Error)
return
}
infra.Response.Success(ctx, OrgGasStationListReply{Total: total, List: list})
}
// OrgDeliveryPointListReply 是 org_delivery_point 的标准分页响应。
type OrgDeliveryPointListReply struct {
Total int64 `json:"total"`
List []models.OrgDeliveryPoint `json:"list"`
}
// ListOrgDeliveryPoint 查询配送点分页列表。
func ListOrgDeliveryPoint(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListOrgDeliveryPoint(page, size)
if err != nil {
infra.Response.Error(ctx, err)
if result.RowsAffected == 0 {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, OrgDeliveryPointListReply{Total: total, List: list})
infra.Response.Success(ctx, gin.H{"updated": true})
}
// OrgServicePersonListReply 是 org_service_person 的标准分页响应。
type OrgServicePersonListReply struct {
Total int64 `json:"total"`
List []models.OrgServicePerson `json:"list"`
}
// ListOrgServicePerson 查询服务人员分页列表。
func ListOrgServicePerson(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListOrgServicePerson(page, size)
if err != nil {
infra.Response.Error(ctx, err)
func respondRecordError(ctx *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, OrgServicePersonListReply{Total: total, List: list})
}
// IdnAccountView 是 idn_account 的最小必要输出,手机号始终脱敏。
type IdnAccountView struct {
Identity string `json:"identity"`
PhoneMasked string `json:"phone_masked"`
AccountType string `json:"account_type"`
Status string `json:"status"`
ServiceArea string `json:"service_area"`
}
// IdnAccountListReply 是 idn_account 的标准分页响应。
type IdnAccountListReply struct {
Total int64 `json:"total"`
List []IdnAccountView `json:"list"`
}
// ListIdnAccount 查询普通用户账户列表。
func ListIdnAccount(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListIdnAccount(page, size)
if err != nil {
infra.Response.Error(ctx, err)
return
}
views := make([]IdnAccountView, 0, len(list))
for _, item := range list {
views = append(views, IdnAccountView{Identity: item.Identity.String(), PhoneMasked: maskPhone(item.Phone), AccountType: item.AccountType, Status: item.Status, ServiceArea: item.ServiceArea})
}
infra.Response.Success(ctx, IdnAccountListReply{Total: total, List: views})
}
// SafEventListReply 是 saf_event 的标准分页响应。
type SafEventListReply struct {
Total int64 `json:"total"`
List []models.SafEvent `json:"list"`
}
// ListSafEvent 查询安全事件列表。
func ListSafEvent(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListSafEvent(page, size)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, SafEventListReply{Total: total, List: list})
}
// pageSize 统一约束分页参数,避免各接口出现不同边界。
func pageSize(ctx *gin.Context) (int, int) {
page := utils.String2Int(ctx.DefaultQuery("page", "1"))
size := utils.String2Int(ctx.DefaultQuery("size", "20"))
@@ -161,7 +378,6 @@ func pageSize(ctx *gin.Context) (int, int) {
return page, size
}
// maskPhone 遵循敏感数据最小展示原则。
func maskPhone(phone string) string {
if len(phone) < 7 {
return "***"

View File

@@ -0,0 +1,87 @@
// Package upload 提供平台总后台的受控文件上传服务。
package upload
import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
const maxUploadSize int64 = 10 << 20
var allowedExtensions = map[string]struct{}{
".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".pdf": {},
}
// UploadFileReply 是文件上传完成后返回的受控资源标识。
type UploadFileReply struct {
URI string `json:"uri"` // 资源访问标识,后续可由对象存储适配层解析
OriginalName string `json:"original_name"` // 原始文件名,仅用于展示
ContentType string `json:"content_type"` // 客户端声明的媒体类型
Size int64 `json:"size"` // 文件字节数
}
// UploadFile 将允许类型的文件保存至本地 Mock 存储,不直接暴露绝对磁盘路径。
func UploadFile(ctx *gin.Context) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxUploadSize)
fileHeader, err := ctx.FormFile("file")
if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > maxUploadSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
extension := strings.ToLower(filepath.Ext(fileHeader.Filename))
if _, allowed := allowedExtensions[extension]; !allowed {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
file, err := fileHeader.Open()
if err != nil {
infra.Response.Error(ctx, err)
return
}
defer file.Close()
datePath := time.Now().Format("2006/01/02")
filename := models.NewIdentity() + extension
directory := filepath.Join(uploadRoot(), filepath.FromSlash(datePath))
if err := os.MkdirAll(directory, 0o750); err != nil {
infra.Response.Error(ctx, err)
return
}
target, err := os.OpenFile(filepath.Join(directory, filename), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
infra.Response.Error(ctx, err)
return
}
defer target.Close()
if _, err := io.Copy(target, file); err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, UploadFileReply{
URI: "/uploads/" + datePath + "/" + filename,
OriginalName: fileHeader.Filename,
ContentType: fileHeader.Header.Get("Content-Type"),
Size: fileHeader.Size,
})
}
// uploadRoot 返回本地 Mock 存储根目录;生产环境可通过环境变量映射到受控挂载目录。
func uploadRoot() string {
if directory := strings.TrimSpace(os.Getenv("HEQI_UPLOAD_DIR")); directory != "" {
return directory
}
return filepath.Join("runtime", "uploads")
}

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// AudApproval 对应 aud_approval保存审批流与复核意见。
type AudApproval struct {
Entity
BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"`
BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"`
ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"`
Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"`
}
func init() { database.AppendMigrate(&AudApproval{}) }
func (table *AudApproval) TableName() string { return "aud_approval" }

View File

@@ -0,0 +1,19 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// AudExportLog 对应 aud_export_log保存敏感导出审计。
type AudExportLog struct {
Entity
ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"`
Purpose string `gorm:"column:purpose;type:varchar(255);not null" json:"purpose"`
FieldScope string `gorm:"column:field_scope;type:jsonb;not null;default:'{}'" json:"field_scope"`
ApprovedAt *time.Time `gorm:"column:approved_at;type:timestamptz" json:"approved_at"`
FileURI string `gorm:"column:file_uri;type:varchar(512);not null;default:''" json:"file_uri"`
}
func init() { database.AppendMigrate(&AudExportLog{}) }
func (table *AudExportLog) TableName() string { return "aud_export_log" }

View File

@@ -0,0 +1,17 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// AudOperationLog 对应 aud_operation_log保存不可变操作审计。
type AudOperationLog struct {
Entity
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"`
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"`
ObjectType string `gorm:"column:object_type;type:varchar(64);not null" json:"object_type"`
ObjectIdentity string `gorm:"column:object_identity;type:varchar(36);not null;index" json:"object_identity"`
BeforeData string `gorm:"column:before_data;type:jsonb;not null;default:'{}'" json:"before_data"`
AfterData string `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"`
}
func init() { database.AppendMigrate(&AudOperationLog{}) }
func (table *AudOperationLog) TableName() string { return "aud_operation_log" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// CntContent 对应 cnt_content保存公告与协议内容。
type CntContent struct {
Entity
ContentType string `gorm:"column:content_type;type:varchar(32);not null" json:"content_type"`
Title string `gorm:"column:title;type:varchar(256);not null" json:"title"`
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"`
VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"`
PublishStatus string `gorm:"column:publish_status;type:varchar(32);not null;default:'draft'" json:"publish_status"`
}
func init() { database.AppendMigrate(&CntContent{}) }
func (table *CntContent) TableName() string { return "cnt_content" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// CsTicket 对应 cs_ticket保存客服工单。
type CsTicket struct {
Entity
TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
Category string `gorm:"column:category;type:varchar(64);not null" json:"category"`
Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"`
}
func init() { database.AppendMigrate(&CsTicket{}) }
func (table *CsTicket) TableName() string { return "cs_ticket" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// DeliveryAccount 对应 delivery_account保存配送点登录账户。
type DeliveryAccount struct {
Entity
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;index" json:"delivery_basic_id"`
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"`
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"`
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"`
RoleCode string `gorm:"column:role_code;type:varchar(64);not null" json:"role_code"`
}
func init() { database.AppendMigrate(&DeliveryAccount{}) }
func (table *DeliveryAccount) TableName() string { return "delivery_account" }

View File

@@ -0,0 +1,18 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// DeliveryBasic 对应 delivery_basic保存配送点主档案。
type DeliveryBasic struct {
Entity
DeliveryCode string `gorm:"column:delivery_code;type:varchar(32);not null;uniqueIndex" json:"delivery_code"` // 配送点编码
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 所属可燃气体站自增主键0 表示平台直属
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 配送点名称
Principal string `gorm:"column:principal;type:varchar(64);not null;default:''" json:"principal"` // 负责人
Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 配送点地址
}
func init() { database.AppendMigrate(&DeliveryBasic{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *DeliveryBasic) TableName() string { return "delivery_basic" }

View File

@@ -0,0 +1,14 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// DeliveryTask 对应 delivery_task保存配送履约任务。
type DeliveryTask struct {
Entity
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"`
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"`
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;index" json:"delivery_point_id"`
}
func init() { database.AppendMigrate(&DeliveryTask{}) }
func (table *DeliveryTask) TableName() string { return "delivery_task" }

View File

@@ -0,0 +1,17 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// DeliveryTrack 对应 delivery_track保存配送轨迹摘要。
type DeliveryTrack struct {
Entity
DeliveryTaskID uint64 `gorm:"column:delivery_task_id;not null;index" json:"delivery_task_id"`
StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"`
}
func init() { database.AppendMigrate(&DeliveryTrack{}) }
func (table *DeliveryTrack) TableName() string { return "delivery_track" }

View File

@@ -0,0 +1,19 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// DeliveryTrackPoint 对应 delivery_track_point保存配送节点和位置。
type DeliveryTrackPoint struct {
Entity
DeliveryTrackID uint64 `gorm:"column:delivery_track_id;not null;index" json:"delivery_track_id"`
PointType string `gorm:"column:point_type;type:varchar(32);not null" json:"point_type"`
OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"`
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"`
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"`
}
func init() { database.AppendMigrate(&DeliveryTrackPoint{}) }
func (table *DeliveryTrackPoint) TableName() string { return "delivery_track_point" }

View File

@@ -0,0 +1,18 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// DevDeviceBinding 对应 dev_device_binding保存设备授权绑定。
type DevDeviceBinding struct {
Entity
SmartCylinderValveID uint64 `gorm:"column:smart_cylinder_valve_id;not null;index" json:"smart_cylinder_valve_id"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"`
ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"`
}
func init() { database.AppendMigrate(&DevDeviceBinding{}) }
func (table *DevDeviceBinding) TableName() string { return "dev_device_binding" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// DevSmartCylinderValve 对应 dev_smart_cylinder_valve保存智能瓶阀档案。
type DevSmartCylinderValve struct {
Entity
DeviceNo string `gorm:"column:device_no;type:varchar(64);not null;uniqueIndex" json:"device_no"`
Model string `gorm:"column:model;type:varchar(64);not null;default:''" json:"model"`
OnlineStatus string `gorm:"column:online_status;type:varchar(32);not null;default:'offline'" json:"online_status"`
OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;default:'';index" json:"owner_identity"`
}
func init() { database.AppendMigrate(&DevSmartCylinderValve{}) }
func (table *DevSmartCylinderValve) TableName() string { return "dev_smart_cylinder_valve" }

View File

@@ -0,0 +1,18 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// DevTelemetry 对应 dev_telemetry保存设备遥测摘要。
type DevTelemetry struct {
Entity
SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;index" json:"smart_cylinder_valve_identity"`
ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null;index" json:"reported_at"`
Payload string `gorm:"column:payload;type:jsonb;not null;default:'{}'" json:"payload"`
QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"`
}
func init() { database.AppendMigrate(&DevTelemetry{}) }
func (table *DevTelemetry) TableName() string { return "dev_telemetry" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcCart 对应 ec_cart保存用户购物车明细。
type EcCart struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"`
Selected bool `gorm:"column:selected;not null;default:true" json:"selected"`
}
func init() { database.AppendMigrate(&EcCart{}) }
func (table *EcCart) TableName() string { return "ec_cart" }

View File

@@ -0,0 +1,14 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcCategory 对应 ec_category保存商品分类树。
type EcCategory struct {
Entity
ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"`
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"`
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"`
}
func init() { database.AppendMigrate(&EcCategory{}) }
func (table *EcCategory) TableName() string { return "ec_category" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcOrder 对应 ec_order保存电商订单与组织快照。
type EcOrder struct {
Entity
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"`
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"`
TotalAmount int64 `gorm:"column:total_amount;not null;default:0" json:"total_amount"`
}
func init() { database.AppendMigrate(&EcOrder{}) }
func (table *EcOrder) TableName() string { return "ec_order" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcOrderItem 对应 ec_order_item保存订单商品快照。
type EcOrderItem struct {
Entity
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"`
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
ProductSnapshot string `gorm:"column:product_snapshot;type:jsonb;not null;default:'{}'" json:"product_snapshot"`
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"`
SaleAmount int64 `gorm:"column:sale_amount;not null;default:0" json:"sale_amount"`
}
func init() { database.AppendMigrate(&EcOrderItem{}) }
func (table *EcOrderItem) TableName() string { return "ec_order_item" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcProduct 对应 ec_product保存可燃气体商品与服务。
type EcProduct struct {
Entity
EcCategoryID uint64 `gorm:"column:ec_category_id;not null;index" json:"ec_category_id"`
ProductCode string `gorm:"column:product_code;type:varchar(64);not null;uniqueIndex" json:"product_code"`
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"`
PriceAmount int64 `gorm:"column:price_amount;not null;default:0" json:"price_amount"`
StockQuantity int `gorm:"column:stock_quantity;not null;default:0" json:"stock_quantity"`
}
func init() { database.AppendMigrate(&EcProduct{}) }
func (table *EcProduct) TableName() string { return "ec_product" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcProductAttribute 对应 ec_product_attribute保存商品属性。
type EcProductAttribute struct {
Entity
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"`
Value string `gorm:"column:value;type:varchar(255);not null" json:"value"`
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"`
}
func init() { database.AppendMigrate(&EcProductAttribute{}) }
func (table *EcProductAttribute) TableName() string { return "ec_product_attribute" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcProductImage 对应 ec_product_image保存商品受控图片资源。
type EcProductImage struct {
Entity
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
ImageURI string `gorm:"column:image_uri;type:varchar(512);not null" json:"image_uri"`
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"`
IsCover bool `gorm:"column:is_cover;not null;default:false" json:"is_cover"`
}
func init() { database.AppendMigrate(&EcProductImage{}) }
func (table *EcProductImage) TableName() string { return "ec_product_image" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// EcReview 对应 ec_review保存商品评论与审核状态。
type EcReview struct {
Entity
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"`
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
Score int `gorm:"column:score;not null;default:5" json:"score"`
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"`
}
func init() { database.AppendMigrate(&EcReview{}) }
func (table *EcReview) TableName() string { return "ec_review" }

View File

@@ -1,4 +1,4 @@
// Package models 定义与数据表同名的领域模型和数据访问方法。
// Package models 定义与数据表同名的领域模型和数据访问方法。
package models
import (
@@ -7,22 +7,21 @@ import (
"github.com/google/uuid"
)
// Entity 是所有主表共享字段identity 必须由应用生成 UUID V7,禁止自增主键
// Entity 是所有主表共享字段。id 是数据库自增主键,identity 应用生成 UUID V7 业务标识
type Entity struct {
Identity uuid.UUID `gorm:"column:identity;type:uuid;primaryKey" json:"identity"` // 主键UUID V7
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键
Identity string `gorm:"column:identity;type:varchar(36);not null;uniqueIndex" json:"identity"` // UUID V7 业务标识
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null" json:"updated_at"` // 更新时间
CreatedByIdentity *uuid.UUID `gorm:"column:created_by_identity;type:uuid" json:"created_by_identity,omitempty"` // 创建人主键
UpdatedByIdentity *uuid.UUID `gorm:"column:updated_by_identity;type:uuid" json:"updated_by_identity,omitempty"` // 更新人主键
Status string `gorm:"column:status;type:varchar(32);not null;default:'draft'" json:"status"` // 业务状态
Version int `gorm:"column:version;not null;default:1" json:"version"` // 乐观锁版本
}
// NewIdentity 生成时间有序 UUID V7生成失败属于不可恢复的运行时错误。
func NewIdentity() uuid.UUID {
// NewIdentity 生成时间有序 UUID V7 字符串,生成失败属于不可恢复的运行时错误。
func NewIdentity() string {
identity, err := uuid.NewV7()
if err != nil {
panic(err)
}
return identity
return identity.String()
}

View File

@@ -0,0 +1,18 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// FinPayment 对应 fin_payment保存支付与退款记录。
type FinPayment struct {
Entity
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"`
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"`
Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"`
PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"`
}
func init() { database.AppendMigrate(&FinPayment{}) }
func (table *FinPayment) TableName() string { return "fin_payment" }

View File

@@ -0,0 +1,17 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// FinReconciliation 对应 fin_reconciliation保存渠道对账记录。
type FinReconciliation struct {
Entity
Channel string `gorm:"column:channel;type:varchar(32);not null;index" json:"channel"`
BillDate time.Time `gorm:"column:bill_date;type:date;not null" json:"bill_date"`
DifferenceAmount int64 `gorm:"column:difference_amount;not null;default:0" json:"difference_amount"`
}
func init() { database.AppendMigrate(&FinReconciliation{}) }
func (table *FinReconciliation) TableName() string { return "fin_reconciliation" }

View File

@@ -0,0 +1,19 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// FinSettlement 对应 fin_settlement保存结算单。
type FinSettlement struct {
Entity
SettlementNo string `gorm:"column:settlement_no;type:varchar(64);not null;uniqueIndex" json:"settlement_no"`
SubjectType string `gorm:"column:subject_type;type:varchar(32);not null" json:"subject_type"`
SubjectID uint64 `gorm:"column:subject_id;not null;index" json:"subject_id"`
PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"`
PeriodEnd time.Time `gorm:"column:period_end;type:timestamptz;not null" json:"period_end"`
}
func init() { database.AppendMigrate(&FinSettlement{}) }
func (table *FinSettlement) TableName() string { return "fin_settlement" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// GasAccount 对应 gas_account保存可燃气体站登录账户。
type GasAccount struct {
Entity
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 可燃气体站主键
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 展示名称
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希
RoleCode string `gorm:"column:role_code;type:varchar(64);not null" json:"role_code"` // 角色编码
}
func init() { database.AppendMigrate(&GasAccount{}) }
func (table *GasAccount) TableName() string { return "gas_account" }

View File

@@ -0,0 +1,20 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// GasBasic 对应 gas_basic保存可燃气体站的主体主档案。
type GasBasic struct {
Entity
Code string `gorm:"column:code;type:varchar(32);not null;uniqueIndex" json:"code"` // 站点编码
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 站点名称
CreditCode string `gorm:"column:credit_code;type:varchar(64);not null;default:''" json:"credit_code"` // 统一社会信用代码
Principal string `gorm:"column:principal;type:varchar(64);not null;default:''" json:"principal"` // 负责人
Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 站点地址
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // 经度
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // 纬度
}
func init() { database.AppendMigrate(&GasBasic{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *GasBasic) TableName() string { return "gas_basic" }

View File

@@ -1,22 +0,0 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// IdnAccount 对应 idn_account表示用户或服务人员身份账户。
type IdnAccount struct {
Entity
Username string `gorm:"column:username;type:varchar(64);uniqueIndex" json:"username"` // 登录用户名。
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称。
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值,禁止在接口中返回。
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:'user'" json:"role_code"` // 平台角色编码。
MustChangePassword bool `gorm:"column:must_change_password;not null;default:false" json:"must_change_password"` // 是否必须修改初始密码。
MFAEnabled bool `gorm:"column:mfa_enabled;not null;default:false" json:"mfa_enabled"` // 是否启用多因素认证。
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,用于登录和通知。
AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账号类型,例如 user、operator。
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域描述。
}
func init() { database.AppendMigrate(&IdnAccount{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *IdnAccount) TableName() string { return "idn_account" }

View File

@@ -0,0 +1,14 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// NtfTemplate 对应 ntf_template保存通知模板。
type NtfTemplate struct {
Entity
TemplateCode string `gorm:"column:template_code;type:varchar(64);not null;uniqueIndex" json:"template_code"`
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"`
Content string `gorm:"column:content;type:text;not null" json:"content"`
}
func init() { database.AppendMigrate(&NtfTemplate{}) }
func (table *NtfTemplate) TableName() string { return "ntf_template" }

View File

@@ -1,17 +0,0 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// OrgDeliveryPoint 对应 org_delivery_point表示末端配送组织单元。
type OrgDeliveryPoint struct {
Entity
DeliveryCode string `gorm:"column:delivery_code;type:varchar(32);uniqueIndex;not null" json:"delivery_code"` // 配送点编码
GasStationIdentity string `gorm:"column:gas_station_identity;type:uuid" json:"gas_station_identity"` // 归属气站主键
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 配送点名称
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域
}
func init() { database.AppendMigrate(&OrgDeliveryPoint{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *OrgDeliveryPoint) TableName() string { return "org_delivery_point" }

View File

@@ -1,60 +0,0 @@
package models
import (
"errors"
"git.apinb.com/bsm-sdk/core/database"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"gorm.io/gorm"
)
// OrgGasStation 对应 org_gas_station表示可燃气体站经营主体。
type OrgGasStation struct {
Entity
StationCode string `gorm:"column:station_code;type:varchar(32);uniqueIndex;not null" json:"station_code"` // 气站编码
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 气站名称
Principal string `gorm:"column:principal;type:varchar(64);not null" json:"principal"` // 负责人
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域
}
func init() {
database.AppendMigrate(&OrgGasStation{})
}
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *OrgGasStation) TableName() string { return "org_gas_station" }
// CreateOrgGasStation 创建待审核气站。
func CreateOrgGasStation(data *OrgGasStation) error {
if err := impl.DBService.Create(data).Error; err != nil {
return errcode.ErrDB
}
return nil
}
// ListOrgGasStation 按创建时间倒序查询气站。
func ListOrgGasStation(page, size int) ([]OrgGasStation, int64, error) {
var list []OrgGasStation
var total int64
databaseQuery := impl.DBService.Model(&OrgGasStation{})
if err := databaseQuery.Count(&total).Error; err != nil {
return nil, 0, errcode.ErrDB
}
if err := databaseQuery.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
return nil, 0, errcode.ErrDB
}
return list, total, nil
}
// GetOrgGasStationByIdentity 查询单个气站。
func GetOrgGasStationByIdentity(identity string) (*OrgGasStation, error) {
var data OrgGasStation
if err := impl.DBService.Where("identity = ?", identity).First(&data).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errcode.ErrRecordNotFound
}
return nil, errcode.ErrDB
}
return &data, nil
}

View File

@@ -1,20 +0,0 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// OrgServicePerson 对应 org_service_person表示安装维修、安检或配送服务人员。
type OrgServicePerson struct {
Entity
AccountIdentity string `gorm:"column:account_identity;type:uuid;uniqueIndex" json:"account_identity"` // 关联 idn_account 主键
GasStationIdentity string `gorm:"column:gas_station_identity;type:uuid" json:"gas_station_identity"` // 归属气站主键
DeliveryPointIdentity string `gorm:"column:delivery_point_identity;type:uuid" json:"delivery_point_identity"` // 主归属配送点主键
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 服务人员姓名
Roles string `gorm:"column:roles;type:varchar(128);not null" json:"roles"` // 可执行角色集合
WorkStatus string `gorm:"column:work_status;type:varchar(32);not null" json:"work_status"` // 上班与接单状态
CredentialStatus string `gorm:"column:credential_status;type:varchar(32);not null" json:"credential_status"` // 资质状态
}
func init() { database.AppendMigrate(&OrgServicePerson{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *OrgServicePerson) TableName() string { return "org_service_person" }

View File

@@ -0,0 +1,19 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// PlatformMenu 对应 platform_menu定义平台总后台的菜单树和访问路由。
type PlatformMenu struct {
Entity
ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"` // 父菜单自增主键,顶级菜单为 0
MenuCode string `gorm:"column:menu_code;type:varchar(64);not null;uniqueIndex" json:"menu_code"` // 菜单编码
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 菜单名称
Icon string `gorm:"column:icon;type:varchar(64);not null;default:''" json:"icon"` // 前端图标名称
Path string `gorm:"column:path;type:varchar(255);not null;default:''" json:"path"` // 前端路由地址
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // 同级排序号
}
func init() { database.AppendMigrate(&PlatformMenu{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *PlatformMenu) TableName() string { return "platform_menu" }

View File

@@ -0,0 +1,17 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// PlatformRole 对应 platform_role定义平台总后台的数据范围与菜单权限角色。
type PlatformRole struct {
Entity
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;uniqueIndex" json:"role_code"` // 角色编码
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 角色名称
DataScope string `gorm:"column:data_scope;type:varchar(32);not null;default:'global'" json:"data_scope"` // 数据权限范围
IsSystem bool `gorm:"column:is_system;not null;default:false" json:"is_system"` // 是否系统内置角色
}
func init() { database.AppendMigrate(&PlatformRole{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *PlatformRole) TableName() string { return "platform_role" }

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"git.apinb.com/bsm-sdk/core/database"
)
// PlatformRoleMenuRelation 对应 platform_role_menu_relation记录角色拥有的菜单权限。
type PlatformRoleMenuRelation struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键
PlatformRoleID uint64 `gorm:"column:platform_role_id;not null;uniqueIndex:uk_platform_role_menu" json:"platform_role_id"` // 角色自增主键
PlatformMenuID uint64 `gorm:"column:platform_menu_id;not null;uniqueIndex:uk_platform_role_menu" json:"platform_menu_id"` // 菜单自增主键
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间
}
func init() { database.AppendMigrate(&PlatformRoleMenuRelation{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *PlatformRoleMenuRelation) TableName() string { return "platform_role_menu_relation" }

View File

@@ -0,0 +1,19 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// PlatfromAccount 对应 platfrom_account表示平台总后台登录账号。
type PlatfromAccount struct {
Entity
Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` // 登录用户名
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称
Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值
PlatformRoleCode string `gorm:"column:platform_role_code;type:varchar(64);not null;default:'root';index" json:"platform_role_code"` // 平台角色编码
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null;default:''" json:"phone"` // 手机号
}
func init() { database.AppendMigrate(&PlatfromAccount{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *PlatfromAccount) TableName() string { return "platfrom_account" }

View File

@@ -2,28 +2,28 @@ package models
import "git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
// DashboardOverview 是平台总后台的安全与组织聚合指标。
// DashboardOverview 是平台总后台的跨组织运营概览指标。
type DashboardOverview struct {
GasStationCount int64 `json:"gas_station_count"` // 启用站数量
DeliveryPointCount int64 `json:"delivery_point_count"` // 启用配送点数量
ServicePersonCount int64 `json:"service_person_count"` // 在岗服务人员数量
UserCount int64 `json:"user_count"` // 启用普通用户数量
GasBasicCount int64 `json:"gas_basic_count"` // 启用可燃气体站数量
DeliveryBasicCount int64 `json:"delivery_basic_count"` // 启用配送点数量
StaffCount int64 `json:"staff_count"` // 在岗服务人员数量
UserCount int64 `json:"user_count"` // 启用业主客户数量
PendingSafetyCount int64 `json:"pending_safety_count"` // 待处理安全事件数量
}
// GetDashboardOverview 通过独立查询返回首期仪表盘指标。
// GetDashboardOverview 通过独立查询返回首页概览指标。
func GetDashboardOverview() (DashboardOverview, error) {
var overview DashboardOverview
if err := impl.DBService.Model(&OrgGasStation{}).Where("status = ?", "enabled").Count(&overview.GasStationCount).Error; err != nil {
if err := impl.DBService.Model(&GasBasic{}).Where("status = ?", "enabled").Count(&overview.GasBasicCount).Error; err != nil {
return DashboardOverview{}, err
}
if err := impl.DBService.Model(&OrgDeliveryPoint{}).Where("status = ?", "enabled").Count(&overview.DeliveryPointCount).Error; err != nil {
if err := impl.DBService.Model(&DeliveryBasic{}).Where("status = ?", "enabled").Count(&overview.DeliveryBasicCount).Error; err != nil {
return DashboardOverview{}, err
}
if err := impl.DBService.Model(&OrgServicePerson{}).Where("work_status = ?", "on_duty").Count(&overview.ServicePersonCount).Error; err != nil {
if err := impl.DBService.Model(&StaffAccount{}).Where("work_status = ?", "on_duty").Count(&overview.StaffCount).Error; err != nil {
return DashboardOverview{}, err
}
if err := impl.DBService.Model(&IdnAccount{}).Where("account_type = ? AND status = ?", "user", "enabled").Count(&overview.UserCount).Error; err != nil {
if err := impl.DBService.Model(&UserAccount{}).Where("status = ?", "enabled").Count(&overview.UserCount).Error; err != nil {
return DashboardOverview{}, err
}
if err := impl.DBService.Model(&SafEvent{}).Where("status = ?", "pending").Count(&overview.PendingSafetyCount).Error; err != nil {
@@ -32,11 +32,11 @@ func GetDashboardOverview() (DashboardOverview, error) {
return overview, nil
}
// ListOrgDeliveryPoint 返回配送点分页列表
func ListOrgDeliveryPoint(page, size int) ([]OrgDeliveryPoint, int64, error) {
var list []OrgDeliveryPoint
// ListPlatfromAccount 返回平台账号分页列表,敏感字段由接口展示层脱敏
func ListPlatfromAccount(page, size int) ([]PlatfromAccount, int64, error) {
var list []PlatfromAccount
var total int64
databaseQuery := impl.DBService.Model(&OrgDeliveryPoint{})
databaseQuery := impl.DBService.Model(&PlatfromAccount{})
if err := databaseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
@@ -45,45 +45,3 @@ func ListOrgDeliveryPoint(page, size int) ([]OrgDeliveryPoint, int64, error) {
}
return list, total, nil
}
// ListOrgServicePerson 返回服务人员分页列表。
func ListOrgServicePerson(page, size int) ([]OrgServicePerson, int64, error) {
var list []OrgServicePerson
var total int64
databaseQuery := impl.DBService.Model(&OrgServicePerson{})
if err := databaseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := databaseQuery.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
return nil, 0, err
}
return list, total, nil
}
// ListIdnAccount 返回普通用户分页列表,手机号脱敏由前端展示层处理。
func ListIdnAccount(page, size int) ([]IdnAccount, int64, error) {
var list []IdnAccount
var total int64
databaseQuery := impl.DBService.Model(&IdnAccount{}).Where("account_type = ?", "user")
if err := databaseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := databaseQuery.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
return nil, 0, err
}
return list, total, nil
}
// ListSafEvent 返回安全事件分页列表。
func ListSafEvent(page, size int) ([]SafEvent, int64, error) {
var list []SafEvent
var total int64
databaseQuery := impl.DBService.Model(&SafEvent{})
if err := databaseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := databaseQuery.Order("level asc, created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
return nil, 0, err
}
return list, total, nil
}

View File

@@ -0,0 +1,18 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// Report 对应 report保存统计报表档案。
type Report struct {
Entity
ReportCode string `gorm:"column:report_code;type:varchar(64);not null;uniqueIndex" json:"report_code"`
ReportType string `gorm:"column:report_type;type:varchar(32);not null" json:"report_type"`
StatPeriod string `gorm:"column:stat_period;type:varchar(64);not null" json:"stat_period"`
GeneratedAt time.Time `gorm:"column:generated_at;type:timestamptz;not null" json:"generated_at"`
}
func init() { database.AppendMigrate(&Report{}) }
func (table *Report) TableName() string { return "report" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// ReportItem 对应 report_item保存报表维度明细。
type ReportItem struct {
Entity
ReportID uint64 `gorm:"column:report_id;not null;index" json:"report_id"`
Dimension string `gorm:"column:dimension;type:varchar(128);not null" json:"dimension"`
MetricCode string `gorm:"column:metric_code;type:varchar(64);not null" json:"metric_code"`
MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"`
}
func init() { database.AppendMigrate(&ReportItem{}) }
func (table *ReportItem) TableName() string { return "report_item" }

View File

@@ -0,0 +1,19 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// ReportMetricSnapshot 对应 report_metric_snapshot保存指标快照。
type ReportMetricSnapshot struct {
Entity
MetricCode string `gorm:"column:metric_code;type:varchar(64);not null;index" json:"metric_code"`
ScopeType string `gorm:"column:scope_type;type:varchar(32);not null" json:"scope_type"`
ScopeID uint64 `gorm:"column:scope_id;not null;default:0;index" json:"scope_id"`
StatAt time.Time `gorm:"column:stat_at;type:timestamptz;not null;index" json:"stat_at"`
MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"`
}
func init() { database.AppendMigrate(&ReportMetricSnapshot{}) }
func (table *ReportMetricSnapshot) TableName() string { return "report_metric_snapshot" }

View File

@@ -1,18 +1,19 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// SafEvent 对应 saf_event表示需要平台跟踪处置的安全事件
// SafEvent 对应 saf_event保存安全事件统一入口
type SafEvent struct {
Entity
EventCode string `gorm:"column:event_code;type:varchar(32);uniqueIndex;not null" json:"event_code"` // 安全事件编码
Level int `gorm:"column:level;type:integer;not null" json:"level"` // 风险等级1 至 3 级
Title string `gorm:"column:title;type:varchar(256);not null" json:"title"` // 事件说明
EventCode string `gorm:"column:event_code;type:varchar(64);not null;uniqueIndex" json:"event_code"`
Level int `gorm:"column:level;not null;default:3" json:"level"`
Title string `gorm:"column:title;type:varchar(256);not null;default:''" json:"title"`
SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;default:'';index" json:"smart_cylinder_valve_identity"`
SLAAt *time.Time `gorm:"column:sla_at;type:timestamptz" json:"sla_at"`
}
func init() {
database.AppendMigrate(&SafEvent{})
}
// TableName 返回与模型、文件名一致的单数数据表名。
func init() { database.AppendMigrate(&SafEvent{}) }
func (table *SafEvent) TableName() string { return "saf_event" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// SafEventDisposal 对应 saf_event_disposal保存安全处置记录。
type SafEventDisposal struct {
Entity
SafEventIdentity string `gorm:"column:saf_event_identity;type:varchar(36);not null;index" json:"saf_event_identity"`
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"`
Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"`
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"`
}
func init() { database.AppendMigrate(&SafEventDisposal{}) }
func (table *SafEventDisposal) TableName() string { return "saf_event_disposal" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// SafInspection 对应 saf_inspection保存安检与复检记录。
type SafInspection struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"`
Result string `gorm:"column:result;type:varchar(32);not null" json:"result"`
EvidenceURI string `gorm:"column:evidence_uri;type:varchar(512);not null;default:''" json:"evidence_uri"`
}
func init() { database.AppendMigrate(&SafInspection{}) }
func (table *SafInspection) TableName() string { return "saf_inspection" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// SafRule 对应 saf_rule保存安全规则。
type SafRule struct {
Entity
RuleCode string `gorm:"column:rule_code;type:varchar(64);not null;uniqueIndex" json:"rule_code"`
VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"`
Threshold string `gorm:"column:threshold;type:jsonb;not null;default:'{}'" json:"threshold"`
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"`
GrayScope string `gorm:"column:gray_scope;type:jsonb;not null;default:'{}'" json:"gray_scope"`
}
func init() { database.AppendMigrate(&SafRule{}) }
func (table *SafRule) TableName() string { return "saf_rule" }

View File

@@ -0,0 +1,22 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// StaffAccount 对应 staff_account是服务人员唯一的档案和 App 登录账户。
type StaffAccount struct {
Entity
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 人员姓名
Phone string `gorm:"column:phone;type:varchar(32);not null;default:'';index" json:"phone"` // 联系手机号
Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:''" json:"role_code"` // 服务角色编码
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 所属可燃气体站主键
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 所属配送点主键
WorkStatus string `gorm:"column:work_status;type:varchar(32);not null;default:'off_duty'" json:"work_status"` // 在岗接单状态
}
func init() { database.AppendMigrate(&StaffAccount{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *StaffAccount) TableName() string { return "staff_account" }

View File

@@ -0,0 +1,18 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// StaffCredential 对应 staff_credential保存人员资质。
type StaffCredential struct {
Entity
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"`
CredentialType string `gorm:"column:credential_type;type:varchar(64);not null" json:"credential_type"`
CredentialNo string `gorm:"column:credential_no;type:varchar(128);not null;default:''" json:"credential_no"`
ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"`
}
func init() { database.AppendMigrate(&StaffCredential{}) }
func (table *StaffCredential) TableName() string { return "staff_credential" }

View File

@@ -0,0 +1,19 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// UserAccount 对应 user_account是业主客户唯一的档案和用户端登录账户。
type UserAccount struct {
Entity
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 客户姓名
Phone string `gorm:"column:phone;type:varchar(32);not null;default:'';index" json:"phone"` // 联系手机号
Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址
RealName string `gorm:"column:real_name;type:varchar(64);not null;default:''" json:"real_name"` // 实名认证名称
}
func init() { database.AppendMigrate(&UserAccount{}) }
// TableName 返回与模型、文件名一致的单数数据表名。
func (table *UserAccount) TableName() string { return "user_account" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// UserAddress 对应 user_address保存用户地址。
type UserAddress struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
Address string `gorm:"column:address;type:varchar(255);not null" json:"address"`
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"`
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"`
IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"`
}
func init() { database.AppendMigrate(&UserAddress{}) }
func (table *UserAddress) TableName() string { return "user_address" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// UserServiceRelation 对应 user_service_relation保存用户服务归属快照。
type UserServiceRelation struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"`
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"`
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"`
}
func init() { database.AppendMigrate(&UserServiceRelation{}) }
func (table *UserServiceRelation) TableName() string { return "user_service_relation" }

View File

@@ -0,0 +1,15 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// Wallet 对应 wallet保存余额账户。
type Wallet struct {
Entity
OwnerType string `gorm:"column:owner_type;type:varchar(32);not null" json:"owner_type"`
OwnerID uint64 `gorm:"column:owner_id;not null;index" json:"owner_id"`
BalanceAmount int64 `gorm:"column:balance_amount;not null;default:0" json:"balance_amount"`
FrozenAmount int64 `gorm:"column:frozen_amount;not null;default:0" json:"frozen_amount"`
}
func init() { database.AppendMigrate(&Wallet{}) }
func (table *Wallet) TableName() string { return "wallet" }

View File

@@ -0,0 +1,16 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// WalletLedger 对应 wallet_ledger保存不可变资金流水。
type WalletLedger struct {
Entity
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"`
Amount int64 `gorm:"column:amount;not null" json:"amount"`
Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"`
BalanceAfter int64 `gorm:"column:balance_after;not null" json:"balance_after"`
ReferenceIdentity string `gorm:"column:reference_identity;type:varchar(36);not null;default:'';index" json:"reference_identity"`
}
func init() { database.AppendMigrate(&WalletLedger{}) }
func (table *WalletLedger) TableName() string { return "wallet_ledger" }

View File

@@ -0,0 +1,14 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// WalletRecharge 对应 wallet_recharge保存充值记录。
type WalletRecharge struct {
Entity
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"`
Amount int64 `gorm:"column:amount;not null" json:"amount"`
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"`
}
func init() { database.AppendMigrate(&WalletRecharge{}) }
func (table *WalletRecharge) TableName() string { return "wallet_recharge" }

View File

@@ -0,0 +1,14 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// WalletWithdrawal 对应 wallet_withdrawal保存提现记录。
type WalletWithdrawal struct {
Entity
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"`
Amount int64 `gorm:"column:amount;not null" json:"amount"`
BankAccountMasked string `gorm:"column:bank_account_masked;type:varchar(128);not null;default:''" json:"bank_account_masked"`
}
func init() { database.AppendMigrate(&WalletWithdrawal{}) }
func (table *WalletWithdrawal) TableName() string { return "wallet_withdrawal" }

View File

@@ -0,0 +1,82 @@
package routers
import (
"fmt"
"git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
// RegisterPlatform 注册 /heqi/platform/v1 前缀下的平台总后台路由。
func RegisterPlatform(serviceKey string, engine *gin.Engine) {
basePath := fmt.Sprintf("/%s/platform/v1", serviceKey)
anonymous := engine.Group(basePath)
anonymous.GET("/ping/hello", platform.PingHello)
anonymous.POST("/auth/login", platform.Login)
protected := engine.Group(basePath)
protected.Use(middleware.JwtAuth(true))
protected.GET("/auth/profile", platform.CurrentProfile)
protected.PUT("/auth/password", platform.ChangePassword)
protected.GET("/dashboard/overview", platform.DashboardOverview)
registerGasRoute(protected)
registerDeliveryRoute(protected)
registerStaffRoute(protected)
registerUserRoute(protected)
registerPlatformRoute(protected)
}
func registerGasRoute(group *gin.RouterGroup) {
resource := group.Group("/gas/gas_basic")
resource.GET("", platform.ListGasBasic)
resource.POST("", platform.CreateGasBasic)
resource.GET("/:identity", platform.GetGasBasic)
resource.PUT("/:identity", platform.UpdateGasBasic)
resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, &models.GasBasic{}) })
resource.DELETE("/:identity", func(ctx *gin.Context) { platform.ArchiveRecord(ctx, &models.GasBasic{}) })
}
func registerDeliveryRoute(group *gin.RouterGroup) {
resource := group.Group("/delivery/delivery_basic")
resource.GET("", platform.ListDeliveryBasic)
resource.POST("", platform.CreateDeliveryBasic)
resource.GET("/:identity", platform.GetDeliveryBasic)
resource.PUT("/:identity", platform.UpdateDeliveryBasic)
resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, &models.DeliveryBasic{}) })
resource.DELETE("/:identity", func(ctx *gin.Context) { platform.ArchiveRecord(ctx, &models.DeliveryBasic{}) })
}
func registerStaffRoute(group *gin.RouterGroup) {
resource := group.Group("/staff")
resource.GET("/account", platform.ListStaff)
resource.POST("/account", platform.CreateStaff)
resource.GET("/:identity", platform.GetStaff)
resource.PUT("/:identity", platform.UpdateStaff)
resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, &models.StaffAccount{}) })
resource.DELETE("/:identity", func(ctx *gin.Context) { platform.ArchiveRecord(ctx, &models.StaffAccount{}) })
}
func registerUserRoute(group *gin.RouterGroup) {
resource := group.Group("/user")
resource.GET("/account", platform.ListUser)
resource.POST("/account", platform.CreateUser)
resource.GET("/:identity", platform.GetUser)
resource.PUT("/:identity", platform.UpdateUser)
resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, &models.UserAccount{}) })
resource.DELETE("/:identity", func(ctx *gin.Context) { platform.ArchiveRecord(ctx, &models.UserAccount{}) })
}
func registerPlatformRoute(group *gin.RouterGroup) {
group.GET("/platform/platfrom_account", platform.ListPlatfromAccount)
role := group.Group("/platform/platform_role")
role.GET("", platform.ListPlatformRole)
role.POST("", platform.CreatePlatformRole)
role.GET("/:identity", platform.GetPlatformRole)
role.PUT("/:identity", platform.UpdatePlatformRole)
role.PATCH("/:identity/status", platform.UpdatePlatformRoleStatus)
role.DELETE("/:identity", platform.ArchivePlatformRole)
group.GET("/platform/platform_menu", platform.ListPlatformMenu)
}

View File

@@ -1,33 +1,10 @@
// Package routers 注册与 sample/server 一致的匿名和 JWT 受保护路由组
// Package routers 提供 API 路由注册入口
package routers
import (
"fmt"
import "github.com/gin-gonic/gin"
"git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
"github.com/gin-gonic/gin"
)
// Register 注册路由,请求地址格式: /{serviceKey}/v1/{domain}/{resource}。
func Register(srvKey string, engine *gin.Engine) {
v1Key := fmt.Sprintf("/%s/%s", srvKey, "v1")
anonymous := engine.Group(v1Key)
anonymous.GET("/ping/hello", platform.PingHello)
anonymous.POST("/auth/login", platform.Login)
protected := engine.Group(v1Key)
protected.Use(middleware.JwtAuth(true))
{
protected.GET("/auth/profile", platform.CurrentProfile)
protected.PUT("/auth/password", platform.ChangePassword)
protected.GET("/dashboard/overview", platform.DashboardOverview)
gasStationGroup := protected.Group("/organization/org_gas_station")
gasStationGroup.POST("", platform.CreateOrgGasStation)
gasStationGroup.GET("", platform.ListOrgGasStation)
protected.GET("/organization/org_delivery_point", platform.ListOrgDeliveryPoint)
protected.GET("/organization/org_service_person", platform.ListOrgServicePerson)
protected.GET("/identity/idn_account", platform.ListIdnAccount)
protected.GET("/safety/saf_event", platform.ListSafEvent)
}
// Register 注册路由。
func Register(serviceKey string, engine *gin.Engine) {
RegisterPlatform(serviceKey, engine)
registerUploadRoute(serviceKey, engine)
}

View File

@@ -0,0 +1,11 @@
package routers
import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload"
"github.com/gin-gonic/gin"
)
// registerUploadRoute 注册已认证的文件上传接口。
func registerUploadRoute(serviceKey string, engine *gin.Engine) {
engine.POST("/upload/file", upload.UploadFile)
}

View File

@@ -1,27 +0,0 @@
-- org_gas_station可燃气体站主表。identity 由应用生成 UUID V7禁止自增主键。
CREATE TABLE IF NOT EXISTS org_gas_station (
identity uuid PRIMARY KEY,
station_code varchar(32) NOT NULL UNIQUE,
name varchar(128) NOT NULL,
principal varchar(64) NOT NULL,
service_area varchar(128) NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
created_by_identity uuid,
updated_by_identity uuid,
status varchar(32) NOT NULL DEFAULT 'draft',
version integer NOT NULL DEFAULT 1
);
COMMENT ON TABLE org_gas_station IS '可燃气体站主表';
COMMENT ON COLUMN org_gas_station.identity IS '主键,应用生成的 UUID V7';
COMMENT ON COLUMN org_gas_station.station_code IS '气站全局唯一业务编码';
COMMENT ON COLUMN org_gas_station.name IS '气站名称';
COMMENT ON COLUMN org_gas_station.principal IS '气站负责人姓名';
COMMENT ON COLUMN org_gas_station.service_area IS '气站授权服务区域';
COMMENT ON COLUMN org_gas_station.created_at IS '记录创建时间UTC';
COMMENT ON COLUMN org_gas_station.updated_at IS '记录更新时间UTC';
COMMENT ON COLUMN org_gas_station.created_by_identity IS '创建人 identity';
COMMENT ON COLUMN org_gas_station.updated_by_identity IS '更新人 identity';
COMMENT ON COLUMN org_gas_station.status IS '状态draft、enabled、frozen、archived';
COMMENT ON COLUMN org_gas_station.version IS '乐观锁版本';

View File

@@ -1,28 +0,0 @@
-- saf_event安全事件主表。事件和审计记录不允许物理删除。
CREATE TABLE IF NOT EXISTS saf_event (
identity uuid PRIMARY KEY,
event_code varchar(32) NOT NULL UNIQUE,
device_identity uuid,
level integer NOT NULL CHECK (level BETWEEN 1 AND 3),
title varchar(256) NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
created_by_identity uuid,
updated_by_identity uuid,
status varchar(32) NOT NULL DEFAULT 'pending',
version integer NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_saf_event_status_level ON saf_event(status, level);
COMMENT ON TABLE saf_event IS '安全事件主表';
COMMENT ON COLUMN saf_event.identity IS '主键,应用生成的 UUID V7';
COMMENT ON COLUMN saf_event.event_code IS '安全事件全局唯一业务编码';
COMMENT ON COLUMN saf_event.device_identity IS '关联 dev_device 的 identity';
COMMENT ON COLUMN saf_event.level IS '风险等级1 高风险、2 中风险、3 低风险';
COMMENT ON COLUMN saf_event.title IS '安全事件说明';
COMMENT ON COLUMN saf_event.created_at IS '记录创建时间UTC';
COMMENT ON COLUMN saf_event.updated_at IS '记录更新时间UTC';
COMMENT ON COLUMN saf_event.created_by_identity IS '创建人 identity';
COMMENT ON COLUMN saf_event.updated_by_identity IS '更新人 identity';
COMMENT ON COLUMN saf_event.status IS '状态pending、processing、closed、overdue';
COMMENT ON COLUMN saf_event.version IS '乐观锁版本';

View File

@@ -1,39 +0,0 @@
-- 首期平台组织与身份主表。所有 identity 均为应用生成的 UUID V7。
CREATE TABLE IF NOT EXISTS idn_account (
identity uuid PRIMARY KEY, phone varchar(32) NOT NULL UNIQUE, account_type varchar(32) NOT NULL,
service_area varchar(128) NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
created_by_identity uuid, updated_by_identity uuid, status varchar(32) NOT NULL DEFAULT 'enabled', version integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS org_delivery_point (
identity uuid PRIMARY KEY, delivery_code varchar(32) NOT NULL UNIQUE, gas_station_identity uuid,
name varchar(128) NOT NULL, service_area varchar(128) NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
created_by_identity uuid, updated_by_identity uuid, status varchar(32) NOT NULL DEFAULT 'draft', version integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS org_service_person (
identity uuid PRIMARY KEY, account_identity uuid UNIQUE, gas_station_identity uuid, delivery_point_identity uuid,
name varchar(64) NOT NULL, roles varchar(128) NOT NULL, work_status varchar(32) NOT NULL, credential_status varchar(32) NOT NULL,
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, created_by_identity uuid, updated_by_identity uuid,
status varchar(32) NOT NULL DEFAULT 'draft', version integer NOT NULL DEFAULT 1
);
COMMENT ON TABLE idn_account IS '身份账户主表';
COMMENT ON COLUMN idn_account.identity IS '主键,应用生成的 UUID V7';
COMMENT ON COLUMN idn_account.phone IS '手机号,敏感字段,展示时脱敏';
COMMENT ON COLUMN idn_account.account_type IS '账户类型user、service_person、admin';
COMMENT ON COLUMN idn_account.service_area IS '授权服务区域';
COMMENT ON COLUMN idn_account.status IS '状态enabled、frozen、archived';
COMMENT ON TABLE org_delivery_point IS '配送点主表';
COMMENT ON COLUMN org_delivery_point.identity IS '主键,应用生成的 UUID V7';
COMMENT ON COLUMN org_delivery_point.delivery_code IS '配送点全局唯一业务编码';
COMMENT ON COLUMN org_delivery_point.gas_station_identity IS '归属 org_gas_station 的 identity';
COMMENT ON COLUMN org_delivery_point.name IS '配送点名称';
COMMENT ON COLUMN org_delivery_point.service_area IS '配送服务区域';
COMMENT ON COLUMN org_delivery_point.status IS '状态draft、enabled、frozen、archived';
COMMENT ON TABLE org_service_person IS '服务人员主表';
COMMENT ON COLUMN org_service_person.identity IS '主键,应用生成的 UUID V7';
COMMENT ON COLUMN org_service_person.account_identity IS '关联 idn_account 的 identity';
COMMENT ON COLUMN org_service_person.gas_station_identity IS '归属 org_gas_station 的 identity';
COMMENT ON COLUMN org_service_person.delivery_point_identity IS '主归属 org_delivery_point 的 identity';
COMMENT ON COLUMN org_service_person.roles IS '可执行角色集合';
COMMENT ON COLUMN org_service_person.work_status IS '上班与接单状态';
COMMENT ON COLUMN org_service_person.credential_status IS '资质状态';
COMMENT ON COLUMN org_service_person.status IS '状态draft、enabled、frozen、archived';

View File

@@ -1,15 +0,0 @@
-- 平台总后台登录字段;所有业务主表仍使用应用生成的 UUID V7 identity。
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS username varchar(64);
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS display_name varchar(64) NOT NULL DEFAULT '';
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS password_hash varchar(255) NOT NULL DEFAULT '';
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS role_code varchar(64) NOT NULL DEFAULT 'user';
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS must_change_password boolean NOT NULL DEFAULT false;
ALTER TABLE idn_account ADD COLUMN IF NOT EXISTS mfa_enabled boolean NOT NULL DEFAULT false;
CREATE UNIQUE INDEX IF NOT EXISTS uk_idn_account_username ON idn_account (username) WHERE username IS NOT NULL;
COMMENT ON COLUMN idn_account.username IS '登录用户名;平台 root 由 internal/initdb/platform.go 幂等初始化';
COMMENT ON COLUMN idn_account.display_name IS '后台界面展示名称';
COMMENT ON COLUMN idn_account.password_hash IS 'bcrypt 密码哈希,禁止在 API 中返回';
COMMENT ON COLUMN idn_account.role_code IS '平台角色编码';
COMMENT ON COLUMN idn_account.must_change_password IS '首次登录或重置密码后必须修改密码';
COMMENT ON COLUMN idn_account.mfa_enabled IS '是否启用多因素认证';

View File

@@ -1,5 +0,0 @@
# PostgreSQL 迁移
迁移文件名遵循 `<序号>_create_<模块前缀_单数实体>.sql`。每个主表必须以应用生成的 UUID V7 `identity` 为主键,并对表、字段、索引、约束和枚举补充中文注释。
当前 API 的 GORM 自动迁移用于本地开发;生产发布必须先评审并执行本目录的显式 SQL 迁移,再部署新版本服务。

View File

@@ -0,0 +1,349 @@
# 平台总后台需求分析
## 1. 文档目标与范围
本文将 [平台总后台需求](05-平台总后台需求.md) 拆解为可实施的领域模型、接口边界与 Vue 管理端页面规划,作为平台总后台的产品、前端与后端共同基线。
平台总后台是全平台唯一的跨组织治理中心,管理所有可燃气体站、配送点、服务人员、用户、智能瓶阀安全事件、全局规则、资金结算和审计数据。它不替代气站、配送点、生产和 API 中心系统处理各自的日常业务,而是维护主数据、全局策略、跨组织协同和高风险审批。
本项目不维护独立数据库迁移功能。表结构由 Go 模型在应用启动时通过 GORM 自动同步;模型、接口契约和中文注释须在同一需求变更中更新。
气站与配送点邀请二维码不属于平台总后台本期功能范围;本期不规划二维码数据表、接口、菜单或页面。
### 1.1 前后端实现目录
| 范围 | 目录 / 文件 | 责任 |
| --- | --- | --- |
| 前端项目 | `frontend/platform_admin` | Vue 平台总后台,承载登录、菜单、页面、数据权限提示和 API 调用 |
| 前端页面 | `frontend/platform_admin/src/views` | 当前后台壳与页面编排;后续可按业务域拆分至 `src/views/` |
| 前端接口 | `frontend/platform_admin/src/api` | 平台总后台 HTTP 客户端、类型和登录会话处理 |
| 后端 API | `backend/api` | Go/Gin 平台总后台 API 进程 |
| 后端路由文件 | `backend/api/internal/routers/platform.go` | 唯一 HTTP 路由注册入口,注册 `/heqi/platform/v1` 前缀及认证路由组 |
| 后端业务逻辑 | `backend/api/internal/logic/platform` | 平台认证、组织、人员、用户、安全、交易、资金与审计逻辑 |
| 后端模型 | `backend/api/internal/models` | GORM 数据模型、表名、数据访问和中文模型注释 |
| 初始数据 | `backend/api/internal/initdb/platform.go` | 初始化 `root` 系统管理员角色与根账户 |
## 2. 实施原则
- 所有表均使用数据库 `id` 自增主键,作为物理主键和内部关联键。
- 所有主表额外包含 `identity` 字段,类型为 `varchar(36)`,由应用生成 UUID V7`identity` 是对外接口、审计、跨服务关联使用的唯一业务主键,并建立唯一索引。明细表是否保留 `identity` 由是否需要对外暴露或被审计引用决定。
- 表名、Go 模型名、模型文件名使用同一单数实体词根;接口仅以 `list``items` 表达集合语义,不使用复数实体名。
- 平台角色权限统一使用 `platform_role``platform_role_menu_relation`;系统初始化时创建 `root` 系统管理员角色并授予全部菜单权限。
- 可燃气体站、配送点、服务人员与用户的归属、服务关系和状态变更必须留存历史快照,禁止直接覆盖历史业务归属。
- 手机号、身份证件、详细地址、精确位置、头像访问地址、支付和资质资料均属于敏感字段;列表默认脱敏,导出与精确轨迹须审批。
- 高风险安全动作、跨组织变更、组织冻结、资金动作、敏感导出实行职责分离、理由必填和审计留痕。
- 外部支付、地图、短信、对象存储、IoT、生产系统与 API 网关均通过适配层接入;开发阶段可使用 Mock 边界。
## 4. 数据模型规划
### 4.1 公共字段与关联规则
所有表统一包含 `id bigint` 自增物理主键、`created_at``updated_at`。主表统一增加 `identity varchar(36)`,由应用生成 UUID V7 并设置唯一索引,同时包含 `status``version`。跨服务、审计日志和 HTTP 路由只传递 `identity`,数据库内部关联可使用 `<实体词根>_id`;业务编码另设唯一字段,例如 `gas_code``delivery_code``order_code`
主表的 Go 模型应同时声明 `ID uint64`(自增主键)和 `Identity string`UUID V7 业务主键);`identity` 不由数据库默认生成,必须在应用层创建记录前写入。
### 4.2 身份、组织与服务关系
| 实体 | 表 / 模型 / 文件 | 关键字段 | 说明 |
| --- | --- | --- | --- |
| 平台账户 | `platfrom_account` / `PlatfromAccount` / `platfrom_account.go` | `id``identity``username``display_name``avatar``password_hash``platform_role_code``phone` | 平台后台登录账户;头像保存受控资源地址 |
| 平台角色 | `platform_role` / `PlatformRole` / `platform_role.go` | `id``identity``role_code``name``data_scope``is_system` | 角色定义;初始化内置 `root` 系统管理员角色 |
| 气站基础资料 | `gas_basic` / `GasBasic` / `gas_basic.go` | `id``identity``code``name``credit_code``principal``address``longitude``latitude` | 气站主体主档案 |
| 气站账户 | `gas_account` / `GasAccount` / `gas_account.go` | `id``identity``gas_basic_id``username``display_name``password_hash``role_code` | 气站身份账户 |
| 配送点资料 | `delivery_basic` / `DeliveryBasic` / `delivery_basic.go` | `id``identity``delivery_code``gas_basic_id``name``principal``address` | 末端配送组织单元;可归属平台或气站 |
| 配送点账户 | `delivery_account` / `DeliveryAccount` / `delivery_account.go` | `id``identity``delivery_basic_id``username``display_name``password_hash``role_code` | 配送点身份账户;仅能访问本点数据 |
| 服务人员账户 | `staff_account` / `StaffAccount` / `staff_account.go` | `id``identity``staff_id``username``password_hash``gas_basic_id``delivery_basic_id``work_status` | 安装维修、安检、配送人员账号及资料 |
| 人员资质 | `staff_credential` / `StaffCredential` / `staff_credential.go` | `id``identity``staff_id``credential_type``credential_no``expired_at` | 培训、证照、保险与技能等级 |
| 业主客户账户 | `user_account` / `UserAccount` / `user_account.go` | `id``identity``user_id``username``password_hash``status` | 用户端登录账户 |
| 用户地址 | `user_address` / `UserAddress` / `user_address.go` | `id``identity``user_id``address``longitude``latitude``is_default` | 地址历史和订单快照来源 |
| 用户服务关系 | `user_service_relation` / `UserServiceRelation` / `user_service_relation.go` | `id``identity``user_id``gas_basic_id``delivery_basic_id``staff_id` | 用户与气站/配送点的服务归属 |
### 4.3 设备与安全
| 实体 | 表 / 模型 / 文件 | 关键字段 | 说明 |
| --- | --- | --- | --- |
| 智能瓶阀 | `dev_smart_cylinder_valve` / `DevSmartCylinderValve` / `dev_smart_cylinder_valve.go` | `device_no``model``online_status``owner_identity` | 智能瓶阀全生命周期档案 |
| 设备绑定 | `dev_device_binding` / `DevDeviceBinding` / `dev_device_binding.go` | `id``identity``smart_cylinder_valve_id``user_id``effective_at``expired_at` | 用户/家庭与智能瓶阀授权关系 |
| 遥测 | `dev_telemetry` / `DevTelemetry` / `dev_telemetry.go` | `smart_cylinder_valve_identity``reported_at``payload``quality_flag` | 遥测摘要与质量标记 |
| 安全规则 | `saf_rule` / `SafRule` / `saf_rule.go` | `rule_code``version_no``threshold``action``gray_scope` | 告警、自动关阀、静默和灰度规则 |
| 安全事件 | `saf_event` / `SafEvent` / `saf_event.go` | `event_code``level``smart_cylinder_valve_identity``status``sla_at` | 告警、安检和人工发现事件统一入口 |
| 安全处置 | `saf_event_disposal` / `SafEventDisposal` / `saf_event_disposal.go` | `saf_event_identity``action``reason``operator_identity` | 派发、升级、关阀、复核和结案记录 |
| 安检记录 | `saf_inspection` / `SafInspection` / `saf_inspection.go` | `id``identity``user_id``staff_id``result``evidence_uri` | 安检、整改和复检证据 |
### 4.4 电商、订单与配送轨迹
| 实体 | 表 / 模型 / 文件 | 关键字段 | 说明 |
| --- | --- | --- | --- |
| 商品分类 | `ec_category` / `EcCategory` / `ec_category.go` | `id``identity``parent_id``name``sort_no``status` | 可燃气体商品及服务分类树 |
| 商品 | `ec_product` / `EcProduct` / `ec_product.go` | `id``identity``ec_category_id``product_code``name``price_amount``stock_quantity``status` | 可燃气体商品与服务主数据 |
| 商品属性 | `ec_product_attribute` / `EcProductAttribute` / `ec_product_attribute.go` | `id``identity``ec_product_id``name``value``sort_no` | 商品属性子表,例如规格、重量和服务时长 |
| 商品图片 | `ec_product_image` / `EcProductImage` / `ec_product_image.go` | `id``identity``ec_product_id``image_uri``sort_no``is_cover` | 商品图片子表,保存受控资源地址 |
| 购物车 | `ec_cart` / `EcCart` / `ec_cart.go` | `id``identity``user_id``ec_product_id``quantity``selected` | 用户购物车明细 |
| 订单 | `ec_order` / `EcOrder` / `ec_order.go` | `id``identity``order_no``user_id``gas_station_id``delivery_point_id``total_amount``status` | 电商订单主表及组织快照 |
| 订单项 | `ec_order_item` / `EcOrderItem` / `ec_order_item.go` | `id``identity``ec_order_id``ec_product_id``product_snapshot``quantity``sale_amount` | 订单商品快照和金额明细 |
| 商品评论 | `ec_review` / `EcReview` / `ec_review.go` | `id``identity``ec_order_id``ec_product_id``user_id``score``content``status` | 用户评价、审核和隐藏状态 |
| 配送任务 | `delivery_task` / `DeliveryTask` / `delivery_task.go` | `id``identity``ec_order_id``staff_id``delivery_point_id``status` | 配送履约任务 |
| 配送轨迹 | `delivery_track` / `DeliveryTrack` / `delivery_track.go` | `id``identity``delivery_task_id``status``started_at``completed_at` | 用户可见简化配送轨迹 |
| 配送轨迹点 | `delivery_track_point` / `DeliveryTrackPoint` / `delivery_track_point.go` | `id``identity``delivery_track_id``point_type``occurred_at``longitude``latitude` | 精确位置与任务节点;访问受授权控制 |
### 4.5 财务、钱包、统计报表、内容与审计
| 实体 | 表 / 模型 / 文件 | 关键字段 | 说明 |
| --- | --- | --- | --- |
| 支付记录 | `fin_payment` / `FinPayment` / `fin_payment.go` | `id``identity``ec_order_id``channel``amount``status``paid_at` | 支付、退款和外部渠道流水关联 |
| 财务结算 | `fin_settlement` / `FinSettlement` / `fin_settlement.go` | `id``identity``settlement_no``subject_type``subject_id``period_start``period_end``status` | 气站、配送点、服务人员结算单 |
| 财务对账 | `fin_reconciliation` / `FinReconciliation` / `fin_reconciliation.go` | `id``identity``channel``bill_date``difference_amount``status` | 支付渠道对账及差异处理 |
| 钱包 | `wallet` / `Wallet` / `wallet.go` | `id``identity``owner_type``owner_id``balance_amount``frozen_amount``status` | 用户、组织和服务人员的钱包余额账户 |
| 钱包流水 | `wallet_ledger` / `WalletLedger` / `wallet_ledger.go` | `id``identity``wallet_id``amount``direction``balance_after``reference_identity` | 钱包唯一资金事实流水 |
| 钱包充值 | `wallet_recharge` / `WalletRecharge` / `wallet_recharge.go` | `id``identity``wallet_id``amount``channel``status` | 钱包充值申请和支付结果 |
| 钱包提现 | `wallet_withdrawal` / `WalletWithdrawal` / `wallet_withdrawal.go` | `id``identity``wallet_id``amount``bank_account_masked``status` | 提现申请、审核与付款凭证 |
| 统计报表 | `report` / `Report` / `report.go` | `id``identity``report_code``report_type``stat_period``generated_at` | 可下载或在线查看的统计报表主档案 |
| 统计报表明细 | `report_item` / `ReportItem` / `report_item.go` | `id``identity``report_id``dimension``metric_code``metric_value` | 按组织、区域、商品、时间拆分的报表明细 |
| 内容 | `cnt_content` / `CntContent` / `cnt_content.go` | `content_type``title``version_no``publish_status` | 公告、协议、安全宣教内容 |
| 消息模板 | `ntf_template` / `NtfTemplate` / `ntf_template.go` | `template_code``channel``content``status` | 短信、推送、站内信模板 |
| 客服工单 | `cs_ticket` / `CsTicket` / `cs_ticket.go` | `id``identity``ticket_no``user_id``category``status``priority` | 咨询、投诉、回访与升级 |
| 运营指标快照 | `report_metric_snapshot` / `ReportMetricSnapshot` / `report_metric_snapshot.go` | `id``identity``metric_code``scope_type``scope_id``stat_at``metric_value` | 看板与预警计算结果 |
| 操作审计 | `aud_operation_log` / `AudOperationLog` / `aud_operation_log.go` | `operator_identity``action``object_type``object_identity``before_data``after_data` | 不可由普通管理员改写或删除 |
| 导出审计 | `aud_export_log` / `AudExportLog` / `aud_export_log.go` | `applicant_identity``purpose``field_scope``approved_at``file_uri` | 敏感导出、下载与水印记录 |
| 审批单 | `aud_approval` / `AudApproval` / `aud_approval.go` | `business_type``business_identity``applicant_identity``status` | 双人复核、审批流与意见 |
## 5. API 路由规则
### 5.1 通用约定
- 基础路径:`/heqi/platform/v1`;本节列出的路由均为该前缀后的相对路径。
- 资源路径采用模块名与单数蛇形实体名:`/{domain}/{resource}`,例如 `/gas/gas_station`
- 平台总后台接口以 CRUD 为主:`GET` 列表/详情、`POST` 创建、`PUT /:identity` 更新、`PATCH /:identity/status` 启用/停用、`DELETE /:identity` 逻辑删除或归档;主数据不做物理删除。
- 分页参数统一为 `page``size`;筛选参数使用明确字段名;列表返回 `{ total, list }`
- 批量启停、导入、导出等扩展动作使用明确动作路径,例如 `/:identity/status``/import``/export`;请求必须携带 `reason`(如适用)。
- 认证接口以 `/auth` 开头;除登录和健康检查外,均要求原始 JWT `Authorization` 请求头。
- 请求和响应字段使用 `snake_case`;时间使用 ISO 8601金额使用最小货币单位整数位置字段仅在获授权接口中返回。
### 5.2 认证与平台账户
| 方法 | 路由 | 作用 |
| --- | --- | --- |
| `POST` | `/auth/login` | 平台账户登录并签发 JWT |
| `GET` | `/auth/profile` | 当前登录账户资料,含头像 |
| `PUT` | `/auth/password` | 修改当前账户密码 |
| `GET` | `/platform/platfrom_account` | 平台账户分页列表 |
| `POST` | `/platform/platfrom_account` | 创建平台账户 |
| `GET` | `/platform/platfrom_account/:identity` | 平台账户详情 |
| `PUT` | `/platform/platfrom_account/:identity` | 更新名称、头像、角色、手机号 |
| `PATCH` | `/platform/platfrom_account/:identity/status` | 启用、冻结、归档账户 |
| `GET/POST` | `/platform/platform_role` | 角色列表、创建角色;初始化内置 `root` 系统管理员角色 |
| `PUT` | `/platform/platform_role/:identity/menu` | 覆盖角色菜单与按钮权限集合 |
| `GET` | `/platform/platform_menu` | 菜单树和当前账户可见菜单 |
### 5.3 组织、人员和用户
| 方法 | 路由 | 作用 |
| --- | --- | --- |
| `GET/POST` | `/gas/gas_station` | 查询、创建可燃气体站 |
| `GET/PUT/DELETE` | `/gas/gas_station/:identity` | 气站详情、更新、逻辑删除/归档 |
| `PATCH` | `/gas/gas_station/:identity/status` | 气站启用、停用、冻结 |
| `GET/POST` | `/gas/gas_account` | 气站账户查询、创建和授权 |
| `GET/POST` | `/delivery/delivery_point` | 查询、创建配送点 |
| `GET/PUT/DELETE` | `/delivery/delivery_point/:identity` | 配送点详情、更新、逻辑删除/归档 |
| `PATCH` | `/delivery/delivery_point/:identity/status` | 配送点启用、停用、冻结 |
| `GET/POST` | `/delivery/delivery_account` | 配送点账户查询、创建和授权 |
| `GET/POST` | `/staff/account` | 服务人员查询、建档或导入 |
| `GET/PUT/DELETE` | `/staff/:identity` | 服务人员详情、更新、逻辑删除/归档 |
| `GET/POST/PUT/DELETE` | `/staff/credential` | 服务人员资质 CRUD |
| `GET/POST` | `/user/account` | 业主客户查询、创建 |
| `GET/PUT/DELETE` | `/user/:identity` | 用户详情、更新、逻辑删除/归档 |
| `PATCH` | `/user/:identity/status` | 冻结登录、限制下单或设备控制 |
| `POST` | `/user/service_relation` | 建立或变更用户服务关系 |
### 5.4 智能瓶阀安全、订单与配送轨迹
| 方法 | 路由 | 作用 |
| --- | --- | --- |
| `GET/POST` | `/device/dev_smart_cylinder_valve` | 智能瓶阀查询、建档 |
| `GET/PUT/DELETE` | `/device/dev_smart_cylinder_valve/:identity` | 智能瓶阀详情、更新、逻辑删除/归档 |
| `GET/POST` | `/safety/saf_event` | 安全事件查询、创建 |
| `GET/PUT/DELETE` | `/safety/saf_event/:identity` | 安全事件详情、更新、逻辑删除/归档 |
| `GET/POST` | `/safety/saf_rule` | 安全规则 CRUD 的列表与创建 |
| `GET/PUT/DELETE` | `/safety/saf_rule/:identity` | 安全规则详情、更新、逻辑删除 |
| `GET/POST` | `/ec/ec_order` | 电商订单查询、创建 |
| `GET/PUT/DELETE` | `/ec/ec_order/:identity` | 电商订单详情、更新、逻辑删除/归档 |
| `GET/POST` | `/delivery/delivery_track` | 配送轨迹查询、创建 |
| `GET/PUT/DELETE` | `/delivery/delivery_track/:identity` | 配送轨迹详情、更新、逻辑删除/归档 |
### 5.5 电商、财务、钱包、统计报表、内容与审计
| 方法 | 路由 | 作用 |
| --- | --- | --- |
| `GET/POST` | `/ec/ec_category` | 商品分类查询、创建 |
| `GET/PUT/DELETE` | `/ec/ec_category/:identity` | 商品分类详情、更新、逻辑删除 |
| `GET/POST` | `/ec/ec_product` | 商品查询、创建 |
| `GET/PUT/DELETE` | `/ec/ec_product/:identity` | 商品详情、更新、逻辑删除 |
| `GET/POST/PUT/DELETE` | `/ec/ec_product_attribute` | 商品属性子表 CRUD |
| `GET/POST/PUT/DELETE` | `/ec/ec_product_image` | 商品图片子表 CRUD |
| `GET/POST/PUT/DELETE` | `/ec/ec_cart` | 购物车 CRUD |
| `GET/POST/PUT/DELETE` | `/ec/ec_review` | 商品评论 CRUD |
| `GET/POST` | `/finance/fin_payment` | 支付记录查询、创建 |
| `GET/PUT/DELETE` | `/finance/fin_payment/:identity` | 支付记录详情、更新、逻辑删除 |
| `GET/POST/PUT/DELETE` | `/finance/fin_settlement` | 财务结算 CRUD |
| `GET/POST/PUT/DELETE` | `/finance/fin_reconciliation` | 财务对账 CRUD |
| `GET` | `/wallet/wallet` | 钱包列表查询 |
| `GET` | `/wallet/wallet/:identity` | 钱包详情查看 |
| `GET` | `/wallet/wallet_ledger` | 钱包流水列表查询 |
| `GET` | `/wallet/wallet_ledger/:identity` | 钱包流水详情查看 |
| `GET` | `/wallet/wallet_recharge` | 钱包充值记录列表查询 |
| `GET` | `/wallet/wallet_withdrawal` | 钱包提现记录列表查询 |
| `GET` | `/report/report` | 统计报表列表查询 |
| `GET` | `/report/report/:identity` | 统计报表详情查看 |
| `GET` | `/report/report_item` | 统计报表明细列表查询 |
| `GET` | `/report/report_item/:identity` | 统计报表明细详情查看 |
| `GET/POST/PUT/DELETE` | `/content/cnt_content` | 内容 CRUD |
| `GET/POST/PUT/DELETE` | `/customer_service/cs_ticket` | 客服工单 CRUD |
| `GET` | `/report/report_metric_snapshot` | 运营指标看板查询 |
| `GET` | `/audit/aud_operation_log` | 操作审计查询 |
| `GET` | `/audit/aud_export_log` | 导出审计查询 |
| `POST` | `/audit/aud_approval/:identity/approve` | 审批通过或驳回 |
## 6. 前端页面规划
### 6.1 全局框架
前端项目位于 `frontend/platform_admin`,使用 Vue 3、TypeScript 和 Vite。页面采用“应用壳 + 一级侧边栏 + 二级菜单 + 列表工作区 + 详情抽屉/编辑弹窗”的后台信息架构;每个实体页面围绕其模型的列表、详情、新增、编辑、状态和逻辑删除组织。
- 路由配置文件:`frontend/platform_admin/src/router/routes.ts`;菜单配置由 `platform_menu` 驱动并映射到受控本地路由。
- 页面根目录:`frontend/platform_admin/src/views`;按一级菜单拆分目录,禁止将所有页面堆放在 `App.vue`
- 共享组件目录:`frontend/platform_admin/src/components`;提供 `DataTable``FilterBar``DetailDrawer``FormDrawer``StatusTag``ConfirmDialog``EmptyState`
- 图标库:`lucide-vue-next`;菜单仅保存图标名称,页面通过统一 `IconRenderer` 映射,避免散落硬编码 SVG。
| 一级导航 | 页面 | 核心内容 |
| --- | --- | --- |
| 登录 | 登录页 | 账号密码登录、错误提示、服务条款入口 |
| 工作台 | 运营总览 | 气站、配送点、人员、用户、智能瓶阀、安全事件、订单、结算核心指标;异常待办与地图概览 |
| 组织权限 | 可燃气体站列表、站点详情、配送点列表、配送点详情、服务区域、角色权限 | 主档案、审批、归属、服务能力、组织 KPI、账号与数据范围 |
| 人员用户 | 服务人员列表、人员详情、资质审核、调配中心、用户列表、用户 360 | 人员生命周期、任务绩效、资质预警、用户设备/订单/安全/投诉概览 |
| 安全设备 | 智能瓶阀列表、设备详情、安全事件中心、安全规则、安检整改 | 实时状态、遥测摘要、告警处置、规则版本、证据和复核 |
| 电商管理 | 分类管理、商品管理、商品属性、商品图片、购物车、订单、评论 | `ec` 电商实体 CRUD、商品上下架、订单与评论管理 |
| 财务钱包 | 支付记录、财务结算、财务对账、钱包、钱包流水、充值、提现 | `fin` 财务实体 CRUD钱包、流水、充值和提现记录仅列表与详情查看 |
| 统计报表 | 报表列表、报表明细、指标看板 | 报表生成记录、维度明细和聚合指标只读查询 |
| 内容客服 | 内容中心、消息模板、客服工单 | 草稿/审核/发布/撤回、触达渠道、工单 SLA 与满意度 |
| 配送轨迹 | 配送任务、配送轨迹、轨迹点 | 配送履约记录、轨迹节点和异常信息 CRUD |
| 全局运营 | 指标中心、地图态势、运营规则、消息任务 | 跨组织下钻、阈值预警、规则灰度、触达效果 |
| 审计合规 | 审批中心、操作审计、导出审计、合规工单 | 审批队列、前后值追溯、敏感访问与留存处置 |
### 6.2 前端目录、菜单与页面映射(以本表为准)
页面目录统一位于 `frontend/platform_admin/src/views``ListPage.vue` 负责筛选、分页和表格,`DetailDrawer.vue` 负责查看详情,`FormDrawer.vue` 负责创建与编辑。钱包、流水和报表目录不创建 `FormDrawer.vue`,只保留列表和详情组件。图标使用 `lucide-vue-next`
| 一级菜单 / 图标 | 二级菜单 / 图标 | 路由 | 目录名 | 文件名 | 核心内容 |
| --- | --- | --- | --- | --- | --- |
| 工作台 `LayoutDashboard` | 运营概览 `ChartNoAxesCombined` | `/dashboard` | `views/dashboard` | `DashboardPage.vue` | 气站、配送点、人员、用户、智能瓶阀、电商订单、财务和安全指标卡片 |
| 气站管理 `Fuel` | 气站管理 `Building2` | `/gas/basic` | `views/gas/basic` | `ListPage.vue` | `gas_basic` CRUD、状态和基础档案 |
| 气站管理 `Fuel` | 气站账户 `UserCog` | `/gas/account` | `views/gas/account` | `ListPage.vue` | `gas_account` CRUD、账号状态和角色 |
| 配送管理 `Truck` | 配送点管理 `Warehouse` | `/delivery/basic` | `views/delivery/basic` | `ListPage.vue` | `delivery_basic` CRUD、归属气站、负责人和状态 |
| 配送管理 `Truck` | 配送点账户 `UserRoundCog` | `/delivery/account` | `views/delivery/account` | `ListPage.vue` | `delivery_account` CRUD、登录账号和权限 |
| 配送管理 `Truck` | 配送任务 `ListTodo` | `/delivery/task` | `views/delivery/task` | `ListPage.vue` | `delivery_task` CRUD、订单、配送员、配送点和状态 |
| 配送管理 `Truck` | 配送轨迹 `Route` | `/delivery/track` | `views/delivery/track` | `ListPage.vue` | `delivery_track``delivery_track_point` CRUD 和轨迹时间线 |
| 服务人员 `HardHat` | 人员档案 `Contact` | `/staff/list` | `views/staff/list` | `ListPage.vue` | `staff` CRUD、头像、组织归属、岗位状态 |
| 服务人员 `HardHat` | 人员账户 `Smartphone` | `/staff/account` | `views/staff/account` | `ListPage.vue` | `staff_account` CRUD、App 账号和登录状态 |
| 服务人员 `HardHat` | 人员资质 `FileBadge` | `/staff/credential` | `views/staff/credential` | `ListPage.vue` | `staff_credential` CRUD、证照、有效期和附件 |
| 业主客户 `UsersRound` | 客户档案 `UserRound` | `/user/list` | `views/user/list` | `ListPage.vue` | `user` CRUD、实名状态和账户状态 |
| 业主客户 `UsersRound` | 客户账户 `KeyRound` | `/user/account` | `views/user/account` | `ListPage.vue` | `user_account` CRUD、登录账号和状态 |
| 业主客户 `UsersRound` | 客户地址 `MapPinHouse` | `/user/address` | `views/user/address` | `ListPage.vue` | `user_address` CRUD、默认地址和位置 |
| 业主客户 `UsersRound` | 服务关系 `GitFork` | `/user/service-relation` | `views/user/service-relation` | `ListPage.vue` | `user_service_relation` CRUD、气站/配送点归属 |
| 设备管理 `ShieldAlert` | 智能瓶阀 `Gauge` | `/device/valve` | `views/device/valve` | `ListPage.vue` | `dev_smart_cylinder_valve` CRUD、型号、在线状态、归属 |
| 设备管理 `ShieldAlert` | 设备绑定 `Link2` | `/device/binding` | `views/device/binding` | `ListPage.vue` | `dev_device_binding` CRUD、用户与智能瓶阀绑定 |
| 设备管理 `ShieldAlert` | 安全规则 `Siren` | `/safety/rule` | `views/safety/rule` | `ListPage.vue` | `saf_rule` CRUD、阈值、规则版本和状态 |
| 设备管理 `ShieldAlert` | 安全事件 `TriangleAlert` | `/safety/event` | `views/safety/event` | `ListPage.vue` | `saf_event` CRUD、等级、设备、用户和状态 |
| 设备管理 `ShieldAlert` | 安检记录 `ClipboardCheck` | `/safety/inspection` | `views/safety/inspection` | `ListPage.vue` | `saf_inspection` CRUD、人员、结果和证据 |
| 电商管理 `ShoppingBag` | 商品分类 `FolderTree` | `/ec/category` | `views/ec/category` | `TreePage.vue` | `ec_category` 分类树 CRUD、排序和状态 |
| 电商管理 `ShoppingBag` | 商品管理 `PackageSearch` | `/ec/product` | `views/ec/product` | `ListPage.vue` | `ec_product` CRUD、分类、价格、库存和状态 |
| 电商管理 `ShoppingBag` | 商品属性 `Tags` | `/ec/product-attribute` | `views/ec/product-attribute` | `ListPage.vue` | `ec_product_attribute` CRUD、商品规格与属性 |
| 电商管理 `ShoppingBag` | 商品图片 `Image` | `/ec/product-image` | `views/ec/product-image` | `ListPage.vue` | `ec_product_image` CRUD、封面、排序和预览 |
| 电商管理 `ShoppingBag` | 购物车 `ShoppingCart` | `/ec/cart` | `views/ec/cart` | `ListPage.vue` | `ec_cart` CRUD、用户、商品、数量和选中状态 |
| 电商管理 `ShoppingBag` | 电商订单 `ReceiptText` | `/ec/order` | `views/ec/order` | `ListPage.vue` | `ec_order``ec_order_item` CRUD、金额和履约状态 |
| 电商管理 `ShoppingBag` | 商品评论 `MessageSquareText` | `/ec/review` | `views/ec/review` | `ListPage.vue` | `ec_review` CRUD、评分、评论内容和显示状态 |
| 财务管理 `Landmark` | 支付记录 `CreditCard` | `/finance/payment` | `views/finance/payment` | `ListPage.vue` | `fin_payment` CRUD、订单、渠道、金额和支付状态 |
| 财务管理 `Landmark` | 财务结算 `Scale` | `/finance/settlement` | `views/finance/settlement` | `ListPage.vue` | `fin_settlement` CRUD、结算主体、周期和金额 |
| 财务管理 `Landmark` | 财务对账 `BookOpenCheck` | `/finance/reconciliation` | `views/finance/reconciliation` | `ListPage.vue` | `fin_reconciliation` CRUD、渠道账单和差异 |
| 钱包中心 `WalletCards` | 钱包列表 `Wallet` | `/wallet/list` | `views/wallet/list` | `ListPage.vue` | `wallet` 只读列表、余额、冻结金额和状态 |
| 钱包中心 `WalletCards` | 钱包流水 `ListOrdered` | `/wallet/ledger` | `views/wallet/ledger` | `ListPage.vue` | `wallet_ledger` 只读列表、方向、金额和余额快照 |
| 钱包中心 `WalletCards` | 充值记录 `CirclePlus` | `/wallet/recharge` | `views/wallet/recharge` | `ListPage.vue` | `wallet_recharge` 只读列表和详情 |
| 钱包中心 `WalletCards` | 提现记录 `CircleMinus` | `/wallet/withdrawal` | `views/wallet/withdrawal` | `ListPage.vue` | `wallet_withdrawal` 只读列表和详情 |
| 统计报表 `ChartNoAxesCombined` | 报表列表 `FileBarChart` | `/report/list` | `views/report/list` | `ListPage.vue` | `report` 只读列表、周期、生成时间和下载入口 |
| 统计报表 `ChartNoAxesCombined` | 报表明细 `TableProperties` | `/report/item` | `views/report/item` | `ListPage.vue` | `report_item` 只读列表、维度和指标值 |
| 统计报表 `ChartNoAxesCombined` | 指标快照 `ChartLine` | `/report/metric-snapshot` | `views/report/metric-snapshot` | `DashboardPage.vue` | `report_metric_snapshot` 只读图表、同比和环比 |
| 内容客服 `MessagesSquare` | 内容管理 `FileText` | `/content/list` | `views/content/list` | `ListPage.vue` | `cnt_content` CRUD、类型、标题和发布状态 |
| 内容客服 `MessagesSquare` | 消息模板 `Send` | `/content/template` | `views/content/template` | `ListPage.vue` | `ntf_template` CRUD、渠道、模板编码和状态 |
| 内容客服 `MessagesSquare` | 客服工单 `Headset` | `/content/ticket` | `views/content/ticket` | `ListPage.vue` | `cs_ticket` CRUD、客户、分类、优先级和状态 |
| 审计合规 `ScrollText` | 操作审计 `History` | `/audit/operation-log` | `views/audit/operation-log` | `ListPage.vue` | `aud_operation_log` 只读检索、对象、动作和前后值摘要 |
| 审计合规 `ScrollText` | 导出审计 `FileOutput` | `/audit/export-log` | `views/audit/export-log` | `ListPage.vue` | `aud_export_log` 只读检索、用途、字段范围和导出文件 |
| 平台配置 `ShieldCheck` | 平台账户 `ContactRound` | `/platform/account` | `views/platform/account` | `ListPage.vue` | `platfrom_account` 列表、头像、角色、状态;新增/编辑抽屉 |
| 平台配置 `ShieldCheck` | 角色管理 `BadgeCheck` | `/platform/role` | `views/platform/role` | `ListPage.vue` | `platform_role` CRUD`root` 角色只读保护 |
| 平台配置 `ShieldCheck` | 菜单管理 `MenuSquare` | `/platform/menu` | `views/platform/menu` | `TreePage.vue` | `platform_menu` 树、图标、路由、排序和角色菜单授权 |
### 6.3 页面类型与组件规范
| 页面类型 | 适用实体 | 目录内文件 | 前端职责 |
| --- | --- | --- | --- |
| 标准 CRUD 列表页 | 气站、配送点、人员、用户、电商、财务、内容客服 | `ListPage.vue``DetailDrawer.vue``FormDrawer.vue` | 筛选、分页、详情、新增、编辑、逻辑删除和状态更新 |
| 树形管理页 | `platform_menu``ec_category` | `TreePage.vue``FormDrawer.vue` | 层级展示、拖拽排序、节点新增、编辑和逻辑删除 |
| 只读列表页 | 钱包、流水、充值、提现、报表、审计 | `ListPage.vue``DetailDrawer.vue` | 条件筛选、分页、详情、复制标识和受控导出;无新增/编辑/删除按钮 |
| 指标看板页 | 工作台、报表指标快照 | `DashboardPage.vue``MetricCard.vue``TrendChart.vue` | 指标卡、趋势图、筛选条件和下钻链接 |
### 6.4 重点页面规格
| 页面 | 查询区 | 列表/主视图 | 关键动作 |
| --- | --- | --- | --- |
| 可燃气体站列表 | 区域、状态、资质、风险、创建时间 | 站点编码、名称、负责人、服务能力、订单/安全/结算摘要 | 新建、导入、审核、启停、合并、归档 |
| 气站详情 | 固定站点上下文 | 基础档案、服务能力、配送点、人员、用户、设备、财务、资质、审计标签页 | 编辑草稿、提交审核、冻结、查看下钻 |
| 配送点列表 | 归属气站、区域、状态、负载 | 编码、负责人、配送能力、在岗人数、准时率、库存摘要 | 创建、审核、归属变更、启停 |
| 服务人员列表 | 角色、组织、资质、在岗、区域 | 姓名、头像、角色、资质有效期、当前负载、评分、状态 | 导入、审核、授予角色、调配、冻结 |
| 用户 360 | 用户编号/手机号/订单号 | 身份、地址、智能瓶阀、订单、支付摘要、安全事件、工单和服务关系时间线 | 合规处置发起、查看最小必要信息 |
| 安全事件中心 | 等级、状态、设备、区域、SLA | 告警队列、地图、处置时钟和责任方 | 派发、升级、关阀、复核、结案 |
| 商品分类与商品 | 分类、状态、商品编码、创建时间 | 分类树、商品名称、封面、价格、库存、上下架状态 | 新增、编辑、逻辑删除、维护属性和图片 |
| 购物车、订单与评论 | 用户、商品、订单状态、评论状态、日期 | 购物车商品项、订单金额、履约状态、评分和评论内容 | 新增、编辑、逻辑删除、状态更新 |
| 配送轨迹 | 订单、配送员、配送点、日期、异常类型 | 时间线与地图;默认隐藏精确坐标 | 查看简化轨迹、申请精确回放、导出审批 |
| 财务与钱包 | 主体、周期、状态、金额区间、渠道 | 支付、结算、对账、钱包余额、流水、充值和提现 | 财务记录可维护;钱包、流水、充值和提现记录仅列表与详情查看 |
| 统计报表 | 报表类型、统计周期、组织、状态 | 报表编号、生成时间、维度明细和指标值 | 列表筛选、查看报表及明细,不提供新增、编辑、删除 |
| 审计中心 | 操作人、对象、动作、时间、结果 | 不可变日志、前后值摘要、审批关联、导出记录 | 检索、筛选、合规导出申请 |
### 6.5 页面交互规则
- 列表页默认服务端分页;筛选条件可保存为个人视图,不影响他人。
- 新建、编辑和逻辑删除使用统一表单与二次确认;页面主要完成列表查询、详情、新增、编辑、状态更新和逻辑删除。
- 状态动作必须展示影响范围提示;安全、支付与敏感导出等高风险能力在后续迭代中再增加审批与复核流程。
- 详情抽屉展示主字段、创建/更新时间和关联数据;复杂审批时间线不作为本阶段 CRUD 的必做能力。
- 头像为空时显示名称首字或默认图形;头像 URL 失效时回退为默认图形,不暴露对象存储签名。
## 7. 关键流程与验收口径
### 7.1 组织准入与跨组织变更
1. 运营创建草稿或批量导入。
2. 系统校验编码唯一、区域冲突、资质有效期、结算主体和未完成任务。
3. 运营初审,平台管理员或合规员复审。
4. 审批通过后生效;拒绝、冻结、合并和归档均保留原组织、订单、资金、安全事件和服务关系快照。
### 7.2 服务人员准入
1. 人员自主注册、组织创建或批量导入形成草稿。
2. 审核实名、角色、证照、培训、保险、组织和服务区域。
3. 安全相关角色由安全主管复核;到期后自动限制对应任务能力。
4. 调配须记录来源、目标、有效期、影响任务与审批意见。
### 7.3 电商 CRUD 与财务、钱包、报表查询
1. 管理员维护 `ec_category``ec_product``ec_product_attribute``ec_product_image`,商品详情聚合展示分类、属性和图片。
2. 购物车、订单和评论按用户、商品、状态和时间进行查询、详情查看、创建、更新和逻辑删除。
3. 财务人员维护支付、结算、对账记录;钱包、钱包流水、充值和提现记录由业务动作生成,后台仅支持列表与详情查看。金额字段采用最小货币单位整数。
4. 运营人员按统计周期、组织、区域和商品维度查询报表、报表明细和指标;报表数据由统计任务生成,后台不提供新增、编辑或删除。
### 7.4 最小验收集
- 可创建、审核、启停和查询可燃气体站、配送点、服务人员及平台账户。
- 气站可在授权范围内管理所属配送点、服务人员和用户服务关系;配送点仅管理本点人员和用户关系。
- 电商分类、商品、属性、图片、购物车、订单和评论均可完成 CRUD。
- 财务支付、结算和对账记录可完成 CRUD钱包、钱包流水、充值、提现、统计报表和报表明细仅支持列表与详情查看。
- 订单详情可查询配送轨迹,轨迹和轨迹点数据均可完成 CRUD。
- 智能瓶阀安全事件具备分级、派发、升级、处置、复核和审计闭环。
- 所有高风险操作、资金审批、跨组织变更及敏感导出均可按操作人、对象、时间和理由追溯。

View File

@@ -63,7 +63,7 @@ flowchart LR
| 基线 | 路径 | 使用要求 |
| --- | --- | --- |
| 前端标准库 | `sample/front` | 五个 Vue 管理系统从该工程统一前端框架、路由、状态管理、请求封装、权限指令、表格表单、主题、错误处理、国际化与测试规范 |
| 后端标准库 | `sample/server` | Go API、Worker、IoT 进程统一沿用配置、日志、错误码、认证、数据库访问、迁移、任务、测试和发布规范 |
| 后端标准库 | `sample/server` | Go API、Worker、IoT 进程统一沿用配置、日志、错误码、认证、数据库访问、任务、测试和发布规范 |
业务项目应通过共享包、模板或上游同步机制复用标准库,禁止将标准库目录复制到每个子项目后自行漂移。标准库升级需要记录版本、影响范围、兼容策略和回滚方式。
@@ -92,7 +92,6 @@ platforms/
api/ # Go HTTP API、BFF、同步领域事务
worker/ # Go 异步任务:派单、告警、通知、对账、超时扫描
iot/ # Go MQTT 协议适配、设备命令、遥测与回执
migrations/ # PostgreSQL 迁移、初始化数据与回滚说明
contracts/
openapi/ # HTTP API 契约及生成配置
asyncapi/ # MQTT/Redis Streams 事件契约与 Schema
@@ -150,7 +149,7 @@ platforms/
- 所有主表必须包含 `identity` 字段,类型为 UUID V7并作为该表的主键。UUID V7 由应用服务生成,保证时间有序性;禁止使用数据库自增主键、随机 UUID V4 或将业务编号作为主键。
- 引用主表时,外键字段命名为 `<实体名>_identity`,例如 `order_identity``service_person_identity`。业务展示编号(订单号、设备编码、站点编码等)应使用独立字段并设置唯一约束,不能替代 `identity`
- 每个主表还应按需要包含 `created_at``updated_at``created_by_identity``updated_by_identity``status``version` 等审计/并发字段;资金流水、安全事件、审计日志等不可变记录不得被物理删除。
- 数据库表、字段、索引、约束和枚举必须编写中文注释;注释说明业务含义、取值/单位、脱敏或留存要求。迁移脚本需同步维护注释,禁止只在设计文档中说明。
- 数据库表、字段、索引、约束和枚举必须编写中文注释;注释说明业务含义、取值/单位、脱敏或留存要求。模型注释与接口契约必须同步维护,禁止只在设计文档中说明。
#### 实体名、文件名、表名、模型名一致性
@@ -163,25 +162,24 @@ platforms/
| Flutter 模型文件/类型 | `org_gas_station.dart` / `OrgGasStation` | `gas_stations.dart``GasStationEntity` |
| Vue 模型文件/类型 | `org_gas_station.ts` / `OrgGasStation` | `gasStation.ts``GasStations` |
| OpenAPI/AsyncAPI Schema | `org_gas_station` | `GasStationDto``gas_stations` |
| 迁移文件 | `<时间戳>_create_org_gas_station.sql` | `<时间戳>_create_gas_stations.sql` |
- 所有实体一律使用单数:一个 `org_gas_station` 既可表示单个站点模型,也可作为列表返回项的模型名称。列表、批量和分页仅在 API 动词或响应字段表达,例如 `GET /org/gas-station/list``items: []`;不改变实体名。
- 关联表使用参与实体的单数词根和明确关系词,例如 `org_user_service_relation``idn_account_role_relation`,不得使用 `users_roles``user_roles` 等复数或含糊名称。
- `ord_delivery_track` 是配送任务的状态轨迹主表,`dsp_delivery_track_point` 是其定位点明细表;二者均为独立实体,不得再创建同义的 `delivery_tracks``track_points` 等表或模型。定位点通过 `delivery_track_identity` 关联主表。
- 钱包事实流水的唯一实体名为 `wal_wallet_ledger`;用户和服务人员的资金归属通过关联对象字段区分,禁止另建同义的 `wal_ledger``wallet_ledgers``service_wallet_ledger`
- 文件目录可以按业务模块组织,但目录名不参与实体命名;模型、迁移、契约、测试文件都必须能从其文件名唯一定位到同名的数据库表和模型。
- 新增实体前应先登记规范名称;重命名须同时修改表、模型、文件、契约、迁移和中文注释,并进行全仓引用检查,禁止仅改其中一层。
- 文件目录可以按业务模块组织,但目录名不参与实体命名;模型、契约、测试文件都必须能从其文件名唯一定位到同名的数据库表和模型。
- 新增实体前应先登记规范名称;重命名须同时修改表、模型、文件、契约和中文注释,并进行全仓引用检查,禁止仅改其中一层。
### 7.2 代码与模型中文注释规范
- Go、Flutter 和 Vue 代码中的业务类型、领域模型、枚举、公开接口、复杂规则、状态机、金额计算、权限判断和异步事件必须使用中文注释说明业务意图。
- 中文注释应解释“为什么”和业务口径,不重复代码字面含义;对外 API 的字段说明、OpenAPI/AsyncAPI Schema 描述和错误码说明同样必须为中文。
- 模型注释应与数据库注释和接口契约保持一致。需求变更导致字段、状态或规则变化时,代码、迁移、模型和契约注释必须在同一变更中更新。
- 模型注释应与数据库注释和接口契约保持一致。需求变更导致字段、状态或规则变化时,代码、模型和契约注释必须在同一变更中更新。
- 注释中应使用与表名/模型名一致的中文业务名称,例如“气站”对应 `org_gas_station`不能在同一业务语境混用“站点”“气站信息”“GasStations”等不同实体名。
- 禁止以无意义拼音、英文缩写或临时注释代替业务说明;第三方库、协议标准和专有名词可保留其原文,并在首次出现处附中文解释。
- API 使用 OpenAPIIoT/事件使用 AsyncAPI 或明确的版本化 Schema客户端由契约生成类型。
- Redis Streams 的生产者、消费者、重试和死信处理均须有监控任何消费者可安全重复执行Redis 不可用时由 Outbox 补偿投递。
- 所有管理端沿用 `sample/front` 的鉴权、数据权限、错误处理和审计埋点;所有 Go 进程沿用 `sample/server` 的配置、日志、迁移和健康检查规范。
- 所有管理端沿用 `sample/front` 的鉴权、数据权限、错误处理和审计埋点;所有 Go 进程沿用 `sample/server` 的配置、日志和健康检查规范。
- 单元测试覆盖规则、金额、状态机、权限;集成测试覆盖支付回调、设备回执、派单和并发库存;端到端测试覆盖高风险安全闭环。
- CI 必须执行静态检查、依赖漏洞扫描、迁移检查、契约兼容性检查和关键路径自动化测试CD 必须执行数据库迁移兼容性检查、健康检查和可回滚发布。
- CI 必须执行静态检查、依赖漏洞扫描、契约兼容性检查和关键路径自动化测试CD 必须执行健康检查和可回滚发布。

View File

@@ -5,7 +5,7 @@
### 数据模型强制约定
- 主表命名使用领域模块前缀,具体前缀以 [技术实现规划](10-技术实现规划.md) 的“数据模型与命名强制规范”为准;禁止跨模块使用无前缀的通用表名。
- 同一实体的数据库表、迁移文件、Go/Flutter/Vue 模型文件、模型类型和 OpenAPI/AsyncAPI Schema 必须使用相同的模块前缀与单数实体词根。例如 `org_gas_station``org_gas_station.go``OrgGasStation` 属于同一实体;禁止使用 `org_gas_stations``GasStations` 等复数或不同词根。
- 同一实体的数据库表、Go/Flutter/Vue 模型文件、模型类型和 OpenAPI/AsyncAPI Schema 必须使用相同的模块前缀与单数实体词根。例如 `org_gas_station``org_gas_station.go``OrgGasStation` 属于同一实体;禁止使用 `org_gas_stations``GasStations` 等复数或不同词根。
- 每个主表必须以 `identity` 字段作为 UUID V7 主键。所有关联字段使用 `<实体名>_identity` 命名,业务编号仅作展示和检索,不作为主键或跨表关联依据。
- 表、字段、索引、约束、枚举及接口模型必须有中文注释;涉及金额、单位、状态、定位、脱敏和留存的数据须在注释中明确口径。

View File

@@ -38,7 +38,7 @@
| AC-21 | 服务人员作业前置与离线补传 | 自主注册仅生成待审核账户;资质、每日培训、上班、区域和授权设备任一不满足时不能开始任务;弱网补传保留原始采集时间并去重,不能伪造轨迹或覆盖现场事实 |
| AC-22 | 设备共享与紧急联系人 | 设备所有者可独立授予或撤销成员查看/控制权限;高风险告警自动关阀后仅通知已授权紧急联系人,且审计完整 |
| AC-23 | 二维码与轨迹隐私 | 二维码不包含用户隐私、账号凭证或接口密钥;用户只看本人订单简化轨迹,精确轨迹回放/导出须经审批、水印和审计,非履约位置不可访问 |
| AC-24 | 钱包实体命名一致性 | 数据库迁移、表、Go/Flutter/Vue 模型和契约均使用 `wal_wallet_ledger`;不得出现 `wal_ledger`、复数表名或同义钱包流水实体 |
| AC-24 | 钱包实体命名一致性 | 数据库表、Go/Flutter/Vue 模型和契约均使用 `wal_wallet_ledger`;不得出现 `wal_ledger`、复数表名或同义钱包流水实体 |
## 3. 非功能验收

View File

@@ -24,7 +24,7 @@
- “应/必须”表示上线验收项;“建议”表示增强项;“可选”表示扩展能力。
- 需求变更应先更新本目录中的对应文档,并记录版本、变更人、变更原因和影响范围。
- 两类 App 的最新需求以 [03-用户端 App 需求](03-用户端App需求.md) 与 [04-服务端 App 需求](04-服务端App需求.md) 为端侧基线。涉及邀请注册、服务关系、配送轨迹、现场取证、人员准入或离线定位的变更,必须同步审查总览、流程、后台、技术、接口与验收文档。
- 同一数据实体的表名、模型名、文件名、迁移和接口 Schema 必须遵循 [技术实现规划](10-技术实现规划.md) 的唯一命名;命名变更须在变更记录中说明旧名、目标名和兼容/迁移方案。
- 同一数据实体的表名、模型名、文件名和接口 Schema 必须遵循 [技术实现规划](10-技术实现规划.md) 的唯一命名;命名变更须在变更记录中说明旧名、目标名和兼容方案。
- 涉及阀门自动关闭、告警分级、支付、合同、提现、隐私信息的变更,须由产品、技术、安全/法务共同评审。
- 原附件只给出功能脑图,未明确的业务口径已在文档中标记为“待确认”或作为可配置规则提出,不应直接视为既定政策。

View File

@@ -0,0 +1,68 @@
# Arco Design Pro Vite
基于 [Arco Design Pro](https://arco.design/pro/) 的 Vue 3 中后台模板,使用 Vite 8 + Pinia + TypeScript 构建。
## 环境要求
- Node.js >= 20.19.0
- pnpm
## 常用命令
```bash
pnpm install # 安装依赖
pnpm dev # 开发服务器
pnpm build # 生产构建
pnpm report # 构建并生成 bundle 分析报告
pnpm type:check # TypeScript 检查
pnpm lint # Biome 代码检查
pnpm lint:fix # 自动修复
```
## 目录结构
```
src/
├── api/ # 接口定义(按业务域)
├── assets/ # 静态资源与全局样式
├── components/ # 全局 / 布局级组件
├── directive/ # 自定义指令
├── hooks/ # 组合式函数
├── layout/ # 页面布局
├── locale/ # i18n 入口与全局文案
├── mocks/ # Mock 数据(开发环境)
│ ├── handlers/ # 全局 mock 处理器
│ └── setup.ts # mock 启用与响应包装
├── plugins/ # 应用插件(如 HTTP 拦截器)
├── router/ # 路由与守卫
├── store/ # Pinia 状态
├── types/ # 全局类型
├── utils/ # 工具函数
└── views/ # 页面(每页可含 components/、locale/、mock.ts
config/
└── vite.config.ts # Vite 配置
public/ # 静态公共资源
```
## Mock 说明
仅在开发环境(`import.meta.env.DEV`)下,`main.ts` 会动态加载 `src/mocks/index.ts`;生产构建不会打入 mockjs。
- 全局 handler 位于 `mocks/handlers/`
- 页面级 mock 保留在 `views/**/mock.ts`,由 `import.meta.glob` 自动注册
## 环境变量
| 变量 | 说明 |
|------|------|
| `VITE_API_BASE_URL` | 后端 API 地址(见 `.env.development` |
| `VITE_ERROR_REPORT_URL` | 可选,配置后启用前端错误上报(`utils/error-report.ts` |
## i18n 说明
- 菜单等全局文案:`locale/zh-CN.ts``locale/en-US.ts`
- 页面文案:`views/**/locale/``components/**/locale/`,通过 `import.meta.glob` 自动聚合
## 模板标记
路由与部分功能块带有 `/** simple */``/** simple end */` 注释,表示 Arco Pro「精简版 / 完整版」的可选模块边界。

Binary file not shown.

View File

@@ -0,0 +1,51 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"src/**",
"config/**",
"*.ts",
"*.js",
"*.vue",
"components.d.ts"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always",
"quoteProperties": "asNeeded"
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "off"
},
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
}
}
}
}

14
frontend/platform_admin/components.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -0,0 +1,113 @@
import { vitePluginForArco } from '@arco-plugins/vite-vue';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { resolve } from 'path';
import visualizer from 'rollup-plugin-visualizer';
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import { defineConfig, type PluginOption } from 'vite';
import compressPlugin from 'vite-plugin-compression';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import svgLoader from 'vite-svg-loader';
const manualChunkGroups: Record<string, string[]> = {
arco: ['@arco-design/web-vue'],
chart: ['echarts', 'vue-echarts'],
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
};
function manualChunks(id: string) {
if (!id.includes('node_modules')) return;
for (const [chunkName, packages] of Object.entries(manualChunkGroups)) {
for (const pkg of packages) {
if (id.includes(`node_modules/${pkg}`)) {
return chunkName;
}
}
}
}
export default defineConfig(({ command, mode }) => {
const isDev = command === 'serve';
const isReport = mode === 'report';
const plugins: PluginOption[] = [
vue(),
vueJsx(),
svgLoader({ svgoConfig: {} }),
vitePluginForArco({}),
];
if (!isDev) {
plugins.push(
Components({
dirs: [],
deep: false,
resolvers: [ArcoResolver()],
}),
compressPlugin({ ext: '.gz' }),
ViteImageOptimizer({
png: { quality: 80 },
jpeg: { quality: 80 },
jpg: { quality: 80 },
webp: { quality: 80 },
}),
);
if (isReport) {
plugins.push(
visualizer({
filename: './node_modules/.cache/visualizer/stats.html',
open: true,
gzipSize: true,
brotliSize: true,
}),
);
}
}
return {
plugins,
resolve: {
alias: [
{ find: '@', replacement: resolve(__dirname, '../src') },
{ find: 'assets', replacement: resolve(__dirname, '../src/assets') },
{
find: 'vue-i18n',
replacement: 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js',
},
{
find: 'vue',
replacement: 'vue/dist/vue.esm-bundler.js',
},
],
extensions: ['.ts', '.js'],
},
css: {
preprocessorOptions: {
less: {
modifyVars: {
hack: `true; @import (reference) "${resolve(
'src/assets/style/breakpoint.less',
)}";`,
},
javascriptEnabled: true,
},
},
},
server: isDev
? {
open: true,
fs: { strict: true },
}
: undefined,
build: isDev
? undefined
: {
rollupOptions: {
output: { manualChunks },
},
chunkSizeWarningLimit: 2000,
},
};
});

View File

@@ -1,12 +1,13 @@
<!doctype html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>可燃气体平台总后台</title>
<title>Arco Design Pro - 开箱即用的中台前端/设计解决方案</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/app/main.ts"></script>
</body>
</html>

View File

@@ -1,20 +1,58 @@
{
"name": "platform-admin",
"version": "0.1.0",
"name": "arco-design-pro-vue",
"description": "Arco Design Pro for Vue",
"version": "1.0.0",
"private": true,
"type": "module",
"author": "ArcoDesign Team",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"type:check": "vue-tsc --noEmit"
"dev": "vite --config ./config/vite.config.ts",
"build": "vue-tsc -p tsconfig.build.json --noEmit && vite build --config ./config/vite.config.ts",
"report": "vite build --config ./config/vite.config.ts --mode report",
"preview": "pnpm run build && vite preview --host",
"type:check": "vue-tsc -p tsconfig.build.json --noEmit --skipLibCheck",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
},
"dependencies": {
"vue": "^3.5.13"
"@arco-design/web-vue": "^2.58.0",
"@vueuse/core": "^13.9.0",
"axios": "^1.8.4",
"dayjs": "^1.11.13",
"echarts": "^6.1.0",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"sortablejs": "^1.15.6",
"vue": "^3.5.13",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.1.2",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@arco-plugins/vite-vue": "^1.4.6",
"@biomejs/biome": "^2.5.0",
"@types/lodash-es": "^4.17.12",
"@types/mockjs": "^1.0.10",
"@types/nprogress": "^0.2.3",
"@types/sortablejs": "^1.15.8",
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue-jsx": "^5.0.0",
"less": "^4.2.2",
"mockjs": "^1.1.0",
"rollup-plugin-visualizer": "^6.0.3",
"sharp": "^0.34.1",
"typescript": "^5.8.3",
"unplugin-vue-components": "^28.8.0",
"vite": "^8.0.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^2.0.0",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^2.2.8"
},
"engines": {
"node": ">=20.19.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<circle cx="24" cy="24" r="24" fill="#F2F3F5"/>
<circle cx="24" cy="19" r="7" stroke="#86909C" stroke-width="2"/>
<path
d="M10 40c0-7.732 6.268-14 14-14s14 6.268 14 14"
stroke="#86909C"
stroke-width="2"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@@ -0,0 +1,12 @@
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,51 @@
import fs from 'node:fs';
import path from 'node:path';
function walk(dir, acc = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'node_modules') {
walk(full, acc);
} else if (/\.(ts|vue)$/.test(entry.name)) {
acc.push(full);
}
}
return acc;
}
const srcFiles = walk('src');
const badPlaceholder = [];
const zhLocales = srcFiles.filter((f) => f.includes(`${path.sep}locale${path.sep}zh-CN.ts`));
for (const file of srcFiles) {
const text = fs.readFileSync(file, 'utf8');
if (text.includes("'???'") || /'(\?\?[^']*)'/.test(text)) {
badPlaceholder.push(file);
}
}
let zhOk = 0;
for (const file of zhLocales) {
if (/[\u4e00-\u9fff]/.test(fs.readFileSync(file, 'utf8'))) zhOk += 1;
}
let mockInDist = false;
if (fs.existsSync('dist/assets')) {
for (const name of fs.readdirSync('dist/assets')) {
if (!name.endsWith('.js')) continue;
const chunk = fs.readFileSync(path.join('dist/assets', name), 'utf8');
if (chunk.includes('mockjs') || chunk.includes('Mock.mock')) {
mockInDist = true;
break;
}
}
}
console.log(JSON.stringify({
badPlaceholder: badPlaceholder.length,
zhLocales: `${zhOk}/${zhLocales.length}`,
mockInDist,
hasGit: fs.existsSync('.env.development') && fs.readFileSync('.env.development', 'utf8').includes('VITE_API_BASE_URL=http'),
settingsHttp: fs.readFileSync('src/locale/zh-CN/settings.ts', 'utf8').includes('http.logout.title'),
rootMenu: fs.readFileSync('src/locale/zh-CN.ts', 'utf8').includes('仪表盘'),
}, null, 2));

View File

@@ -0,0 +1,67 @@
import fs from 'node:fs';
import path from 'node:path';
const BASE =
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
const vueFiles = [
'views/visualization/multi-dimension-data-analysis/components/content-publishing-source.vue',
'views/user/info/components/my-project.vue',
'views/user/info/components/my-team.vue',
'views/user/setting/components/enterprise-certification.vue',
];
async function download(relPath) {
const res = await fetch(`${BASE}/${relPath}`);
if (!res.ok) throw new Error(`${relPath}: HTTP ${res.status}`);
return res.text();
}
function patchForProject(content, relPath) {
let text = content;
if (relPath.includes('my-project.vue')) {
text = text.replace(
"import { queryMyProjectList, MyProjectRecord } from '@/api/user-center';",
"import { type MyProjectRecord, queryMyProjectList } from '@/api/user';",
);
text = text.replace(/\{\{ project\.contributors \}\}\s*/g, '');
}
if (relPath.includes('my-team.vue')) {
text = text.replace(
"import { queryMyTeamList, MyTeamRecord } from '@/api/user-center';",
"import { type MyTeamRecord, queryMyTeamList } from '@/api/user';",
);
}
if (relPath.includes('enterprise-certification.vue')) {
text = text.replace(
"import { EnterpriseCertificationModel } from '@/api/user-center';",
"import type { EnterpriseCertificationModel } from '@/api/user';",
);
text = text.replace(
/type: Object as PropType<EnterpriseCertificationModel>/,
'type: Object as PropType<EnterpriseCertificationModel>,',
);
}
return text;
}
async function main() {
for (const relPath of vueFiles) {
let content = await download(relPath);
content = patchForProject(content, relPath);
const fullPath = path.join('src', relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const hasCn = /[\u4e00-\u9fff]/.test(content);
console.log(`OK ${relPath} (cn=${hasCn})`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,109 @@
import fs from 'node:fs';
import path from 'node:path';
const BASE =
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
const localeFiles = [
'locale/zh-CN/settings.ts',
'views/login/locale/zh-CN.ts',
'views/form/group/locale/zh-CN.ts',
'views/form/step/locale/zh-CN.ts',
'views/dashboard/workplace/locale/zh-CN.ts',
'views/dashboard/monitor/locale/zh-CN.ts',
'views/list/card/locale/zh-CN.ts',
'views/list/search-table/locale/zh-CN.ts',
'views/profile/basic/locale/zh-CN.ts',
'views/result/success/locale/zh-CN.ts',
'views/result/error/locale/zh-CN.ts',
'views/exception/403/locale/zh-CN.ts',
'views/exception/404/locale/zh-CN.ts',
'views/user/info/locale/zh-CN.ts',
'views/user/setting/locale/zh-CN.ts',
'views/visualization/data-analysis/locale/zh-CN.ts',
'views/visualization/multi-dimension-data-analysis/locale/zh-CN.ts',
];
const rootZhCN = `import { mergeLocaleModules } from './merge-locales';
import localeSettings from './zh-CN/settings';
const componentLocales = mergeLocaleModules(
import.meta.glob('@/components/**/locale/zh-CN.ts', { eager: true }),
);
const viewLocales = mergeLocaleModules(
import.meta.glob('@/views/**/locale/zh-CN.ts', { eager: true }),
);
export default {
'menu.dashboard': '仪表盘',
'menu.server.dashboard': '仪表盘-服务端',
'menu.server.workplace': '工作台-服务端',
'menu.server.monitor': '实时监控-服务端',
'menu.list': '列表页',
'menu.result': '结果页',
'menu.exception': '异常页',
'menu.form': '表单页',
'menu.profile': '详情页',
'menu.visualization': '数据可视化',
'menu.user': '个人中心',
'menu.arcoWebsite': 'Arco Design',
'menu.faq': '常见问题',
'navbar.docs': '文档中心',
'navbar.action.locale': '切换为中文',
...localeSettings,
...componentLocales,
...viewLocales,
};
`;
async function download(relPath) {
const url = `${BASE}/${relPath}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`${relPath}: HTTP ${res.status}`);
}
return res.text();
}
async function main() {
for (const relPath of localeFiles) {
const content = await download(relPath);
const dest = path.join('src', relPath.replace(/^locale\//, 'locale/'));
const fullPath = path.join('src', relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const hasCn = /[\u4e00-\u9fff]/.test(content);
console.log(`OK ${relPath} (cn=${hasCn})`);
}
fs.writeFileSync(path.join('src', 'locale/zh-CN.ts'), rootZhCN, 'utf8');
console.log('OK locale/zh-CN.ts (cn=true)');
// search-table column setting label
const stPath = path.join('src', 'views/list/search-table/index.vue');
let st = fs.readFileSync(stPath, 'utf8');
st = st.replace(
"{{ item.title === '#' ? '???' : item.title }}",
"{{ item.title === '#' ? '序列号' : item.title }}",
);
fs.writeFileSync(stPath, st, 'utf8');
console.log('OK search-table/index.vue');
// verify
let bad = 0;
for (const relPath of ['locale/zh-CN.ts', ...localeFiles]) {
const fullPath = path.join('src', relPath);
const text = fs.readFileSync(fullPath, 'utf8');
if (text.includes("'???'") || text.includes("'??'")) {
console.error('STILL BAD:', relPath);
bad += 1;
}
}
if (bad) process.exit(1);
console.log('All locale files verified');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { platformAPI, type DashboardOverview, type IdnAccount, type OrgDeliveryPoint, type OrgGasStation, type OrgServicePerson, type Profile, type SafEvent } from './api/platform';
import { platformAPI, type DashboardOverview, type OrgDeliveryPoint, type OrgGasStation, type OrgServicePerson, type PlatfromAccount, type Profile, type SafEvent } from './api/platform';
type Tab = 'dashboard' | 'station' | 'delivery' | 'person' | 'user' | 'safety' | 'trade' | 'finance' | 'content' | 'track' | 'operation' | 'audit';
type CatalogItem = { title: string; description: string; operations: string[] };
@@ -13,12 +13,11 @@ const overview = ref<DashboardOverview>({});
const stations = ref<OrgGasStation[]>([]);
const deliveryPoints = ref<OrgDeliveryPoint[]>([]);
const servicePeople = ref<OrgServicePerson[]>([]);
const users = ref<IdnAccount[]>([]);
const users = ref<PlatfromAccount[]>([]);
const safetyEvents = ref<SafEvent[]>([]);
const profile = ref<Profile>();
const loginForm = reactive({ username: 'root', password: '' });
const stationForm = reactive({ stationCode: '', name: '', principal: '', serviceArea: '' });
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const tabs: Array<{ key: Tab; label: string }> = [
{ key: 'dashboard', label: '运营总览' }, { key: 'station', label: '气站管理' }, { key: 'delivery', label: '配送点管理' },
@@ -49,7 +48,7 @@ async function loadData() {
try {
[profile.value, overview.value, stations.value, deliveryPoints.value, servicePeople.value, users.value, safetyEvents.value] = await Promise.all([
platformAPI.getProfile(), platformAPI.getDashboard(), platformAPI.listOrgGasStation(), platformAPI.listOrgDeliveryPoint(),
platformAPI.listOrgServicePerson(), platformAPI.listIdnAccount(), platformAPI.listSafEvent(),
platformAPI.listOrgServicePerson(), platformAPI.listPlatfromAccount(), platformAPI.listSafEvent(),
]);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '加载数据失败';
@@ -75,14 +74,6 @@ async function createStation() {
finally { loading.value = false; }
}
async function changePassword() {
if (passwordForm.newPassword !== passwordForm.confirmPassword) { errorMessage.value = '两次输入的新密码不一致'; return; }
loading.value = true;
try { await platformAPI.changePassword(passwordForm.currentPassword, passwordForm.newPassword); Object.assign(passwordForm, { currentPassword: '', newPassword: '', confirmPassword: '' }); await loadData(); }
catch (error) { errorMessage.value = error instanceof Error ? error.message : '修改密码失败'; }
finally { loading.value = false; }
}
onMounted(loadData);
</script>
@@ -90,7 +81,7 @@ onMounted(loadData);
<main v-if="!loggedIn" class="login-page">
<form class="login-card" @submit.prevent="login">
<span class="brand-mark"></span><p class="eyebrow">HEQI PLATFORM</p><h1>可燃气体平台总后台</h1>
<p>使用平台管理员账号登录首次登录后必须修改初始密码</p>
<p>使用平台管理员账号登录</p>
<input v-model.trim="loginForm.username" autocomplete="username" placeholder="账号" required />
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="密码" required />
<p v-if="errorMessage" class="error">{{ errorMessage }}</p><button :disabled="loading" type="submit">{{ loading ? '登录中' : '登录' }}</button>
@@ -99,7 +90,7 @@ onMounted(loadData);
<main v-else class="shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark"></span><div><strong>可燃气体平台</strong><small>平台总后台</small></div></div>
<div class="brand"><img v-if="profile?.avatar" class="avatar" :src="profile.avatar" :alt="profile.displayName" /><span v-else class="brand-mark"></span><div><strong>可燃气体平台</strong><small>平台总后台</small></div></div>
<nav><button v-for="tab in tabs" :key="tab.key" :class="{ active: currentTab === tab.key }" @click="currentTab = tab.key">{{ tab.label }}</button></nav>
<div class="account"><strong>{{ profile?.displayName || '平台管理员' }}</strong><small>{{ profile?.roleCode || '加载中' }}</small><button class="link-button" @click="logout">退出登录</button></div>
</aside>
@@ -107,13 +98,11 @@ onMounted(loadData);
<section class="content">
<header><div><p class="eyebrow">PLATFORM ADMIN</p><h1>{{ activeLabel }}</h1></div><button class="secondary" :disabled="loading" @click="loadData">{{ loading ? '同步中' : '刷新数据' }}</button></header>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<article v-if="profile?.mustChangePassword" class="panel warning"><h2>首次登录安全设置</h2><p>root 初始密码仅可用于首次进入,请立即设置不少于 12 位的新密码。</p><form class="password-form" @submit.prevent="changePassword"><input v-model="passwordForm.currentPassword" type="password" placeholder="当前密码" required /><input v-model="passwordForm.newPassword" type="password" placeholder="新密码(至少 12 位)" required /><input v-model="passwordForm.confirmPassword" type="password" placeholder="确认新密码" required /><button :disabled="loading">修改密码</button></form></article>
<template v-if="currentTab === 'dashboard'"><div class="cards"><article v-for="([label, value]) in overviewCards" :key="label"><span>{{ label }}</span><strong>{{ value }}</strong></article></div><article class="panel"><h2>平台职责边界</h2><p>统一管理所有气站配送点服务人员和用户并对可燃气体业务的安全订单配送轨迹资金结算邀请二维码和高风险操作保留审计闭环</p></article></template>
<template v-if="currentTab === 'station'"><article class="panel"><h2>创建气站</h2><form @submit.prevent="createStation"><input v-model.trim="stationForm.stationCode" placeholder="气站编码,例如 GS-1002" /><input v-model.trim="stationForm.name" placeholder="气站名称" /><input v-model.trim="stationForm.principal" placeholder="负责人" /><input v-model.trim="stationForm.serviceArea" placeholder="服务区域" /><button :disabled="loading">提交审核</button></form><p>气站可继续管理其配送点、服务人员、用户及专属邀请注册二维码。</p></article><DataTable :headers="['编码', '名称', '负责人', '服务区域', '状态']" :rows="stations.map((item) => [item.stationCode, item.name, item.principal, item.serviceArea, item.status])" /></template>
<template v-if="currentTab === 'delivery'"><article class="panel"><h2>配送点管理边界</h2><p>配送点归属气站,负责管理服务人员、用户、配送班次、订单交付与配送轨迹;平台可跨组织查看、审核、冻结和迁移。</p></article><DataTable :headers="['编码', '配送点', '归属气站', '服务区域', '状态']" :rows="deliveryPoints.map((item) => [item.deliveryCode, item.name, item.gasStationName, item.serviceArea, item.status])" /></template>
<template v-if="currentTab === 'person'"><article class="panel"><h2>服务人员生命周期</h2><p>覆盖安装维修、安检、配送角色、资质、登录设备、接单状态、任务绩效及停用留档。</p></article><DataTable :headers="['姓名', '手机号', '角色', '工作状态', '资质状态']" :rows="servicePeople.map((item) => [item.name, item.phoneMasked, item.roles, item.workStatus, item.credentialStatus])" /></template>
<template v-if="currentTab === 'user'"><article class="panel"><h2>用户 360° 管理</h2><p>统一查看户资料、地址、智能瓶阀、订单、权益、投诉与风险处置;敏感信息按最小化原则展示。</p></article><DataTable :headers="['手机号', '账户类型', '状态', '服务区域']" :rows="users.map((item) => [item.phoneMasked, item.accountType, item.status, item.serviceArea])" /></template>
<template v-if="currentTab === 'user'"><article class="panel"><h2>平台账户管理</h2><p>统一查看平台账户资料、角色、头像、手机号、状态与操作记录;敏感信息按最小化原则展示。</p></article><DataTable :headers="['账号', '名称', '头像', '手机号', '角色', '状态']" :rows="users.map((item) => [item.username, item.displayName, item.avatar || '未设置', item.phoneMasked, item.roleCode, item.status])" /></template>
<template v-if="currentTab === 'safety'"><article class="panel"><h2>安全运营中心</h2><p>对智能瓶阀告警、安检异常、可燃气体风险、资质临期与订单异常进行分级、派发、升级和复盘。</p></article><DataTable :headers="['事件编码', '等级', '事件说明', '状态', '创建时间']" :rows="safetyEvents.map((item) => [item.eventCode, `${item.level} 级`, item.title, item.status, new Date(item.createdAt).toLocaleString()])" /></template>
<template v-if="catalog[currentTab]"><div class="catalog"><article v-for="item in catalog[currentTab]" :key="item.title" class="panel"><h2>{{ item.title }}</h2><p>{{ item.description }}</p><div class="tags"><span v-for="operation in item.operations" :key="operation">{{ operation }}</span></div></article></div></template>
</section>

View File

@@ -0,0 +1,12 @@
import { request } from './http';
/** 平台登录和当前账号资料的接口模型。 */
export type LoginData = { username: string; password: string };
export type LoginReply = { access_token: string; token_type: string; identity: string; display_name: string; role_code: string };
export type Profile = { identity: string; username: string; display_name: string; avatar: string; role_code: string };
export const authApi = {
login: (data: LoginData) => request<LoginReply>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
profile: () => request<Profile>('/auth/profile'),
changePassword: (currentPassword: string, newPassword: string) => request<{ changed: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }),
};

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 配送点管理接口。 */
export type DeliveryBasic = { identity: string; delivery_code: string; name: string; principal: string; address: string; status: string };
export const deliveryApi = { list: () => resourceApi.list<DeliveryBasic>('/delivery/delivery_basic'), create: (data: Record<string, unknown>) => resourceApi.create<DeliveryBasic>('/delivery/delivery_basic', data) };

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 可燃气体站管理接口。 */
export type GasBasic = { identity: string; code: string; name: string; principal: string; address: string; status: string };
export const gasApi = { list: () => resourceApi.list<GasBasic>('/gas/gas_basic'), create: (data: Record<string, unknown>) => resourceApi.create<GasBasic>('/gas/gas_basic', data) };

View File

@@ -0,0 +1,21 @@
/** 平台总后台的共享 HTTP 客户端,统一处理响应体和 JWT 请求头。 */
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/platform/v1';
export const tokenStorageKey = 'token';
export type PageResult<T> = { total: number; list: T[] };
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = localStorage.getItem(tokenStorageKey);
const response = await fetch(`${apiBaseURL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: token } : {}),
...(init?.headers ?? {}),
},
});
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.details as T;
}

View File

@@ -1,62 +1,14 @@
// 平台总后台 API 客户端JWT 使用 BSM 中间件要求的原始令牌值。
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/v1';
const developmentJWT = import.meta.env.VITE_DEV_JWT?.trim();
const tokenStorageKey = 'heqi.platform_admin.access_token';
import { request } from './http';
import { resourceApi } from './resource';
export type OrgGasStation = { identity: string; stationCode: string; name: string; principal: string; serviceArea: string; status: string; createdAt: string };
export type OrgDeliveryPoint = { identity: string; deliveryCode: string; name: string; gasStationName: string; serviceArea: string; status: string };
export type OrgServicePerson = { identity: string; name: string; phoneMasked: string; roles: string; workStatus: string; credentialStatus: string };
export type IdnAccount = { identity: string; phoneMasked: string; accountType: string; status: string; serviceArea: string };
export type SafEvent = { identity: string; eventCode: string; level: number; title: string; status: string; createdAt: string };
export type DashboardOverview = Record<string, number>;
export type LoginReply = { accessToken: string; tokenType: string; identity: string; displayName: string; roleCode: string; mustChangePassword: boolean };
export type Profile = { identity: string; username: string; displayName: string; roleCode: string; mustChangePassword: boolean; mfaEnabled: boolean };
/** 平台角色、菜单、账号和工作台接口。 */
export type PlatformRole = { identity: string; role_code: string; name: string; data_scope: string; is_system: boolean; status: string };
export type PlatformMenu = { id: number; identity: string; parent_id: number; menu_code: string; name: string; icon: string; path: string; sort_no: number };
type ListReply<T> = { total: number; list: T[] };
type RawOrgDeliveryPoint = { identity: string; delivery_code: string; name: string; gas_station_name?: string; service_area: string; status: string };
type RawOrgServicePerson = { identity: string; name: string; phone_masked?: string; roles: string; work_status: string; credential_status: string };
type RawIdnAccount = { identity: string; phone_masked: string; account_type: string; status: string; service_area: string };
type RawSafEvent = { identity: string; event_code: string; level: number; title: string; status: string; created_at: string };
function currentToken(): string | undefined {
return localStorage.getItem(tokenStorageKey) || developmentJWT;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const accessToken = currentToken();
const response = await fetch(`${apiBaseURL}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...(accessToken ? { Authorization: accessToken } : {}), ...(init?.headers ?? {}) },
});
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.details as T;
}
export const platformAPI = {
hasSession: () => Boolean(currentToken()),
clearSession: () => localStorage.removeItem(tokenStorageKey),
login: async (username: string, password: string) => {
const item = await request<{ access_token: string; token_type: string; identity: string; display_name: string; role_code: string; must_change_password: boolean }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
localStorage.setItem(tokenStorageKey, item.access_token);
return { accessToken: item.access_token, tokenType: item.token_type, identity: item.identity, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password } satisfies LoginReply;
},
getProfile: async () => {
const item = await request<{ identity: string; username: string; display_name: string; role_code: string; must_change_password: boolean; mfa_enabled: boolean }>('/auth/profile');
return { identity: item.identity, username: item.username, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password, mfaEnabled: item.mfa_enabled } satisfies Profile;
},
changePassword: (currentPassword: string, newPassword: string) => request<{ changed: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }),
getDashboard: async () => {
const item = await request<{ gas_station_count: number; delivery_point_count: number; service_person_count: number; user_count: number; pending_safety_count: number }>('/dashboard/overview');
return { gasStationCount: item.gas_station_count, deliveryPointCount: item.delivery_point_count, servicePersonCount: item.service_person_count, userCount: item.user_count, pendingSafetyCount: item.pending_safety_count };
},
listOrgGasStation: async () => {
const reply = await request<ListReply<{ identity: string; station_code: string; name: string; principal: string; service_area: string; status: string; created_at: string }>>('/organization/org_gas_station');
return reply.list.map((item) => ({ identity: item.identity, stationCode: item.station_code, name: item.name, principal: item.principal, serviceArea: item.service_area, status: item.status, createdAt: item.created_at }));
},
createOrgGasStation: (body: Pick<OrgGasStation, 'stationCode' | 'name' | 'principal' | 'serviceArea'>) => request<OrgGasStation>('/organization/org_gas_station', { method: 'POST', body: JSON.stringify({ station_code: body.stationCode, name: body.name, principal: body.principal, service_area: body.serviceArea }) }),
listOrgDeliveryPoint: async () => (await request<ListReply<RawOrgDeliveryPoint>>('/organization/org_delivery_point')).list.map((item) => ({ identity: item.identity, deliveryCode: item.delivery_code, name: item.name, gasStationName: item.gas_station_name ?? '未关联', serviceArea: item.service_area, status: item.status })),
listOrgServicePerson: async () => (await request<ListReply<RawOrgServicePerson>>('/organization/org_service_person')).list.map((item) => ({ identity: item.identity, name: item.name, phoneMasked: item.phone_masked ?? '***', roles: item.roles, workStatus: item.work_status, credentialStatus: item.credential_status })),
listIdnAccount: async () => (await request<ListReply<RawIdnAccount>>('/identity/idn_account')).list.map((item) => ({ identity: item.identity, phoneMasked: item.phone_masked, accountType: item.account_type, status: item.status, serviceArea: item.service_area })),
listSafEvent: async () => (await request<ListReply<RawSafEvent>>('/safety/saf_event')).list.map((item) => ({ identity: item.identity, eventCode: item.event_code, level: item.level, title: item.title, status: item.status, createdAt: item.created_at })),
export const platformApi = {
overview: () => request<Record<string, number>>('/dashboard/overview'),
listAccount: () => request<{ total: number; list: Record<string, unknown>[] }>('/platform/platfrom_account'),
listRole: () => resourceApi.list<PlatformRole>('/platform/platform_role'),
createRole: (data: Record<string, unknown>) => resourceApi.create<PlatformRole>('/platform/platform_role', data),
listMenu: () => request<{ total: number; list: PlatformMenu[] }>('/platform/platform_menu'),
};

View File

@@ -0,0 +1,11 @@
import { request, type PageResult } from './http';
/** 所有标准 CRUD 资源共享的调用方法。 */
export const resourceApi = {
list: <T>(resource: string, page = 1, size = 20) => request<PageResult<T>>(`${resource}?page=${page}&size=${size}`),
detail: <T>(resource: string, identity: string) => request<T>(`${resource}/${identity}`),
create: <T>(resource: string, data: Record<string, unknown>) => request<T>(resource, { method: 'POST', body: JSON.stringify(data) }),
update: <T>(resource: string, identity: string, data: Record<string, unknown>) => request<T>(`${resource}/${identity}`, { method: 'PUT', body: JSON.stringify(data) }),
updateStatus: (resource: string, identity: string, status: string) => request<{ updated: boolean }>(`${resource}/${identity}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }),
archive: (resource: string, identity: string) => request<{ updated: boolean }>(`${resource}/${identity}`, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 服务人员管理接口。 */
export type Staff = { identity: string; name: string; phone: string; role_code: string; work_status: string; status: string };
export const staffApi = { list: () => resourceApi.list<Staff>('/staff/staff'), create: (data: Record<string, unknown>) => resourceApi.create<Staff>('/staff/staff', data) };

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 业主客户管理接口。 */
export type User = { identity: string; name: string; phone: string; real_name: string; status: string };
export const userApi = { list: () => resourceApi.list<User>('/user/user'), create: (data: Record<string, unknown>) => resourceApi.create<User>('/user/user', data) };

View File

@@ -0,0 +1,24 @@
<template>
<a-config-provider :locale="locale">
<router-view />
</a-config-provider>
</template>
<script lang="ts" setup>
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
import { computed } from 'vue';
import useLocale from '@/hooks/locale';
const { currentLocale } = useLocale();
const locale = computed(() => {
switch (currentLocale.value) {
case 'zh-CN':
return zhCN;
case 'en-US':
return enUS;
default:
return enUS;
}
});
</script>

View File

@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
/// <reference types="vue/jsx" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<
Record<string, unknown>,
Record<string, unknown>,
unknown
>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_ERROR_REPORT_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,38 @@
import ArcoVue from '@arco-design/web-vue';
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
import { createApp } from 'vue';
import globalComponents from '@/components';
import '@/assets/style/global.less';
import { setupHttp } from '@/plugins/http';
import directive from '@/directive';
import i18n from '@/locale';
import router from '@/router';
import store from '@/store';
import setupErrorReport from '@/utils/error-report';
import App from './App.vue';
async function bootstrap() {
if (import.meta.env.DEV) {
await import('@/mocks');
}
const app = createApp(App);
app.use(ArcoVue, {});
app.use(ArcoVueIcon);
app.use(router);
app.use(store);
app.use(i18n);
app.use(globalComponents);
app.use(directive);
setupHttp();
setupErrorReport(
app,
import.meta.env.VITE_ERROR_REPORT_URL?.trim() ?? '',
);
app.mount('#app');
}
bootstrap();

Some files were not shown because too many files have changed in this diff Show More