feat: 初始化平台总后台与核心API
This commit is contained in:
7
backend/.env.example
Normal file
7
backend/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# PostgreSQL 为首期唯一必需的真实依赖;请仅在本地环境文件中填写真实连接串。
|
||||
DATABASE_URL=postgres://postgres:change-me@localhost:5432/agent_dev?sslmode=disable
|
||||
PORT=8080
|
||||
|
||||
# 其它外部能力在首期使用 Mock;不要在仓库提交真实 Redis、支付或 IoT 凭证。
|
||||
REDIS_URL=redis://default:change-me@127.0.0.1:6379/0
|
||||
APP_ENV=development
|
||||
11
backend/README.md
Normal file
11
backend/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 平台核心后端
|
||||
|
||||
首期实现 M0 + M1 的平台总后台 API:组织、服务人员、用户、安全事件和仪表盘。PostgreSQL 保存业务事实;支付、地图、IoT、短信和 Redis 在本阶段均通过 Mock 边界预留,不连接生产外部服务。
|
||||
|
||||
## 运行
|
||||
|
||||
1. 将 `.env.example` 复制为本地环境文件,设置 `DATABASE_URL`。
|
||||
2. 在 PowerShell 设置 `DATABASE_URL` 后执行 `go run ./cmd/api`。
|
||||
3. 服务启动时自动执行幂等迁移和演示数据初始化。
|
||||
|
||||
接口前缀为 `/api/v1`,例如:`GET /api/v1/dashboard/overview`。
|
||||
58
backend/cmd/api/main.go
Normal file
58
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// 平台核心 API 的启动入口。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/internal/config"
|
||||
"git.apinb.com/heqiapp/platforms/backend/internal/database"
|
||||
platformhttp "git.apinb.com/heqiapp/platforms/backend/internal/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
appConfig := config.Load()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
pool, err := database.Connect(context.Background(), appConfig.DatabaseURL)
|
||||
if err != nil {
|
||||
logger.Error("连接 PostgreSQL 失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := database.MigrateAndSeed(context.Background(), pool); err != nil {
|
||||
logger.Error("初始化数据库失败", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + appConfig.Port,
|
||||
Handler: platformhttp.NewRouter(pool, logger),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info("平台核心 API 已启动", "address", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("HTTP 服务异常退出", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownContext); err != nil {
|
||||
logger.Error("HTTP 服务停止失败", "error", err)
|
||||
}
|
||||
}
|
||||
16
backend/go.mod
Normal file
16
backend/go.mod
Normal file
@@ -0,0 +1,16 @@
|
||||
module git.apinb.com/heqiapp/platforms/backend
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
28
backend/go.sum
Normal file
28
backend/go.sum
Normal file
@@ -0,0 +1,28 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
19
backend/internal/config/config.go
Normal file
19
backend/internal/config/config.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Package config 提供运行配置,不在代码或仓库中保存真实密钥。
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
// Config 是 API 进程的最小运行配置。
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
Port string
|
||||
}
|
||||
|
||||
// Load 读取环境变量并提供适用于本地开发的端口默认值。
|
||||
func Load() Config {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
return Config{DatabaseURL: os.Getenv("DATABASE_URL"), Port: port}
|
||||
}
|
||||
17
backend/internal/database/database.go
Normal file
17
backend/internal/database/database.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package database 负责 PostgreSQL 连接、迁移和演示数据初始化。
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Connect 建立 PostgreSQL 连接池;数据库地址必须通过环境变量提供。
|
||||
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
if databaseURL == "" {
|
||||
return nil, errors.New("缺少 DATABASE_URL 环境变量")
|
||||
}
|
||||
return pgxpool.New(ctx, databaseURL)
|
||||
}
|
||||
80
backend/internal/database/migration.go
Normal file
80
backend/internal/database/migration.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// MigrateAndSeed 执行幂等结构迁移,并只在空库时创建演示数据。
|
||||
func MigrateAndSeed(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS idn_account (
|
||||
identity uuid PRIMARY KEY, phone varchar(32) NOT NULL UNIQUE, account_type varchar(32) NOT NULL,
|
||||
status varchar(32) NOT NULL, service_area varchar(128) NOT NULL, created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
|
||||
)`,
|
||||
`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, status varchar(32) NOT NULL,
|
||||
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, 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 REFERENCES org_gas_station(identity),
|
||||
name varchar(128) NOT NULL, service_area varchar(128) NOT NULL, status varchar(32) NOT NULL,
|
||||
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS org_service_person (
|
||||
identity uuid PRIMARY KEY, account_identity uuid UNIQUE REFERENCES idn_account(identity), gas_station_identity uuid REFERENCES org_gas_station(identity),
|
||||
delivery_point_identity uuid REFERENCES org_delivery_point(identity), 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, version integer NOT NULL DEFAULT 1
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS org_user_service_relation (
|
||||
identity uuid PRIMARY KEY, account_identity uuid NOT NULL REFERENCES idn_account(identity), gas_station_identity uuid REFERENCES org_gas_station(identity),
|
||||
delivery_point_identity uuid REFERENCES org_delivery_point(identity), source varchar(32) NOT NULL, status varchar(32) NOT NULL,
|
||||
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS dev_device (
|
||||
identity uuid PRIMARY KEY, device_code varchar(64) NOT NULL UNIQUE, account_identity uuid REFERENCES idn_account(identity),
|
||||
online_status varchar(32) NOT NULL, valve_status varchar(32) NOT NULL, created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS saf_event (
|
||||
identity uuid PRIMARY KEY, event_code varchar(32) NOT NULL UNIQUE, device_identity uuid REFERENCES dev_device(identity),
|
||||
level integer NOT NULL CHECK (level BETWEEN 1 AND 3), title varchar(256) NOT NULL, status varchar(32) NOT NULL,
|
||||
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, version integer NOT NULL DEFAULT 1
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS aud_operation_log (
|
||||
identity uuid PRIMARY KEY, operator_identity uuid, action varchar(64) NOT NULL, object_type varchar(64) NOT NULL,
|
||||
object_identity uuid, detail jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_delivery_point_gas_station_identity ON org_delivery_point(gas_station_identity)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_service_person_gas_station_identity ON org_service_person(gas_station_identity)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_saf_event_status_level ON saf_event(status, level)`,
|
||||
`COMMENT ON TABLE idn_account IS '身份账户主表'`,
|
||||
`COMMENT ON COLUMN idn_account.identity IS '主键,应用生成的 UUID V7'`,
|
||||
`COMMENT ON TABLE org_gas_station IS '可燃气体站主表'`,
|
||||
`COMMENT ON COLUMN org_gas_station.identity IS '主键,应用生成的 UUID V7'`,
|
||||
`COMMENT ON TABLE org_delivery_point IS '配送点主表'`,
|
||||
`COMMENT ON COLUMN org_delivery_point.identity IS '主键,应用生成的 UUID V7'`,
|
||||
`COMMENT ON TABLE org_service_person IS '服务人员主表'`,
|
||||
`COMMENT ON COLUMN org_service_person.identity IS '主键,应用生成的 UUID V7'`,
|
||||
`COMMENT ON TABLE org_user_service_relation IS '用户服务关系表'`,
|
||||
`COMMENT ON COLUMN org_user_service_relation.identity IS '主键,应用生成的 UUID V7'`,
|
||||
`COMMENT ON TABLE dev_device IS '智能瓶阀设备主表'`,
|
||||
`COMMENT ON COLUMN dev_device.identity IS '主键,应用生成的 UUID V7'`,
|
||||
`COMMENT ON TABLE saf_event IS '安全事件主表'`,
|
||||
`COMMENT ON COLUMN saf_event.identity IS '主键,应用生成的 UUID V7'`,
|
||||
`COMMENT ON TABLE aud_operation_log IS '不可变操作审计日志表'`,
|
||||
`COMMENT ON COLUMN aud_operation_log.identity IS '主键,应用生成的 UUID V7'`,
|
||||
}
|
||||
|
||||
for _, statement := range statements {
|
||||
if _, err := pool.Exec(ctx, statement); err != nil {
|
||||
return fmt.Errorf("执行数据库迁移失败: %w", err)
|
||||
}
|
||||
}
|
||||
return seed(ctx, pool)
|
||||
}
|
||||
66
backend/internal/database/seed.go
Normal file
66
backend/internal/database/seed.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// seed 仅为空数据库提供可用于平台总后台演示的初始数据。
|
||||
func seed(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM org_gas_station`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
now := time.Now().UTC()
|
||||
stationID := newIdentity()
|
||||
deliveryID := newIdentity()
|
||||
userID := newIdentity()
|
||||
personAccountID := newIdentity()
|
||||
personID := newIdentity()
|
||||
deviceID := newIdentity()
|
||||
eventID := newIdentity()
|
||||
|
||||
queries := []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{`INSERT INTO org_gas_station (identity, station_code, name, principal, service_area, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{stationID, "GS-1001", "浦东可燃气体站", "张敏", "浦东新区", "enabled", now}},
|
||||
{`INSERT INTO org_delivery_point (identity, delivery_code, gas_station_identity, name, service_area, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{deliveryID, "DP-2001", stationID, "陆家嘴配送点", "陆家嘴片区", "enabled", now}},
|
||||
{`INSERT INTO idn_account (identity, phone, account_type, status, service_area, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$6)`, []any{userID, "13800000001", "user", "enabled", "浦东新区", now}},
|
||||
{`INSERT INTO idn_account (identity, phone, account_type, status, service_area, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$6)`, []any{personAccountID, "13900000001", "service_person", "enabled", "陆家嘴片区", now}},
|
||||
{`INSERT INTO org_service_person (identity, account_identity, gas_station_identity, delivery_point_identity, name, roles, work_status, credential_status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9)`, []any{personID, personAccountID, stationID, deliveryID, "李强", "delivery", "on_duty", "valid", now}},
|
||||
{`INSERT INTO org_user_service_relation (identity, account_identity, gas_station_identity, delivery_point_identity, source, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{newIdentity(), userID, stationID, deliveryID, "seed", "active", now}},
|
||||
{`INSERT INTO dev_device (identity, device_code, account_identity, online_status, valve_status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$6)`, []any{deviceID, "DEV-1001", userID, "online", "closed", now}},
|
||||
{`INSERT INTO saf_event (identity, event_code, device_identity, level, title, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, []any{eventID, "SAF-3001", deviceID, 1, "设备压力异常待处置", "pending", now}},
|
||||
{`INSERT INTO aud_operation_log (identity, action, object_type, object_identity, detail, created_at) VALUES ($1,$2,$3,$4,$5,$6)`, []any{newIdentity(), "seed", "org_gas_station", stationID, `{"source":"development"}`, now}},
|
||||
}
|
||||
for _, query := range queries {
|
||||
if _, err := tx.Exec(ctx, query.sql, query.args...); err != nil {
|
||||
return fmt.Errorf("写入演示数据失败: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// newIdentity 生成符合全局命名规范的时间有序 UUID V7 主键。
|
||||
func newIdentity() uuid.UUID {
|
||||
identity, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
107
backend/internal/http/router.go
Normal file
107
backend/internal/http/router.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package http 提供平台总后台的 HTTP 路由和统一响应。
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/internal/platform"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// NewRouter 注册首期平台总后台所需的只读列表、仪表盘与气站创建接口。
|
||||
func NewRouter(pool *pgxpool.Pool, logger *slog.Logger) http.Handler {
|
||||
repository := platform.NewRepository(pool)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/dashboard/overview", func(writer http.ResponseWriter, request *http.Request) {
|
||||
respondRepository(writer, request, logger, func() (any, error) { return repository.DashboardOverview(request.Context()) })
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/org/gas-station", func(writer http.ResponseWriter, request *http.Request) {
|
||||
respondRepository(writer, request, logger, func() (any, error) { return repository.ListOrgGasStation(request.Context()) })
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/org/gas-station", func(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
StationCode string `json:"stationCode"`
|
||||
Name string `json:"name"`
|
||||
Principal string `json:"principal"`
|
||||
ServiceArea string `json:"serviceArea"`
|
||||
}
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.StationCode == "" || body.Name == "" || body.Principal == "" || body.ServiceArea == "" {
|
||||
writeError(writer, http.StatusBadRequest, "参数不完整:站点编码、名称、负责人和服务区域均为必填")
|
||||
return
|
||||
}
|
||||
respondRepositoryWithStatus(writer, request, logger, http.StatusCreated, func() (any, error) {
|
||||
return repository.CreateOrgGasStation(request.Context(), body.StationCode, body.Name, body.Principal, body.ServiceArea)
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/org/delivery-point", func(writer http.ResponseWriter, request *http.Request) {
|
||||
respondRepository(writer, request, logger, func() (any, error) { return repository.ListOrgDeliveryPoint(request.Context()) })
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/org/service-person", func(writer http.ResponseWriter, request *http.Request) {
|
||||
respondRepository(writer, request, logger, func() (any, error) { return repository.ListOrgServicePerson(request.Context()) })
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/idn/account", func(writer http.ResponseWriter, request *http.Request) {
|
||||
respondRepository(writer, request, logger, func() (any, error) { return repository.ListIdnAccount(request.Context()) })
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/saf/event", func(writer http.ResponseWriter, request *http.Request) {
|
||||
respondRepository(writer, request, logger, func() (any, error) { return repository.ListSafEvent(request.Context()) })
|
||||
})
|
||||
|
||||
return requestLogger(cors(mux), logger)
|
||||
}
|
||||
|
||||
func respondRepository(writer http.ResponseWriter, request *http.Request, logger *slog.Logger, query func() (any, error)) {
|
||||
respondRepositoryWithStatus(writer, request, logger, http.StatusOK, query)
|
||||
}
|
||||
|
||||
func respondRepositoryWithStatus(writer http.ResponseWriter, request *http.Request, logger *slog.Logger, status int, query func() (any, error)) {
|
||||
result, err := query()
|
||||
if err != nil {
|
||||
logger.Error("处理平台 API 请求失败", "method", request.Method, "path", request.URL.Path, "error", err)
|
||||
writeError(writer, http.StatusInternalServerError, "服务暂不可用,请稍后重试")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, status, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, data any) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, message string) {
|
||||
writeJSON(writer, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
|
||||
writer.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type,Idempotency-Key")
|
||||
if request.Method == http.MethodOptions {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
|
||||
func requestLogger(next http.Handler, logger *slog.Logger) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestID, err := uuid.NewV7()
|
||||
if err == nil {
|
||||
writer.Header().Set("X-Request-Id", requestID.String())
|
||||
}
|
||||
startedAt := time.Now()
|
||||
next.ServeHTTP(writer, request)
|
||||
logger.Info("平台 API 请求完成", "method", request.Method, "path", request.URL.Path, "duration", time.Since(startedAt).String())
|
||||
})
|
||||
}
|
||||
10
backend/internal/platform/idn_account.go
Normal file
10
backend/internal/platform/idn_account.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package platform
|
||||
|
||||
// IdnAccount 对应 idn_account,表示平台用户的最小账户视图。
|
||||
type IdnAccount struct {
|
||||
Identity string `json:"identity"`
|
||||
PhoneMasked string `json:"phoneMasked"`
|
||||
AccountType string `json:"accountType"`
|
||||
Status string `json:"status"`
|
||||
ServiceArea string `json:"serviceArea"`
|
||||
}
|
||||
11
backend/internal/platform/org_delivery_point.go
Normal file
11
backend/internal/platform/org_delivery_point.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package platform
|
||||
|
||||
// OrgDeliveryPoint 对应 org_delivery_point,表示末端配送组织单元。
|
||||
type OrgDeliveryPoint struct {
|
||||
Identity string `json:"identity"`
|
||||
DeliveryCode string `json:"deliveryCode"`
|
||||
Name string `json:"name"`
|
||||
GasStationName string `json:"gasStationName"`
|
||||
ServiceArea string `json:"serviceArea"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
15
backend/internal/platform/org_gas_station.go
Normal file
15
backend/internal/platform/org_gas_station.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Package platform 包含平台总后台的领域模型和数据访问。
|
||||
package platform
|
||||
|
||||
import "time"
|
||||
|
||||
// OrgGasStation 对应 org_gas_station,表示可燃气体站经营主体。
|
||||
type OrgGasStation struct {
|
||||
Identity string `json:"identity"`
|
||||
StationCode string `json:"stationCode"`
|
||||
Name string `json:"name"`
|
||||
Principal string `json:"principal"`
|
||||
ServiceArea string `json:"serviceArea"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
11
backend/internal/platform/org_service_person.go
Normal file
11
backend/internal/platform/org_service_person.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package platform
|
||||
|
||||
// OrgServicePerson 对应 org_service_person,表示具备一个或多个服务角色的人员。
|
||||
type OrgServicePerson struct {
|
||||
Identity string `json:"identity"`
|
||||
Name string `json:"name"`
|
||||
PhoneMasked string `json:"phoneMasked"`
|
||||
Roles string `json:"roles"`
|
||||
WorkStatus string `json:"workStatus"`
|
||||
Credential string `json:"credentialStatus"`
|
||||
}
|
||||
181
backend/internal/platform/repository.go
Normal file
181
backend/internal/platform/repository.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository 封装平台总后台首期需要的 PostgreSQL 查询。
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository 创建平台领域数据访问实例。
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// DashboardOverview 返回平台总后台首页所需的聚合指标。
|
||||
func (repository *Repository) DashboardOverview(ctx context.Context) (map[string]int, error) {
|
||||
queries := map[string]string{
|
||||
"gasStationCount": `SELECT count(*) FROM org_gas_station WHERE status = 'enabled'`,
|
||||
"deliveryPointCount": `SELECT count(*) FROM org_delivery_point WHERE status = 'enabled'`,
|
||||
"servicePersonCount": `SELECT count(*) FROM org_service_person WHERE work_status = 'on_duty'`,
|
||||
"userCount": `SELECT count(*) FROM idn_account WHERE account_type = 'user' AND status = 'enabled'`,
|
||||
"pendingSafetyCount": `SELECT count(*) FROM saf_event WHERE status = 'pending'`,
|
||||
}
|
||||
result := make(map[string]int, len(queries))
|
||||
for key, query := range queries {
|
||||
var count int
|
||||
if err := repository.pool.QueryRow(ctx, query).Scan(&count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListOrgGasStation 查询可燃气体站列表。
|
||||
func (repository *Repository) ListOrgGasStation(ctx context.Context) ([]OrgGasStation, error) {
|
||||
rows, err := repository.pool.Query(ctx, `SELECT identity, station_code, name, principal, service_area, status, created_at FROM org_gas_station ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]OrgGasStation, 0)
|
||||
for rows.Next() {
|
||||
var item OrgGasStation
|
||||
if err := rows.Scan(&item.Identity, &item.StationCode, &item.Name, &item.Principal, &item.ServiceArea, &item.Status, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// CreateOrgGasStation 创建可燃气体站并写入不可变审计记录。
|
||||
func (repository *Repository) CreateOrgGasStation(ctx context.Context, stationCode, name, principal, serviceArea string) (OrgGasStation, error) {
|
||||
identity, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return OrgGasStation{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := OrgGasStation{Identity: identity.String(), StationCode: stationCode, Name: name, Principal: principal, ServiceArea: serviceArea, Status: "draft", CreatedAt: now}
|
||||
|
||||
tx, err := repository.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return OrgGasStation{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
_, err = tx.Exec(ctx, `INSERT INTO org_gas_station (identity, station_code, name, principal, service_area, status, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`, identity, stationCode, name, principal, serviceArea, item.Status, now)
|
||||
if err != nil {
|
||||
return OrgGasStation{}, fmt.Errorf("创建气站失败: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO aud_operation_log (identity, action, object_type, object_identity, detail, created_at) VALUES ($1,$2,$3,$4,$5,$6)`, newIdentity(), "create", "org_gas_station", identity, `{"channel":"platform_admin"}`, now)
|
||||
if err != nil {
|
||||
return OrgGasStation{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return OrgGasStation{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ListOrgDeliveryPoint 查询配送点及其气站归属。
|
||||
func (repository *Repository) ListOrgDeliveryPoint(ctx context.Context) ([]OrgDeliveryPoint, error) {
|
||||
rows, err := repository.pool.Query(ctx, `SELECT d.identity, d.delivery_code, d.name, COALESCE(g.name, ''), d.service_area, d.status FROM org_delivery_point d LEFT JOIN org_gas_station g ON d.gas_station_identity = g.identity ORDER BY d.created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]OrgDeliveryPoint, 0)
|
||||
for rows.Next() {
|
||||
var item OrgDeliveryPoint
|
||||
if err := rows.Scan(&item.Identity, &item.DeliveryCode, &item.Name, &item.GasStationName, &item.ServiceArea, &item.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// ListOrgServicePerson 查询服务人员并在 API 层完成手机号脱敏。
|
||||
func (repository *Repository) ListOrgServicePerson(ctx context.Context) ([]OrgServicePerson, error) {
|
||||
rows, err := repository.pool.Query(ctx, `SELECT p.identity, p.name, a.phone, p.roles, p.work_status, p.credential_status FROM org_service_person p JOIN idn_account a ON p.account_identity = a.identity ORDER BY p.created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]OrgServicePerson, 0)
|
||||
for rows.Next() {
|
||||
var item OrgServicePerson
|
||||
var phone string
|
||||
if err := rows.Scan(&item.Identity, &item.Name, &phone, &item.Roles, &item.WorkStatus, &item.Credential); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.PhoneMasked = maskPhone(phone)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// ListIdnAccount 查询用户最小必要视图,不返回完整手机号。
|
||||
func (repository *Repository) ListIdnAccount(ctx context.Context) ([]IdnAccount, error) {
|
||||
rows, err := repository.pool.Query(ctx, `SELECT identity, phone, account_type, status, service_area FROM idn_account WHERE account_type = 'user' ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]IdnAccount, 0)
|
||||
for rows.Next() {
|
||||
var item IdnAccount
|
||||
var phone string
|
||||
if err := rows.Scan(&item.Identity, &phone, &item.AccountType, &item.Status, &item.ServiceArea); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.PhoneMasked = maskPhone(phone)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// ListSafEvent 查询安全事件列表,供平台安全运营中心使用。
|
||||
func (repository *Repository) ListSafEvent(ctx context.Context) ([]SafEvent, error) {
|
||||
rows, err := repository.pool.Query(ctx, `SELECT identity, event_code, level, title, status, created_at FROM saf_event ORDER BY level ASC, created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]SafEvent, 0)
|
||||
for rows.Next() {
|
||||
var item SafEvent
|
||||
if err := rows.Scan(&item.Identity, &item.EventCode, &item.Level, &item.Title, &item.Status, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// maskPhone 按最小必要原则返回脱敏手机号。
|
||||
func maskPhone(phone string) string {
|
||||
if len(phone) < 7 {
|
||||
return "***"
|
||||
}
|
||||
return phone[:3] + "****" + phone[len(phone)-4:]
|
||||
}
|
||||
|
||||
// newIdentity 生成审计记录使用的 UUID V7。
|
||||
func newIdentity() uuid.UUID {
|
||||
identity, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
13
backend/internal/platform/saf_event.go
Normal file
13
backend/internal/platform/saf_event.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package platform
|
||||
|
||||
import "time"
|
||||
|
||||
// SafEvent 对应 saf_event,表示需要跟踪处置的安全事件。
|
||||
type SafEvent struct {
|
||||
Identity string `json:"identity"`
|
||||
EventCode string `json:"eventCode"`
|
||||
Level int `json:"level"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
Reference in New Issue
Block a user