commit 82163962272aab480611c41cb76793184173bbc8 Author: zmf Date: Wed Oct 2 19:30:19 2024 +0800 first commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..9ce80fd --- /dev/null +++ b/.air.toml @@ -0,0 +1,51 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = ".air" + +[build] + args_bin = [] + bin = "./.air/main" + cmd = "go build -o ./.air/main ." + delay = 1000 + exclude_dir = ["assets", ".air", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.env b/.env new file mode 100644 index 0000000..6b9129a --- /dev/null +++ b/.env @@ -0,0 +1,134 @@ +################ +# 服务器配置 +################ + +# 设置运行环境 +# 可以设置的值有 dev、prod +APP_ENV=dev + +# 设置运行时区 +# TIME_ZONE=Asia/Shanghai + +# 服务器监听的地址 +# 默认值为 0.0.0.0 +# +# SERVER_ADDR=0.0.0.0 + +# 服务器监听的端口 +# 默认值为 1234 +# +# SERVER_PORT=1234 + +# 允许的报头大小上限 +# 默认值为 Golang 标准库定义的值 `http.DefaultMaxHeaderBytes` +# +# HTTP_MAX_HEADER_BYTES= + +# 写超时,如果提供流服务,切勿设置该值。 +# +# HTTP_WRITE_TIMEOUT= + +# 读超时,暂时不建议设置 +# HTTP_READ_TIMEOUT= + +# 闲置超时,暂时不建议设置 +# HTTP_IDLE_TIMEOUT= + +################## +# 数据库配置 +################## + +DB_DRIVER=postgres + +# 数据库表前缀 +DB_PREFIX=ims_ + +# 数据库存储引擎 +# DB_STORE_ENGINE=InnoDB + +DB_USER=postgres +DB_AUTH=password +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=postgres + +DB_PUBLIC_SCHEMA=public + +# DB_CHARSET=utf8mb4 # 数据库字符类型,仅支持 mysql +# DB_STRING_SIZE=256 # 数据库字符串类型长度,仅支持 mysql +# DB_SSLMODE=disable # 数据库SSL模式,仅支持 postgres +# DB_SINGULAR_TABLE=false +# DB_IDENTIFIER_MAX_LENGTH=0 +# DB_QUERY_FIELDS=true +# DB_DISABLE_FOREIGN_KEY_CONSTRAINT=false +# DB_IGNORE_RELATIONSHIPS=false +# DB_MAX_IDLE_CONNS= +# DB_MAX_OPEN_CONNS= +# DB_CONN_MAX_LIFETIME= + + +################ +# 跨域设置 +################ + +CORS_EXPOSE_HEADERS=X-Token-Expired + + +################ +# 授权认证 +################ + +# 在线获取公私钥网站 https://travistidwell.com/jsencrypt/demo/ +# 或者使用 scripts/rs256/rs256.go 生成 + +# 授权令牌私钥 +JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIIEogIBAAKCAQEAq04SRRhAjKD9DjQANg4cPIyTUGU732ExlVeDEaee7RhNsHNH +4FfwYJqU60LFEN5WVoYIJ4pFgZD2GgIlLRAWZm/MtUcKRTG2U0wnsy5bgoLwaWWK +iKOUljyXi8P+wzucUYtTCm+/cI9Iz1BKNm8gJ62Af4UCURN1jecw/hfb6TsdSb+E +56Al31Jce9XXXxPu4WOUnnKtK5SNsOF6jzVMo4C/cdqEyX4Yss2QIn9L9bcNsXZR +U4QHe2fstPj0FnvkYCz2KcWMnImwMfL8mFY7aXilxeqeTfqVz2vXVTTI4sKjvEbC +Ag+kBdgT7NOmQpqrTqeBRuNvoEPgtvAwGVSOtwIDAQABAoIBAFKWdSByrMwf4WCz +mVZ2Pw7CB1O/OrpbNXh2lG4yjeBo0yu6qHB0dSNd42X47uFPD/ju7YeCljf9F2k6 +l4m+M853IA5VjZIGgYxvLsSoGN43Goj1t3BgzQYReE6d03l0h8yYixSBA57UtZmd +b+oGcU4vy7+u9Ir6ArbDO0+FWTR7zQ3IvWJx0vwPjVLgeLY5hj8B7yEtE01aVvAI +6JyNgleuLcPDVAmbGvc9qYND92qSwQjUz1ljpZVPE8OP71K4KVnstBEONHd36Svp +jcHBw5ZePAbWvp+qLPco6O/wi/aDqHMXrhvyw+WJiawAZ2gUmOVray6qwAn6D307 +KRnmS+ECgYEAwoTy1ywDv7xwM84paCldwe9q39kFjlrBrDdfFM8ZKg96FtMiD8Oc +VOLsiR6yqt1tpPc66r0GicSIokZErf9UKbC7gnSFBaNJahSVHgLKB3hxvPYzYf+e +s79NuRIL3b+cLRFbLmQOHcWkqWS9jmP3sQXj7I9v+l/Pyfk0PgJltuUCgYEA4XLK +MJuQjtv76bhaoxM1zI5YIkGua8SPTiffNkqkU8xRBqfZ32HdtYhxsiCyQkcJHMvQ +tVMFv14ZIeZL5JUJXHzFYCaqbZVtgwud5X4vPhWidDoXtK/GPq1y7qV6K0hb6ibR +xOogxKLPcLWSa2qk6hA1GviLnQMNskdKI2AV2WsCgYAmWcHrDGF81vEkNZxSTf2P +M93VXltLEremdZBIcQBcG4sNnvjTNNTIo6jQ6/171IY+hQPXrgbw+I+bttDpoTJ9 +ubtuX/yt/OCHiqFPtmsQh/2crMm6o4QtmiT/vQAb6yAmfoqEnfNuiT4Kq7L3tNvr +yeaDCFCig2tbPcfABgb5xQKBgBPvVYeUyeSH+S+CeKlA0poC4DIvQqAO4mkTx34C +faNXRrbi8rX47mTV6s/IalrH8ztTnmHaNIDmbix8M6vDre86rS1CXgGQm/1hcpXP +YZgIy7OhS2VTwaDbL3WAtlvLay06P4Q8+6bHirY5p/fNl0WHJ1r6LUY19ekUuq6E +GYp/AoGAGzR94Ao1GwLHmBuFzjm4j8kGaK8eRyhV/YBtYKYGYpW7H52hDhXxghN3 +sq5Br0L4PtWsS6Qbk4H15P5FBo9iLLOOsvDpyNwe7cgU6/GHCapvjGJWu3lPVGa+ +7WVOetKnnSHYoboZBFT9dHNgsEt4MSXAQ3AClD2tXDzKCAEzLAY= +-----END PRIVATE KEY-----" + +# 授权令牌公钥 +JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- +MIIBCgKCAQEAq04SRRhAjKD9DjQANg4cPIyTUGU732ExlVeDEaee7RhNsHNH4Ffw +YJqU60LFEN5WVoYIJ4pFgZD2GgIlLRAWZm/MtUcKRTG2U0wnsy5bgoLwaWWKiKOU +ljyXi8P+wzucUYtTCm+/cI9Iz1BKNm8gJ62Af4UCURN1jecw/hfb6TsdSb+E56Al +31Jce9XXXxPu4WOUnnKtK5SNsOF6jzVMo4C/cdqEyX4Yss2QIn9L9bcNsXZRU4QH +e2fstPj0FnvkYCz2KcWMnImwMfL8mFY7aXilxeqeTfqVz2vXVTTI4sKjvEbCAg+k +BdgT7NOmQpqrTqeBRuNvoEPgtvAwGVSOtwIDAQAB +-----END PUBLIC KEY-----" + +# 令牌时长 +JWT_TTL=168h + +# 令牌签发者 +JWT_ISSUER=slim + +# 令牌主体 +JWT_SUBJECT=slim + +# 令牌受众,多个值可以使用逗号分开,比如:app,pc,wap,routine +# 若值为 '*' 表示所有。 +JWT_AUDIENCE=* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4ae622 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/.idea +/.vscode +/.air +/storage +.DS_Store +*.iml +*.log +*.db +/*.zip +.env.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..91e21ff --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +## 目录结构说明 + +```text +├─app 应用目录 +│ ├─events 事件目录 +│ │ +│ ├─http 网络服务 +│ │ ├─controllers 控制器目录 +│ │ ├─middleware 中间件目录 +│ │ ├─requests 请求参数 +│ │ ├─routes 路由目录 +│ │ └─http.go 网络服务入口 +│ │ +│ ├─jobs 任务目录 +│ ├─models 模型目录 +│ ├─routes 路由目录 +│ └─app.go 应用入口文件 +│ +├─cmd 程序命令 +│ ├─web.go 网络服务名称 +│ ├─cmd.go 命令入口 +│ └─ ... +│ +├─database 配置目录 +│ ├─system 系统迁移 +│ └─tenant 租户迁移 +│ +├─storage 运行时目录 +│ ├─cache 缓存文件目录 +│ ├─logs 日志目录 +│ ├─uploads 文件上传目录 +│ +├─util 工具集 +│ ├─backoff 函数失败重试 +│ ├─cache 缓存组件 +│ ├─district 内置三级地区初始化 +│ ├─db 数据库操作 +│ ├─jwt JWT 封装 +│ ├─rdb Redis 操作 +│ ├─rsp 接口返回封装 +│ └─init.go 工具集初始化 +│ +├─.air.toml air 工具配置 +├─.env 默认配置(不建议修改) +├─.env.dev 开发配置 +├─.example.env 环境变量示例文件 +├─LICENSE.txt 授权说明文件 +├─README.md README 文件 +├─main.go 命令行入口文件 +├─ ... 其它文件 +``` diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..da425b7 --- /dev/null +++ b/app/app.go @@ -0,0 +1,49 @@ +package app + +import ( + "context" + "ims/app/http" + "ims/app/listeners" + "ims/database" + "ims/util" + "ims/util/evio" + "ims/util/log" + "sync/atomic" +) + +var closed uint32 + +func Start(ctx context.Context) error { + var err error + check := func(fn func(ctx context.Context) error) { + if err == nil { + err = fn(ctx) + } + } + check(log.Init) // 初始化日志 + check(listeners.Init) // 初始化注册事件 + check(util.Init) // 初始化工具包 + check(database.Init) // 初始化数据库 + check(callEvent("app.init")) // 触发初始化事件 + check(evio.Start) // 启动事件总线 + check(http.Start) // 启动HTTP服务器 + check(callEvent("app.boot")) // 触发启动事件 + return err +} + +func callEvent(topic string) func(ctx context.Context) error { + return func(ctx context.Context) error { + evio.Call(topic, nil) // 触发事件 + return nil + } +} + +func Stop() error { + if !atomic.CompareAndSwapUint32(&closed, 0, 1) { + return nil + } + err := http.Stop() // 停止HTTP服务器 + evio.Call("app.exit", err) // 触发退出事件 + evio.Stop() // 停止事件总线 + return err +} diff --git a/app/http/http.go b/app/http/http.go new file mode 100644 index 0000000..74242d2 --- /dev/null +++ b/app/http/http.go @@ -0,0 +1,99 @@ +package http + +import ( + "context" + "errors" + "fmt" + "ims/app/http/middleware" + "ims/app/http/routes" + "ims/util/log" + "net/http" + + "zestack.dev/env" + "zestack.dev/slim" +) + +var ( + started chan struct{} + exit chan chan error +) + +func init() { + started = make(chan struct{}) + exit = make(chan chan error) +} + +func Start(ctx context.Context) error { + select { + case <-started: + return errors.New("app: server already started") + default: + close(started) + } + + app := createKernel() + srv := createServer(app) + routes.Init(app) + + go func() { + if srvErr := app.StartServer(srv); srvErr != nil { + log.Error("encountered an error while serving listener: ", srvErr) + } + }() + + go func() { + // 监听停止命令,停止网络服务 + errChan := <-exit + // 停止 slim 应用 + errChan <- app.Close() + }() + + return nil +} + +func createKernel() *slim.Slim { + app := slim.New() + app.Debug = !env.IsEnv("prod") + app.Logger = log.Default() + app.ErrorHandler = middleware.ErrorHandler + app.ResetRouterCreator(func(s *slim.Slim) slim.Router { + return slim.NewRouter(slim.RouterConfig{ + RoutingTrailingSlash: true, + }) + }) + app.Use(slim.Logging()) + app.Use(slim.Recovery()) + app.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: env.List("CORS_ALLOW_ORIGINS"), + AllowMethods: env.List("CORS_ALLOW_METHODS"), + AllowHeaders: env.List("CORS_ALLOW_HEADERS"), + AllowCredentials: env.Bool("CORS_ALLOW_CREDENTIALS"), + ExposeHeaders: env.List("CORS_EXPOSE_HEADERS"), + MaxAge: env.Int("CORS_MAX_AGE"), + })) + return app +} + +func createServer(app *slim.Slim) *http.Server { + // TODO 支持 TLSConfig 配置 + return &http.Server{ + Addr: fmt.Sprintf("%s:%d", env.String("SERVER_ADDR"), env.Int("SERVER_PORT", 1234)), + Handler: app, + ReadTimeout: env.Duration("HTTP_READ_TIMEOUT", 0), + ReadHeaderTimeout: env.Duration("HTTP_READ_HEADER_TIMEOUT", 0), + WriteTimeout: env.Duration("HTTP_WRITE_TIMEOUT", 0), + IdleTimeout: env.Duration("HTTP_IDLE_TIMEOUT", 0), + MaxHeaderBytes: env.Int("HTTP_MAX_HEADER_BYTES", http.DefaultMaxHeaderBytes), + } +} + +func Stop() error { + select { + case <-started: + errCh := make(chan error) + exit <- errCh + return <-errCh + default: + return nil + } +} diff --git a/app/http/middleware/auth.go b/app/http/middleware/auth.go new file mode 100644 index 0000000..3d972ee --- /dev/null +++ b/app/http/middleware/auth.go @@ -0,0 +1,148 @@ +package middleware + +import ( + "errors" + "fmt" + "ims/app/models" + "ims/util/db" + "ims/util/jwt" + "ims/util/rdb" + + "github.com/jinzhu/inflection" + "github.com/redis/go-redis/v9" + "golang.org/x/sync/singleflight" + "zestack.dev/log" + "zestack.dev/slim" +) + +var sfg singleflight.Group + +type JWTExtra struct { + AccountID uint `redis:"account"` + EmployeeID uint `redis:"employee"` + TenantID uint `redis:"tenant"` + + Account *models.Account + Employee *models.Employee + Tenant *models.Tenant +} + +func Auth(withTenant, anonymously bool) slim.MiddlewareFunc { + return jwt.Auth(jwt.AuthConfig{ + Skipper: func(c slim.Context) bool { + if c.RouteMatchType() == slim.RouteMatchUnknown { + panic("unknown route") + } + switch c.RouteInfo().Name() { + case "login": + return true + default: + return false + } + }, + Anonymously: anonymously, + Claims: func(c slim.Context, token string, _ *jwt.Claims) error { + var extra JWTExtra + if err := extra.load(c, token); err != nil { + return err + } + // 针对租户,如果加载失败,说明没有权限 + if withTenant && (extra.Tenant == nil || extra.Employee == nil) { + return jwt.ErrForbidden + } + + // // 如果租户数据没有初始化,那就只能访问有限的几个页面, + // // 比如基本设置、初始化等。 + // if withTenant && !tenant.IsReady() { + // return c.Redirect(http.StatusMovedPermanently, "/tenant/init") + // } + + return nil + }, + }) +} + +func (e *JWTExtra) load(c slim.Context, token string) error { + // 加载额外信息 + if err := rdb.Redis().HGetAll(c, "jwt:"+token).Scan(e); err != nil { + return err + } + // 加载用户信息 + if err := e.loadAccount(c); err != nil { + return err + } + // 加载员工信息 + if err := e.loadEmployee(c); err != nil { + return err + } + // 加载租户信息 + if err := e.loadTenant(c); err != nil { + return err + } + return nil +} + +func (e *JWTExtra) loadAccount(c slim.Context) error { + var account models.Account + err := e.loadModel(c, "account", e.AccountID, &account) + if err != nil { + return err + } + e.Account = &account + return nil +} + +func (e *JWTExtra) loadEmployee(c slim.Context) error { + if e.EmployeeID == 0 { + return nil + } + var employee models.Employee + err := e.loadModel(c, "employee", e.EmployeeID, &employee) + if err != nil { + return err + } + e.Employee = &employee + return nil +} + +func (e *JWTExtra) loadTenant(c slim.Context) error { + if e.TenantID == 0 { + return nil + } + var tenant models.Tenant + err := e.loadModel(c, "tenant", e.TenantID, &tenant) + if err != nil { + return err + } + e.Tenant = &tenant + return nil +} + +func (e *JWTExtra) loadModel(c slim.Context, label string, id uint, val any) error { + client := rdb.Redis() + key := fmt.Sprintf("%s:%d", inflection.Plural(label), id) + _, err, _ := sfg.Do(key, func() (any, error) { + // 加载 Redis 中的数据 + err := client.HGetAll(c, key).Scan(val) + if err == nil { + return nil, nil + } + if !errors.Is(err, redis.Nil) { + return nil, err + } + // 加载数据库中的数据 + err = db.Engine().First(val, id).Error + if err == nil && val != nil { + err = client.HSet(c, key, val).Err() + if err != nil { + log.Error("failed to cache "+label, "error", err, "id", id) + } + } + return nil, err + }) + if err != nil { + return err + } + c.Set("jwt:"+label, val) + return nil +} diff --git a/app/http/middleware/cors.go b/app/http/middleware/cors.go new file mode 100644 index 0000000..d461a32 --- /dev/null +++ b/app/http/middleware/cors.go @@ -0,0 +1,239 @@ +package middleware + +import ( + "net/http" + "regexp" + "strconv" + "strings" + + "zestack.dev/slim" +) + +// CORSConfig defines the config for CORS middleware. +type CORSConfig struct { + // AllowOrigin defines a list of origins that may access the resource. + // Optional. Default value []string{"*"}. + AllowOrigins []string + + // AllowOriginFunc is a custom function to validate the origin. It takes the + // origin as an argument and returns true if allowed or false otherwise. If + // an error is returned, it is returned by the handler. If this option is + // set, AllowOrigins is ignored. + // Optional. + AllowOriginFunc func(origin string) (bool, error) + + // AllowMethods defines a list methods allowed when accessing the resource. + // This is used in response to a preflight request. + // Optional. Default value DefaultCORSConfig.AllowMethods. + AllowMethods []string + + // AllowHeaders defines a list of request headers that can be used when + // making the actual request. This is in response to a preflight request. + // Optional. Default value []string{}. + AllowHeaders []string + + // AllowCredentials indicates whether or not the response to the request + // can be exposed when the credential flag is true. When used as part of + // a response to a preflight request, this indicates whether or not the + // actual request can be made using credentials. + // Optional. Default value is false. + AllowCredentials bool + + // ExposeHeaders defines the whitelist headers that clients are allowed to + // access. + // Optional. Default value []string{}. + ExposeHeaders []string + + // MaxAge indicates how long (in seconds) the results of a preflight request + // can be cached. + // Optional. Default value 0. + MaxAge int +} + +func CORS() slim.MiddlewareFunc { + return CORSWithConfig(CORSConfig{}) +} + +func CORSWithConfig(config CORSConfig) slim.MiddlewareFunc { + if len(config.AllowOrigins) == 0 { + config.AllowOrigins = []string{"*"} + } + if len(config.AllowMethods) == 0 { + config.AllowMethods = []string{ + http.MethodGet, + http.MethodHead, + http.MethodPut, + http.MethodPatch, + http.MethodPost, + http.MethodDelete, + } + } + + var allowOriginPatterns []string + for _, origin := range config.AllowOrigins { + pattern := regexp.QuoteMeta(origin) + pattern = strings.Replace(pattern, "\\*", ".*", -1) + pattern = strings.Replace(pattern, "\\?", ".", -1) + pattern = "^" + pattern + "$" + allowOriginPatterns = append(allowOriginPatterns, pattern) + } + + allowMethods := strings.Join(config.AllowMethods, ",") + allowHeaders := strings.Join(config.AllowHeaders, ",") + exposeHeaders := strings.Join(config.ExposeHeaders, ",") + maxAge := strconv.Itoa(config.MaxAge) + + return func(c slim.Context, next slim.HandlerFunc) error { + req := c.Request() + res := c.Response() + origin := req.Header.Get(slim.HeaderOrigin) + allowOrigin := "" + + preflight := req.Method == http.MethodOptions + res.Header().Add(slim.HeaderVary, slim.HeaderOrigin) + + // No Origin provided + if origin == "" { + if !preflight { + return next(c) + } + return c.NoContent(http.StatusNoContent) + } + + if config.AllowOriginFunc != nil { + allowed, err := config.AllowOriginFunc(origin) + if err != nil { + return err + } + if allowed { + allowOrigin = origin + } + } else { + // Check allowed origins + for _, o := range config.AllowOrigins { + if o == "*" && config.AllowCredentials { + allowOrigin = origin + break + } + if o == "*" || o == origin { + allowOrigin = o + break + } + if matchSubdomain(origin, o) { + allowOrigin = origin + break + } + } + + // Check allowed origin patterns + for _, re := range allowOriginPatterns { + if allowOrigin == "" { + didx := strings.Index(origin, "://") + if didx == -1 { + continue + } + domAuth := origin[didx+3:] + // to avoid regex cost by invalid long domain + if len(domAuth) > 253 { + break + } + + if match, _ := regexp.MatchString(re, origin); match { + allowOrigin = origin + break + } + } + } + } + + // Origin isn't allowed + if allowOrigin == "" { + if !preflight { + return next(c) + } + return c.NoContent(http.StatusNoContent) + } + + // Simple request + if !preflight { + res.Header().Set(slim.HeaderAccessControlAllowOrigin, allowOrigin) + if config.AllowCredentials { + res.Header().Set(slim.HeaderAccessControlAllowCredentials, "true") + } + if exposeHeaders != "" { + res.Header().Set(slim.HeaderAccessControlExposeHeaders, exposeHeaders) + } + return next(c) + } + + // Preflight request + res.Header().Add(slim.HeaderVary, slim.HeaderAccessControlRequestMethod) + res.Header().Add(slim.HeaderVary, slim.HeaderAccessControlRequestHeaders) + res.Header().Set(slim.HeaderAccessControlAllowOrigin, allowOrigin) + res.Header().Set(slim.HeaderAccessControlAllowMethods, allowMethods) + if config.AllowCredentials { + res.Header().Set(slim.HeaderAccessControlAllowCredentials, "true") + } + if allowHeaders != "" { + res.Header().Set(slim.HeaderAccessControlAllowHeaders, allowHeaders) + } else { + h := req.Header.Get(slim.HeaderAccessControlRequestHeaders) + if h != "" { + res.Header().Set(slim.HeaderAccessControlAllowHeaders, h) + } + } + if config.MaxAge > 0 { + res.Header().Set(slim.HeaderAccessControlMaxAge, maxAge) + } + return c.NoContent(http.StatusNoContent) + } +} + +func matchScheme(domain, pattern string) bool { + didx := strings.Index(domain, ":") + pidx := strings.Index(pattern, ":") + return didx != -1 && pidx != -1 && domain[:didx] == pattern[:pidx] +} + +// matchSubdomain compares authority with wildcard +func matchSubdomain(domain, pattern string) bool { + if !matchScheme(domain, pattern) { + return false + } + didx := strings.Index(domain, "://") + pidx := strings.Index(pattern, "://") + if didx == -1 || pidx == -1 { + return false + } + domAuth := domain[didx+3:] + // to avoid long loop by invalid long domain + if len(domAuth) > 253 { + return false + } + patAuth := pattern[pidx+3:] + + domComp := strings.Split(domAuth, ".") + patComp := strings.Split(patAuth, ".") + for i := len(domComp)/2 - 1; i >= 0; i-- { + opp := len(domComp) - 1 - i + domComp[i], domComp[opp] = domComp[opp], domComp[i] + } + for i := len(patComp)/2 - 1; i >= 0; i-- { + opp := len(patComp) - 1 - i + patComp[i], patComp[opp] = patComp[opp], patComp[i] + } + + for i, v := range domComp { + if len(patComp) <= i { + return false + } + p := patComp[i] + if p == "*" { + return true + } + if p != v { + return false + } + } + return false +} diff --git a/app/http/middleware/error_handler.go b/app/http/middleware/error_handler.go new file mode 100644 index 0000000..6427f7f --- /dev/null +++ b/app/http/middleware/error_handler.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "errors" + "ims/util/jwt" + "ims/util/rsp" + + "gorm.io/gorm" + "zestack.dev/slim" +) + +func ErrorHandler(c slim.Context, err error) { + if c.Written() { + return + } + + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + err = rsp.ErrRecordNotFound + case errors.Is(err, gorm.ErrDuplicatedKey): + err = rsp.ErrInternal.WithText(rsp.ErrInternal.Text() + ",数据已经存在") + case errors.Is(err, jwt.ErrInvalidToken), + errors.Is(err, jwt.ErrUnauthorized), + errors.Is(err, jwt.ErrTokenNotFound): + err = rsp.ErrUnauthorized + case errors.Is(err, jwt.ErrTokenExpired): + err = rsp.ErrUnauthorized.WithText("授权令牌已过期") + case errors.Is(err, slim.ErrNotFound): + err = rsp.ErrNotFound + case errors.Is(err, slim.ErrMethodNotAllowed): + err = rsp.ErrMethodNotAllowed + case errors.Is(err, slim.ErrUnsupportedMediaType): + err = rsp.ErrUnsupportedMediaType.WithInternal(err) + } + + _ = rsp.Fail(c, err) +} diff --git a/app/http/routes/init.go b/app/http/routes/init.go new file mode 100644 index 0000000..c0ac811 --- /dev/null +++ b/app/http/routes/init.go @@ -0,0 +1,49 @@ +package routes + +import ( + "net/http/pprof" + + "zestack.dev/slim" +) + +// Init 初始化路由 +func Init(app *slim.Slim) { + router := app.Router() + + // 注册系统路由 + router.Group(func(r slim.RouteCollector) { + registerSystemRoutes(r) + }) + + // 注册租户路由 + router.Group(func(r slim.RouteCollector) { + registerTenantRoutes(r) + }) + + // 调试模式下 + // 1.注册 pprof 路由 + // 2.打印注册的路由 + if app.Debug { + usePprof(router) + printRoutes(router) + } +} + +// 注册性能分析路由 +func usePprof(router slim.Router) { + router.GET("/debug/pprof/", slim.WrapHandlerFunc(pprof.Index)) + router.GET("/debug/pprof/cmdline", slim.WrapHandlerFunc(pprof.Cmdline)) + router.GET("/debug/pprof/profile", slim.WrapHandlerFunc(pprof.Profile)) + router.GET("/debug/pprof/symbol", slim.WrapHandlerFunc(pprof.Symbol)) + router.GET("/debug/pprof/trace", slim.WrapHandlerFunc(pprof.Trace)) + router.GET("/debug/pprof/allocs", prof("allocs")) + router.GET("/debug/pprof/block", prof("block")) + router.GET("/debug/pprof/goroutine", prof("goroutine")) + router.GET("/debug/pprof/heap", prof("heap")) + router.GET("/debug/pprof/mutex", prof("mutex")) + router.GET("/debug/pprof/threadcreate", prof("threadcreate")) +} + +func prof(name string) slim.HandlerFunc { + return slim.WrapHandlerFunc(pprof.Handler(name).ServeHTTP) +} diff --git a/app/http/routes/print.go b/app/http/routes/print.go new file mode 100644 index 0000000..8dae069 --- /dev/null +++ b/app/http/routes/print.go @@ -0,0 +1,32 @@ +package routes + +import ( + "os" + "reflect" + "runtime" + "strings" + + "github.com/olekukonko/tablewriter" + "zestack.dev/slim" +) + +func printRoutes(router slim.Router) { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Method", "Pattern", "Name", "Handler"}) + table.SetHeaderAlignment(3) + + for _, route := range router.Routes() { + table.Append([]string{ + strings.Join(route.Methods(), ","), + route.Pattern(), + route.Name(), + nameOfFunction(route.Handler()), + }) + } + + table.Render() +} + +func nameOfFunction(f any) string { + return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() +} diff --git a/app/http/routes/system.go b/app/http/routes/system.go new file mode 100644 index 0000000..7b36c04 --- /dev/null +++ b/app/http/routes/system.go @@ -0,0 +1,7 @@ +package routes + +import "zestack.dev/slim" + +func registerSystemRoutes(r slim.RouteCollector) { + // +} diff --git a/app/http/routes/tenant.go b/app/http/routes/tenant.go new file mode 100644 index 0000000..97e50ff --- /dev/null +++ b/app/http/routes/tenant.go @@ -0,0 +1,7 @@ +package routes + +import "zestack.dev/slim" + +func registerTenantRoutes(r slim.RouteCollector) { + // +} diff --git a/app/listeners/listeners.go b/app/listeners/listeners.go new file mode 100644 index 0000000..af0e745 --- /dev/null +++ b/app/listeners/listeners.go @@ -0,0 +1,7 @@ +package listeners + +import "context" + +func Init(_ context.Context) error { + return nil +} diff --git a/app/models/account.go b/app/models/account.go new file mode 100644 index 0000000..b16ddf8 --- /dev/null +++ b/app/models/account.go @@ -0,0 +1,38 @@ +package models + +import ( + "errors" + "ims/util/db/dts" + "time" + + "gorm.io/gorm" + "zestack.dev/misc" +) + +// Account 平台账户 +type Account struct { + ID uint `json:"id" gorm:"primaryKey;comment:账户ID"` + Nickname string `json:"nickname" gorm:"comment:昵称"` + AvatarUrl string `json:"avatar_url" gorm:"comment:头像地址"` + Username string `json:"username" gorm:"unique;comment:用户名"` + PhoneNumber string `json:"phone_number" gorm:"uniqueIndex;comment:手机号码"` + WechatOpenid dts.NullString `json:"wechat_openid" gorm:"uniqueIndex;comment:微信openid"` + Email dts.NullString `json:"email" gorm:"uniqueIndex;comment:电子邮箱"` + RawPassword string `json:"-" gorm:"-"` // 原始密码 + Password string `json:"-" gorm:"comment:登录密码"` + CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"` + UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"` + DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"comment:删除时间"` +} + +func (u *Account) BeforeCreate(_ *gorm.DB) error { + if u.RawPassword != "" { + hash, err := misc.PasswordHash(u.RawPassword) + if err != nil { + return err + } + u.Password = hash + return nil + } + return errors.New("缺少密码") +} diff --git a/app/models/address.go b/app/models/address.go new file mode 100644 index 0000000..6328e49 --- /dev/null +++ b/app/models/address.go @@ -0,0 +1,40 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +const ( + AddressForCustomer = "customer" // 消费者 + AddressForSupplier = "supplier" // 供应商 + AddressForOutbound = "outbound" // 出库单 +) + +// Address 地址信息 +type Address struct { + ID uint `json:"id" gorm:"primaryKey"` // 地址编号 + OwnerID uint `json:"owner_id"` // 所属者编号 + OwnerType string `json:"owner_type"` // 所属者类型 + ProvinceID int `json:"province_id"` // 省份编号 + CityID int `json:"city_id"` // 城市编号 + CountyID int `json:"county_id"` // 区县编号 + Detail string `json:"detail"` // 详细地址 + + Province *District `json:"district,omitempty" gorm:"foreignKey:ProvinceID"` // 省份 + City *District `json:"city,omitempty" gorm:"foreignKey:CityID"` // 城市 + County *District `json:"county,omitempty" gorm:"foreignKey:CountyID"` // 区县 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/certification.go b/app/models/certification.go new file mode 100644 index 0000000..b5df55d --- /dev/null +++ b/app/models/certification.go @@ -0,0 +1,22 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + CertForSupplier = "supplier" // 供应商资质 +) + +// Certification 资质 +type Certification struct { + ID uint `json:"id" gorm:"primarykey"` // 标签编号 + OwnerID uint `json:"owner_id"` // 所属者编号 + OwnerType string `json:"owner_type"` // 所属者类型 + Attachments []File `json:"attachments" gorm:"polymorphic:Owner;polymorphicValue:certification"` // 证书附件 + CreatedAt time.Time `json:"create_time"` // 创建时间 + UpdatedAt time.Time `json:"update_time"` // 上次更新时间 + DeletedAt gorm.DeletedAt `json:"delete_time,omitempty"` // 数据删除时间 +} diff --git a/app/models/customer.go b/app/models/customer.go new file mode 100644 index 0000000..cf5a868 --- /dev/null +++ b/app/models/customer.go @@ -0,0 +1,52 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// CustomerSource 客户来源 +type CustomerSource int8 + +const ( + FromRecommend CustomerSource = iota + 1 // 转介绍 + FromPC // 网站注册 + FromMiniProgram // 小程序注册 + FromSalesman // 销售自拓 +) + +// Customer 客户 +type Customer struct { + ID uint `json:"id" gorm:"primarykey"` // 客户ID + Code string `json:"code" gorm:"uniqueIndex"` // 客户编码 + Name string `json:"name" gorm:"uniqueIndex"` // 客户名称 + Source CustomerSource `json:"source"` // 客户来源 + TypeID uint `json:"type_id"` // 客户类型 + TagID dts.NullUint `json:"tag_id"` // 客户标签 + PriceStrategyID dts.NullUint `json:"price_strategy_id"` // 价格策略 + SettlementID dts.NullUint `json:"settlement_id"` // 结算方式 + CreditLimit int `json:"credit_limit"` // 信用额度,单位分 + PrincipalID dts.NullUint `json:"principal_id"` // 销售负责人编号 + + Type *Tag `json:"type,omitempty" gorm:"foreignKey:TypeID"` // 客户类型 + Tag *Tag `json:"Tag,omitempty" gorm:"foreignKey:TagID"` // 客户标签 + PriceStrategy *Tag `json:"price_strategy" gorm:"foreignKey:PriceStrategyID"` // 价格策略 + Settlement *Tag `json:"settlement" gorm:"foreignKey:SettlementID"` // 结算方式 + Principal *Employee `json:"principal" gorm:"foreignKey:PrincipalID"` // 销售负责人 + Address *Address `json:"address,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 客户地址 + Remittance *Remittance `json:"remittance,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 汇款信息 + Linkmen []Linkman `json:"linkmen,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 联系人列表 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/customer_tack.go b/app/models/customer_tack.go new file mode 100644 index 0000000..1583239 --- /dev/null +++ b/app/models/customer_tack.go @@ -0,0 +1,12 @@ +package models + +// CustomerTrack 客户跟进记录 +type CustomerTrack struct { + ID uint `json:"id" gorm:"primary_key"` + CustomerId uint `json:"customer_id"` // 客户编号 + // Content string // 跟进内容记录 + // 跟进时间 + // 跟进方式 - 上门拜访、电话拜访、微信沟通、其他 + // 跟进人 + // 跟进内容 - 初次沟通、需求沟通、方案确认、报价、合同签署、销售回访 +} diff --git a/app/models/department.go b/app/models/department.go new file mode 100644 index 0000000..8516b38 --- /dev/null +++ b/app/models/department.go @@ -0,0 +1,31 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// Department 部门 +type Department struct { + ID uint `json:"id" gorm:"primaryKey"` // 部门编号 + PID dts.NullUint `json:"pid" gorm:"column:pid;default:null;index:,unique,composite:uni_name_with_pid_and_company"` // 上级部门编号 + Name string `json:"name" gorm:"index:,unique,composite:uni_name_with_pid_and_company"` // 部门名称 + ManagerId dts.NullUint `json:"manager_id"` // 部门经理编号 + + Manager *Employee `json:"manager,omitempty" gorm:"foreignKey:ManagerId"` // 部门经理 + Children []Department `json:"children,omitempty" gorm:"foreignKey:PID"` // 下级部门 + Employees []Employee `json:"employees,omitempty" gorm:"many2many:department_employees;"` // 部门员工 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/distribution_plan.go b/app/models/distribution_plan.go new file mode 100644 index 0000000..9f08e38 --- /dev/null +++ b/app/models/distribution_plan.go @@ -0,0 +1,38 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +const ( + DistributionForPurchase = "purchase" // 采购 + DistributionForSale = "sale" // 销售 +) + +// DistributionPlan 配送计划 +// +// 支持采购和销售 +type DistributionPlan struct { + ID uint `json:"id" gorm:"primaryKey"` // 审核ID + OwnerID uint `json:"owner_id"` // 所属者编号 + OwnerType string `json:"owner_type"` // 所属者类型 + BatchID uint `json:"batch_id"` // 配送批次 + Date dts.Date `json:"date"` // 计划配送日期 + Remark string `json:"remark"` // 计划配送内容备注 + + Batch *Tag `json:"batch,omitempty" gorm:"foreignKey:BatchID"` + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/district.go b/app/models/district.go new file mode 100644 index 0000000..f25937c --- /dev/null +++ b/app/models/district.go @@ -0,0 +1,50 @@ +package models + +import ( + _ "embed" + "encoding/json" + "ims/util/db/dts" + + "gorm.io/gorm" +) + +//go:embed district.json +var districtBytes []byte + +// District 行政区域 +// 数据来源 https://github.com/uiwjs/province-city-china +type District struct { + ID uint `json:"id" gorm:"primaryKey;size:6;comment:'地区ID'"` + PID dts.NullUint `json:"pid" gorm:"size:6;column:pid;default:null;comment:'上级地区ID'"` + Name string `json:"name" gorm:"size:200;comment:'地区名字'"` + Level uint `json:"level" gorm:"size:1;comment:'地区等级,1省、2市、3县'"` + + Children []District `json:"children,omitempty" gorm:"foreignKey:PID"` +} + +func InstallDistricts(tx *gorm.DB) error { + var districts []District + err := json.Unmarshal(districtBytes, &districts) + if err != nil { + return err + } + districts = ensureDistricts(districts, 1, 0) + err = tx.Exec("TRUNCATE TABLE " + tx.NamingStrategy.TableName("district")).Error + if err != nil { + return err + } + return tx.Create(&districts).Error +} + +func ensureDistricts(districts []District, level, pid uint) []District { + if districts == nil || len(districts) > 0 { + return nil + } + for i, d := range districts { + d.PID = dts.NullUint{Uint: pid, Valid: pid > 0} + d.Level = level + d.Children = ensureDistricts(d.Children, level+1, d.ID) + districts[i] = d + } + return districts +} diff --git a/app/models/district.json b/app/models/district.json new file mode 100644 index 0000000..822cdba --- /dev/null +++ b/app/models/district.json @@ -0,0 +1,5296 @@ +[ + { + "id": 11, + "name": "北京市", + "children": [ + { + "id": 1101, + "name": "市辖区", + "children": [ + { "id": 110101, "name": "东城区" }, + { "id": 110102, "name": "西城区" }, + { "id": 110105, "name": "朝阳区" }, + { "id": 110106, "name": "丰台区" }, + { "id": 110107, "name": "石景山区" }, + { "id": 110108, "name": "海淀区" }, + { "id": 110109, "name": "门头沟区" }, + { "id": 110111, "name": "房山区" }, + { "id": 110112, "name": "通州区" }, + { "id": 110113, "name": "顺义区" }, + { "id": 110114, "name": "昌平区" }, + { "id": 110115, "name": "大兴区" }, + { "id": 110116, "name": "怀柔区" }, + { "id": 110117, "name": "平谷区" }, + { "id": 110118, "name": "密云区" }, + { "id": 110119, "name": "延庆区" } + ] + } + ] + }, + { + "id": 12, + "name": "天津市", + "children": [ + { + "id": 1201, + "name": "市辖区", + "children": [ + { "id": 120101, "name": "和平区" }, + { "id": 120102, "name": "河东区" }, + { "id": 120103, "name": "河西区" }, + { "id": 120104, "name": "南开区" }, + { "id": 120105, "name": "河北区" }, + { "id": 120106, "name": "红桥区" }, + { "id": 120110, "name": "东丽区" }, + { "id": 120111, "name": "西青区" }, + { "id": 120112, "name": "津南区" }, + { "id": 120113, "name": "北辰区" }, + { "id": 120114, "name": "武清区" }, + { "id": 120115, "name": "宝坻区" }, + { "id": 120116, "name": "滨海新区" }, + { "id": 120117, "name": "宁河区" }, + { "id": 120118, "name": "静海区" }, + { "id": 120119, "name": "蓟州区" } + ] + } + ] + }, + { + "id": 13, + "name": "河北省", + "children": [ + { + "id": 1301, + "name": "石家庄市", + "children": [ + { "id": 130102, "name": "长安区" }, + { "id": 130104, "name": "桥西区" }, + { "id": 130105, "name": "新华区" }, + { "id": 130107, "name": "井陉矿区" }, + { "id": 130108, "name": "裕华区" }, + { "id": 130109, "name": "藁城区" }, + { "id": 130110, "name": "鹿泉区" }, + { "id": 130111, "name": "栾城区" }, + { "id": 130121, "name": "井陉县" }, + { "id": 130123, "name": "正定县" }, + { "id": 130125, "name": "行唐县" }, + { "id": 130126, "name": "灵寿县" }, + { "id": 130127, "name": "高邑县" }, + { "id": 130128, "name": "深泽县" }, + { "id": 130129, "name": "赞皇县" }, + { "id": 130130, "name": "无极县" }, + { "id": 130131, "name": "平山县" }, + { "id": 130132, "name": "元氏县" }, + { "id": 130133, "name": "赵县" }, + { "id": 130171, "name": "石家庄高新技术产业开发区" }, + { "id": 130172, "name": "石家庄循环化工园区" }, + { "id": 130181, "name": "辛集市" }, + { "id": 130183, "name": "晋州市" }, + { "id": 130184, "name": "新乐市" } + ] + }, + { + "id": 1302, + "name": "唐山市", + "children": [ + { "id": 130202, "name": "路南区" }, + { "id": 130203, "name": "路北区" }, + { "id": 130204, "name": "古冶区" }, + { "id": 130205, "name": "开平区" }, + { "id": 130207, "name": "丰南区" }, + { "id": 130208, "name": "丰润区" }, + { "id": 130209, "name": "曹妃甸区" }, + { "id": 130224, "name": "滦南县" }, + { "id": 130225, "name": "乐亭县" }, + { "id": 130227, "name": "迁西县" }, + { "id": 130229, "name": "玉田县" }, + { "id": 130271, "name": "河北唐山芦台经济开发区" }, + { "id": 130272, "name": "唐山市汉沽管理区" }, + { "id": 130273, "name": "唐山高新技术产业开发区" }, + { "id": 130274, "name": "河北唐山海港经济开发区" }, + { "id": 130281, "name": "遵化市" }, + { "id": 130283, "name": "迁安市" }, + { "id": 130284, "name": "滦州市" } + ] + }, + { + "id": 1303, + "name": "秦皇岛市", + "children": [ + { "id": 130302, "name": "海港区" }, + { "id": 130303, "name": "山海关区" }, + { "id": 130304, "name": "北戴河区" }, + { "id": 130306, "name": "抚宁区" }, + { "id": 130321, "name": "青龙满族自治县" }, + { "id": 130322, "name": "昌黎县" }, + { "id": 130324, "name": "卢龙县" }, + { "id": 130371, "name": "秦皇岛市经济技术开发区" }, + { "id": 130372, "name": "北戴河新区" } + ] + }, + { + "id": 1304, + "name": "邯郸市", + "children": [ + { "id": 130402, "name": "邯山区" }, + { "id": 130403, "name": "丛台区" }, + { "id": 130404, "name": "复兴区" }, + { "id": 130406, "name": "峰峰矿区" }, + { "id": 130407, "name": "肥乡区" }, + { "id": 130408, "name": "永年区" }, + { "id": 130423, "name": "临漳县" }, + { "id": 130424, "name": "成安县" }, + { "id": 130425, "name": "大名县" }, + { "id": 130426, "name": "涉县" }, + { "id": 130427, "name": "磁县" }, + { "id": 130430, "name": "邱县" }, + { "id": 130431, "name": "鸡泽县" }, + { "id": 130432, "name": "广平县" }, + { "id": 130433, "name": "馆陶县" }, + { "id": 130434, "name": "魏县" }, + { "id": 130435, "name": "曲周县" }, + { "id": 130471, "name": "邯郸经济技术开发区" }, + { "id": 130473, "name": "邯郸冀南新区" }, + { "id": 130481, "name": "武安市" } + ] + }, + { + "id": 1305, + "name": "邢台市", + "children": [ + { "id": 130502, "name": "襄都区" }, + { "id": 130503, "name": "信都区" }, + { "id": 130505, "name": "任泽区" }, + { "id": 130506, "name": "南和区" }, + { "id": 130522, "name": "临城县" }, + { "id": 130523, "name": "内丘县" }, + { "id": 130524, "name": "柏乡县" }, + { "id": 130525, "name": "隆尧县" }, + { "id": 130528, "name": "宁晋县" }, + { "id": 130529, "name": "巨鹿县" }, + { "id": 130530, "name": "新河县" }, + { "id": 130531, "name": "广宗县" }, + { "id": 130532, "name": "平乡县" }, + { "id": 130533, "name": "威县" }, + { "id": 130534, "name": "清河县" }, + { "id": 130535, "name": "临西县" }, + { "id": 130571, "name": "河北邢台经济开发区" }, + { "id": 130581, "name": "南宫市" }, + { "id": 130582, "name": "沙河市" } + ] + }, + { + "id": 1306, + "name": "保定市", + "children": [ + { "id": 130602, "name": "竞秀区" }, + { "id": 130606, "name": "莲池区" }, + { "id": 130607, "name": "满城区" }, + { "id": 130608, "name": "清苑区" }, + { "id": 130609, "name": "徐水区" }, + { "id": 130623, "name": "涞水县" }, + { "id": 130624, "name": "阜平县" }, + { "id": 130626, "name": "定兴县" }, + { "id": 130627, "name": "唐县" }, + { "id": 130628, "name": "高阳县" }, + { "id": 130629, "name": "容城县" }, + { "id": 130630, "name": "涞源县" }, + { "id": 130631, "name": "望都县" }, + { "id": 130632, "name": "安新县" }, + { "id": 130633, "name": "易县" }, + { "id": 130634, "name": "曲阳县" }, + { "id": 130635, "name": "蠡县" }, + { "id": 130636, "name": "顺平县" }, + { "id": 130637, "name": "博野县" }, + { "id": 130638, "name": "雄县" }, + { "id": 130671, "name": "保定高新技术产业开发区" }, + { "id": 130672, "name": "保定白沟新城" }, + { "id": 130681, "name": "涿州市" }, + { "id": 130682, "name": "定州市" }, + { "id": 130683, "name": "安国市" }, + { "id": 130684, "name": "高碑店市" } + ] + }, + { + "id": 1307, + "name": "张家口市", + "children": [ + { "id": 130702, "name": "桥东区" }, + { "id": 130703, "name": "桥西区" }, + { "id": 130705, "name": "宣化区" }, + { "id": 130706, "name": "下花园区" }, + { "id": 130708, "name": "万全区" }, + { "id": 130709, "name": "崇礼区" }, + { "id": 130722, "name": "张北县" }, + { "id": 130723, "name": "康保县" }, + { "id": 130724, "name": "沽源县" }, + { "id": 130725, "name": "尚义县" }, + { "id": 130726, "name": "蔚县" }, + { "id": 130727, "name": "阳原县" }, + { "id": 130728, "name": "怀安县" }, + { "id": 130730, "name": "怀来县" }, + { "id": 130731, "name": "涿鹿县" }, + { "id": 130732, "name": "赤城县" }, + { "id": 130771, "name": "张家口经济开发区" }, + { "id": 130772, "name": "张家口市察北管理区" }, + { "id": 130773, "name": "张家口市塞北管理区" } + ] + }, + { + "id": 1308, + "name": "承德市", + "children": [ + { "id": 130802, "name": "双桥区" }, + { "id": 130803, "name": "双滦区" }, + { "id": 130804, "name": "鹰手营子矿区" }, + { "id": 130821, "name": "承德县" }, + { "id": 130822, "name": "兴隆县" }, + { "id": 130824, "name": "滦平县" }, + { "id": 130825, "name": "隆化县" }, + { "id": 130826, "name": "丰宁满族自治县" }, + { "id": 130827, "name": "宽城满族自治县" }, + { "id": 130828, "name": "围场满族蒙古族自治县" }, + { "id": 130871, "name": "承德高新技术产业开发区" }, + { "id": 130881, "name": "平泉市" } + ] + }, + { + "id": 1309, + "name": "沧州市", + "children": [ + { "id": 130902, "name": "新华区" }, + { "id": 130903, "name": "运河区" }, + { "id": 130921, "name": "沧县" }, + { "id": 130922, "name": "青县" }, + { "id": 130923, "name": "东光县" }, + { "id": 130924, "name": "海兴县" }, + { "id": 130925, "name": "盐山县" }, + { "id": 130926, "name": "肃宁县" }, + { "id": 130927, "name": "南皮县" }, + { "id": 130928, "name": "吴桥县" }, + { "id": 130929, "name": "献县" }, + { "id": 130930, "name": "孟村回族自治县" }, + { "id": 130971, "name": "河北沧州经济开发区" }, + { "id": 130972, "name": "沧州高新技术产业开发区" }, + { "id": 130973, "name": "沧州渤海新区" }, + { "id": 130981, "name": "泊头市" }, + { "id": 130982, "name": "任丘市" }, + { "id": 130983, "name": "黄骅市" }, + { "id": 130984, "name": "河间市" } + ] + }, + { + "id": 1310, + "name": "廊坊市", + "children": [ + { "id": 131002, "name": "安次区" }, + { "id": 131003, "name": "广阳区" }, + { "id": 131022, "name": "固安县" }, + { "id": 131023, "name": "永清县" }, + { "id": 131024, "name": "香河县" }, + { "id": 131025, "name": "大城县" }, + { "id": 131026, "name": "文安县" }, + { "id": 131028, "name": "大厂回族自治县" }, + { "id": 131071, "name": "廊坊经济技术开发区" }, + { "id": 131081, "name": "霸州市" }, + { "id": 131082, "name": "三河市" } + ] + }, + { + "id": 1311, + "name": "衡水市", + "children": [ + { "id": 131102, "name": "桃城区" }, + { "id": 131103, "name": "冀州区" }, + { "id": 131121, "name": "枣强县" }, + { "id": 131122, "name": "武邑县" }, + { "id": 131123, "name": "武强县" }, + { "id": 131124, "name": "饶阳县" }, + { "id": 131125, "name": "安平县" }, + { "id": 131126, "name": "故城县" }, + { "id": 131127, "name": "景县" }, + { "id": 131128, "name": "阜城县" }, + { "id": 131171, "name": "河北衡水高新技术产业开发区" }, + { "id": 131172, "name": "衡水滨湖新区" }, + { "id": 131182, "name": "深州市" } + ] + } + ] + }, + { + "id": 14, + "name": "山西省", + "children": [ + { + "id": 1401, + "name": "太原市", + "children": [ + { "id": 140105, "name": "小店区" }, + { "id": 140106, "name": "迎泽区" }, + { "id": 140107, "name": "杏花岭区" }, + { "id": 140108, "name": "尖草坪区" }, + { "id": 140109, "name": "万柏林区" }, + { "id": 140110, "name": "晋源区" }, + { "id": 140121, "name": "清徐县" }, + { "id": 140122, "name": "阳曲县" }, + { "id": 140123, "name": "娄烦县" }, + { "id": 140171, "name": "山西转型综合改革示范区" }, + { "id": 140181, "name": "古交市" } + ] + }, + { + "id": 1402, + "name": "大同市", + "children": [ + { "id": 140212, "name": "新荣区" }, + { "id": 140213, "name": "平城区" }, + { "id": 140214, "name": "云冈区" }, + { "id": 140215, "name": "云州区" }, + { "id": 140221, "name": "阳高县" }, + { "id": 140222, "name": "天镇县" }, + { "id": 140223, "name": "广灵县" }, + { "id": 140224, "name": "灵丘县" }, + { "id": 140225, "name": "浑源县" }, + { "id": 140226, "name": "左云县" }, + { "id": 140271, "name": "山西大同经济开发区" } + ] + }, + { + "id": 1403, + "name": "阳泉市", + "children": [ + { "id": 140302, "name": "城区" }, + { "id": 140303, "name": "矿区" }, + { "id": 140311, "name": "郊区" }, + { "id": 140321, "name": "平定县" }, + { "id": 140322, "name": "盂县" } + ] + }, + { + "id": 1404, + "name": "长治市", + "children": [ + { "id": 140403, "name": "潞州区" }, + { "id": 140404, "name": "上党区" }, + { "id": 140405, "name": "屯留区" }, + { "id": 140406, "name": "潞城区" }, + { "id": 140423, "name": "襄垣县" }, + { "id": 140425, "name": "平顺县" }, + { "id": 140426, "name": "黎城县" }, + { "id": 140427, "name": "壶关县" }, + { "id": 140428, "name": "长子县" }, + { "id": 140429, "name": "武乡县" }, + { "id": 140430, "name": "沁县" }, + { "id": 140431, "name": "沁源县" } + ] + }, + { + "id": 1405, + "name": "晋城市", + "children": [ + { "id": 140502, "name": "城区" }, + { "id": 140521, "name": "沁水县" }, + { "id": 140522, "name": "阳城县" }, + { "id": 140524, "name": "陵川县" }, + { "id": 140525, "name": "泽州县" }, + { "id": 140581, "name": "高平市" } + ] + }, + { + "id": 1406, + "name": "朔州市", + "children": [ + { "id": 140602, "name": "朔城区" }, + { "id": 140603, "name": "平鲁区" }, + { "id": 140621, "name": "山阴县" }, + { "id": 140622, "name": "应县" }, + { "id": 140623, "name": "右玉县" }, + { "id": 140671, "name": "山西朔州经济开发区" }, + { "id": 140681, "name": "怀仁市" } + ] + }, + { + "id": 1407, + "name": "晋中市", + "children": [ + { "id": 140702, "name": "榆次区" }, + { "id": 140703, "name": "太谷区" }, + { "id": 140721, "name": "榆社县" }, + { "id": 140722, "name": "左权县" }, + { "id": 140723, "name": "和顺县" }, + { "id": 140724, "name": "昔阳县" }, + { "id": 140725, "name": "寿阳县" }, + { "id": 140727, "name": "祁县" }, + { "id": 140728, "name": "平遥县" }, + { "id": 140729, "name": "灵石县" }, + { "id": 140781, "name": "介休市" } + ] + }, + { + "id": 1408, + "name": "运城市", + "children": [ + { "id": 140802, "name": "盐湖区" }, + { "id": 140821, "name": "临猗县" }, + { "id": 140822, "name": "万荣县" }, + { "id": 140823, "name": "闻喜县" }, + { "id": 140824, "name": "稷山县" }, + { "id": 140825, "name": "新绛县" }, + { "id": 140826, "name": "绛县" }, + { "id": 140827, "name": "垣曲县" }, + { "id": 140828, "name": "夏县" }, + { "id": 140829, "name": "平陆县" }, + { "id": 140830, "name": "芮城县" }, + { "id": 140881, "name": "永济市" }, + { "id": 140882, "name": "河津市" } + ] + }, + { + "id": 1409, + "name": "忻州市", + "children": [ + { "id": 140902, "name": "忻府区" }, + { "id": 140921, "name": "定襄县" }, + { "id": 140922, "name": "五台县" }, + { "id": 140923, "name": "代县" }, + { "id": 140924, "name": "繁峙县" }, + { "id": 140925, "name": "宁武县" }, + { "id": 140926, "name": "静乐县" }, + { "id": 140927, "name": "神池县" }, + { "id": 140928, "name": "五寨县" }, + { "id": 140929, "name": "岢岚县" }, + { "id": 140930, "name": "河曲县" }, + { "id": 140931, "name": "保德县" }, + { "id": 140932, "name": "偏关县" }, + { "id": 140971, "name": "五台山风景名胜区" }, + { "id": 140981, "name": "原平市" } + ] + }, + { + "id": 1410, + "name": "临汾市", + "children": [ + { "id": 141002, "name": "尧都区" }, + { "id": 141021, "name": "曲沃县" }, + { "id": 141022, "name": "翼城县" }, + { "id": 141023, "name": "襄汾县" }, + { "id": 141024, "name": "洪洞县" }, + { "id": 141025, "name": "古县" }, + { "id": 141026, "name": "安泽县" }, + { "id": 141027, "name": "浮山县" }, + { "id": 141028, "name": "吉县" }, + { "id": 141029, "name": "乡宁县" }, + { "id": 141030, "name": "大宁县" }, + { "id": 141031, "name": "隰县" }, + { "id": 141032, "name": "永和县" }, + { "id": 141033, "name": "蒲县" }, + { "id": 141034, "name": "汾西县" }, + { "id": 141081, "name": "侯马市" }, + { "id": 141082, "name": "霍州市" } + ] + }, + { + "id": 1411, + "name": "吕梁市", + "children": [ + { "id": 141102, "name": "离石区" }, + { "id": 141121, "name": "文水县" }, + { "id": 141122, "name": "交城县" }, + { "id": 141123, "name": "兴县" }, + { "id": 141124, "name": "临县" }, + { "id": 141125, "name": "柳林县" }, + { "id": 141126, "name": "石楼县" }, + { "id": 141127, "name": "岚县" }, + { "id": 141128, "name": "方山县" }, + { "id": 141129, "name": "中阳县" }, + { "id": 141130, "name": "交口县" }, + { "id": 141181, "name": "孝义市" }, + { "id": 141182, "name": "汾阳市" } + ] + } + ] + }, + { + "id": 15, + "name": "内蒙古自治区", + "children": [ + { + "id": 1501, + "name": "呼和浩特市", + "children": [ + { "id": 150102, "name": "新城区" }, + { "id": 150103, "name": "回民区" }, + { "id": 150104, "name": "玉泉区" }, + { "id": 150105, "name": "赛罕区" }, + { "id": 150121, "name": "土默特左旗" }, + { "id": 150122, "name": "托克托县" }, + { "id": 150123, "name": "和林格尔县" }, + { "id": 150124, "name": "清水河县" }, + { "id": 150125, "name": "武川县" }, + { "id": 150172, "name": "呼和浩特经济技术开发区" } + ] + }, + { + "id": 1502, + "name": "包头市", + "children": [ + { "id": 150202, "name": "东河区" }, + { "id": 150203, "name": "昆都仑区" }, + { "id": 150204, "name": "青山区" }, + { "id": 150205, "name": "石拐区" }, + { "id": 150206, "name": "白云鄂博矿区" }, + { "id": 150207, "name": "九原区" }, + { "id": 150221, "name": "土默特右旗" }, + { "id": 150222, "name": "固阳县" }, + { "id": 150223, "name": "达尔罕茂明安联合旗" }, + { "id": 150271, "name": "包头稀土高新技术产业开发区" } + ] + }, + { + "id": 1503, + "name": "乌海市", + "children": [ + { "id": 150302, "name": "海勃湾区" }, + { "id": 150303, "name": "海南区" }, + { "id": 150304, "name": "乌达区" } + ] + }, + { + "id": 1504, + "name": "赤峰市", + "children": [ + { "id": 150402, "name": "红山区" }, + { "id": 150403, "name": "元宝山区" }, + { "id": 150404, "name": "松山区" }, + { "id": 150421, "name": "阿鲁科尔沁旗" }, + { "id": 150422, "name": "巴林左旗" }, + { "id": 150423, "name": "巴林右旗" }, + { "id": 150424, "name": "林西县" }, + { "id": 150425, "name": "克什克腾旗" }, + { "id": 150426, "name": "翁牛特旗" }, + { "id": 150428, "name": "喀喇沁旗" }, + { "id": 150429, "name": "宁城县" }, + { "id": 150430, "name": "敖汉旗" } + ] + }, + { + "id": 1505, + "name": "通辽市", + "children": [ + { "id": 150502, "name": "科尔沁区" }, + { "id": 150521, "name": "科尔沁左翼中旗" }, + { "id": 150522, "name": "科尔沁左翼后旗" }, + { "id": 150523, "name": "开鲁县" }, + { "id": 150524, "name": "库伦旗" }, + { "id": 150525, "name": "奈曼旗" }, + { "id": 150526, "name": "扎鲁特旗" }, + { "id": 150571, "name": "通辽经济技术开发区" }, + { "id": 150581, "name": "霍林郭勒市" } + ] + }, + { + "id": 1506, + "name": "鄂尔多斯市", + "children": [ + { "id": 150602, "name": "东胜区" }, + { "id": 150603, "name": "康巴什区" }, + { "id": 150621, "name": "达拉特旗" }, + { "id": 150622, "name": "准格尔旗" }, + { "id": 150623, "name": "鄂托克前旗" }, + { "id": 150624, "name": "鄂托克旗" }, + { "id": 150625, "name": "杭锦旗" }, + { "id": 150626, "name": "乌审旗" }, + { "id": 150627, "name": "伊金霍洛旗" } + ] + }, + { + "id": 1507, + "name": "呼伦贝尔市", + "children": [ + { "id": 150702, "name": "海拉尔区" }, + { "id": 150703, "name": "扎赉诺尔区" }, + { "id": 150721, "name": "阿荣旗" }, + { "id": 150722, "name": "莫力达瓦达斡尔族自治旗" }, + { "id": 150723, "name": "鄂伦春自治旗" }, + { "id": 150724, "name": "鄂温克族自治旗" }, + { "id": 150725, "name": "陈巴尔虎旗" }, + { "id": 150726, "name": "新巴尔虎左旗" }, + { "id": 150727, "name": "新巴尔虎右旗" }, + { "id": 150781, "name": "满洲里市" }, + { "id": 150782, "name": "牙克石市" }, + { "id": 150783, "name": "扎兰屯市" }, + { "id": 150784, "name": "额尔古纳市" }, + { "id": 150785, "name": "根河市" } + ] + }, + { + "id": 1508, + "name": "巴彦淖尔市", + "children": [ + { "id": 150802, "name": "临河区" }, + { "id": 150821, "name": "五原县" }, + { "id": 150822, "name": "磴口县" }, + { "id": 150823, "name": "乌拉特前旗" }, + { "id": 150824, "name": "乌拉特中旗" }, + { "id": 150825, "name": "乌拉特后旗" }, + { "id": 150826, "name": "杭锦后旗" } + ] + }, + { + "id": 1509, + "name": "乌兰察布市", + "children": [ + { "id": 150902, "name": "集宁区" }, + { "id": 150921, "name": "卓资县" }, + { "id": 150922, "name": "化德县" }, + { "id": 150923, "name": "商都县" }, + { "id": 150924, "name": "兴和县" }, + { "id": 150925, "name": "凉城县" }, + { "id": 150926, "name": "察哈尔右翼前旗" }, + { "id": 150927, "name": "察哈尔右翼中旗" }, + { "id": 150928, "name": "察哈尔右翼后旗" }, + { "id": 150929, "name": "四子王旗" }, + { "id": 150981, "name": "丰镇市" } + ] + }, + { + "id": 1522, + "name": "兴安盟", + "children": [ + { "id": 152201, "name": "乌兰浩特市" }, + { "id": 152202, "name": "阿尔山市" }, + { "id": 152221, "name": "科尔沁右翼前旗" }, + { "id": 152222, "name": "科尔沁右翼中旗" }, + { "id": 152223, "name": "扎赉特旗" }, + { "id": 152224, "name": "突泉县" } + ] + }, + { + "id": 1525, + "name": "锡林郭勒盟", + "children": [ + { "id": 152501, "name": "二连浩特市" }, + { "id": 152502, "name": "锡林浩特市" }, + { "id": 152522, "name": "阿巴嘎旗" }, + { "id": 152523, "name": "苏尼特左旗" }, + { "id": 152524, "name": "苏尼特右旗" }, + { "id": 152525, "name": "东乌珠穆沁旗" }, + { "id": 152526, "name": "西乌珠穆沁旗" }, + { "id": 152527, "name": "太仆寺旗" }, + { "id": 152528, "name": "镶黄旗" }, + { "id": 152529, "name": "正镶白旗" }, + { "id": 152530, "name": "正蓝旗" }, + { "id": 152531, "name": "多伦县" }, + { "id": 152571, "name": "乌拉盖管理区管委会" } + ] + }, + { + "id": 1529, + "name": "阿拉善盟", + "children": [ + { "id": 152921, "name": "阿拉善左旗" }, + { "id": 152922, "name": "阿拉善右旗" }, + { "id": 152923, "name": "额济纳旗" }, + { "id": 152971, "name": "内蒙古阿拉善高新技术产业开发区" } + ] + } + ] + }, + { + "id": 21, + "name": "辽宁省", + "children": [ + { + "id": 2101, + "name": "沈阳市", + "children": [ + { "id": 210102, "name": "和平区" }, + { "id": 210103, "name": "沈河区" }, + { "id": 210104, "name": "大东区" }, + { "id": 210105, "name": "皇姑区" }, + { "id": 210106, "name": "铁西区" }, + { "id": 210111, "name": "苏家屯区" }, + { "id": 210112, "name": "浑南区" }, + { "id": 210113, "name": "沈北新区" }, + { "id": 210114, "name": "于洪区" }, + { "id": 210115, "name": "辽中区" }, + { "id": 210123, "name": "康平县" }, + { "id": 210124, "name": "法库县" }, + { "id": 210181, "name": "新民市" } + ] + }, + { + "id": 2102, + "name": "大连市", + "children": [ + { "id": 210202, "name": "中山区" }, + { "id": 210203, "name": "西岗区" }, + { "id": 210204, "name": "沙河口区" }, + { "id": 210211, "name": "甘井子区" }, + { "id": 210212, "name": "旅顺口区" }, + { "id": 210213, "name": "金州区" }, + { "id": 210214, "name": "普兰店区" }, + { "id": 210224, "name": "长海县" }, + { "id": 210281, "name": "瓦房店市" }, + { "id": 210283, "name": "庄河市" } + ] + }, + { + "id": 2103, + "name": "鞍山市", + "children": [ + { "id": 210302, "name": "铁东区" }, + { "id": 210303, "name": "铁西区" }, + { "id": 210304, "name": "立山区" }, + { "id": 210311, "name": "千山区" }, + { "id": 210321, "name": "台安县" }, + { "id": 210323, "name": "岫岩满族自治县" }, + { "id": 210381, "name": "海城市" } + ] + }, + { + "id": 2104, + "name": "抚顺市", + "children": [ + { "id": 210402, "name": "新抚区" }, + { "id": 210403, "name": "东洲区" }, + { "id": 210404, "name": "望花区" }, + { "id": 210411, "name": "顺城区" }, + { "id": 210421, "name": "抚顺县" }, + { "id": 210422, "name": "新宾满族自治县" }, + { "id": 210423, "name": "清原满族自治县" } + ] + }, + { + "id": 2105, + "name": "本溪市", + "children": [ + { "id": 210502, "name": "平山区" }, + { "id": 210503, "name": "溪湖区" }, + { "id": 210504, "name": "明山区" }, + { "id": 210505, "name": "南芬区" }, + { "id": 210521, "name": "本溪满族自治县" }, + { "id": 210522, "name": "桓仁满族自治县" } + ] + }, + { + "id": 2106, + "name": "丹东市", + "children": [ + { "id": 210602, "name": "元宝区" }, + { "id": 210603, "name": "振兴区" }, + { "id": 210604, "name": "振安区" }, + { "id": 210624, "name": "宽甸满族自治县" }, + { "id": 210681, "name": "东港市" }, + { "id": 210682, "name": "凤城市" } + ] + }, + { + "id": 2107, + "name": "锦州市", + "children": [ + { "id": 210702, "name": "古塔区" }, + { "id": 210703, "name": "凌河区" }, + { "id": 210711, "name": "太和区" }, + { "id": 210726, "name": "黑山县" }, + { "id": 210727, "name": "义县" }, + { "id": 210781, "name": "凌海市" }, + { "id": 210782, "name": "北镇市" } + ] + }, + { + "id": 2108, + "name": "营口市", + "children": [ + { "id": 210802, "name": "站前区" }, + { "id": 210803, "name": "西市区" }, + { "id": 210804, "name": "鲅鱼圈区" }, + { "id": 210811, "name": "老边区" }, + { "id": 210881, "name": "盖州市" }, + { "id": 210882, "name": "大石桥市" } + ] + }, + { + "id": 2109, + "name": "阜新市", + "children": [ + { "id": 210902, "name": "海州区" }, + { "id": 210903, "name": "新邱区" }, + { "id": 210904, "name": "太平区" }, + { "id": 210905, "name": "清河门区" }, + { "id": 210911, "name": "细河区" }, + { "id": 210921, "name": "阜新蒙古族自治县" }, + { "id": 210922, "name": "彰武县" } + ] + }, + { + "id": 2110, + "name": "辽阳市", + "children": [ + { "id": 211002, "name": "白塔区" }, + { "id": 211003, "name": "文圣区" }, + { "id": 211004, "name": "宏伟区" }, + { "id": 211005, "name": "弓长岭区" }, + { "id": 211011, "name": "太子河区" }, + { "id": 211021, "name": "辽阳县" }, + { "id": 211081, "name": "灯塔市" } + ] + }, + { + "id": 2111, + "name": "盘锦市", + "children": [ + { "id": 211102, "name": "双台子区" }, + { "id": 211103, "name": "兴隆台区" }, + { "id": 211104, "name": "大洼区" }, + { "id": 211122, "name": "盘山县" } + ] + }, + { + "id": 2112, + "name": "铁岭市", + "children": [ + { "id": 211202, "name": "银州区" }, + { "id": 211204, "name": "清河区" }, + { "id": 211221, "name": "铁岭县" }, + { "id": 211223, "name": "西丰县" }, + { "id": 211224, "name": "昌图县" }, + { "id": 211281, "name": "调兵山市" }, + { "id": 211282, "name": "开原市" } + ] + }, + { + "id": 2113, + "name": "朝阳市", + "children": [ + { "id": 211302, "name": "双塔区" }, + { "id": 211303, "name": "龙城区" }, + { "id": 211321, "name": "朝阳县" }, + { "id": 211322, "name": "建平县" }, + { "id": 211324, "name": "喀喇沁左翼蒙古族自治县" }, + { "id": 211381, "name": "北票市" }, + { "id": 211382, "name": "凌源市" } + ] + }, + { + "id": 2114, + "name": "葫芦岛市", + "children": [ + { "id": 211402, "name": "连山区" }, + { "id": 211403, "name": "龙港区" }, + { "id": 211404, "name": "南票区" }, + { "id": 211421, "name": "绥中县" }, + { "id": 211422, "name": "建昌县" }, + { "id": 211481, "name": "兴城市" } + ] + } + ] + }, + { + "id": 22, + "name": "吉林省", + "children": [ + { + "id": 2201, + "name": "长春市", + "children": [ + { "id": 220102, "name": "南关区" }, + { "id": 220103, "name": "宽城区" }, + { "id": 220104, "name": "朝阳区" }, + { "id": 220105, "name": "二道区" }, + { "id": 220106, "name": "绿园区" }, + { "id": 220112, "name": "双阳区" }, + { "id": 220113, "name": "九台区" }, + { "id": 220122, "name": "农安县" }, + { "id": 220171, "name": "长春经济技术开发区" }, + { "id": 220172, "name": "长春净月高新技术产业开发区" }, + { "id": 220173, "name": "长春高新技术产业开发区" }, + { "id": 220174, "name": "长春汽车经济技术开发区" }, + { "id": 220182, "name": "榆树市" }, + { "id": 220183, "name": "德惠市" }, + { "id": 220184, "name": "公主岭市" } + ] + }, + { + "id": 2202, + "name": "吉林市", + "children": [ + { "id": 220202, "name": "昌邑区" }, + { "id": 220203, "name": "龙潭区" }, + { "id": 220204, "name": "船营区" }, + { "id": 220211, "name": "丰满区" }, + { "id": 220221, "name": "永吉县" }, + { "id": 220271, "name": "吉林经济开发区" }, + { "id": 220272, "name": "吉林高新技术产业开发区" }, + { "id": 220273, "name": "吉林中国新加坡食品区" }, + { "id": 220281, "name": "蛟河市" }, + { "id": 220282, "name": "桦甸市" }, + { "id": 220283, "name": "舒兰市" }, + { "id": 220284, "name": "磐石市" } + ] + }, + { + "id": 2203, + "name": "四平市", + "children": [ + { "id": 220302, "name": "铁西区" }, + { "id": 220303, "name": "铁东区" }, + { "id": 220322, "name": "梨树县" }, + { "id": 220323, "name": "伊通满族自治县" }, + { "id": 220382, "name": "双辽市" } + ] + }, + { + "id": 2204, + "name": "辽源市", + "children": [ + { "id": 220402, "name": "龙山区" }, + { "id": 220403, "name": "西安区" }, + { "id": 220421, "name": "东丰县" }, + { "id": 220422, "name": "东辽县" } + ] + }, + { + "id": 2205, + "name": "通化市", + "children": [ + { "id": 220502, "name": "东昌区" }, + { "id": 220503, "name": "二道江区" }, + { "id": 220521, "name": "通化县" }, + { "id": 220523, "name": "辉南县" }, + { "id": 220524, "name": "柳河县" }, + { "id": 220581, "name": "梅河口市" }, + { "id": 220582, "name": "集安市" } + ] + }, + { + "id": 2206, + "name": "白山市", + "children": [ + { "id": 220602, "name": "浑江区" }, + { "id": 220605, "name": "江源区" }, + { "id": 220621, "name": "抚松县" }, + { "id": 220622, "name": "靖宇县" }, + { "id": 220623, "name": "长白朝鲜族自治县" }, + { "id": 220681, "name": "临江市" } + ] + }, + { + "id": 2207, + "name": "松原市", + "children": [ + { "id": 220702, "name": "宁江区" }, + { "id": 220721, "name": "前郭尔罗斯蒙古族自治县" }, + { "id": 220722, "name": "长岭县" }, + { "id": 220723, "name": "乾安县" }, + { "id": 220771, "name": "吉林松原经济开发区" }, + { "id": 220781, "name": "扶余市" } + ] + }, + { + "id": 2208, + "name": "白城市", + "children": [ + { "id": 220802, "name": "洮北区" }, + { "id": 220821, "name": "镇赉县" }, + { "id": 220822, "name": "通榆县" }, + { "id": 220871, "name": "吉林白城经济开发区" }, + { "id": 220881, "name": "洮南市" }, + { "id": 220882, "name": "大安市" } + ] + }, + { + "id": 2224, + "name": "延边朝鲜族自治州", + "children": [ + { "id": 222401, "name": "延吉市" }, + { "id": 222402, "name": "图们市" }, + { "id": 222403, "name": "敦化市" }, + { "id": 222404, "name": "珲春市" }, + { "id": 222405, "name": "龙井市" }, + { "id": 222406, "name": "和龙市" }, + { "id": 222424, "name": "汪清县" }, + { "id": 222426, "name": "安图县" } + ] + } + ] + }, + { + "id": 23, + "name": "黑龙江省", + "children": [ + { + "id": 2301, + "name": "哈尔滨市", + "children": [ + { "id": 230102, "name": "道里区" }, + { "id": 230103, "name": "南岗区" }, + { "id": 230104, "name": "道外区" }, + { "id": 230108, "name": "平房区" }, + { "id": 230109, "name": "松北区" }, + { "id": 230110, "name": "香坊区" }, + { "id": 230111, "name": "呼兰区" }, + { "id": 230112, "name": "阿城区" }, + { "id": 230113, "name": "双城区" }, + { "id": 230123, "name": "依兰县" }, + { "id": 230124, "name": "方正县" }, + { "id": 230125, "name": "宾县" }, + { "id": 230126, "name": "巴彦县" }, + { "id": 230127, "name": "木兰县" }, + { "id": 230128, "name": "通河县" }, + { "id": 230129, "name": "延寿县" }, + { "id": 230183, "name": "尚志市" }, + { "id": 230184, "name": "五常市" } + ] + }, + { + "id": 2302, + "name": "齐齐哈尔市", + "children": [ + { "id": 230202, "name": "龙沙区" }, + { "id": 230203, "name": "建华区" }, + { "id": 230204, "name": "铁锋区" }, + { "id": 230205, "name": "昂昂溪区" }, + { "id": 230206, "name": "富拉尔基区" }, + { "id": 230207, "name": "碾子山区" }, + { "id": 230208, "name": "梅里斯达斡尔族区" }, + { "id": 230221, "name": "龙江县" }, + { "id": 230223, "name": "依安县" }, + { "id": 230224, "name": "泰来县" }, + { "id": 230225, "name": "甘南县" }, + { "id": 230227, "name": "富裕县" }, + { "id": 230229, "name": "克山县" }, + { "id": 230230, "name": "克东县" }, + { "id": 230231, "name": "拜泉县" }, + { "id": 230281, "name": "讷河市" } + ] + }, + { + "id": 2303, + "name": "鸡西市", + "children": [ + { "id": 230302, "name": "鸡冠区" }, + { "id": 230303, "name": "恒山区" }, + { "id": 230304, "name": "滴道区" }, + { "id": 230305, "name": "梨树区" }, + { "id": 230306, "name": "城子河区" }, + { "id": 230307, "name": "麻山区" }, + { "id": 230321, "name": "鸡东县" }, + { "id": 230381, "name": "虎林市" }, + { "id": 230382, "name": "密山市" } + ] + }, + { + "id": 2304, + "name": "鹤岗市", + "children": [ + { "id": 230402, "name": "向阳区" }, + { "id": 230403, "name": "工农区" }, + { "id": 230404, "name": "南山区" }, + { "id": 230405, "name": "兴安区" }, + { "id": 230406, "name": "东山区" }, + { "id": 230407, "name": "兴山区" }, + { "id": 230421, "name": "萝北县" }, + { "id": 230422, "name": "绥滨县" } + ] + }, + { + "id": 2305, + "name": "双鸭山市", + "children": [ + { "id": 230502, "name": "尖山区" }, + { "id": 230503, "name": "岭东区" }, + { "id": 230505, "name": "四方台区" }, + { "id": 230506, "name": "宝山区" }, + { "id": 230521, "name": "集贤县" }, + { "id": 230522, "name": "友谊县" }, + { "id": 230523, "name": "宝清县" }, + { "id": 230524, "name": "饶河县" } + ] + }, + { + "id": 2306, + "name": "大庆市", + "children": [ + { "id": 230602, "name": "萨尔图区" }, + { "id": 230603, "name": "龙凤区" }, + { "id": 230604, "name": "让胡路区" }, + { "id": 230605, "name": "红岗区" }, + { "id": 230606, "name": "大同区" }, + { "id": 230621, "name": "肇州县" }, + { "id": 230622, "name": "肇源县" }, + { "id": 230623, "name": "林甸县" }, + { "id": 230624, "name": "杜尔伯特蒙古族自治县" }, + { "id": 230671, "name": "大庆高新技术产业开发区" } + ] + }, + { + "id": 2307, + "name": "伊春市", + "children": [ + { "id": 230717, "name": "伊美区" }, + { "id": 230718, "name": "乌翠区" }, + { "id": 230719, "name": "友好区" }, + { "id": 230722, "name": "嘉荫县" }, + { "id": 230723, "name": "汤旺县" }, + { "id": 230724, "name": "丰林县" }, + { "id": 230725, "name": "大箐山县" }, + { "id": 230726, "name": "南岔县" }, + { "id": 230751, "name": "金林区" }, + { "id": 230781, "name": "铁力市" } + ] + }, + { + "id": 2308, + "name": "佳木斯市", + "children": [ + { "id": 230803, "name": "向阳区" }, + { "id": 230804, "name": "前进区" }, + { "id": 230805, "name": "东风区" }, + { "id": 230811, "name": "郊区" }, + { "id": 230822, "name": "桦南县" }, + { "id": 230826, "name": "桦川县" }, + { "id": 230828, "name": "汤原县" }, + { "id": 230881, "name": "同江市" }, + { "id": 230882, "name": "富锦市" }, + { "id": 230883, "name": "抚远市" } + ] + }, + { + "id": 2309, + "name": "七台河市", + "children": [ + { "id": 230902, "name": "新兴区" }, + { "id": 230903, "name": "桃山区" }, + { "id": 230904, "name": "茄子河区" }, + { "id": 230921, "name": "勃利县" } + ] + }, + { + "id": 2310, + "name": "牡丹江市", + "children": [ + { "id": 231002, "name": "东安区" }, + { "id": 231003, "name": "阳明区" }, + { "id": 231004, "name": "爱民区" }, + { "id": 231005, "name": "西安区" }, + { "id": 231025, "name": "林口县" }, + { "id": 231081, "name": "绥芬河市" }, + { "id": 231083, "name": "海林市" }, + { "id": 231084, "name": "宁安市" }, + { "id": 231085, "name": "穆棱市" }, + { "id": 231086, "name": "东宁市" } + ] + }, + { + "id": 2311, + "name": "黑河市", + "children": [ + { "id": 231102, "name": "爱辉区" }, + { "id": 231123, "name": "逊克县" }, + { "id": 231124, "name": "孙吴县" }, + { "id": 231181, "name": "北安市" }, + { "id": 231182, "name": "五大连池市" }, + { "id": 231183, "name": "嫩江市" } + ] + }, + { + "id": 2312, + "name": "绥化市", + "children": [ + { "id": 231202, "name": "北林区" }, + { "id": 231221, "name": "望奎县" }, + { "id": 231222, "name": "兰西县" }, + { "id": 231223, "name": "青冈县" }, + { "id": 231224, "name": "庆安县" }, + { "id": 231225, "name": "明水县" }, + { "id": 231226, "name": "绥棱县" }, + { "id": 231281, "name": "安达市" }, + { "id": 231282, "name": "肇东市" }, + { "id": 231283, "name": "海伦市" } + ] + }, + { + "id": 2327, + "name": "大兴安岭地区", + "children": [ + { "id": 232701, "name": "漠河市" }, + { "id": 232721, "name": "呼玛县" }, + { "id": 232722, "name": "塔河县" }, + { "id": 232761, "name": "加格达奇区" }, + { "id": 232762, "name": "松岭区" }, + { "id": 232763, "name": "新林区" }, + { "id": 232764, "name": "呼中区" } + ] + } + ] + }, + { + "id": 31, + "name": "上海市", + "children": [ + { + "id": 3101, + "name": "市辖区", + "children": [ + { "id": 310101, "name": "黄浦区" }, + { "id": 310104, "name": "徐汇区" }, + { "id": 310105, "name": "长宁区" }, + { "id": 310106, "name": "静安区" }, + { "id": 310107, "name": "普陀区" }, + { "id": 310109, "name": "虹口区" }, + { "id": 310110, "name": "杨浦区" }, + { "id": 310112, "name": "闵行区" }, + { "id": 310113, "name": "宝山区" }, + { "id": 310114, "name": "嘉定区" }, + { "id": 310115, "name": "浦东新区" }, + { "id": 310116, "name": "金山区" }, + { "id": 310117, "name": "松江区" }, + { "id": 310118, "name": "青浦区" }, + { "id": 310120, "name": "奉贤区" }, + { "id": 310151, "name": "崇明区" } + ] + } + ] + }, + { + "id": 32, + "name": "江苏省", + "children": [ + { + "id": 3201, + "name": "南京市", + "children": [ + { "id": 320102, "name": "玄武区" }, + { "id": 320104, "name": "秦淮区" }, + { "id": 320105, "name": "建邺区" }, + { "id": 320106, "name": "鼓楼区" }, + { "id": 320111, "name": "浦口区" }, + { "id": 320113, "name": "栖霞区" }, + { "id": 320114, "name": "雨花台区" }, + { "id": 320115, "name": "江宁区" }, + { "id": 320116, "name": "六合区" }, + { "id": 320117, "name": "溧水区" }, + { "id": 320118, "name": "高淳区" } + ] + }, + { + "id": 3202, + "name": "无锡市", + "children": [ + { "id": 320205, "name": "锡山区" }, + { "id": 320206, "name": "惠山区" }, + { "id": 320211, "name": "滨湖区" }, + { "id": 320213, "name": "梁溪区" }, + { "id": 320214, "name": "新吴区" }, + { "id": 320281, "name": "江阴市" }, + { "id": 320282, "name": "宜兴市" } + ] + }, + { + "id": 3203, + "name": "徐州市", + "children": [ + { "id": 320302, "name": "鼓楼区" }, + { "id": 320303, "name": "云龙区" }, + { "id": 320305, "name": "贾汪区" }, + { "id": 320311, "name": "泉山区" }, + { "id": 320312, "name": "铜山区" }, + { "id": 320321, "name": "丰县" }, + { "id": 320322, "name": "沛县" }, + { "id": 320324, "name": "睢宁县" }, + { "id": 320371, "name": "徐州经济技术开发区" }, + { "id": 320381, "name": "新沂市" }, + { "id": 320382, "name": "邳州市" } + ] + }, + { + "id": 3204, + "name": "常州市", + "children": [ + { "id": 320402, "name": "天宁区" }, + { "id": 320404, "name": "钟楼区" }, + { "id": 320411, "name": "新北区" }, + { "id": 320412, "name": "武进区" }, + { "id": 320413, "name": "金坛区" }, + { "id": 320481, "name": "溧阳市" } + ] + }, + { + "id": 3205, + "name": "苏州市", + "children": [ + { "id": 320505, "name": "虎丘区" }, + { "id": 320506, "name": "吴中区" }, + { "id": 320507, "name": "相城区" }, + { "id": 320508, "name": "姑苏区" }, + { "id": 320509, "name": "吴江区" }, + { "id": 320576, "name": "苏州工业园区" }, + { "id": 320581, "name": "常熟市" }, + { "id": 320582, "name": "张家港市" }, + { "id": 320583, "name": "昆山市" }, + { "id": 320585, "name": "太仓市" } + ] + }, + { + "id": 3206, + "name": "南通市", + "children": [ + { "id": 320612, "name": "通州区" }, + { "id": 320613, "name": "崇川区" }, + { "id": 320614, "name": "海门区" }, + { "id": 320623, "name": "如东县" }, + { "id": 320671, "name": "南通经济技术开发区" }, + { "id": 320681, "name": "启东市" }, + { "id": 320682, "name": "如皋市" }, + { "id": 320685, "name": "海安市" } + ] + }, + { + "id": 3207, + "name": "连云港市", + "children": [ + { "id": 320703, "name": "连云区" }, + { "id": 320706, "name": "海州区" }, + { "id": 320707, "name": "赣榆区" }, + { "id": 320722, "name": "东海县" }, + { "id": 320723, "name": "灌云县" }, + { "id": 320724, "name": "灌南县" }, + { "id": 320771, "name": "连云港经济技术开发区" } + ] + }, + { + "id": 3208, + "name": "淮安市", + "children": [ + { "id": 320803, "name": "淮安区" }, + { "id": 320804, "name": "淮阴区" }, + { "id": 320812, "name": "清江浦区" }, + { "id": 320813, "name": "洪泽区" }, + { "id": 320826, "name": "涟水县" }, + { "id": 320830, "name": "盱眙县" }, + { "id": 320831, "name": "金湖县" }, + { "id": 320871, "name": "淮安经济技术开发区" } + ] + }, + { + "id": 3209, + "name": "盐城市", + "children": [ + { "id": 320902, "name": "亭湖区" }, + { "id": 320903, "name": "盐都区" }, + { "id": 320904, "name": "大丰区" }, + { "id": 320921, "name": "响水县" }, + { "id": 320922, "name": "滨海县" }, + { "id": 320923, "name": "阜宁县" }, + { "id": 320924, "name": "射阳县" }, + { "id": 320925, "name": "建湖县" }, + { "id": 320971, "name": "盐城经济技术开发区" }, + { "id": 320981, "name": "东台市" } + ] + }, + { + "id": 3210, + "name": "扬州市", + "children": [ + { "id": 321002, "name": "广陵区" }, + { "id": 321003, "name": "邗江区" }, + { "id": 321012, "name": "江都区" }, + { "id": 321023, "name": "宝应县" }, + { "id": 321071, "name": "扬州经济技术开发区" }, + { "id": 321081, "name": "仪征市" }, + { "id": 321084, "name": "高邮市" } + ] + }, + { + "id": 3211, + "name": "镇江市", + "children": [ + { "id": 321102, "name": "京口区" }, + { "id": 321111, "name": "润州区" }, + { "id": 321112, "name": "丹徒区" }, + { "id": 321171, "name": "镇江新区" }, + { "id": 321181, "name": "丹阳市" }, + { "id": 321182, "name": "扬中市" }, + { "id": 321183, "name": "句容市" } + ] + }, + { + "id": 3212, + "name": "泰州市", + "children": [ + { "id": 321202, "name": "海陵区" }, + { "id": 321203, "name": "高港区" }, + { "id": 321204, "name": "姜堰区" }, + { "id": 321281, "name": "兴化市" }, + { "id": 321282, "name": "靖江市" }, + { "id": 321283, "name": "泰兴市" } + ] + }, + { + "id": 3213, + "name": "宿迁市", + "children": [ + { "id": 321302, "name": "宿城区" }, + { "id": 321311, "name": "宿豫区" }, + { "id": 321322, "name": "沭阳县" }, + { "id": 321323, "name": "泗阳县" }, + { "id": 321324, "name": "泗洪县" }, + { "id": 321371, "name": "宿迁经济技术开发区" } + ] + } + ] + }, + { + "id": 33, + "name": "浙江省", + "children": [ + { + "id": 3301, + "name": "杭州市", + "children": [ + { "id": 330102, "name": "上城区" }, + { "id": 330105, "name": "拱墅区" }, + { "id": 330106, "name": "西湖区" }, + { "id": 330108, "name": "滨江区" }, + { "id": 330109, "name": "萧山区" }, + { "id": 330110, "name": "余杭区" }, + { "id": 330111, "name": "富阳区" }, + { "id": 330112, "name": "临安区" }, + { "id": 330113, "name": "临平区" }, + { "id": 330114, "name": "钱塘区" }, + { "id": 330122, "name": "桐庐县" }, + { "id": 330127, "name": "淳安县" }, + { "id": 330182, "name": "建德市" } + ] + }, + { + "id": 3302, + "name": "宁波市", + "children": [ + { "id": 330203, "name": "海曙区" }, + { "id": 330205, "name": "江北区" }, + { "id": 330206, "name": "北仑区" }, + { "id": 330211, "name": "镇海区" }, + { "id": 330212, "name": "鄞州区" }, + { "id": 330213, "name": "奉化区" }, + { "id": 330225, "name": "象山县" }, + { "id": 330226, "name": "宁海县" }, + { "id": 330281, "name": "余姚市" }, + { "id": 330282, "name": "慈溪市" } + ] + }, + { + "id": 3303, + "name": "温州市", + "children": [ + { "id": 330302, "name": "鹿城区" }, + { "id": 330303, "name": "龙湾区" }, + { "id": 330304, "name": "瓯海区" }, + { "id": 330305, "name": "洞头区" }, + { "id": 330324, "name": "永嘉县" }, + { "id": 330326, "name": "平阳县" }, + { "id": 330327, "name": "苍南县" }, + { "id": 330328, "name": "文成县" }, + { "id": 330329, "name": "泰顺县" }, + { "id": 330381, "name": "瑞安市" }, + { "id": 330382, "name": "乐清市" }, + { "id": 330383, "name": "龙港市" } + ] + }, + { + "id": 3304, + "name": "嘉兴市", + "children": [ + { "id": 330402, "name": "南湖区" }, + { "id": 330411, "name": "秀洲区" }, + { "id": 330421, "name": "嘉善县" }, + { "id": 330424, "name": "海盐县" }, + { "id": 330481, "name": "海宁市" }, + { "id": 330482, "name": "平湖市" }, + { "id": 330483, "name": "桐乡市" } + ] + }, + { + "id": 3305, + "name": "湖州市", + "children": [ + { "id": 330502, "name": "吴兴区" }, + { "id": 330503, "name": "南浔区" }, + { "id": 330521, "name": "德清县" }, + { "id": 330522, "name": "长兴县" }, + { "id": 330523, "name": "安吉县" } + ] + }, + { + "id": 3306, + "name": "绍兴市", + "children": [ + { "id": 330602, "name": "越城区" }, + { "id": 330603, "name": "柯桥区" }, + { "id": 330604, "name": "上虞区" }, + { "id": 330624, "name": "新昌县" }, + { "id": 330681, "name": "诸暨市" }, + { "id": 330683, "name": "嵊州市" } + ] + }, + { + "id": 3307, + "name": "金华市", + "children": [ + { "id": 330702, "name": "婺城区" }, + { "id": 330703, "name": "金东区" }, + { "id": 330723, "name": "武义县" }, + { "id": 330726, "name": "浦江县" }, + { "id": 330727, "name": "磐安县" }, + { "id": 330781, "name": "兰溪市" }, + { "id": 330782, "name": "义乌市" }, + { "id": 330783, "name": "东阳市" }, + { "id": 330784, "name": "永康市" } + ] + }, + { + "id": 3308, + "name": "衢州市", + "children": [ + { "id": 330802, "name": "柯城区" }, + { "id": 330803, "name": "衢江区" }, + { "id": 330822, "name": "常山县" }, + { "id": 330824, "name": "开化县" }, + { "id": 330825, "name": "龙游县" }, + { "id": 330881, "name": "江山市" } + ] + }, + { + "id": 3309, + "name": "舟山市", + "children": [ + { "id": 330902, "name": "定海区" }, + { "id": 330903, "name": "普陀区" }, + { "id": 330921, "name": "岱山县" }, + { "id": 330922, "name": "嵊泗县" } + ] + }, + { + "id": 3310, + "name": "台州市", + "children": [ + { "id": 331002, "name": "椒江区" }, + { "id": 331003, "name": "黄岩区" }, + { "id": 331004, "name": "路桥区" }, + { "id": 331022, "name": "三门县" }, + { "id": 331023, "name": "天台县" }, + { "id": 331024, "name": "仙居县" }, + { "id": 331081, "name": "温岭市" }, + { "id": 331082, "name": "临海市" }, + { "id": 331083, "name": "玉环市" } + ] + }, + { + "id": 3311, + "name": "丽水市", + "children": [ + { "id": 331102, "name": "莲都区" }, + { "id": 331121, "name": "青田县" }, + { "id": 331122, "name": "缙云县" }, + { "id": 331123, "name": "遂昌县" }, + { "id": 331124, "name": "松阳县" }, + { "id": 331125, "name": "云和县" }, + { "id": 331126, "name": "庆元县" }, + { "id": 331127, "name": "景宁畲族自治县" }, + { "id": 331181, "name": "龙泉市" } + ] + } + ] + }, + { + "id": 34, + "name": "安徽省", + "children": [ + { + "id": 3401, + "name": "合肥市", + "children": [ + { "id": 340102, "name": "瑶海区" }, + { "id": 340103, "name": "庐阳区" }, + { "id": 340104, "name": "蜀山区" }, + { "id": 340111, "name": "包河区" }, + { "id": 340121, "name": "长丰县" }, + { "id": 340122, "name": "肥东县" }, + { "id": 340123, "name": "肥西县" }, + { "id": 340124, "name": "庐江县" }, + { "id": 340176, "name": "合肥高新技术产业开发区" }, + { "id": 340177, "name": "合肥经济技术开发区" }, + { "id": 340178, "name": "合肥新站高新技术产业开发区" }, + { "id": 340181, "name": "巢湖市" } + ] + }, + { + "id": 3402, + "name": "芜湖市", + "children": [ + { "id": 340202, "name": "镜湖区" }, + { "id": 340207, "name": "鸠江区" }, + { "id": 340209, "name": "弋江区" }, + { "id": 340210, "name": "湾沚区" }, + { "id": 340212, "name": "繁昌区" }, + { "id": 340223, "name": "南陵县" }, + { "id": 340271, "name": "芜湖经济技术开发区" }, + { "id": 340272, "name": "安徽芜湖三山经济开发区" }, + { "id": 340281, "name": "无为市" } + ] + }, + { + "id": 3403, + "name": "蚌埠市", + "children": [ + { "id": 340302, "name": "龙子湖区" }, + { "id": 340303, "name": "蚌山区" }, + { "id": 340304, "name": "禹会区" }, + { "id": 340311, "name": "淮上区" }, + { "id": 340321, "name": "怀远县" }, + { "id": 340322, "name": "五河县" }, + { "id": 340323, "name": "固镇县" }, + { "id": 340371, "name": "蚌埠市高新技术开发区" }, + { "id": 340372, "name": "蚌埠市经济开发区" } + ] + }, + { + "id": 3404, + "name": "淮南市", + "children": [ + { "id": 340402, "name": "大通区" }, + { "id": 340403, "name": "田家庵区" }, + { "id": 340404, "name": "谢家集区" }, + { "id": 340405, "name": "八公山区" }, + { "id": 340406, "name": "潘集区" }, + { "id": 340421, "name": "凤台县" }, + { "id": 340422, "name": "寿县" } + ] + }, + { + "id": 3405, + "name": "马鞍山市", + "children": [ + { "id": 340503, "name": "花山区" }, + { "id": 340504, "name": "雨山区" }, + { "id": 340506, "name": "博望区" }, + { "id": 340521, "name": "当涂县" }, + { "id": 340522, "name": "含山县" }, + { "id": 340523, "name": "和县" } + ] + }, + { + "id": 3406, + "name": "淮北市", + "children": [ + { "id": 340602, "name": "杜集区" }, + { "id": 340603, "name": "相山区" }, + { "id": 340604, "name": "烈山区" }, + { "id": 340621, "name": "濉溪县" } + ] + }, + { + "id": 3407, + "name": "铜陵市", + "children": [ + { "id": 340705, "name": "铜官区" }, + { "id": 340706, "name": "义安区" }, + { "id": 340711, "name": "郊区" }, + { "id": 340722, "name": "枞阳县" } + ] + }, + { + "id": 3408, + "name": "安庆市", + "children": [ + { "id": 340802, "name": "迎江区" }, + { "id": 340803, "name": "大观区" }, + { "id": 340811, "name": "宜秀区" }, + { "id": 340822, "name": "怀宁县" }, + { "id": 340825, "name": "太湖县" }, + { "id": 340826, "name": "宿松县" }, + { "id": 340827, "name": "望江县" }, + { "id": 340828, "name": "岳西县" }, + { "id": 340871, "name": "安徽安庆经济开发区" }, + { "id": 340881, "name": "桐城市" }, + { "id": 340882, "name": "潜山市" } + ] + }, + { + "id": 3410, + "name": "黄山市", + "children": [ + { "id": 341002, "name": "屯溪区" }, + { "id": 341003, "name": "黄山区" }, + { "id": 341004, "name": "徽州区" }, + { "id": 341021, "name": "歙县" }, + { "id": 341022, "name": "休宁县" }, + { "id": 341023, "name": "黟县" }, + { "id": 341024, "name": "祁门县" } + ] + }, + { + "id": 3411, + "name": "滁州市", + "children": [ + { "id": 341102, "name": "琅琊区" }, + { "id": 341103, "name": "南谯区" }, + { "id": 341122, "name": "来安县" }, + { "id": 341124, "name": "全椒县" }, + { "id": 341125, "name": "定远县" }, + { "id": 341126, "name": "凤阳县" }, + { "id": 341171, "name": "中新苏滁高新技术产业开发区" }, + { "id": 341172, "name": "滁州经济技术开发区" }, + { "id": 341181, "name": "天长市" }, + { "id": 341182, "name": "明光市" } + ] + }, + { + "id": 3412, + "name": "阜阳市", + "children": [ + { "id": 341202, "name": "颍州区" }, + { "id": 341203, "name": "颍东区" }, + { "id": 341204, "name": "颍泉区" }, + { "id": 341221, "name": "临泉县" }, + { "id": 341222, "name": "太和县" }, + { "id": 341225, "name": "阜南县" }, + { "id": 341226, "name": "颍上县" }, + { "id": 341271, "name": "阜阳合肥现代产业园区" }, + { "id": 341272, "name": "阜阳经济技术开发区" }, + { "id": 341282, "name": "界首市" } + ] + }, + { + "id": 3413, + "name": "宿州市", + "children": [ + { "id": 341302, "name": "埇桥区" }, + { "id": 341321, "name": "砀山县" }, + { "id": 341322, "name": "萧县" }, + { "id": 341323, "name": "灵璧县" }, + { "id": 341324, "name": "泗县" }, + { "id": 341371, "name": "宿州马鞍山现代产业园区" }, + { "id": 341372, "name": "宿州经济技术开发区" } + ] + }, + { + "id": 3415, + "name": "六安市", + "children": [ + { "id": 341502, "name": "金安区" }, + { "id": 341503, "name": "裕安区" }, + { "id": 341504, "name": "叶集区" }, + { "id": 341522, "name": "霍邱县" }, + { "id": 341523, "name": "舒城县" }, + { "id": 341524, "name": "金寨县" }, + { "id": 341525, "name": "霍山县" } + ] + }, + { + "id": 3416, + "name": "亳州市", + "children": [ + { "id": 341602, "name": "谯城区" }, + { "id": 341621, "name": "涡阳县" }, + { "id": 341622, "name": "蒙城县" }, + { "id": 341623, "name": "利辛县" } + ] + }, + { + "id": 3417, + "name": "池州市", + "children": [ + { "id": 341702, "name": "贵池区" }, + { "id": 341721, "name": "东至县" }, + { "id": 341722, "name": "石台县" }, + { "id": 341723, "name": "青阳县" } + ] + }, + { + "id": 3418, + "name": "宣城市", + "children": [ + { "id": 341802, "name": "宣州区" }, + { "id": 341821, "name": "郎溪县" }, + { "id": 341823, "name": "泾县" }, + { "id": 341824, "name": "绩溪县" }, + { "id": 341825, "name": "旌德县" }, + { "id": 341871, "name": "宣城市经济开发区" }, + { "id": 341881, "name": "宁国市" }, + { "id": 341882, "name": "广德市" } + ] + } + ] + }, + { + "id": 35, + "name": "福建省", + "children": [ + { + "id": 3501, + "name": "福州市", + "children": [ + { "id": 350102, "name": "鼓楼区" }, + { "id": 350103, "name": "台江区" }, + { "id": 350104, "name": "仓山区" }, + { "id": 350105, "name": "马尾区" }, + { "id": 350111, "name": "晋安区" }, + { "id": 350112, "name": "长乐区" }, + { "id": 350121, "name": "闽侯县" }, + { "id": 350122, "name": "连江县" }, + { "id": 350123, "name": "罗源县" }, + { "id": 350124, "name": "闽清县" }, + { "id": 350125, "name": "永泰县" }, + { "id": 350128, "name": "平潭县" }, + { "id": 350181, "name": "福清市" } + ] + }, + { + "id": 3502, + "name": "厦门市", + "children": [ + { "id": 350203, "name": "思明区" }, + { "id": 350205, "name": "海沧区" }, + { "id": 350206, "name": "湖里区" }, + { "id": 350211, "name": "集美区" }, + { "id": 350212, "name": "同安区" }, + { "id": 350213, "name": "翔安区" } + ] + }, + { + "id": 3503, + "name": "莆田市", + "children": [ + { "id": 350302, "name": "城厢区" }, + { "id": 350303, "name": "涵江区" }, + { "id": 350304, "name": "荔城区" }, + { "id": 350305, "name": "秀屿区" }, + { "id": 350322, "name": "仙游县" } + ] + }, + { + "id": 3504, + "name": "三明市", + "children": [ + { "id": 350404, "name": "三元区" }, + { "id": 350405, "name": "沙县区" }, + { "id": 350421, "name": "明溪县" }, + { "id": 350423, "name": "清流县" }, + { "id": 350424, "name": "宁化县" }, + { "id": 350425, "name": "大田县" }, + { "id": 350426, "name": "尤溪县" }, + { "id": 350428, "name": "将乐县" }, + { "id": 350429, "name": "泰宁县" }, + { "id": 350430, "name": "建宁县" }, + { "id": 350481, "name": "永安市" } + ] + }, + { + "id": 3505, + "name": "泉州市", + "children": [ + { "id": 350502, "name": "鲤城区" }, + { "id": 350503, "name": "丰泽区" }, + { "id": 350504, "name": "洛江区" }, + { "id": 350505, "name": "泉港区" }, + { "id": 350521, "name": "惠安县" }, + { "id": 350524, "name": "安溪县" }, + { "id": 350525, "name": "永春县" }, + { "id": 350526, "name": "德化县" }, + { "id": 350527, "name": "金门县" }, + { "id": 350581, "name": "石狮市" }, + { "id": 350582, "name": "晋江市" }, + { "id": 350583, "name": "南安市" } + ] + }, + { + "id": 3506, + "name": "漳州市", + "children": [ + { "id": 350602, "name": "芗城区" }, + { "id": 350603, "name": "龙文区" }, + { "id": 350604, "name": "龙海区" }, + { "id": 350605, "name": "长泰区" }, + { "id": 350622, "name": "云霄县" }, + { "id": 350623, "name": "漳浦县" }, + { "id": 350624, "name": "诏安县" }, + { "id": 350626, "name": "东山县" }, + { "id": 350627, "name": "南靖县" }, + { "id": 350628, "name": "平和县" }, + { "id": 350629, "name": "华安县" } + ] + }, + { + "id": 3507, + "name": "南平市", + "children": [ + { "id": 350702, "name": "延平区" }, + { "id": 350703, "name": "建阳区" }, + { "id": 350721, "name": "顺昌县" }, + { "id": 350722, "name": "浦城县" }, + { "id": 350723, "name": "光泽县" }, + { "id": 350724, "name": "松溪县" }, + { "id": 350725, "name": "政和县" }, + { "id": 350781, "name": "邵武市" }, + { "id": 350782, "name": "武夷山市" }, + { "id": 350783, "name": "建瓯市" } + ] + }, + { + "id": 3508, + "name": "龙岩市", + "children": [ + { "id": 350802, "name": "新罗区" }, + { "id": 350803, "name": "永定区" }, + { "id": 350821, "name": "长汀县" }, + { "id": 350823, "name": "上杭县" }, + { "id": 350824, "name": "武平县" }, + { "id": 350825, "name": "连城县" }, + { "id": 350881, "name": "漳平市" } + ] + }, + { + "id": 3509, + "name": "宁德市", + "children": [ + { "id": 350902, "name": "蕉城区" }, + { "id": 350921, "name": "霞浦县" }, + { "id": 350922, "name": "古田县" }, + { "id": 350923, "name": "屏南县" }, + { "id": 350924, "name": "寿宁县" }, + { "id": 350925, "name": "周宁县" }, + { "id": 350926, "name": "柘荣县" }, + { "id": 350981, "name": "福安市" }, + { "id": 350982, "name": "福鼎市" } + ] + } + ] + }, + { + "id": 36, + "name": "江西省", + "children": [ + { + "id": 3601, + "name": "南昌市", + "children": [ + { "id": 360102, "name": "东湖区" }, + { "id": 360103, "name": "西湖区" }, + { "id": 360104, "name": "青云谱区" }, + { "id": 360111, "name": "青山湖区" }, + { "id": 360112, "name": "新建区" }, + { "id": 360113, "name": "红谷滩区" }, + { "id": 360121, "name": "南昌县" }, + { "id": 360123, "name": "安义县" }, + { "id": 360124, "name": "进贤县" } + ] + }, + { + "id": 3602, + "name": "景德镇市", + "children": [ + { "id": 360202, "name": "昌江区" }, + { "id": 360203, "name": "珠山区" }, + { "id": 360222, "name": "浮梁县" }, + { "id": 360281, "name": "乐平市" } + ] + }, + { + "id": 3603, + "name": "萍乡市", + "children": [ + { "id": 360302, "name": "安源区" }, + { "id": 360313, "name": "湘东区" }, + { "id": 360321, "name": "莲花县" }, + { "id": 360322, "name": "上栗县" }, + { "id": 360323, "name": "芦溪县" } + ] + }, + { + "id": 3604, + "name": "九江市", + "children": [ + { "id": 360402, "name": "濂溪区" }, + { "id": 360403, "name": "浔阳区" }, + { "id": 360404, "name": "柴桑区" }, + { "id": 360423, "name": "武宁县" }, + { "id": 360424, "name": "修水县" }, + { "id": 360425, "name": "永修县" }, + { "id": 360426, "name": "德安县" }, + { "id": 360428, "name": "都昌县" }, + { "id": 360429, "name": "湖口县" }, + { "id": 360430, "name": "彭泽县" }, + { "id": 360481, "name": "瑞昌市" }, + { "id": 360482, "name": "共青城市" }, + { "id": 360483, "name": "庐山市" } + ] + }, + { + "id": 3605, + "name": "新余市", + "children": [ + { "id": 360502, "name": "渝水区" }, + { "id": 360521, "name": "分宜县" } + ] + }, + { + "id": 3606, + "name": "鹰潭市", + "children": [ + { "id": 360602, "name": "月湖区" }, + { "id": 360603, "name": "余江区" }, + { "id": 360681, "name": "贵溪市" } + ] + }, + { + "id": 3607, + "name": "赣州市", + "children": [ + { "id": 360702, "name": "章贡区" }, + { "id": 360703, "name": "南康区" }, + { "id": 360704, "name": "赣县区" }, + { "id": 360722, "name": "信丰县" }, + { "id": 360723, "name": "大余县" }, + { "id": 360724, "name": "上犹县" }, + { "id": 360725, "name": "崇义县" }, + { "id": 360726, "name": "安远县" }, + { "id": 360728, "name": "定南县" }, + { "id": 360729, "name": "全南县" }, + { "id": 360730, "name": "宁都县" }, + { "id": 360731, "name": "于都县" }, + { "id": 360732, "name": "兴国县" }, + { "id": 360733, "name": "会昌县" }, + { "id": 360734, "name": "寻乌县" }, + { "id": 360735, "name": "石城县" }, + { "id": 360781, "name": "瑞金市" }, + { "id": 360783, "name": "龙南市" } + ] + }, + { + "id": 3608, + "name": "吉安市", + "children": [ + { "id": 360802, "name": "吉州区" }, + { "id": 360803, "name": "青原区" }, + { "id": 360821, "name": "吉安县" }, + { "id": 360822, "name": "吉水县" }, + { "id": 360823, "name": "峡江县" }, + { "id": 360824, "name": "新干县" }, + { "id": 360825, "name": "永丰县" }, + { "id": 360826, "name": "泰和县" }, + { "id": 360827, "name": "遂川县" }, + { "id": 360828, "name": "万安县" }, + { "id": 360829, "name": "安福县" }, + { "id": 360830, "name": "永新县" }, + { "id": 360881, "name": "井冈山市" } + ] + }, + { + "id": 3609, + "name": "宜春市", + "children": [ + { "id": 360902, "name": "袁州区" }, + { "id": 360921, "name": "奉新县" }, + { "id": 360922, "name": "万载县" }, + { "id": 360923, "name": "上高县" }, + { "id": 360924, "name": "宜丰县" }, + { "id": 360925, "name": "靖安县" }, + { "id": 360926, "name": "铜鼓县" }, + { "id": 360981, "name": "丰城市" }, + { "id": 360982, "name": "樟树市" }, + { "id": 360983, "name": "高安市" } + ] + }, + { + "id": 3610, + "name": "抚州市", + "children": [ + { "id": 361002, "name": "临川区" }, + { "id": 361003, "name": "东乡区" }, + { "id": 361021, "name": "南城县" }, + { "id": 361022, "name": "黎川县" }, + { "id": 361023, "name": "南丰县" }, + { "id": 361024, "name": "崇仁县" }, + { "id": 361025, "name": "乐安县" }, + { "id": 361026, "name": "宜黄县" }, + { "id": 361027, "name": "金溪县" }, + { "id": 361028, "name": "资溪县" }, + { "id": 361030, "name": "广昌县" } + ] + }, + { + "id": 3611, + "name": "上饶市", + "children": [ + { "id": 361102, "name": "信州区" }, + { "id": 361103, "name": "广丰区" }, + { "id": 361104, "name": "广信区" }, + { "id": 361123, "name": "玉山县" }, + { "id": 361124, "name": "铅山县" }, + { "id": 361125, "name": "横峰县" }, + { "id": 361126, "name": "弋阳县" }, + { "id": 361127, "name": "余干县" }, + { "id": 361128, "name": "鄱阳县" }, + { "id": 361129, "name": "万年县" }, + { "id": 361130, "name": "婺源县" }, + { "id": 361181, "name": "德兴市" } + ] + } + ] + }, + { + "id": 37, + "name": "山东省", + "children": [ + { + "id": 3701, + "name": "济南市", + "children": [ + { "id": 370102, "name": "历下区" }, + { "id": 370103, "name": "市中区" }, + { "id": 370104, "name": "槐荫区" }, + { "id": 370105, "name": "天桥区" }, + { "id": 370112, "name": "历城区" }, + { "id": 370113, "name": "长清区" }, + { "id": 370114, "name": "章丘区" }, + { "id": 370115, "name": "济阳区" }, + { "id": 370116, "name": "莱芜区" }, + { "id": 370117, "name": "钢城区" }, + { "id": 370124, "name": "平阴县" }, + { "id": 370126, "name": "商河县" }, + { "id": 370176, "name": "济南高新技术产业开发区" } + ] + }, + { + "id": 3702, + "name": "青岛市", + "children": [ + { "id": 370202, "name": "市南区" }, + { "id": 370203, "name": "市北区" }, + { "id": 370211, "name": "黄岛区" }, + { "id": 370212, "name": "崂山区" }, + { "id": 370213, "name": "李沧区" }, + { "id": 370214, "name": "城阳区" }, + { "id": 370215, "name": "即墨区" }, + { "id": 370281, "name": "胶州市" }, + { "id": 370283, "name": "平度市" }, + { "id": 370285, "name": "莱西市" } + ] + }, + { + "id": 3703, + "name": "淄博市", + "children": [ + { "id": 370302, "name": "淄川区" }, + { "id": 370303, "name": "张店区" }, + { "id": 370304, "name": "博山区" }, + { "id": 370305, "name": "临淄区" }, + { "id": 370306, "name": "周村区" }, + { "id": 370321, "name": "桓台县" }, + { "id": 370322, "name": "高青县" }, + { "id": 370323, "name": "沂源县" } + ] + }, + { + "id": 3704, + "name": "枣庄市", + "children": [ + { "id": 370402, "name": "市中区" }, + { "id": 370403, "name": "薛城区" }, + { "id": 370404, "name": "峄城区" }, + { "id": 370405, "name": "台儿庄区" }, + { "id": 370406, "name": "山亭区" }, + { "id": 370481, "name": "滕州市" } + ] + }, + { + "id": 3705, + "name": "东营市", + "children": [ + { "id": 370502, "name": "东营区" }, + { "id": 370503, "name": "河口区" }, + { "id": 370505, "name": "垦利区" }, + { "id": 370522, "name": "利津县" }, + { "id": 370523, "name": "广饶县" }, + { "id": 370571, "name": "东营经济技术开发区" }, + { "id": 370572, "name": "东营港经济开发区" } + ] + }, + { + "id": 3706, + "name": "烟台市", + "children": [ + { "id": 370602, "name": "芝罘区" }, + { "id": 370611, "name": "福山区" }, + { "id": 370612, "name": "牟平区" }, + { "id": 370613, "name": "莱山区" }, + { "id": 370614, "name": "蓬莱区" }, + { "id": 370671, "name": "烟台高新技术产业开发区" }, + { "id": 370676, "name": "烟台经济技术开发区" }, + { "id": 370681, "name": "龙口市" }, + { "id": 370682, "name": "莱阳市" }, + { "id": 370683, "name": "莱州市" }, + { "id": 370685, "name": "招远市" }, + { "id": 370686, "name": "栖霞市" }, + { "id": 370687, "name": "海阳市" } + ] + }, + { + "id": 3707, + "name": "潍坊市", + "children": [ + { "id": 370702, "name": "潍城区" }, + { "id": 370703, "name": "寒亭区" }, + { "id": 370704, "name": "坊子区" }, + { "id": 370705, "name": "奎文区" }, + { "id": 370724, "name": "临朐县" }, + { "id": 370725, "name": "昌乐县" }, + { "id": 370772, "name": "潍坊滨海经济技术开发区" }, + { "id": 370781, "name": "青州市" }, + { "id": 370782, "name": "诸城市" }, + { "id": 370783, "name": "寿光市" }, + { "id": 370784, "name": "安丘市" }, + { "id": 370785, "name": "高密市" }, + { "id": 370786, "name": "昌邑市" } + ] + }, + { + "id": 3708, + "name": "济宁市", + "children": [ + { "id": 370811, "name": "任城区" }, + { "id": 370812, "name": "兖州区" }, + { "id": 370826, "name": "微山县" }, + { "id": 370827, "name": "鱼台县" }, + { "id": 370828, "name": "金乡县" }, + { "id": 370829, "name": "嘉祥县" }, + { "id": 370830, "name": "汶上县" }, + { "id": 370831, "name": "泗水县" }, + { "id": 370832, "name": "梁山县" }, + { "id": 370871, "name": "济宁高新技术产业开发区" }, + { "id": 370881, "name": "曲阜市" }, + { "id": 370883, "name": "邹城市" } + ] + }, + { + "id": 3709, + "name": "泰安市", + "children": [ + { "id": 370902, "name": "泰山区" }, + { "id": 370911, "name": "岱岳区" }, + { "id": 370921, "name": "宁阳县" }, + { "id": 370923, "name": "东平县" }, + { "id": 370982, "name": "新泰市" }, + { "id": 370983, "name": "肥城市" } + ] + }, + { + "id": 3710, + "name": "威海市", + "children": [ + { "id": 371002, "name": "环翠区" }, + { "id": 371003, "name": "文登区" }, + { "id": 371071, "name": "威海火炬高技术产业开发区" }, + { "id": 371072, "name": "威海经济技术开发区" }, + { "id": 371073, "name": "威海临港经济技术开发区" }, + { "id": 371082, "name": "荣成市" }, + { "id": 371083, "name": "乳山市" } + ] + }, + { + "id": 3711, + "name": "日照市", + "children": [ + { "id": 371102, "name": "东港区" }, + { "id": 371103, "name": "岚山区" }, + { "id": 371121, "name": "五莲县" }, + { "id": 371122, "name": "莒县" }, + { "id": 371171, "name": "日照经济技术开发区" } + ] + }, + { + "id": 3713, + "name": "临沂市", + "children": [ + { "id": 371302, "name": "兰山区" }, + { "id": 371311, "name": "罗庄区" }, + { "id": 371312, "name": "河东区" }, + { "id": 371321, "name": "沂南县" }, + { "id": 371322, "name": "郯城县" }, + { "id": 371323, "name": "沂水县" }, + { "id": 371324, "name": "兰陵县" }, + { "id": 371325, "name": "费县" }, + { "id": 371326, "name": "平邑县" }, + { "id": 371327, "name": "莒南县" }, + { "id": 371328, "name": "蒙阴县" }, + { "id": 371329, "name": "临沭县" }, + { "id": 371371, "name": "临沂高新技术产业开发区" } + ] + }, + { + "id": 3714, + "name": "德州市", + "children": [ + { "id": 371402, "name": "德城区" }, + { "id": 371403, "name": "陵城区" }, + { "id": 371422, "name": "宁津县" }, + { "id": 371423, "name": "庆云县" }, + { "id": 371424, "name": "临邑县" }, + { "id": 371425, "name": "齐河县" }, + { "id": 371426, "name": "平原县" }, + { "id": 371427, "name": "夏津县" }, + { "id": 371428, "name": "武城县" }, + { "id": 371471, "name": "德州天衢新区" }, + { "id": 371481, "name": "乐陵市" }, + { "id": 371482, "name": "禹城市" } + ] + }, + { + "id": 3715, + "name": "聊城市", + "children": [ + { "id": 371502, "name": "东昌府区" }, + { "id": 371503, "name": "茌平区" }, + { "id": 371521, "name": "阳谷县" }, + { "id": 371522, "name": "莘县" }, + { "id": 371524, "name": "东阿县" }, + { "id": 371525, "name": "冠县" }, + { "id": 371526, "name": "高唐县" }, + { "id": 371581, "name": "临清市" } + ] + }, + { + "id": 3716, + "name": "滨州市", + "children": [ + { "id": 371602, "name": "滨城区" }, + { "id": 371603, "name": "沾化区" }, + { "id": 371621, "name": "惠民县" }, + { "id": 371622, "name": "阳信县" }, + { "id": 371623, "name": "无棣县" }, + { "id": 371625, "name": "博兴县" }, + { "id": 371681, "name": "邹平市" } + ] + }, + { + "id": 3717, + "name": "菏泽市", + "children": [ + { "id": 371702, "name": "牡丹区" }, + { "id": 371703, "name": "定陶区" }, + { "id": 371721, "name": "曹县" }, + { "id": 371722, "name": "单县" }, + { "id": 371723, "name": "成武县" }, + { "id": 371724, "name": "巨野县" }, + { "id": 371725, "name": "郓城县" }, + { "id": 371726, "name": "鄄城县" }, + { "id": 371728, "name": "东明县" }, + { "id": 371771, "name": "菏泽经济技术开发区" }, + { "id": 371772, "name": "菏泽高新技术开发区" } + ] + } + ] + }, + { + "id": 41, + "name": "河南省", + "children": [ + { + "id": 4101, + "name": "郑州市", + "children": [ + { "id": 410102, "name": "中原区" }, + { "id": 410103, "name": "二七区" }, + { "id": 410104, "name": "管城回族区" }, + { "id": 410105, "name": "金水区" }, + { "id": 410106, "name": "上街区" }, + { "id": 410108, "name": "惠济区" }, + { "id": 410122, "name": "中牟县" }, + { "id": 410171, "name": "郑州经济技术开发区" }, + { "id": 410172, "name": "郑州高新技术产业开发区" }, + { "id": 410173, "name": "郑州航空港经济综合实验区" }, + { "id": 410181, "name": "巩义市" }, + { "id": 410182, "name": "荥阳市" }, + { "id": 410183, "name": "新密市" }, + { "id": 410184, "name": "新郑市" }, + { "id": 410185, "name": "登封市" } + ] + }, + { + "id": 4102, + "name": "开封市", + "children": [ + { "id": 410202, "name": "龙亭区" }, + { "id": 410203, "name": "顺河回族区" }, + { "id": 410204, "name": "鼓楼区" }, + { "id": 410205, "name": "禹王台区" }, + { "id": 410212, "name": "祥符区" }, + { "id": 410221, "name": "杞县" }, + { "id": 410222, "name": "通许县" }, + { "id": 410223, "name": "尉氏县" }, + { "id": 410225, "name": "兰考县" } + ] + }, + { + "id": 4103, + "name": "洛阳市", + "children": [ + { "id": 410302, "name": "老城区" }, + { "id": 410303, "name": "西工区" }, + { "id": 410304, "name": "瀍河回族区" }, + { "id": 410305, "name": "涧西区" }, + { "id": 410307, "name": "偃师区" }, + { "id": 410308, "name": "孟津区" }, + { "id": 410311, "name": "洛龙区" }, + { "id": 410323, "name": "新安县" }, + { "id": 410324, "name": "栾川县" }, + { "id": 410325, "name": "嵩县" }, + { "id": 410326, "name": "汝阳县" }, + { "id": 410327, "name": "宜阳县" }, + { "id": 410328, "name": "洛宁县" }, + { "id": 410329, "name": "伊川县" }, + { "id": 410371, "name": "洛阳高新技术产业开发区" } + ] + }, + { + "id": 4104, + "name": "平顶山市", + "children": [ + { "id": 410402, "name": "新华区" }, + { "id": 410403, "name": "卫东区" }, + { "id": 410404, "name": "石龙区" }, + { "id": 410411, "name": "湛河区" }, + { "id": 410421, "name": "宝丰县" }, + { "id": 410422, "name": "叶县" }, + { "id": 410423, "name": "鲁山县" }, + { "id": 410425, "name": "郏县" }, + { "id": 410471, "name": "平顶山高新技术产业开发区" }, + { "id": 410472, "name": "平顶山市城乡一体化示范区" }, + { "id": 410481, "name": "舞钢市" }, + { "id": 410482, "name": "汝州市" } + ] + }, + { + "id": 4105, + "name": "安阳市", + "children": [ + { "id": 410502, "name": "文峰区" }, + { "id": 410503, "name": "北关区" }, + { "id": 410505, "name": "殷都区" }, + { "id": 410506, "name": "龙安区" }, + { "id": 410522, "name": "安阳县" }, + { "id": 410523, "name": "汤阴县" }, + { "id": 410526, "name": "滑县" }, + { "id": 410527, "name": "内黄县" }, + { "id": 410571, "name": "安阳高新技术产业开发区" }, + { "id": 410581, "name": "林州市" } + ] + }, + { + "id": 4106, + "name": "鹤壁市", + "children": [ + { "id": 410602, "name": "鹤山区" }, + { "id": 410603, "name": "山城区" }, + { "id": 410611, "name": "淇滨区" }, + { "id": 410621, "name": "浚县" }, + { "id": 410622, "name": "淇县" }, + { "id": 410671, "name": "鹤壁经济技术开发区" } + ] + }, + { + "id": 4107, + "name": "新乡市", + "children": [ + { "id": 410702, "name": "红旗区" }, + { "id": 410703, "name": "卫滨区" }, + { "id": 410704, "name": "凤泉区" }, + { "id": 410711, "name": "牧野区" }, + { "id": 410721, "name": "新乡县" }, + { "id": 410724, "name": "获嘉县" }, + { "id": 410725, "name": "原阳县" }, + { "id": 410726, "name": "延津县" }, + { "id": 410727, "name": "封丘县" }, + { "id": 410771, "name": "新乡高新技术产业开发区" }, + { "id": 410772, "name": "新乡经济技术开发区" }, + { "id": 410773, "name": "新乡市平原城乡一体化示范区" }, + { "id": 410781, "name": "卫辉市" }, + { "id": 410782, "name": "辉县市" }, + { "id": 410783, "name": "长垣市" } + ] + }, + { + "id": 4108, + "name": "焦作市", + "children": [ + { "id": 410802, "name": "解放区" }, + { "id": 410803, "name": "中站区" }, + { "id": 410804, "name": "马村区" }, + { "id": 410811, "name": "山阳区" }, + { "id": 410821, "name": "修武县" }, + { "id": 410822, "name": "博爱县" }, + { "id": 410823, "name": "武陟县" }, + { "id": 410825, "name": "温县" }, + { "id": 410871, "name": "焦作城乡一体化示范区" }, + { "id": 410882, "name": "沁阳市" }, + { "id": 410883, "name": "孟州市" } + ] + }, + { + "id": 4109, + "name": "濮阳市", + "children": [ + { "id": 410902, "name": "华龙区" }, + { "id": 410922, "name": "清丰县" }, + { "id": 410923, "name": "南乐县" }, + { "id": 410926, "name": "范县" }, + { "id": 410927, "name": "台前县" }, + { "id": 410928, "name": "濮阳县" }, + { "id": 410971, "name": "河南濮阳工业园区" }, + { "id": 410972, "name": "濮阳经济技术开发区" } + ] + }, + { + "id": 4110, + "name": "许昌市", + "children": [ + { "id": 411002, "name": "魏都区" }, + { "id": 411003, "name": "建安区" }, + { "id": 411024, "name": "鄢陵县" }, + { "id": 411025, "name": "襄城县" }, + { "id": 411071, "name": "许昌经济技术开发区" }, + { "id": 411081, "name": "禹州市" }, + { "id": 411082, "name": "长葛市" } + ] + }, + { + "id": 4111, + "name": "漯河市", + "children": [ + { "id": 411102, "name": "源汇区" }, + { "id": 411103, "name": "郾城区" }, + { "id": 411104, "name": "召陵区" }, + { "id": 411121, "name": "舞阳县" }, + { "id": 411122, "name": "临颍县" }, + { "id": 411171, "name": "漯河经济技术开发区" } + ] + }, + { + "id": 4112, + "name": "三门峡市", + "children": [ + { "id": 411202, "name": "湖滨区" }, + { "id": 411203, "name": "陕州区" }, + { "id": 411221, "name": "渑池县" }, + { "id": 411224, "name": "卢氏县" }, + { "id": 411271, "name": "河南三门峡经济开发区" }, + { "id": 411281, "name": "义马市" }, + { "id": 411282, "name": "灵宝市" } + ] + }, + { + "id": 4113, + "name": "南阳市", + "children": [ + { "id": 411302, "name": "宛城区" }, + { "id": 411303, "name": "卧龙区" }, + { "id": 411321, "name": "南召县" }, + { "id": 411322, "name": "方城县" }, + { "id": 411323, "name": "西峡县" }, + { "id": 411324, "name": "镇平县" }, + { "id": 411325, "name": "内乡县" }, + { "id": 411326, "name": "淅川县" }, + { "id": 411327, "name": "社旗县" }, + { "id": 411328, "name": "唐河县" }, + { "id": 411329, "name": "新野县" }, + { "id": 411330, "name": "桐柏县" }, + { "id": 411371, "name": "南阳高新技术产业开发区" }, + { "id": 411372, "name": "南阳市城乡一体化示范区" }, + { "id": 411381, "name": "邓州市" } + ] + }, + { + "id": 4114, + "name": "商丘市", + "children": [ + { "id": 411402, "name": "梁园区" }, + { "id": 411403, "name": "睢阳区" }, + { "id": 411421, "name": "民权县" }, + { "id": 411422, "name": "睢县" }, + { "id": 411423, "name": "宁陵县" }, + { "id": 411424, "name": "柘城县" }, + { "id": 411425, "name": "虞城县" }, + { "id": 411426, "name": "夏邑县" }, + { "id": 411471, "name": "豫东综合物流产业聚集区" }, + { "id": 411472, "name": "河南商丘经济开发区" }, + { "id": 411481, "name": "永城市" } + ] + }, + { + "id": 4115, + "name": "信阳市", + "children": [ + { "id": 411502, "name": "浉河区" }, + { "id": 411503, "name": "平桥区" }, + { "id": 411521, "name": "罗山县" }, + { "id": 411522, "name": "光山县" }, + { "id": 411523, "name": "新县" }, + { "id": 411524, "name": "商城县" }, + { "id": 411525, "name": "固始县" }, + { "id": 411526, "name": "潢川县" }, + { "id": 411527, "name": "淮滨县" }, + { "id": 411528, "name": "息县" }, + { "id": 411571, "name": "信阳高新技术产业开发区" } + ] + }, + { + "id": 4116, + "name": "周口市", + "children": [ + { "id": 411602, "name": "川汇区" }, + { "id": 411603, "name": "淮阳区" }, + { "id": 411621, "name": "扶沟县" }, + { "id": 411622, "name": "西华县" }, + { "id": 411623, "name": "商水县" }, + { "id": 411624, "name": "沈丘县" }, + { "id": 411625, "name": "郸城县" }, + { "id": 411627, "name": "太康县" }, + { "id": 411628, "name": "鹿邑县" }, + { "id": 411671, "name": "周口临港开发区" }, + { "id": 411681, "name": "项城市" } + ] + }, + { + "id": 4117, + "name": "驻马店市", + "children": [ + { "id": 411702, "name": "驿城区" }, + { "id": 411721, "name": "西平县" }, + { "id": 411722, "name": "上蔡县" }, + { "id": 411723, "name": "平舆县" }, + { "id": 411724, "name": "正阳县" }, + { "id": 411725, "name": "确山县" }, + { "id": 411726, "name": "泌阳县" }, + { "id": 411727, "name": "汝南县" }, + { "id": 411728, "name": "遂平县" }, + { "id": 411729, "name": "新蔡县" }, + { "id": 411771, "name": "河南驻马店经济开发区" } + ] + }, + { + "id": 4190, + "name": "省直辖县级行政区划", + "children": [ + { "id": 419001, "name": "济源市" } + ] + } + ] + }, + { + "id": 42, + "name": "湖北省", + "children": [ + { + "id": 4201, + "name": "武汉市", + "children": [ + { "id": 420102, "name": "江岸区" }, + { "id": 420103, "name": "江汉区" }, + { "id": 420104, "name": "硚口区" }, + { "id": 420105, "name": "汉阳区" }, + { "id": 420106, "name": "武昌区" }, + { "id": 420107, "name": "青山区" }, + { "id": 420111, "name": "洪山区" }, + { "id": 420112, "name": "东西湖区" }, + { "id": 420113, "name": "汉南区" }, + { "id": 420114, "name": "蔡甸区" }, + { "id": 420115, "name": "江夏区" }, + { "id": 420116, "name": "黄陂区" }, + { "id": 420117, "name": "新洲区" } + ] + }, + { + "id": 4202, + "name": "黄石市", + "children": [ + { "id": 420202, "name": "黄石港区" }, + { "id": 420203, "name": "西塞山区" }, + { "id": 420204, "name": "下陆区" }, + { "id": 420205, "name": "铁山区" }, + { "id": 420222, "name": "阳新县" }, + { "id": 420281, "name": "大冶市" } + ] + }, + { + "id": 4203, + "name": "十堰市", + "children": [ + { "id": 420302, "name": "茅箭区" }, + { "id": 420303, "name": "张湾区" }, + { "id": 420304, "name": "郧阳区" }, + { "id": 420322, "name": "郧西县" }, + { "id": 420323, "name": "竹山县" }, + { "id": 420324, "name": "竹溪县" }, + { "id": 420325, "name": "房县" }, + { "id": 420381, "name": "丹江口市" } + ] + }, + { + "id": 4205, + "name": "宜昌市", + "children": [ + { "id": 420502, "name": "西陵区" }, + { "id": 420503, "name": "伍家岗区" }, + { "id": 420504, "name": "点军区" }, + { "id": 420505, "name": "猇亭区" }, + { "id": 420506, "name": "夷陵区" }, + { "id": 420525, "name": "远安县" }, + { "id": 420526, "name": "兴山县" }, + { "id": 420527, "name": "秭归县" }, + { "id": 420528, "name": "长阳土家族自治县" }, + { "id": 420529, "name": "五峰土家族自治县" }, + { "id": 420581, "name": "宜都市" }, + { "id": 420582, "name": "当阳市" }, + { "id": 420583, "name": "枝江市" } + ] + }, + { + "id": 4206, + "name": "襄阳市", + "children": [ + { "id": 420602, "name": "襄城区" }, + { "id": 420606, "name": "樊城区" }, + { "id": 420607, "name": "襄州区" }, + { "id": 420624, "name": "南漳县" }, + { "id": 420625, "name": "谷城县" }, + { "id": 420626, "name": "保康县" }, + { "id": 420682, "name": "老河口市" }, + { "id": 420683, "name": "枣阳市" }, + { "id": 420684, "name": "宜城市" } + ] + }, + { + "id": 4207, + "name": "鄂州市", + "children": [ + { "id": 420702, "name": "梁子湖区" }, + { "id": 420703, "name": "华容区" }, + { "id": 420704, "name": "鄂城区" } + ] + }, + { + "id": 4208, + "name": "荆门市", + "children": [ + { "id": 420802, "name": "东宝区" }, + { "id": 420804, "name": "掇刀区" }, + { "id": 420822, "name": "沙洋县" }, + { "id": 420881, "name": "钟祥市" }, + { "id": 420882, "name": "京山市" } + ] + }, + { + "id": 4209, + "name": "孝感市", + "children": [ + { "id": 420902, "name": "孝南区" }, + { "id": 420921, "name": "孝昌县" }, + { "id": 420922, "name": "大悟县" }, + { "id": 420923, "name": "云梦县" }, + { "id": 420981, "name": "应城市" }, + { "id": 420982, "name": "安陆市" }, + { "id": 420984, "name": "汉川市" } + ] + }, + { + "id": 4210, + "name": "荆州市", + "children": [ + { "id": 421002, "name": "沙市区" }, + { "id": 421003, "name": "荆州区" }, + { "id": 421022, "name": "公安县" }, + { "id": 421024, "name": "江陵县" }, + { "id": 421071, "name": "荆州经济技术开发区" }, + { "id": 421081, "name": "石首市" }, + { "id": 421083, "name": "洪湖市" }, + { "id": 421087, "name": "松滋市" }, + { "id": 421088, "name": "监利市" } + ] + }, + { + "id": 4211, + "name": "黄冈市", + "children": [ + { "id": 421102, "name": "黄州区" }, + { "id": 421121, "name": "团风县" }, + { "id": 421122, "name": "红安县" }, + { "id": 421123, "name": "罗田县" }, + { "id": 421124, "name": "英山县" }, + { "id": 421125, "name": "浠水县" }, + { "id": 421126, "name": "蕲春县" }, + { "id": 421127, "name": "黄梅县" }, + { "id": 421171, "name": "龙感湖管理区" }, + { "id": 421181, "name": "麻城市" }, + { "id": 421182, "name": "武穴市" } + ] + }, + { + "id": 4212, + "name": "咸宁市", + "children": [ + { "id": 421202, "name": "咸安区" }, + { "id": 421221, "name": "嘉鱼县" }, + { "id": 421222, "name": "通城县" }, + { "id": 421223, "name": "崇阳县" }, + { "id": 421224, "name": "通山县" }, + { "id": 421281, "name": "赤壁市" } + ] + }, + { + "id": 4213, + "name": "随州市", + "children": [ + { "id": 421303, "name": "曾都区" }, + { "id": 421321, "name": "随县" }, + { "id": 421381, "name": "广水市" } + ] + }, + { + "id": 4228, + "name": "恩施土家族苗族自治州", + "children": [ + { "id": 422801, "name": "恩施市" }, + { "id": 422802, "name": "利川市" }, + { "id": 422822, "name": "建始县" }, + { "id": 422823, "name": "巴东县" }, + { "id": 422825, "name": "宣恩县" }, + { "id": 422826, "name": "咸丰县" }, + { "id": 422827, "name": "来凤县" }, + { "id": 422828, "name": "鹤峰县" } + ] + }, + { + "id": 4290, + "name": "省直辖县级行政区划", + "children": [ + { "id": 429004, "name": "仙桃市" }, + { "id": 429005, "name": "潜江市" }, + { "id": 429006, "name": "天门市" }, + { "id": 429021, "name": "神农架林区" } + ] + } + ] + }, + { + "id": 43, + "name": "湖南省", + "children": [ + { + "id": 4301, + "name": "长沙市", + "children": [ + { "id": 430102, "name": "芙蓉区" }, + { "id": 430103, "name": "天心区" }, + { "id": 430104, "name": "岳麓区" }, + { "id": 430105, "name": "开福区" }, + { "id": 430111, "name": "雨花区" }, + { "id": 430112, "name": "望城区" }, + { "id": 430121, "name": "长沙县" }, + { "id": 430181, "name": "浏阳市" }, + { "id": 430182, "name": "宁乡市" } + ] + }, + { + "id": 4302, + "name": "株洲市", + "children": [ + { "id": 430202, "name": "荷塘区" }, + { "id": 430203, "name": "芦淞区" }, + { "id": 430204, "name": "石峰区" }, + { "id": 430211, "name": "天元区" }, + { "id": 430212, "name": "渌口区" }, + { "id": 430223, "name": "攸县" }, + { "id": 430224, "name": "茶陵县" }, + { "id": 430225, "name": "炎陵县" }, + { "id": 430281, "name": "醴陵市" } + ] + }, + { + "id": 4303, + "name": "湘潭市", + "children": [ + { "id": 430302, "name": "雨湖区" }, + { "id": 430304, "name": "岳塘区" }, + { "id": 430321, "name": "湘潭县" }, + { "id": 430371, "name": "湖南湘潭高新技术产业园区" }, + { "id": 430372, "name": "湘潭昭山示范区" }, + { "id": 430373, "name": "湘潭九华示范区" }, + { "id": 430381, "name": "湘乡市" }, + { "id": 430382, "name": "韶山市" } + ] + }, + { + "id": 4304, + "name": "衡阳市", + "children": [ + { "id": 430405, "name": "珠晖区" }, + { "id": 430406, "name": "雁峰区" }, + { "id": 430407, "name": "石鼓区" }, + { "id": 430408, "name": "蒸湘区" }, + { "id": 430412, "name": "南岳区" }, + { "id": 430421, "name": "衡阳县" }, + { "id": 430422, "name": "衡南县" }, + { "id": 430423, "name": "衡山县" }, + { "id": 430424, "name": "衡东县" }, + { "id": 430426, "name": "祁东县" }, + { "id": 430473, "name": "湖南衡阳松木经济开发区" }, + { "id": 430476, "name": "湖南衡阳高新技术产业园区" }, + { "id": 430481, "name": "耒阳市" }, + { "id": 430482, "name": "常宁市" } + ] + }, + { + "id": 4305, + "name": "邵阳市", + "children": [ + { "id": 430502, "name": "双清区" }, + { "id": 430503, "name": "大祥区" }, + { "id": 430511, "name": "北塔区" }, + { "id": 430522, "name": "新邵县" }, + { "id": 430523, "name": "邵阳县" }, + { "id": 430524, "name": "隆回县" }, + { "id": 430525, "name": "洞口县" }, + { "id": 430527, "name": "绥宁县" }, + { "id": 430528, "name": "新宁县" }, + { "id": 430529, "name": "城步苗族自治县" }, + { "id": 430581, "name": "武冈市" }, + { "id": 430582, "name": "邵东市" } + ] + }, + { + "id": 4306, + "name": "岳阳市", + "children": [ + { "id": 430602, "name": "岳阳楼区" }, + { "id": 430603, "name": "云溪区" }, + { "id": 430611, "name": "君山区" }, + { "id": 430621, "name": "岳阳县" }, + { "id": 430623, "name": "华容县" }, + { "id": 430624, "name": "湘阴县" }, + { "id": 430626, "name": "平江县" }, + { "id": 430671, "name": "岳阳市屈原管理区" }, + { "id": 430681, "name": "汨罗市" }, + { "id": 430682, "name": "临湘市" } + ] + }, + { + "id": 4307, + "name": "常德市", + "children": [ + { "id": 430702, "name": "武陵区" }, + { "id": 430703, "name": "鼎城区" }, + { "id": 430721, "name": "安乡县" }, + { "id": 430722, "name": "汉寿县" }, + { "id": 430723, "name": "澧县" }, + { "id": 430724, "name": "临澧县" }, + { "id": 430725, "name": "桃源县" }, + { "id": 430726, "name": "石门县" }, + { "id": 430771, "name": "常德市西洞庭管理区" }, + { "id": 430781, "name": "津市市" } + ] + }, + { + "id": 4308, + "name": "张家界市", + "children": [ + { "id": 430802, "name": "永定区" }, + { "id": 430811, "name": "武陵源区" }, + { "id": 430821, "name": "慈利县" }, + { "id": 430822, "name": "桑植县" } + ] + }, + { + "id": 4309, + "name": "益阳市", + "children": [ + { "id": 430902, "name": "资阳区" }, + { "id": 430903, "name": "赫山区" }, + { "id": 430921, "name": "南县" }, + { "id": 430922, "name": "桃江县" }, + { "id": 430923, "name": "安化县" }, + { "id": 430971, "name": "益阳市大通湖管理区" }, + { "id": 430972, "name": "湖南益阳高新技术产业园区" }, + { "id": 430981, "name": "沅江市" } + ] + }, + { + "id": 4310, + "name": "郴州市", + "children": [ + { "id": 431002, "name": "北湖区" }, + { "id": 431003, "name": "苏仙区" }, + { "id": 431021, "name": "桂阳县" }, + { "id": 431022, "name": "宜章县" }, + { "id": 431023, "name": "永兴县" }, + { "id": 431024, "name": "嘉禾县" }, + { "id": 431025, "name": "临武县" }, + { "id": 431026, "name": "汝城县" }, + { "id": 431027, "name": "桂东县" }, + { "id": 431028, "name": "安仁县" }, + { "id": 431081, "name": "资兴市" } + ] + }, + { + "id": 4311, + "name": "永州市", + "children": [ + { "id": 431102, "name": "零陵区" }, + { "id": 431103, "name": "冷水滩区" }, + { "id": 431122, "name": "东安县" }, + { "id": 431123, "name": "双牌县" }, + { "id": 431124, "name": "道县" }, + { "id": 431125, "name": "江永县" }, + { "id": 431126, "name": "宁远县" }, + { "id": 431127, "name": "蓝山县" }, + { "id": 431128, "name": "新田县" }, + { "id": 431129, "name": "江华瑶族自治县" }, + { "id": 431171, "name": "永州经济技术开发区" }, + { "id": 431173, "name": "永州市回龙圩管理区" }, + { "id": 431181, "name": "祁阳市" } + ] + }, + { + "id": 4312, + "name": "怀化市", + "children": [ + { "id": 431202, "name": "鹤城区" }, + { "id": 431221, "name": "中方县" }, + { "id": 431222, "name": "沅陵县" }, + { "id": 431223, "name": "辰溪县" }, + { "id": 431224, "name": "溆浦县" }, + { "id": 431225, "name": "会同县" }, + { "id": 431226, "name": "麻阳苗族自治县" }, + { "id": 431227, "name": "新晃侗族自治县" }, + { "id": 431228, "name": "芷江侗族自治县" }, + { "id": 431229, "name": "靖州苗族侗族自治县" }, + { "id": 431230, "name": "通道侗族自治县" }, + { "id": 431271, "name": "怀化市洪江管理区" }, + { "id": 431281, "name": "洪江市" } + ] + }, + { + "id": 4313, + "name": "娄底市", + "children": [ + { "id": 431302, "name": "娄星区" }, + { "id": 431321, "name": "双峰县" }, + { "id": 431322, "name": "新化县" }, + { "id": 431381, "name": "冷水江市" }, + { "id": 431382, "name": "涟源市" } + ] + }, + { + "id": 4331, + "name": "湘西土家族苗族自治州", + "children": [ + { "id": 433101, "name": "吉首市" }, + { "id": 433122, "name": "泸溪县" }, + { "id": 433123, "name": "凤凰县" }, + { "id": 433124, "name": "花垣县" }, + { "id": 433125, "name": "保靖县" }, + { "id": 433126, "name": "古丈县" }, + { "id": 433127, "name": "永顺县" }, + { "id": 433130, "name": "龙山县" } + ] + } + ] + }, + { + "id": 44, + "name": "广东省", + "children": [ + { + "id": 4401, + "name": "广州市", + "children": [ + { "id": 440103, "name": "荔湾区" }, + { "id": 440104, "name": "越秀区" }, + { "id": 440105, "name": "海珠区" }, + { "id": 440106, "name": "天河区" }, + { "id": 440111, "name": "白云区" }, + { "id": 440112, "name": "黄埔区" }, + { "id": 440113, "name": "番禺区" }, + { "id": 440114, "name": "花都区" }, + { "id": 440115, "name": "南沙区" }, + { "id": 440117, "name": "从化区" }, + { "id": 440118, "name": "增城区" } + ] + }, + { + "id": 4402, + "name": "韶关市", + "children": [ + { "id": 440203, "name": "武江区" }, + { "id": 440204, "name": "浈江区" }, + { "id": 440205, "name": "曲江区" }, + { "id": 440222, "name": "始兴县" }, + { "id": 440224, "name": "仁化县" }, + { "id": 440229, "name": "翁源县" }, + { "id": 440232, "name": "乳源瑶族自治县" }, + { "id": 440233, "name": "新丰县" }, + { "id": 440281, "name": "乐昌市" }, + { "id": 440282, "name": "南雄市" } + ] + }, + { + "id": 4403, + "name": "深圳市", + "children": [ + { "id": 440303, "name": "罗湖区" }, + { "id": 440304, "name": "福田区" }, + { "id": 440305, "name": "南山区" }, + { "id": 440306, "name": "宝安区" }, + { "id": 440307, "name": "龙岗区" }, + { "id": 440308, "name": "盐田区" }, + { "id": 440309, "name": "龙华区" }, + { "id": 440310, "name": "坪山区" }, + { "id": 440311, "name": "光明区" } + ] + }, + { + "id": 4404, + "name": "珠海市", + "children": [ + { "id": 440402, "name": "香洲区" }, + { "id": 440403, "name": "斗门区" }, + { "id": 440404, "name": "金湾区" } + ] + }, + { + "id": 4405, + "name": "汕头市", + "children": [ + { "id": 440507, "name": "龙湖区" }, + { "id": 440511, "name": "金平区" }, + { "id": 440512, "name": "濠江区" }, + { "id": 440513, "name": "潮阳区" }, + { "id": 440514, "name": "潮南区" }, + { "id": 440515, "name": "澄海区" }, + { "id": 440523, "name": "南澳县" } + ] + }, + { + "id": 4406, + "name": "佛山市", + "children": [ + { "id": 440604, "name": "禅城区" }, + { "id": 440605, "name": "南海区" }, + { "id": 440606, "name": "顺德区" }, + { "id": 440607, "name": "三水区" }, + { "id": 440608, "name": "高明区" } + ] + }, + { + "id": 4407, + "name": "江门市", + "children": [ + { "id": 440703, "name": "蓬江区" }, + { "id": 440704, "name": "江海区" }, + { "id": 440705, "name": "新会区" }, + { "id": 440781, "name": "台山市" }, + { "id": 440783, "name": "开平市" }, + { "id": 440784, "name": "鹤山市" }, + { "id": 440785, "name": "恩平市" } + ] + }, + { + "id": 4408, + "name": "湛江市", + "children": [ + { "id": 440802, "name": "赤坎区" }, + { "id": 440803, "name": "霞山区" }, + { "id": 440804, "name": "坡头区" }, + { "id": 440811, "name": "麻章区" }, + { "id": 440823, "name": "遂溪县" }, + { "id": 440825, "name": "徐闻县" }, + { "id": 440881, "name": "廉江市" }, + { "id": 440882, "name": "雷州市" }, + { "id": 440883, "name": "吴川市" } + ] + }, + { + "id": 4409, + "name": "茂名市", + "children": [ + { "id": 440902, "name": "茂南区" }, + { "id": 440904, "name": "电白区" }, + { "id": 440981, "name": "高州市" }, + { "id": 440982, "name": "化州市" }, + { "id": 440983, "name": "信宜市" } + ] + }, + { + "id": 4412, + "name": "肇庆市", + "children": [ + { "id": 441202, "name": "端州区" }, + { "id": 441203, "name": "鼎湖区" }, + { "id": 441204, "name": "高要区" }, + { "id": 441223, "name": "广宁县" }, + { "id": 441224, "name": "怀集县" }, + { "id": 441225, "name": "封开县" }, + { "id": 441226, "name": "德庆县" }, + { "id": 441284, "name": "四会市" } + ] + }, + { + "id": 4413, + "name": "惠州市", + "children": [ + { "id": 441302, "name": "惠城区" }, + { "id": 441303, "name": "惠阳区" }, + { "id": 441322, "name": "博罗县" }, + { "id": 441323, "name": "惠东县" }, + { "id": 441324, "name": "龙门县" } + ] + }, + { + "id": 4414, + "name": "梅州市", + "children": [ + { "id": 441402, "name": "梅江区" }, + { "id": 441403, "name": "梅县区" }, + { "id": 441422, "name": "大埔县" }, + { "id": 441423, "name": "丰顺县" }, + { "id": 441424, "name": "五华县" }, + { "id": 441426, "name": "平远县" }, + { "id": 441427, "name": "蕉岭县" }, + { "id": 441481, "name": "兴宁市" } + ] + }, + { + "id": 4415, + "name": "汕尾市", + "children": [ + { "id": 441502, "name": "城区" }, + { "id": 441521, "name": "海丰县" }, + { "id": 441523, "name": "陆河县" }, + { "id": 441581, "name": "陆丰市" } + ] + }, + { + "id": 4416, + "name": "河源市", + "children": [ + { "id": 441602, "name": "源城区" }, + { "id": 441621, "name": "紫金县" }, + { "id": 441622, "name": "龙川县" }, + { "id": 441623, "name": "连平县" }, + { "id": 441624, "name": "和平县" }, + { "id": 441625, "name": "东源县" } + ] + }, + { + "id": 4417, + "name": "阳江市", + "children": [ + { "id": 441702, "name": "江城区" }, + { "id": 441704, "name": "阳东区" }, + { "id": 441721, "name": "阳西县" }, + { "id": 441781, "name": "阳春市" } + ] + }, + { + "id": 4418, + "name": "清远市", + "children": [ + { "id": 441802, "name": "清城区" }, + { "id": 441803, "name": "清新区" }, + { "id": 441821, "name": "佛冈县" }, + { "id": 441823, "name": "阳山县" }, + { "id": 441825, "name": "连山壮族瑶族自治县" }, + { "id": 441826, "name": "连南瑶族自治县" }, + { "id": 441881, "name": "英德市" }, + { "id": 441882, "name": "连州市" } + ] + }, + { + "id": 4419, + "name": "东莞市", + "children": [ + { "id": 441900003, "name": "东城街道" }, + { "id": 441900004, "name": "南城街道" }, + { "id": 441900005, "name": "万江街道" }, + { "id": 441900006, "name": "莞城街道" }, + { "id": 441900101, "name": "石碣镇" }, + { "id": 441900102, "name": "石龙镇" }, + { "id": 441900103, "name": "茶山镇" }, + { "id": 441900104, "name": "石排镇" }, + { "id": 441900105, "name": "企石镇" }, + { "id": 441900106, "name": "横沥镇" }, + { "id": 441900107, "name": "桥头镇" }, + { "id": 441900108, "name": "谢岗镇" }, + { "id": 441900109, "name": "东坑镇" }, + { "id": 441900110, "name": "常平镇" }, + { "id": 441900111, "name": "寮步镇" }, + { "id": 441900112, "name": "樟木头镇" }, + { "id": 441900113, "name": "大朗镇" }, + { "id": 441900114, "name": "黄江镇" }, + { "id": 441900115, "name": "清溪镇" }, + { "id": 441900116, "name": "塘厦镇" }, + { "id": 441900117, "name": "凤岗镇" }, + { "id": 441900118, "name": "大岭山镇" }, + { "id": 441900119, "name": "长安镇" }, + { "id": 441900121, "name": "虎门镇" }, + { "id": 441900122, "name": "厚街镇" }, + { "id": 441900123, "name": "沙田镇" }, + { "id": 441900124, "name": "道滘镇" }, + { "id": 441900125, "name": "洪梅镇" }, + { "id": 441900126, "name": "麻涌镇" }, + { "id": 441900127, "name": "望牛墩镇" }, + { "id": 441900128, "name": "中堂镇" }, + { "id": 441900129, "name": "高埗镇" }, + { "id": 441900401, "name": "松山湖" }, + { "id": 441900402, "name": "东莞港" }, + { "id": 441900403, "name": "东莞生态园" }, + { "id": 441900404, "name": "东莞滨海湾新区" } + ] + }, + { + "id": 4420, + "name": "中山市", + "children": [ + { "id": 442000001, "name": "石岐街道" }, + { "id": 442000002, "name": "东区街道" }, + { "id": 442000003, "name": "中山港街道" }, + { "id": 442000004, "name": "西区街道" }, + { "id": 442000005, "name": "南区街道" }, + { "id": 442000006, "name": "五桂山街道" }, + { "id": 442000007, "name": "民众街道" }, + { "id": 442000008, "name": "南朗街道" }, + { "id": 442000101, "name": "黄圃镇" }, + { "id": 442000103, "name": "东凤镇" }, + { "id": 442000105, "name": "古镇镇" }, + { "id": 442000106, "name": "沙溪镇" }, + { "id": 442000107, "name": "坦洲镇" }, + { "id": 442000108, "name": "港口镇" }, + { "id": 442000109, "name": "三角镇" }, + { "id": 442000110, "name": "横栏镇" }, + { "id": 442000111, "name": "南头镇" }, + { "id": 442000112, "name": "阜沙镇" }, + { "id": 442000114, "name": "三乡镇" }, + { "id": 442000115, "name": "板芙镇" }, + { "id": 442000116, "name": "大涌镇" }, + { "id": 442000117, "name": "神湾镇" }, + { "id": 442000118, "name": "小榄镇" } + ] + }, + { + "id": 4451, + "name": "潮州市", + "children": [ + { "id": 445102, "name": "湘桥区" }, + { "id": 445103, "name": "潮安区" }, + { "id": 445122, "name": "饶平县" } + ] + }, + { + "id": 4452, + "name": "揭阳市", + "children": [ + { "id": 445202, "name": "榕城区" }, + { "id": 445203, "name": "揭东区" }, + { "id": 445222, "name": "揭西县" }, + { "id": 445224, "name": "惠来县" }, + { "id": 445281, "name": "普宁市" } + ] + }, + { + "id": 4453, + "name": "云浮市", + "children": [ + { "id": 445302, "name": "云城区" }, + { "id": 445303, "name": "云安区" }, + { "id": 445321, "name": "新兴县" }, + { "id": 445322, "name": "郁南县" }, + { "id": 445381, "name": "罗定市" } + ] + } + ] + }, + { + "id": 45, + "name": "广西壮族自治区", + "children": [ + { + "id": 4501, + "name": "南宁市", + "children": [ + { "id": 450102, "name": "兴宁区" }, + { "id": 450103, "name": "青秀区" }, + { "id": 450105, "name": "江南区" }, + { "id": 450107, "name": "西乡塘区" }, + { "id": 450108, "name": "良庆区" }, + { "id": 450109, "name": "邕宁区" }, + { "id": 450110, "name": "武鸣区" }, + { "id": 450123, "name": "隆安县" }, + { "id": 450124, "name": "马山县" }, + { "id": 450125, "name": "上林县" }, + { "id": 450126, "name": "宾阳县" }, + { "id": 450181, "name": "横州市" } + ] + }, + { + "id": 4502, + "name": "柳州市", + "children": [ + { "id": 450202, "name": "城中区" }, + { "id": 450203, "name": "鱼峰区" }, + { "id": 450204, "name": "柳南区" }, + { "id": 450205, "name": "柳北区" }, + { "id": 450206, "name": "柳江区" }, + { "id": 450222, "name": "柳城县" }, + { "id": 450223, "name": "鹿寨县" }, + { "id": 450224, "name": "融安县" }, + { "id": 450225, "name": "融水苗族自治县" }, + { "id": 450226, "name": "三江侗族自治县" } + ] + }, + { + "id": 4503, + "name": "桂林市", + "children": [ + { "id": 450302, "name": "秀峰区" }, + { "id": 450303, "name": "叠彩区" }, + { "id": 450304, "name": "象山区" }, + { "id": 450305, "name": "七星区" }, + { "id": 450311, "name": "雁山区" }, + { "id": 450312, "name": "临桂区" }, + { "id": 450321, "name": "阳朔县" }, + { "id": 450323, "name": "灵川县" }, + { "id": 450324, "name": "全州县" }, + { "id": 450325, "name": "兴安县" }, + { "id": 450326, "name": "永福县" }, + { "id": 450327, "name": "灌阳县" }, + { "id": 450328, "name": "龙胜各族自治县" }, + { "id": 450329, "name": "资源县" }, + { "id": 450330, "name": "平乐县" }, + { "id": 450332, "name": "恭城瑶族自治县" }, + { "id": 450381, "name": "荔浦市" } + ] + }, + { + "id": 4504, + "name": "梧州市", + "children": [ + { "id": 450403, "name": "万秀区" }, + { "id": 450405, "name": "长洲区" }, + { "id": 450406, "name": "龙圩区" }, + { "id": 450421, "name": "苍梧县" }, + { "id": 450422, "name": "藤县" }, + { "id": 450423, "name": "蒙山县" }, + { "id": 450481, "name": "岑溪市" } + ] + }, + { + "id": 4505, + "name": "北海市", + "children": [ + { "id": 450502, "name": "海城区" }, + { "id": 450503, "name": "银海区" }, + { "id": 450512, "name": "铁山港区" }, + { "id": 450521, "name": "合浦县" } + ] + }, + { + "id": 4506, + "name": "防城港市", + "children": [ + { "id": 450602, "name": "港口区" }, + { "id": 450603, "name": "防城区" }, + { "id": 450621, "name": "上思县" }, + { "id": 450681, "name": "东兴市" } + ] + }, + { + "id": 4507, + "name": "钦州市", + "children": [ + { "id": 450702, "name": "钦南区" }, + { "id": 450703, "name": "钦北区" }, + { "id": 450721, "name": "灵山县" }, + { "id": 450722, "name": "浦北县" } + ] + }, + { + "id": 4508, + "name": "贵港市", + "children": [ + { "id": 450802, "name": "港北区" }, + { "id": 450803, "name": "港南区" }, + { "id": 450804, "name": "覃塘区" }, + { "id": 450821, "name": "平南县" }, + { "id": 450881, "name": "桂平市" } + ] + }, + { + "id": 4509, + "name": "玉林市", + "children": [ + { "id": 450902, "name": "玉州区" }, + { "id": 450903, "name": "福绵区" }, + { "id": 450921, "name": "容县" }, + { "id": 450922, "name": "陆川县" }, + { "id": 450923, "name": "博白县" }, + { "id": 450924, "name": "兴业县" }, + { "id": 450981, "name": "北流市" } + ] + }, + { + "id": 4510, + "name": "百色市", + "children": [ + { "id": 451002, "name": "右江区" }, + { "id": 451003, "name": "田阳区" }, + { "id": 451022, "name": "田东县" }, + { "id": 451024, "name": "德保县" }, + { "id": 451026, "name": "那坡县" }, + { "id": 451027, "name": "凌云县" }, + { "id": 451028, "name": "乐业县" }, + { "id": 451029, "name": "田林县" }, + { "id": 451030, "name": "西林县" }, + { "id": 451031, "name": "隆林各族自治县" }, + { "id": 451081, "name": "靖西市" }, + { "id": 451082, "name": "平果市" } + ] + }, + { + "id": 4511, + "name": "贺州市", + "children": [ + { "id": 451102, "name": "八步区" }, + { "id": 451103, "name": "平桂区" }, + { "id": 451121, "name": "昭平县" }, + { "id": 451122, "name": "钟山县" }, + { "id": 451123, "name": "富川瑶族自治县" } + ] + }, + { + "id": 4512, + "name": "河池市", + "children": [ + { "id": 451202, "name": "金城江区" }, + { "id": 451203, "name": "宜州区" }, + { "id": 451221, "name": "南丹县" }, + { "id": 451222, "name": "天峨县" }, + { "id": 451223, "name": "凤山县" }, + { "id": 451224, "name": "东兰县" }, + { "id": 451225, "name": "罗城仫佬族自治县" }, + { "id": 451226, "name": "环江毛南族自治县" }, + { "id": 451227, "name": "巴马瑶族自治县" }, + { "id": 451228, "name": "都安瑶族自治县" }, + { "id": 451229, "name": "大化瑶族自治县" } + ] + }, + { + "id": 4513, + "name": "来宾市", + "children": [ + { "id": 451302, "name": "兴宾区" }, + { "id": 451321, "name": "忻城县" }, + { "id": 451322, "name": "象州县" }, + { "id": 451323, "name": "武宣县" }, + { "id": 451324, "name": "金秀瑶族自治县" }, + { "id": 451381, "name": "合山市" } + ] + }, + { + "id": 4514, + "name": "崇左市", + "children": [ + { "id": 451402, "name": "江州区" }, + { "id": 451421, "name": "扶绥县" }, + { "id": 451422, "name": "宁明县" }, + { "id": 451423, "name": "龙州县" }, + { "id": 451424, "name": "大新县" }, + { "id": 451425, "name": "天等县" }, + { "id": 451481, "name": "凭祥市" } + ] + } + ] + }, + { + "id": 46, + "name": "海南省", + "children": [ + { + "id": 4601, + "name": "海口市", + "children": [ + { "id": 460105, "name": "秀英区" }, + { "id": 460106, "name": "龙华区" }, + { "id": 460107, "name": "琼山区" }, + { "id": 460108, "name": "美兰区" } + ] + }, + { + "id": 4602, + "name": "三亚市", + "children": [ + { "id": 460202, "name": "海棠区" }, + { "id": 460203, "name": "吉阳区" }, + { "id": 460204, "name": "天涯区" }, + { "id": 460205, "name": "崖州区" } + ] + }, + { + "id": 4603, + "name": "三沙市", + "children": [ + { "id": 460321, "name": "西沙群岛" }, + { "id": 460322, "name": "南沙群岛" }, + { "id": 460323, "name": "中沙群岛的岛礁及其海域" } + ] + }, + { + "id": 4604, + "name": "儋州市", + "children": [ + { "id": 460400100, "name": "那大镇" }, + { "id": 460400101, "name": "和庆镇" }, + { "id": 460400102, "name": "南丰镇" }, + { "id": 460400103, "name": "大成镇" }, + { "id": 460400104, "name": "雅星镇" }, + { "id": 460400105, "name": "兰洋镇" }, + { "id": 460400106, "name": "光村镇" }, + { "id": 460400107, "name": "木棠镇" }, + { "id": 460400108, "name": "海头镇" }, + { "id": 460400109, "name": "峨蔓镇" }, + { "id": 460400111, "name": "王五镇" }, + { "id": 460400112, "name": "白马井镇" }, + { "id": 460400113, "name": "中和镇" }, + { "id": 460400114, "name": "排浦镇" }, + { "id": 460400115, "name": "东成镇" }, + { "id": 460400116, "name": "新州镇" }, + { "id": 460400499, "name": "洋浦经济开发区" }, + { "id": 460400500, "name": "华南热作学院" } + ] + }, + { + "id": 4690, + "name": "省直辖县级行政区划", + "children": [ + { "id": 469001, "name": "五指山市" }, + { "id": 469002, "name": "琼海市" }, + { "id": 469005, "name": "文昌市" }, + { "id": 469006, "name": "万宁市" }, + { "id": 469007, "name": "东方市" }, + { "id": 469021, "name": "定安县" }, + { "id": 469022, "name": "屯昌县" }, + { "id": 469023, "name": "澄迈县" }, + { "id": 469024, "name": "临高县" }, + { "id": 469025, "name": "白沙黎族自治县" }, + { "id": 469026, "name": "昌江黎族自治县" }, + { "id": 469027, "name": "乐东黎族自治县" }, + { "id": 469028, "name": "陵水黎族自治县" }, + { "id": 469029, "name": "保亭黎族苗族自治县" }, + { "id": 469030, "name": "琼中黎族苗族自治县" } + ] + } + ] + }, + { + "id": 50, + "name": "重庆市", + "children": [ + { + "id": 5001, + "name": "市辖区", + "children": [ + { "id": 500101, "name": "万州区" }, + { "id": 500102, "name": "涪陵区" }, + { "id": 500103, "name": "渝中区" }, + { "id": 500104, "name": "大渡口区" }, + { "id": 500105, "name": "江北区" }, + { "id": 500106, "name": "沙坪坝区" }, + { "id": 500107, "name": "九龙坡区" }, + { "id": 500108, "name": "南岸区" }, + { "id": 500109, "name": "北碚区" }, + { "id": 500110, "name": "綦江区" }, + { "id": 500111, "name": "大足区" }, + { "id": 500112, "name": "渝北区" }, + { "id": 500113, "name": "巴南区" }, + { "id": 500114, "name": "黔江区" }, + { "id": 500115, "name": "长寿区" }, + { "id": 500116, "name": "江津区" }, + { "id": 500117, "name": "合川区" }, + { "id": 500118, "name": "永川区" }, + { "id": 500119, "name": "南川区" }, + { "id": 500120, "name": "璧山区" }, + { "id": 500151, "name": "铜梁区" }, + { "id": 500152, "name": "潼南区" }, + { "id": 500153, "name": "荣昌区" }, + { "id": 500154, "name": "开州区" }, + { "id": 500155, "name": "梁平区" }, + { "id": 500156, "name": "武隆区" } + ] + }, + { + "id": 5002, + "name": "县", + "children": [ + { "id": 500229, "name": "城口县" }, + { "id": 500230, "name": "丰都县" }, + { "id": 500231, "name": "垫江县" }, + { "id": 500233, "name": "忠县" }, + { "id": 500235, "name": "云阳县" }, + { "id": 500236, "name": "奉节县" }, + { "id": 500237, "name": "巫山县" }, + { "id": 500238, "name": "巫溪县" }, + { "id": 500240, "name": "石柱土家族自治县" }, + { "id": 500241, "name": "秀山土家族苗族自治县" }, + { "id": 500242, "name": "酉阳土家族苗族自治县" }, + { "id": 500243, "name": "彭水苗族土家族自治县" } + ] + } + ] + }, + { + "id": 51, + "name": "四川省", + "children": [ + { + "id": 5101, + "name": "成都市", + "children": [ + { "id": 510104, "name": "锦江区" }, + { "id": 510105, "name": "青羊区" }, + { "id": 510106, "name": "金牛区" }, + { "id": 510107, "name": "武侯区" }, + { "id": 510108, "name": "成华区" }, + { "id": 510112, "name": "龙泉驿区" }, + { "id": 510113, "name": "青白江区" }, + { "id": 510114, "name": "新都区" }, + { "id": 510115, "name": "温江区" }, + { "id": 510116, "name": "双流区" }, + { "id": 510117, "name": "郫都区" }, + { "id": 510118, "name": "新津区" }, + { "id": 510121, "name": "金堂县" }, + { "id": 510129, "name": "大邑县" }, + { "id": 510131, "name": "蒲江县" }, + { "id": 510181, "name": "都江堰市" }, + { "id": 510182, "name": "彭州市" }, + { "id": 510183, "name": "邛崃市" }, + { "id": 510184, "name": "崇州市" }, + { "id": 510185, "name": "简阳市" } + ] + }, + { + "id": 5103, + "name": "自贡市", + "children": [ + { "id": 510302, "name": "自流井区" }, + { "id": 510303, "name": "贡井区" }, + { "id": 510304, "name": "大安区" }, + { "id": 510311, "name": "沿滩区" }, + { "id": 510321, "name": "荣县" }, + { "id": 510322, "name": "富顺县" } + ] + }, + { + "id": 5104, + "name": "攀枝花市", + "children": [ + { "id": 510402, "name": "东区" }, + { "id": 510403, "name": "西区" }, + { "id": 510411, "name": "仁和区" }, + { "id": 510421, "name": "米易县" }, + { "id": 510422, "name": "盐边县" } + ] + }, + { + "id": 5105, + "name": "泸州市", + "children": [ + { "id": 510502, "name": "江阳区" }, + { "id": 510503, "name": "纳溪区" }, + { "id": 510504, "name": "龙马潭区" }, + { "id": 510521, "name": "泸县" }, + { "id": 510522, "name": "合江县" }, + { "id": 510524, "name": "叙永县" }, + { "id": 510525, "name": "古蔺县" } + ] + }, + { + "id": 5106, + "name": "德阳市", + "children": [ + { "id": 510603, "name": "旌阳区" }, + { "id": 510604, "name": "罗江区" }, + { "id": 510623, "name": "中江县" }, + { "id": 510681, "name": "广汉市" }, + { "id": 510682, "name": "什邡市" }, + { "id": 510683, "name": "绵竹市" } + ] + }, + { + "id": 5107, + "name": "绵阳市", + "children": [ + { "id": 510703, "name": "涪城区" }, + { "id": 510704, "name": "游仙区" }, + { "id": 510705, "name": "安州区" }, + { "id": 510722, "name": "三台县" }, + { "id": 510723, "name": "盐亭县" }, + { "id": 510725, "name": "梓潼县" }, + { "id": 510726, "name": "北川羌族自治县" }, + { "id": 510727, "name": "平武县" }, + { "id": 510781, "name": "江油市" } + ] + }, + { + "id": 5108, + "name": "广元市", + "children": [ + { "id": 510802, "name": "利州区" }, + { "id": 510811, "name": "昭化区" }, + { "id": 510812, "name": "朝天区" }, + { "id": 510821, "name": "旺苍县" }, + { "id": 510822, "name": "青川县" }, + { "id": 510823, "name": "剑阁县" }, + { "id": 510824, "name": "苍溪县" } + ] + }, + { + "id": 5109, + "name": "遂宁市", + "children": [ + { "id": 510903, "name": "船山区" }, + { "id": 510904, "name": "安居区" }, + { "id": 510921, "name": "蓬溪县" }, + { "id": 510923, "name": "大英县" }, + { "id": 510981, "name": "射洪市" } + ] + }, + { + "id": 5110, + "name": "内江市", + "children": [ + { "id": 511002, "name": "市中区" }, + { "id": 511011, "name": "东兴区" }, + { "id": 511024, "name": "威远县" }, + { "id": 511025, "name": "资中县" }, + { "id": 511083, "name": "隆昌市" } + ] + }, + { + "id": 5111, + "name": "乐山市", + "children": [ + { "id": 511102, "name": "市中区" }, + { "id": 511111, "name": "沙湾区" }, + { "id": 511112, "name": "五通桥区" }, + { "id": 511113, "name": "金口河区" }, + { "id": 511123, "name": "犍为县" }, + { "id": 511124, "name": "井研县" }, + { "id": 511126, "name": "夹江县" }, + { "id": 511129, "name": "沐川县" }, + { "id": 511132, "name": "峨边彝族自治县" }, + { "id": 511133, "name": "马边彝族自治县" }, + { "id": 511181, "name": "峨眉山市" } + ] + }, + { + "id": 5113, + "name": "南充市", + "children": [ + { "id": 511302, "name": "顺庆区" }, + { "id": 511303, "name": "高坪区" }, + { "id": 511304, "name": "嘉陵区" }, + { "id": 511321, "name": "南部县" }, + { "id": 511322, "name": "营山县" }, + { "id": 511323, "name": "蓬安县" }, + { "id": 511324, "name": "仪陇县" }, + { "id": 511325, "name": "西充县" }, + { "id": 511381, "name": "阆中市" } + ] + }, + { + "id": 5114, + "name": "眉山市", + "children": [ + { "id": 511402, "name": "东坡区" }, + { "id": 511403, "name": "彭山区" }, + { "id": 511421, "name": "仁寿县" }, + { "id": 511423, "name": "洪雅县" }, + { "id": 511424, "name": "丹棱县" }, + { "id": 511425, "name": "青神县" } + ] + }, + { + "id": 5115, + "name": "宜宾市", + "children": [ + { "id": 511502, "name": "翠屏区" }, + { "id": 511503, "name": "南溪区" }, + { "id": 511504, "name": "叙州区" }, + { "id": 511523, "name": "江安县" }, + { "id": 511524, "name": "长宁县" }, + { "id": 511525, "name": "高县" }, + { "id": 511526, "name": "珙县" }, + { "id": 511527, "name": "筠连县" }, + { "id": 511528, "name": "兴文县" }, + { "id": 511529, "name": "屏山县" } + ] + }, + { + "id": 5116, + "name": "广安市", + "children": [ + { "id": 511602, "name": "广安区" }, + { "id": 511603, "name": "前锋区" }, + { "id": 511621, "name": "岳池县" }, + { "id": 511622, "name": "武胜县" }, + { "id": 511623, "name": "邻水县" }, + { "id": 511681, "name": "华蓥市" } + ] + }, + { + "id": 5117, + "name": "达州市", + "children": [ + { "id": 511702, "name": "通川区" }, + { "id": 511703, "name": "达川区" }, + { "id": 511722, "name": "宣汉县" }, + { "id": 511723, "name": "开江县" }, + { "id": 511724, "name": "大竹县" }, + { "id": 511725, "name": "渠县" }, + { "id": 511781, "name": "万源市" } + ] + }, + { + "id": 5118, + "name": "雅安市", + "children": [ + { "id": 511802, "name": "雨城区" }, + { "id": 511803, "name": "名山区" }, + { "id": 511822, "name": "荥经县" }, + { "id": 511823, "name": "汉源县" }, + { "id": 511824, "name": "石棉县" }, + { "id": 511825, "name": "天全县" }, + { "id": 511826, "name": "芦山县" }, + { "id": 511827, "name": "宝兴县" } + ] + }, + { + "id": 5119, + "name": "巴中市", + "children": [ + { "id": 511902, "name": "巴州区" }, + { "id": 511903, "name": "恩阳区" }, + { "id": 511921, "name": "通江县" }, + { "id": 511922, "name": "南江县" }, + { "id": 511923, "name": "平昌县" } + ] + }, + { + "id": 5120, + "name": "资阳市", + "children": [ + { "id": 512002, "name": "雁江区" }, + { "id": 512021, "name": "安岳县" }, + { "id": 512022, "name": "乐至县" } + ] + }, + { + "id": 5132, + "name": "阿坝藏族羌族自治州", + "children": [ + { "id": 513201, "name": "马尔康市" }, + { "id": 513221, "name": "汶川县" }, + { "id": 513222, "name": "理县" }, + { "id": 513223, "name": "茂县" }, + { "id": 513224, "name": "松潘县" }, + { "id": 513225, "name": "九寨沟县" }, + { "id": 513226, "name": "金川县" }, + { "id": 513227, "name": "小金县" }, + { "id": 513228, "name": "黑水县" }, + { "id": 513230, "name": "壤塘县" }, + { "id": 513231, "name": "阿坝县" }, + { "id": 513232, "name": "若尔盖县" }, + { "id": 513233, "name": "红原县" } + ] + }, + { + "id": 5133, + "name": "甘孜藏族自治州", + "children": [ + { "id": 513301, "name": "康定市" }, + { "id": 513322, "name": "泸定县" }, + { "id": 513323, "name": "丹巴县" }, + { "id": 513324, "name": "九龙县" }, + { "id": 513325, "name": "雅江县" }, + { "id": 513326, "name": "道孚县" }, + { "id": 513327, "name": "炉霍县" }, + { "id": 513328, "name": "甘孜县" }, + { "id": 513329, "name": "新龙县" }, + { "id": 513330, "name": "德格县" }, + { "id": 513331, "name": "白玉县" }, + { "id": 513332, "name": "石渠县" }, + { "id": 513333, "name": "色达县" }, + { "id": 513334, "name": "理塘县" }, + { "id": 513335, "name": "巴塘县" }, + { "id": 513336, "name": "乡城县" }, + { "id": 513337, "name": "稻城县" }, + { "id": 513338, "name": "得荣县" } + ] + }, + { + "id": 5134, + "name": "凉山彝族自治州", + "children": [ + { "id": 513401, "name": "西昌市" }, + { "id": 513402, "name": "会理市" }, + { "id": 513422, "name": "木里藏族自治县" }, + { "id": 513423, "name": "盐源县" }, + { "id": 513424, "name": "德昌县" }, + { "id": 513426, "name": "会东县" }, + { "id": 513427, "name": "宁南县" }, + { "id": 513428, "name": "普格县" }, + { "id": 513429, "name": "布拖县" }, + { "id": 513430, "name": "金阳县" }, + { "id": 513431, "name": "昭觉县" }, + { "id": 513432, "name": "喜德县" }, + { "id": 513433, "name": "冕宁县" }, + { "id": 513434, "name": "越西县" }, + { "id": 513435, "name": "甘洛县" }, + { "id": 513436, "name": "美姑县" }, + { "id": 513437, "name": "雷波县" } + ] + } + ] + }, + { + "id": 52, + "name": "贵州省", + "children": [ + { + "id": 5201, + "name": "贵阳市", + "children": [ + { "id": 520102, "name": "南明区" }, + { "id": 520103, "name": "云岩区" }, + { "id": 520111, "name": "花溪区" }, + { "id": 520112, "name": "乌当区" }, + { "id": 520113, "name": "白云区" }, + { "id": 520115, "name": "观山湖区" }, + { "id": 520121, "name": "开阳县" }, + { "id": 520122, "name": "息烽县" }, + { "id": 520123, "name": "修文县" }, + { "id": 520181, "name": "清镇市" } + ] + }, + { + "id": 5202, + "name": "六盘水市", + "children": [ + { "id": 520201, "name": "钟山区" }, + { "id": 520203, "name": "六枝特区" }, + { "id": 520204, "name": "水城区" }, + { "id": 520281, "name": "盘州市" } + ] + }, + { + "id": 5203, + "name": "遵义市", + "children": [ + { "id": 520302, "name": "红花岗区" }, + { "id": 520303, "name": "汇川区" }, + { "id": 520304, "name": "播州区" }, + { "id": 520322, "name": "桐梓县" }, + { "id": 520323, "name": "绥阳县" }, + { "id": 520324, "name": "正安县" }, + { "id": 520325, "name": "道真仡佬族苗族自治县" }, + { "id": 520326, "name": "务川仡佬族苗族自治县" }, + { "id": 520327, "name": "凤冈县" }, + { "id": 520328, "name": "湄潭县" }, + { "id": 520329, "name": "余庆县" }, + { "id": 520330, "name": "习水县" }, + { "id": 520381, "name": "赤水市" }, + { "id": 520382, "name": "仁怀市" } + ] + }, + { + "id": 5204, + "name": "安顺市", + "children": [ + { "id": 520402, "name": "西秀区" }, + { "id": 520403, "name": "平坝区" }, + { "id": 520422, "name": "普定县" }, + { "id": 520423, "name": "镇宁布依族苗族自治县" }, + { "id": 520424, "name": "关岭布依族苗族自治县" }, + { "id": 520425, "name": "紫云苗族布依族自治县" } + ] + }, + { + "id": 5205, + "name": "毕节市", + "children": [ + { "id": 520502, "name": "七星关区" }, + { "id": 520521, "name": "大方县" }, + { "id": 520523, "name": "金沙县" }, + { "id": 520524, "name": "织金县" }, + { "id": 520525, "name": "纳雍县" }, + { "id": 520526, "name": "威宁彝族回族苗族自治县" }, + { "id": 520527, "name": "赫章县" }, + { "id": 520581, "name": "黔西市" } + ] + }, + { + "id": 5206, + "name": "铜仁市", + "children": [ + { "id": 520602, "name": "碧江区" }, + { "id": 520603, "name": "万山区" }, + { "id": 520621, "name": "江口县" }, + { "id": 520622, "name": "玉屏侗族自治县" }, + { "id": 520623, "name": "石阡县" }, + { "id": 520624, "name": "思南县" }, + { "id": 520625, "name": "印江土家族苗族自治县" }, + { "id": 520626, "name": "德江县" }, + { "id": 520627, "name": "沿河土家族自治县" }, + { "id": 520628, "name": "松桃苗族自治县" } + ] + }, + { + "id": 5223, + "name": "黔西南布依族苗族自治州", + "children": [ + { "id": 522301, "name": "兴义市" }, + { "id": 522302, "name": "兴仁市" }, + { "id": 522323, "name": "普安县" }, + { "id": 522324, "name": "晴隆县" }, + { "id": 522325, "name": "贞丰县" }, + { "id": 522326, "name": "望谟县" }, + { "id": 522327, "name": "册亨县" }, + { "id": 522328, "name": "安龙县" } + ] + }, + { + "id": 5226, + "name": "黔东南苗族侗族自治州", + "children": [ + { "id": 522601, "name": "凯里市" }, + { "id": 522622, "name": "黄平县" }, + { "id": 522623, "name": "施秉县" }, + { "id": 522624, "name": "三穗县" }, + { "id": 522625, "name": "镇远县" }, + { "id": 522626, "name": "岑巩县" }, + { "id": 522627, "name": "天柱县" }, + { "id": 522628, "name": "锦屏县" }, + { "id": 522629, "name": "剑河县" }, + { "id": 522630, "name": "台江县" }, + { "id": 522631, "name": "黎平县" }, + { "id": 522632, "name": "榕江县" }, + { "id": 522633, "name": "从江县" }, + { "id": 522634, "name": "雷山县" }, + { "id": 522635, "name": "麻江县" }, + { "id": 522636, "name": "丹寨县" } + ] + }, + { + "id": 5227, + "name": "黔南布依族苗族自治州", + "children": [ + { "id": 522701, "name": "都匀市" }, + { "id": 522702, "name": "福泉市" }, + { "id": 522722, "name": "荔波县" }, + { "id": 522723, "name": "贵定县" }, + { "id": 522725, "name": "瓮安县" }, + { "id": 522726, "name": "独山县" }, + { "id": 522727, "name": "平塘县" }, + { "id": 522728, "name": "罗甸县" }, + { "id": 522729, "name": "长顺县" }, + { "id": 522730, "name": "龙里县" }, + { "id": 522731, "name": "惠水县" }, + { "id": 522732, "name": "三都水族自治县" } + ] + } + ] + }, + { + "id": 53, + "name": "云南省", + "children": [ + { + "id": 5301, + "name": "昆明市", + "children": [ + { "id": 530102, "name": "五华区" }, + { "id": 530103, "name": "盘龙区" }, + { "id": 530111, "name": "官渡区" }, + { "id": 530112, "name": "西山区" }, + { "id": 530113, "name": "东川区" }, + { "id": 530114, "name": "呈贡区" }, + { "id": 530115, "name": "晋宁区" }, + { "id": 530124, "name": "富民县" }, + { "id": 530125, "name": "宜良县" }, + { "id": 530126, "name": "石林彝族自治县" }, + { "id": 530127, "name": "嵩明县" }, + { "id": 530128, "name": "禄劝彝族苗族自治县" }, + { "id": 530129, "name": "寻甸回族彝族自治县" }, + { "id": 530181, "name": "安宁市" } + ] + }, + { + "id": 5303, + "name": "曲靖市", + "children": [ + { "id": 530302, "name": "麒麟区" }, + { "id": 530303, "name": "沾益区" }, + { "id": 530304, "name": "马龙区" }, + { "id": 530322, "name": "陆良县" }, + { "id": 530323, "name": "师宗县" }, + { "id": 530324, "name": "罗平县" }, + { "id": 530325, "name": "富源县" }, + { "id": 530326, "name": "会泽县" }, + { "id": 530381, "name": "宣威市" } + ] + }, + { + "id": 5304, + "name": "玉溪市", + "children": [ + { "id": 530402, "name": "红塔区" }, + { "id": 530403, "name": "江川区" }, + { "id": 530423, "name": "通海县" }, + { "id": 530424, "name": "华宁县" }, + { "id": 530425, "name": "易门县" }, + { "id": 530426, "name": "峨山彝族自治县" }, + { "id": 530427, "name": "新平彝族傣族自治县" }, + { "id": 530428, "name": "元江哈尼族彝族傣族自治县" }, + { "id": 530481, "name": "澄江市" } + ] + }, + { + "id": 5305, + "name": "保山市", + "children": [ + { "id": 530502, "name": "隆阳区" }, + { "id": 530521, "name": "施甸县" }, + { "id": 530523, "name": "龙陵县" }, + { "id": 530524, "name": "昌宁县" }, + { "id": 530581, "name": "腾冲市" } + ] + }, + { + "id": 5306, + "name": "昭通市", + "children": [ + { "id": 530602, "name": "昭阳区" }, + { "id": 530621, "name": "鲁甸县" }, + { "id": 530622, "name": "巧家县" }, + { "id": 530623, "name": "盐津县" }, + { "id": 530624, "name": "大关县" }, + { "id": 530625, "name": "永善县" }, + { "id": 530626, "name": "绥江县" }, + { "id": 530627, "name": "镇雄县" }, + { "id": 530628, "name": "彝良县" }, + { "id": 530629, "name": "威信县" }, + { "id": 530681, "name": "水富市" } + ] + }, + { + "id": 5307, + "name": "丽江市", + "children": [ + { "id": 530702, "name": "古城区" }, + { "id": 530721, "name": "玉龙纳西族自治县" }, + { "id": 530722, "name": "永胜县" }, + { "id": 530723, "name": "华坪县" }, + { "id": 530724, "name": "宁蒗彝族自治县" } + ] + }, + { + "id": 5308, + "name": "普洱市", + "children": [ + { "id": 530802, "name": "思茅区" }, + { "id": 530821, "name": "宁洱哈尼族彝族自治县" }, + { "id": 530822, "name": "墨江哈尼族自治县" }, + { "id": 530823, "name": "景东彝族自治县" }, + { "id": 530824, "name": "景谷傣族彝族自治县" }, + { "id": 530825, "name": "镇沅彝族哈尼族拉祜族自治县" }, + { "id": 530826, "name": "江城哈尼族彝族自治县" }, + { "id": 530827, "name": "孟连傣族拉祜族佤族自治县" }, + { "id": 530828, "name": "澜沧拉祜族自治县" }, + { "id": 530829, "name": "西盟佤族自治县" } + ] + }, + { + "id": 5309, + "name": "临沧市", + "children": [ + { "id": 530902, "name": "临翔区" }, + { "id": 530921, "name": "凤庆县" }, + { "id": 530922, "name": "云县" }, + { "id": 530923, "name": "永德县" }, + { "id": 530924, "name": "镇康县" }, + { "id": 530925, "name": "双江拉祜族佤族布朗族傣族自治县" }, + { "id": 530926, "name": "耿马傣族佤族自治县" }, + { "id": 530927, "name": "沧源佤族自治县" } + ] + }, + { + "id": 5323, + "name": "楚雄彝族自治州", + "children": [ + { "id": 532301, "name": "楚雄市" }, + { "id": 532302, "name": "禄丰市" }, + { "id": 532322, "name": "双柏县" }, + { "id": 532323, "name": "牟定县" }, + { "id": 532324, "name": "南华县" }, + { "id": 532325, "name": "姚安县" }, + { "id": 532326, "name": "大姚县" }, + { "id": 532327, "name": "永仁县" }, + { "id": 532328, "name": "元谋县" }, + { "id": 532329, "name": "武定县" } + ] + }, + { + "id": 5325, + "name": "红河哈尼族彝族自治州", + "children": [ + { "id": 532501, "name": "个旧市" }, + { "id": 532502, "name": "开远市" }, + { "id": 532503, "name": "蒙自市" }, + { "id": 532504, "name": "弥勒市" }, + { "id": 532523, "name": "屏边苗族自治县" }, + { "id": 532524, "name": "建水县" }, + { "id": 532525, "name": "石屏县" }, + { "id": 532527, "name": "泸西县" }, + { "id": 532528, "name": "元阳县" }, + { "id": 532529, "name": "红河县" }, + { "id": 532530, "name": "金平苗族瑶族傣族自治县" }, + { "id": 532531, "name": "绿春县" }, + { "id": 532532, "name": "河口瑶族自治县" } + ] + }, + { + "id": 5326, + "name": "文山壮族苗族自治州", + "children": [ + { "id": 532601, "name": "文山市" }, + { "id": 532622, "name": "砚山县" }, + { "id": 532623, "name": "西畴县" }, + { "id": 532624, "name": "麻栗坡县" }, + { "id": 532625, "name": "马关县" }, + { "id": 532626, "name": "丘北县" }, + { "id": 532627, "name": "广南县" }, + { "id": 532628, "name": "富宁县" } + ] + }, + { + "id": 5328, + "name": "西双版纳傣族自治州", + "children": [ + { "id": 532801, "name": "景洪市" }, + { "id": 532822, "name": "勐海县" }, + { "id": 532823, "name": "勐腊县" } + ] + }, + { + "id": 5329, + "name": "大理白族自治州", + "children": [ + { "id": 532901, "name": "大理市" }, + { "id": 532922, "name": "漾濞彝族自治县" }, + { "id": 532923, "name": "祥云县" }, + { "id": 532924, "name": "宾川县" }, + { "id": 532925, "name": "弥渡县" }, + { "id": 532926, "name": "南涧彝族自治县" }, + { "id": 532927, "name": "巍山彝族回族自治县" }, + { "id": 532928, "name": "永平县" }, + { "id": 532929, "name": "云龙县" }, + { "id": 532930, "name": "洱源县" }, + { "id": 532931, "name": "剑川县" }, + { "id": 532932, "name": "鹤庆县" } + ] + }, + { + "id": 5331, + "name": "德宏傣族景颇族自治州", + "children": [ + { "id": 533102, "name": "瑞丽市" }, + { "id": 533103, "name": "芒市" }, + { "id": 533122, "name": "梁河县" }, + { "id": 533123, "name": "盈江县" }, + { "id": 533124, "name": "陇川县" } + ] + }, + { + "id": 5333, + "name": "怒江傈僳族自治州", + "children": [ + { "id": 533301, "name": "泸水市" }, + { "id": 533323, "name": "福贡县" }, + { "id": 533324, "name": "贡山独龙族怒族自治县" }, + { "id": 533325, "name": "兰坪白族普米族自治县" } + ] + }, + { + "id": 5334, + "name": "迪庆藏族自治州", + "children": [ + { "id": 533401, "name": "香格里拉市" }, + { "id": 533422, "name": "德钦县" }, + { "id": 533423, "name": "维西傈僳族自治县" } + ] + } + ] + }, + { + "id": 54, + "name": "西藏自治区", + "children": [ + { + "id": 5401, + "name": "拉萨市", + "children": [ + { "id": 540102, "name": "城关区" }, + { "id": 540103, "name": "堆龙德庆区" }, + { "id": 540104, "name": "达孜区" }, + { "id": 540121, "name": "林周县" }, + { "id": 540122, "name": "当雄县" }, + { "id": 540123, "name": "尼木县" }, + { "id": 540124, "name": "曲水县" }, + { "id": 540127, "name": "墨竹工卡县" }, + { "id": 540171, "name": "格尔木藏青工业园区" }, + { "id": 540172, "name": "拉萨经济技术开发区" }, + { "id": 540173, "name": "西藏文化旅游创意园区" }, + { "id": 540174, "name": "达孜工业园区" } + ] + }, + { + "id": 5402, + "name": "日喀则市", + "children": [ + { "id": 540202, "name": "桑珠孜区" }, + { "id": 540221, "name": "南木林县" }, + { "id": 540222, "name": "江孜县" }, + { "id": 540223, "name": "定日县" }, + { "id": 540224, "name": "萨迦县" }, + { "id": 540225, "name": "拉孜县" }, + { "id": 540226, "name": "昂仁县" }, + { "id": 540227, "name": "谢通门县" }, + { "id": 540228, "name": "白朗县" }, + { "id": 540229, "name": "仁布县" }, + { "id": 540230, "name": "康马县" }, + { "id": 540231, "name": "定结县" }, + { "id": 540232, "name": "仲巴县" }, + { "id": 540233, "name": "亚东县" }, + { "id": 540234, "name": "吉隆县" }, + { "id": 540235, "name": "聂拉木县" }, + { "id": 540236, "name": "萨嘎县" }, + { "id": 540237, "name": "岗巴县" } + ] + }, + { + "id": 5403, + "name": "昌都市", + "children": [ + { "id": 540302, "name": "卡若区" }, + { "id": 540321, "name": "江达县" }, + { "id": 540322, "name": "贡觉县" }, + { "id": 540323, "name": "类乌齐县" }, + { "id": 540324, "name": "丁青县" }, + { "id": 540325, "name": "察雅县" }, + { "id": 540326, "name": "八宿县" }, + { "id": 540327, "name": "左贡县" }, + { "id": 540328, "name": "芒康县" }, + { "id": 540329, "name": "洛隆县" }, + { "id": 540330, "name": "边坝县" } + ] + }, + { + "id": 5404, + "name": "林芝市", + "children": [ + { "id": 540402, "name": "巴宜区" }, + { "id": 540421, "name": "工布江达县" }, + { "id": 540423, "name": "墨脱县" }, + { "id": 540424, "name": "波密县" }, + { "id": 540425, "name": "察隅县" }, + { "id": 540426, "name": "朗县" }, + { "id": 540481, "name": "米林市" } + ] + }, + { + "id": 5405, + "name": "山南市", + "children": [ + { "id": 540502, "name": "乃东区" }, + { "id": 540521, "name": "扎囊县" }, + { "id": 540522, "name": "贡嘎县" }, + { "id": 540523, "name": "桑日县" }, + { "id": 540524, "name": "琼结县" }, + { "id": 540525, "name": "曲松县" }, + { "id": 540526, "name": "措美县" }, + { "id": 540527, "name": "洛扎县" }, + { "id": 540528, "name": "加查县" }, + { "id": 540529, "name": "隆子县" }, + { "id": 540531, "name": "浪卡子县" }, + { "id": 540581, "name": "错那市" } + ] + }, + { + "id": 5406, + "name": "那曲市", + "children": [ + { "id": 540602, "name": "色尼区" }, + { "id": 540621, "name": "嘉黎县" }, + { "id": 540622, "name": "比如县" }, + { "id": 540623, "name": "聂荣县" }, + { "id": 540624, "name": "安多县" }, + { "id": 540625, "name": "申扎县" }, + { "id": 540626, "name": "索县" }, + { "id": 540627, "name": "班戈县" }, + { "id": 540628, "name": "巴青县" }, + { "id": 540629, "name": "尼玛县" }, + { "id": 540630, "name": "双湖县" } + ] + }, + { + "id": 5425, + "name": "阿里地区", + "children": [ + { "id": 542521, "name": "普兰县" }, + { "id": 542522, "name": "札达县" }, + { "id": 542523, "name": "噶尔县" }, + { "id": 542524, "name": "日土县" }, + { "id": 542525, "name": "革吉县" }, + { "id": 542526, "name": "改则县" }, + { "id": 542527, "name": "措勤县" } + ] + } + ] + }, + { + "id": 61, + "name": "陕西省", + "children": [ + { + "id": 6101, + "name": "西安市", + "children": [ + { "id": 610102, "name": "新城区" }, + { "id": 610103, "name": "碑林区" }, + { "id": 610104, "name": "莲湖区" }, + { "id": 610111, "name": "灞桥区" }, + { "id": 610112, "name": "未央区" }, + { "id": 610113, "name": "雁塔区" }, + { "id": 610114, "name": "阎良区" }, + { "id": 610115, "name": "临潼区" }, + { "id": 610116, "name": "长安区" }, + { "id": 610117, "name": "高陵区" }, + { "id": 610118, "name": "鄠邑区" }, + { "id": 610122, "name": "蓝田县" }, + { "id": 610124, "name": "周至县" } + ] + }, + { + "id": 6102, + "name": "铜川市", + "children": [ + { "id": 610202, "name": "王益区" }, + { "id": 610203, "name": "印台区" }, + { "id": 610204, "name": "耀州区" }, + { "id": 610222, "name": "宜君县" } + ] + }, + { + "id": 6103, + "name": "宝鸡市", + "children": [ + { "id": 610302, "name": "渭滨区" }, + { "id": 610303, "name": "金台区" }, + { "id": 610304, "name": "陈仓区" }, + { "id": 610305, "name": "凤翔区" }, + { "id": 610323, "name": "岐山县" }, + { "id": 610324, "name": "扶风县" }, + { "id": 610326, "name": "眉县" }, + { "id": 610327, "name": "陇县" }, + { "id": 610328, "name": "千阳县" }, + { "id": 610329, "name": "麟游县" }, + { "id": 610330, "name": "凤县" }, + { "id": 610331, "name": "太白县" } + ] + }, + { + "id": 6104, + "name": "咸阳市", + "children": [ + { "id": 610402, "name": "秦都区" }, + { "id": 610403, "name": "杨陵区" }, + { "id": 610404, "name": "渭城区" }, + { "id": 610422, "name": "三原县" }, + { "id": 610423, "name": "泾阳县" }, + { "id": 610424, "name": "乾县" }, + { "id": 610425, "name": "礼泉县" }, + { "id": 610426, "name": "永寿县" }, + { "id": 610428, "name": "长武县" }, + { "id": 610429, "name": "旬邑县" }, + { "id": 610430, "name": "淳化县" }, + { "id": 610431, "name": "武功县" }, + { "id": 610481, "name": "兴平市" }, + { "id": 610482, "name": "彬州市" } + ] + }, + { + "id": 6105, + "name": "渭南市", + "children": [ + { "id": 610502, "name": "临渭区" }, + { "id": 610503, "name": "华州区" }, + { "id": 610522, "name": "潼关县" }, + { "id": 610523, "name": "大荔县" }, + { "id": 610524, "name": "合阳县" }, + { "id": 610525, "name": "澄城县" }, + { "id": 610526, "name": "蒲城县" }, + { "id": 610527, "name": "白水县" }, + { "id": 610528, "name": "富平县" }, + { "id": 610581, "name": "韩城市" }, + { "id": 610582, "name": "华阴市" } + ] + }, + { + "id": 6106, + "name": "延安市", + "children": [ + { "id": 610602, "name": "宝塔区" }, + { "id": 610603, "name": "安塞区" }, + { "id": 610621, "name": "延长县" }, + { "id": 610622, "name": "延川县" }, + { "id": 610625, "name": "志丹县" }, + { "id": 610626, "name": "吴起县" }, + { "id": 610627, "name": "甘泉县" }, + { "id": 610628, "name": "富县" }, + { "id": 610629, "name": "洛川县" }, + { "id": 610630, "name": "宜川县" }, + { "id": 610631, "name": "黄龙县" }, + { "id": 610632, "name": "黄陵县" }, + { "id": 610681, "name": "子长市" } + ] + }, + { + "id": 6107, + "name": "汉中市", + "children": [ + { "id": 610702, "name": "汉台区" }, + { "id": 610703, "name": "南郑区" }, + { "id": 610722, "name": "城固县" }, + { "id": 610723, "name": "洋县" }, + { "id": 610724, "name": "西乡县" }, + { "id": 610725, "name": "勉县" }, + { "id": 610726, "name": "宁强县" }, + { "id": 610727, "name": "略阳县" }, + { "id": 610728, "name": "镇巴县" }, + { "id": 610729, "name": "留坝县" }, + { "id": 610730, "name": "佛坪县" } + ] + }, + { + "id": 6108, + "name": "榆林市", + "children": [ + { "id": 610802, "name": "榆阳区" }, + { "id": 610803, "name": "横山区" }, + { "id": 610822, "name": "府谷县" }, + { "id": 610824, "name": "靖边县" }, + { "id": 610825, "name": "定边县" }, + { "id": 610826, "name": "绥德县" }, + { "id": 610827, "name": "米脂县" }, + { "id": 610828, "name": "佳县" }, + { "id": 610829, "name": "吴堡县" }, + { "id": 610830, "name": "清涧县" }, + { "id": 610831, "name": "子洲县" }, + { "id": 610881, "name": "神木市" } + ] + }, + { + "id": 6109, + "name": "安康市", + "children": [ + { "id": 610902, "name": "汉滨区" }, + { "id": 610921, "name": "汉阴县" }, + { "id": 610922, "name": "石泉县" }, + { "id": 610923, "name": "宁陕县" }, + { "id": 610924, "name": "紫阳县" }, + { "id": 610925, "name": "岚皋县" }, + { "id": 610926, "name": "平利县" }, + { "id": 610927, "name": "镇坪县" }, + { "id": 610929, "name": "白河县" }, + { "id": 610981, "name": "旬阳市" } + ] + }, + { + "id": 6110, + "name": "商洛市", + "children": [ + { "id": 611002, "name": "商州区" }, + { "id": 611021, "name": "洛南县" }, + { "id": 611022, "name": "丹凤县" }, + { "id": 611023, "name": "商南县" }, + { "id": 611024, "name": "山阳县" }, + { "id": 611025, "name": "镇安县" }, + { "id": 611026, "name": "柞水县" } + ] + } + ] + }, + { + "id": 62, + "name": "甘肃省", + "children": [ + { + "id": 6201, + "name": "兰州市", + "children": [ + { "id": 620102, "name": "城关区" }, + { "id": 620103, "name": "七里河区" }, + { "id": 620104, "name": "西固区" }, + { "id": 620105, "name": "安宁区" }, + { "id": 620111, "name": "红古区" }, + { "id": 620121, "name": "永登县" }, + { "id": 620122, "name": "皋兰县" }, + { "id": 620123, "name": "榆中县" }, + { "id": 620171, "name": "兰州新区" } + ] + }, + { + "id": 6202, + "name": "嘉峪关市", + "children": [ + { "id": 620201001, "name": "雄关街道" }, + { "id": 620201002, "name": "钢城街道" }, + { "id": 620201100, "name": "新城镇" }, + { "id": 620201101, "name": "峪泉镇" }, + { "id": 620201102, "name": "文殊镇" } + ] + }, + { + "id": 6203, + "name": "金昌市", + "children": [ + { "id": 620302, "name": "金川区" }, + { "id": 620321, "name": "永昌县" } + ] + }, + { + "id": 6204, + "name": "白银市", + "children": [ + { "id": 620402, "name": "白银区" }, + { "id": 620403, "name": "平川区" }, + { "id": 620421, "name": "靖远县" }, + { "id": 620422, "name": "会宁县" }, + { "id": 620423, "name": "景泰县" } + ] + }, + { + "id": 6205, + "name": "天水市", + "children": [ + { "id": 620502, "name": "秦州区" }, + { "id": 620503, "name": "麦积区" }, + { "id": 620521, "name": "清水县" }, + { "id": 620522, "name": "秦安县" }, + { "id": 620523, "name": "甘谷县" }, + { "id": 620524, "name": "武山县" }, + { "id": 620525, "name": "张家川回族自治县" } + ] + }, + { + "id": 6206, + "name": "武威市", + "children": [ + { "id": 620602, "name": "凉州区" }, + { "id": 620621, "name": "民勤县" }, + { "id": 620622, "name": "古浪县" }, + { "id": 620623, "name": "天祝藏族自治县" } + ] + }, + { + "id": 6207, + "name": "张掖市", + "children": [ + { "id": 620702, "name": "甘州区" }, + { "id": 620721, "name": "肃南裕固族自治县" }, + { "id": 620722, "name": "民乐县" }, + { "id": 620723, "name": "临泽县" }, + { "id": 620724, "name": "高台县" }, + { "id": 620725, "name": "山丹县" } + ] + }, + { + "id": 6208, + "name": "平凉市", + "children": [ + { "id": 620802, "name": "崆峒区" }, + { "id": 620821, "name": "泾川县" }, + { "id": 620822, "name": "灵台县" }, + { "id": 620823, "name": "崇信县" }, + { "id": 620825, "name": "庄浪县" }, + { "id": 620826, "name": "静宁县" }, + { "id": 620881, "name": "华亭市" } + ] + }, + { + "id": 6209, + "name": "酒泉市", + "children": [ + { "id": 620902, "name": "肃州区" }, + { "id": 620921, "name": "金塔县" }, + { "id": 620922, "name": "瓜州县" }, + { "id": 620923, "name": "肃北蒙古族自治县" }, + { "id": 620924, "name": "阿克塞哈萨克族自治县" }, + { "id": 620981, "name": "玉门市" }, + { "id": 620982, "name": "敦煌市" } + ] + }, + { + "id": 6210, + "name": "庆阳市", + "children": [ + { "id": 621002, "name": "西峰区" }, + { "id": 621021, "name": "庆城县" }, + { "id": 621022, "name": "环县" }, + { "id": 621023, "name": "华池县" }, + { "id": 621024, "name": "合水县" }, + { "id": 621025, "name": "正宁县" }, + { "id": 621026, "name": "宁县" }, + { "id": 621027, "name": "镇原县" } + ] + }, + { + "id": 6211, + "name": "定西市", + "children": [ + { "id": 621102, "name": "安定区" }, + { "id": 621121, "name": "通渭县" }, + { "id": 621122, "name": "陇西县" }, + { "id": 621123, "name": "渭源县" }, + { "id": 621124, "name": "临洮县" }, + { "id": 621125, "name": "漳县" }, + { "id": 621126, "name": "岷县" } + ] + }, + { + "id": 6212, + "name": "陇南市", + "children": [ + { "id": 621202, "name": "武都区" }, + { "id": 621221, "name": "成县" }, + { "id": 621222, "name": "文县" }, + { "id": 621223, "name": "宕昌县" }, + { "id": 621224, "name": "康县" }, + { "id": 621225, "name": "西和县" }, + { "id": 621226, "name": "礼县" }, + { "id": 621227, "name": "徽县" }, + { "id": 621228, "name": "两当县" } + ] + }, + { + "id": 6229, + "name": "临夏回族自治州", + "children": [ + { "id": 622901, "name": "临夏市" }, + { "id": 622921, "name": "临夏县" }, + { "id": 622922, "name": "康乐县" }, + { "id": 622923, "name": "永靖县" }, + { "id": 622924, "name": "广河县" }, + { "id": 622925, "name": "和政县" }, + { "id": 622926, "name": "东乡族自治县" }, + { "id": 622927, "name": "积石山保安族东乡族撒拉族自治县" } + ] + }, + { + "id": 6230, + "name": "甘南藏族自治州", + "children": [ + { "id": 623001, "name": "合作市" }, + { "id": 623021, "name": "临潭县" }, + { "id": 623022, "name": "卓尼县" }, + { "id": 623023, "name": "舟曲县" }, + { "id": 623024, "name": "迭部县" }, + { "id": 623025, "name": "玛曲县" }, + { "id": 623026, "name": "碌曲县" }, + { "id": 623027, "name": "夏河县" } + ] + } + ] + }, + { + "id": 63, + "name": "青海省", + "children": [ + { + "id": 6301, + "name": "西宁市", + "children": [ + { "id": 630102, "name": "城东区" }, + { "id": 630103, "name": "城中区" }, + { "id": 630104, "name": "城西区" }, + { "id": 630105, "name": "城北区" }, + { "id": 630106, "name": "湟中区" }, + { "id": 630121, "name": "大通回族土族自治县" }, + { "id": 630123, "name": "湟源县" } + ] + }, + { + "id": 6302, + "name": "海东市", + "children": [ + { "id": 630202, "name": "乐都区" }, + { "id": 630203, "name": "平安区" }, + { "id": 630222, "name": "民和回族土族自治县" }, + { "id": 630223, "name": "互助土族自治县" }, + { "id": 630224, "name": "化隆回族自治县" }, + { "id": 630225, "name": "循化撒拉族自治县" } + ] + }, + { + "id": 6322, + "name": "海北藏族自治州", + "children": [ + { "id": 632221, "name": "门源回族自治县" }, + { "id": 632222, "name": "祁连县" }, + { "id": 632223, "name": "海晏县" }, + { "id": 632224, "name": "刚察县" } + ] + }, + { + "id": 6323, + "name": "黄南藏族自治州", + "children": [ + { "id": 632301, "name": "同仁市" }, + { "id": 632322, "name": "尖扎县" }, + { "id": 632323, "name": "泽库县" }, + { "id": 632324, "name": "河南蒙古族自治县" } + ] + }, + { + "id": 6325, + "name": "海南藏族自治州", + "children": [ + { "id": 632521, "name": "共和县" }, + { "id": 632522, "name": "同德县" }, + { "id": 632523, "name": "贵德县" }, + { "id": 632524, "name": "兴海县" }, + { "id": 632525, "name": "贵南县" } + ] + }, + { + "id": 6326, + "name": "果洛藏族自治州", + "children": [ + { "id": 632621, "name": "玛沁县" }, + { "id": 632622, "name": "班玛县" }, + { "id": 632623, "name": "甘德县" }, + { "id": 632624, "name": "达日县" }, + { "id": 632625, "name": "久治县" }, + { "id": 632626, "name": "玛多县" } + ] + }, + { + "id": 6327, + "name": "玉树藏族自治州", + "children": [ + { "id": 632701, "name": "玉树市" }, + { "id": 632722, "name": "杂多县" }, + { "id": 632723, "name": "称多县" }, + { "id": 632724, "name": "治多县" }, + { "id": 632725, "name": "囊谦县" }, + { "id": 632726, "name": "曲麻莱县" } + ] + }, + { + "id": 6328, + "name": "海西蒙古族藏族自治州", + "children": [ + { "id": 632801, "name": "格尔木市" }, + { "id": 632802, "name": "德令哈市" }, + { "id": 632803, "name": "茫崖市" }, + { "id": 632821, "name": "乌兰县" }, + { "id": 632822, "name": "都兰县" }, + { "id": 632823, "name": "天峻县" }, + { "id": 632857, "name": "大柴旦行政委员会" } + ] + } + ] + }, + { + "id": 64, + "name": "宁夏回族自治区", + "children": [ + { + "id": 6401, + "name": "银川市", + "children": [ + { "id": 640104, "name": "兴庆区" }, + { "id": 640105, "name": "西夏区" }, + { "id": 640106, "name": "金凤区" }, + { "id": 640121, "name": "永宁县" }, + { "id": 640122, "name": "贺兰县" }, + { "id": 640181, "name": "灵武市" } + ] + }, + { + "id": 6402, + "name": "石嘴山市", + "children": [ + { "id": 640202, "name": "大武口区" }, + { "id": 640205, "name": "惠农区" }, + { "id": 640221, "name": "平罗县" } + ] + }, + { + "id": 6403, + "name": "吴忠市", + "children": [ + { "id": 640302, "name": "利通区" }, + { "id": 640303, "name": "红寺堡区" }, + { "id": 640323, "name": "盐池县" }, + { "id": 640324, "name": "同心县" }, + { "id": 640381, "name": "青铜峡市" } + ] + }, + { + "id": 6404, + "name": "固原市", + "children": [ + { "id": 640402, "name": "原州区" }, + { "id": 640422, "name": "西吉县" }, + { "id": 640423, "name": "隆德县" }, + { "id": 640424, "name": "泾源县" }, + { "id": 640425, "name": "彭阳县" } + ] + }, + { + "id": 6405, + "name": "中卫市", + "children": [ + { "id": 640502, "name": "沙坡头区" }, + { "id": 640521, "name": "中宁县" }, + { "id": 640522, "name": "海原县" } + ] + } + ] + }, + { + "id": 65, + "name": "新疆维吾尔自治区", + "children": [ + { + "id": 6501, + "name": "乌鲁木齐市", + "children": [ + { "id": 650102, "name": "天山区" }, + { "id": 650103, "name": "沙依巴克区" }, + { "id": 650104, "name": "新市区" }, + { "id": 650105, "name": "水磨沟区" }, + { "id": 650106, "name": "头屯河区" }, + { "id": 650107, "name": "达坂城区" }, + { "id": 650109, "name": "米东区" }, + { "id": 650121, "name": "乌鲁木齐县" } + ] + }, + { + "id": 6502, + "name": "克拉玛依市", + "children": [ + { "id": 650202, "name": "独山子区" }, + { "id": 650203, "name": "克拉玛依区" }, + { "id": 650204, "name": "白碱滩区" }, + { "id": 650205, "name": "乌尔禾区" } + ] + }, + { + "id": 6504, + "name": "吐鲁番市", + "children": [ + { "id": 650402, "name": "高昌区" }, + { "id": 650421, "name": "鄯善县" }, + { "id": 650422, "name": "托克逊县" } + ] + }, + { + "id": 6505, + "name": "哈密市", + "children": [ + { "id": 650502, "name": "伊州区" }, + { "id": 650521, "name": "巴里坤哈萨克自治县" }, + { "id": 650522, "name": "伊吾县" } + ] + }, + { + "id": 6523, + "name": "昌吉回族自治州", + "children": [ + { "id": 652301, "name": "昌吉市" }, + { "id": 652302, "name": "阜康市" }, + { "id": 652323, "name": "呼图壁县" }, + { "id": 652324, "name": "玛纳斯县" }, + { "id": 652325, "name": "奇台县" }, + { "id": 652327, "name": "吉木萨尔县" }, + { "id": 652328, "name": "木垒哈萨克自治县" } + ] + }, + { + "id": 6527, + "name": "博尔塔拉蒙古自治州", + "children": [ + { "id": 652701, "name": "博乐市" }, + { "id": 652702, "name": "阿拉山口市" }, + { "id": 652722, "name": "精河县" }, + { "id": 652723, "name": "温泉县" } + ] + }, + { + "id": 6528, + "name": "巴音郭楞蒙古自治州", + "children": [ + { "id": 652801, "name": "库尔勒市" }, + { "id": 652822, "name": "轮台县" }, + { "id": 652823, "name": "尉犁县" }, + { "id": 652824, "name": "若羌县" }, + { "id": 652825, "name": "且末县" }, + { "id": 652826, "name": "焉耆回族自治县" }, + { "id": 652827, "name": "和静县" }, + { "id": 652828, "name": "和硕县" }, + { "id": 652829, "name": "博湖县" } + ] + }, + { + "id": 6529, + "name": "阿克苏地区", + "children": [ + { "id": 652901, "name": "阿克苏市" }, + { "id": 652902, "name": "库车市" }, + { "id": 652922, "name": "温宿县" }, + { "id": 652924, "name": "沙雅县" }, + { "id": 652925, "name": "新和县" }, + { "id": 652926, "name": "拜城县" }, + { "id": 652927, "name": "乌什县" }, + { "id": 652928, "name": "阿瓦提县" }, + { "id": 652929, "name": "柯坪县" } + ] + }, + { + "id": 6530, + "name": "克孜勒苏柯尔克孜自治州", + "children": [ + { "id": 653001, "name": "阿图什市" }, + { "id": 653022, "name": "阿克陶县" }, + { "id": 653023, "name": "阿合奇县" }, + { "id": 653024, "name": "乌恰县" } + ] + }, + { + "id": 6531, + "name": "喀什地区", + "children": [ + { "id": 653101, "name": "喀什市" }, + { "id": 653121, "name": "疏附县" }, + { "id": 653122, "name": "疏勒县" }, + { "id": 653123, "name": "英吉沙县" }, + { "id": 653124, "name": "泽普县" }, + { "id": 653125, "name": "莎车县" }, + { "id": 653126, "name": "叶城县" }, + { "id": 653127, "name": "麦盖提县" }, + { "id": 653128, "name": "岳普湖县" }, + { "id": 653129, "name": "伽师县" }, + { "id": 653130, "name": "巴楚县" }, + { "id": 653131, "name": "塔什库尔干塔吉克自治县" } + ] + }, + { + "id": 6532, + "name": "和田地区", + "children": [ + { "id": 653201, "name": "和田市" }, + { "id": 653221, "name": "和田县" }, + { "id": 653222, "name": "墨玉县" }, + { "id": 653223, "name": "皮山县" }, + { "id": 653224, "name": "洛浦县" }, + { "id": 653225, "name": "策勒县" }, + { "id": 653226, "name": "于田县" }, + { "id": 653227, "name": "民丰县" } + ] + }, + { + "id": 6540, + "name": "伊犁哈萨克自治州", + "children": [ + { "id": 654002, "name": "伊宁市" }, + { "id": 654003, "name": "奎屯市" }, + { "id": 654004, "name": "霍尔果斯市" }, + { "id": 654021, "name": "伊宁县" }, + { "id": 654022, "name": "察布查尔锡伯自治县" }, + { "id": 654023, "name": "霍城县" }, + { "id": 654024, "name": "巩留县" }, + { "id": 654025, "name": "新源县" }, + { "id": 654026, "name": "昭苏县" }, + { "id": 654027, "name": "特克斯县" }, + { "id": 654028, "name": "尼勒克县" } + ] + }, + { + "id": 6542, + "name": "塔城地区", + "children": [ + { "id": 654201, "name": "塔城市" }, + { "id": 654202, "name": "乌苏市" }, + { "id": 654203, "name": "沙湾市" }, + { "id": 654221, "name": "额敏县" }, + { "id": 654224, "name": "托里县" }, + { "id": 654225, "name": "裕民县" }, + { "id": 654226, "name": "和布克赛尔蒙古自治县" } + ] + }, + { + "id": 6543, + "name": "阿勒泰地区", + "children": [ + { "id": 654301, "name": "阿勒泰市" }, + { "id": 654321, "name": "布尔津县" }, + { "id": 654322, "name": "富蕴县" }, + { "id": 654323, "name": "福海县" }, + { "id": 654324, "name": "哈巴河县" }, + { "id": 654325, "name": "青河县" }, + { "id": 654326, "name": "吉木乃县" } + ] + }, + { + "id": 6590, + "name": "自治区直辖县级行政区划", + "children": [ + { "id": 659001, "name": "石河子市" }, + { "id": 659002, "name": "阿拉尔市" }, + { "id": 659003, "name": "图木舒克市" }, + { "id": 659004, "name": "五家渠市" }, + { "id": 659005, "name": "北屯市" }, + { "id": 659006, "name": "铁门关市" }, + { "id": 659007, "name": "双河市" }, + { "id": 659008, "name": "可克达拉市" }, + { "id": 659009, "name": "昆玉市" }, + { "id": 659010, "name": "胡杨河市" }, + { "id": 659011, "name": "新星市" }, + { "id": 659012, "name": "白杨市" } + ] + } + ] + } +] \ No newline at end of file diff --git a/app/models/employee.go b/app/models/employee.go new file mode 100644 index 0000000..59c1ddd --- /dev/null +++ b/app/models/employee.go @@ -0,0 +1,32 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// Employee 员工 +// 必须通过微信来初始化 +type Employee struct { + ID uint `json:"id" gorm:"primarykey"` // 员工编号 + AccountID dts.NullUint `json:"account_id"` // 账号编号 + Name string `json:"name"` // 员工姓名 + PhoneNumber string `json:"phone_number" gorm:"uniqueIndex"` // 联系电话 + WechatOpenid dts.NullString `json:"wechat_openid" gorm:"uniqueIndex"` // 微信openid + Email dts.NullString `json:"email" gorm:"uniqueIndex"` // 电子邮箱 + + Departments []Department `json:"departments,omitempty" gorm:"many2many:department_employees;"` // 所在部门 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt time.Time `json:"updated_at"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` +} diff --git a/app/models/file.go b/app/models/file.go new file mode 100644 index 0000000..9750e9f --- /dev/null +++ b/app/models/file.go @@ -0,0 +1,34 @@ +package models + +import ( + "ims/util/db/dts" + "mime/multipart" + "time" + + "gorm.io/gorm" +) + +const ( + FileForSupplierContract = "supplier-contract" // 供应商合同附件 + FileForCertification = "certification" // 资质文件 +) + +// File 上传的文件 +type File struct { + ID uint `json:"id" gorm:"primarykey"` // 标签编号 + RawID dts.NullUint `json:"raw_id"` // 原始文件编号 + OwnerID uint `json:"owner_id"` // 所属者编号 + OwnerType string `json:"owner_type"` // 所属者类型 + IsDir bool `json:"is_dir"` // 是不是目录 + Size uint `json:"size"` // 文件大小 + Name string `json:"name"` // 文件名称 + Mime string `json:"mime"` // 文件的MIME类型 + UploaderID uint `json:"uploader_id"` // 上传者编号 + UploaderType string `json:"uploader_type"` // 上传者类型 + CreatedAt time.Time `json:"create_time"` // 创建时间 + UpdatedAt time.Time `json:"update_time"` // 上次更新时间 + DeletedAt gorm.DeletedAt `json:"delete_time,omitempty"` // 数据删除时间` + + Base64File any `json:"-" gorm:"-"` // 上传的base64文件 + MultipartFile multipart.File `json:"-" gorm:"-"` // 上传的文件 +} diff --git a/app/models/inbound.go b/app/models/inbound.go new file mode 100644 index 0000000..a669775 --- /dev/null +++ b/app/models/inbound.go @@ -0,0 +1,52 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// InboundType 产品入库类型 +type InboundType int8 + +const ( + InboundSellReturn InboundType = iota + 1 // 销售退货入库 + InboundSellExchange // 销售换货入库 + InboundPremium // 报溢入库 https://help.youzan.com/displaylist/detail_5_5-1-28504 + InboundBorrow // 借入入库 + InboundOpening // 期初入库 + InboundOverage // 盘盈入库 + InboundTransfer // 调拨入库 + InboundDonated // 供应商受赠入库 + InboundOther // 其他 +) + +// Inbound 产品入库单 +type Inbound struct { + ID uint `json:"id" gorm:"primarykey"` // 入库ID + Type InboundType `json:"type"` // 入库类型 + WarehouseID uint `json:"warehouse_id"` // 入库仓库 + Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignkey:WarehouseID"` // 入库仓库 + Quality Quality `json:"quality"` // 品质 - 全部合格、存在不合格品、全不合格 + Images []string `json:"images" gorm:"serializer:json"` // 入库拍照 + InspectorID dts.NullUint `json:"inspector_id"` // 质检员编号 + Inspector *Employee `json:"inspector,omitempty" gorm:"foreignkey:InspectorID"` // 质检员 + InspectedAt time.Time `json:"inspect_time"` // 质检时间 + OperatorID uint `json:"operator_id"` // 入库员编号 + Operator *Employee `json:"operator,omitempty" gorm:"foreignkey:OperatorID"` // 入库员 + OperatedAt time.Time `json:"operate_time"` // 入库时间 + + Items []InboundItem `json:"items,omitempty" gorm:"foreignkey:InboundID"` // 入库单明细列表 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/inbound_item.go b/app/models/inbound_item.go new file mode 100644 index 0000000..f513dcf --- /dev/null +++ b/app/models/inbound_item.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// InboundItem 入库单明细 +type InboundItem struct { + Id uint `json:"id" gorm:"primarykey"` // 入库明细编号 + InboundID uint `json:"inbound_id"` // 所属入库单编号 + ProductId uint `json:"product_id"` // 入库商品编号 + Product *Product `json:"product,omitempty" gorm:"foreignkey:ProductId"` // 入库产品 + Batch string `json:"batch"` // 产品批次 + WarehouseLocationID uint `json:"warehouse_location_id"` // 入库仓位编号 + WarehouseLocation *WarehouseLocation `json:"warehouse_location" gorm:"foreignkey:WarehouseLocationID"` // 入库仓位 + Amount int `json:"amount"` // 入库数量 + CostPrice int `json:"cost_price"` // 成本单价 + SellPrice int `json:"sell_price"` // 销售单价(含税) + CreatedAt time.Time `json:"create_time"` // 创建时间 + UpdatedAt time.Time `json:"update_time"` // 上次操作时间 + DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据时间 +} diff --git a/app/models/linkman.go b/app/models/linkman.go new file mode 100644 index 0000000..ac807d4 --- /dev/null +++ b/app/models/linkman.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + LinkmanForCustomer = "customer" + LinkmanForSupplier = "supplier" +) + +// Linkman 联系人 +type Linkman struct { + ID uint `json:"id" gorm:"primarykey"` // 联系人编号 + OwnerID uint `json:"owner_id"` // 所属者编号 + OwnerType string `json:"owner_type"` // 所属者类型 + Name string `json:"name"` // 联系人姓名 + PhoneNumber string `json:"phone_number"` // 联系人手机 + Position string `json:"position"` // 联系人职位 + IsMain bool `json:"is_main"` // 是否主要联系人 + CreatedAt time.Time `json:"create_time"` // 创建时间 + UpdatedAt time.Time `json:"update_time"` // 上次操作时间 + DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据的时间 +} diff --git a/app/models/material.go b/app/models/material.go new file mode 100644 index 0000000..94432c9 --- /dev/null +++ b/app/models/material.go @@ -0,0 +1,30 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// Material 物料 +type Material struct { + ID uint `json:"id" gorm:"primarykey"` + Title string `json:"title"` // 物料名称 + Images []string `json:"images" gorm:"default:null;serializer:json"` // 物料图片 + Spec string `json:"spec"` // 物料规格 + Cost decimal.Decimal `json:"cost"` // 物料成本 + Stock int `json:"stock"` // 物料库存 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt time.Time `json:"updated_at"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/model_trace.go b/app/models/model_trace.go new file mode 100644 index 0000000..e9e0dc4 --- /dev/null +++ b/app/models/model_trace.go @@ -0,0 +1,28 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +type ModelTacking struct { + ID uint `json:"id" gorm:"primarykey"` // 追踪ID + Sequence uint `json:"sequence"` // 追踪的序列 + ModelName string `json:"model_name"` // 追踪的模型名称 + RowID uint `json:"row_id"` // 追踪的模型ID + FieldName string `json:"field_name"` // 追踪的字段名称 + OldValue string `json:"old_value"` // 旧值 + NewValue string `json:"new_value"` // 新值 + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/outbound.go b/app/models/outbound.go new file mode 100644 index 0000000..afe175b --- /dev/null +++ b/app/models/outbound.go @@ -0,0 +1,55 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// OutboundReason 产品出库类型 +type OutboundReason int8 + +const ( + OutboundSellExchangeOut OutboundReason = iota + 1 // 销售换货出库 + OutboundPurchaseReturn // 采购退货出库 + OutboundBreakage // 报损出库 + OutboundLend // 借出出库 + OutboundLendSend // 借出直发出库 + OutboundInventoryLosses // 盘亏出库 + OutboundTransfer // 调拨出库 + OutboundTransferFailed // 调拨异常处理 + OutboundGift // 销售赠品出库 + OutboundOther // 其他 +) + +// OutboundTag 出库标签 +type OutboundTag int8 + +const ( + _ OutboundTag = iota + 1 // 出库 + _ // 库存冻结 + _ // 计划出库 +) + +// Outbound 产品出库单 +type Outbound struct { + ID uint `json:"id" gorm:"primarykey"` // 出库单编号 + Reason OutboundReason `json:"reason"` // 出库原因 + Remark string `json:"remark"` // 备注信息 + WarehouseID uint `json:"warehouse_id"` // 出库仓库 + Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignkey:WarehouseID"` // 出库仓库 + Images []string `json:"images" gorm:"serializer:json"` // 出库拍照 + Tag OutboundTag `json:"tag"` // 出库标签 + Address *Address `json:"address" gorm:"polymorphic:Owner;polymorphicValue:outbound"` // 收货地址 + CustomerID uint `json:"customer_id"` // 收货客户编号 + Customer *Customer `json:"customer" gorm:"foreignkey:CustomerID"` // 收货客户 + IsTaken bool `json:"is_taken"` // 客户是否收货 + OperatorID uint `json:"operator_id"` // 出库员编号 + Operator *Employee `json:"operator,omitempty" gorm:"foreignkey:OperatorID"` // 出库员 + OperatedAt time.Time `json:"operate_time"` // 出库时间 + CreatedAt time.Time `json:"create_time"` // 创建时间 + UpdatedAt time.Time `json:"update_time"` // 上次操作时间 + DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据的时间 + + Items []OutboundItem `json:"items,omitempty" gorm:"foreignkey:OutboundID"` // 出库单明细列表 +} diff --git a/app/models/outbound_item.go b/app/models/outbound_item.go new file mode 100644 index 0000000..1a8d3b4 --- /dev/null +++ b/app/models/outbound_item.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// OutboundItem 出库单明细 +type OutboundItem struct { + ID uint `json:"id" gorm:"primarykey"` // 出库明细ID + OutboundID uint `json:"outbound_id"` // 所属出库单编号 + ProductID uint `json:"product_id"` // 所属商品ID,特意设计的冗余字段 + Product *Product `json:"product,omitempty" gorm:"foreignkey:ProductID"` // 出库产品 + Batch string `json:"batch"` // 产品批次 + WarehouseLocationID uint `json:"warehouse_location_id"` // 出库仓位编号 + WarehouseLocation *WarehouseLocation `json:"warehouse_location" gorm:"foreignkey:WarehouseLocationID"` // 出库仓位 + Amount int `json:"amount"` // 出库数量 + CostPrice int `json:"cost_price"` // 成本单价 + SellPrice int `json:"sell_price"` // 销售单价(含税) + CreatedAt time.Time `json:"create_time"` // 创建时间 + UpdatedAt time.Time `json:"update_time"` // 上次操作时间 + DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据的时间 +} diff --git a/app/models/payment_plan.go b/app/models/payment_plan.go new file mode 100644 index 0000000..55d05e6 --- /dev/null +++ b/app/models/payment_plan.go @@ -0,0 +1,40 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +const ( + PaymentForPurchase = "purchase" // 采购 + PaymentForSale = "sale" // 销售 +) + +// PaymentPlan 收付款计划 +type PaymentPlan struct { + ID uint `json:"id" gorm:"primaryKey"` // 审核ID + OwnerID uint `json:"owner_id"` // 所属者编号 + OwnerType string `json:"owner_type"` // 所属者类型 + BatchID uint `json:"batch_id"` // 收款批次 + Ratio decimal.Decimal `json:"ratio"` //收款比例 % + Amount decimal.Decimal `json:"amount"` //收款金额/元 + PaymentTypeID uint `json:"payment_type_id"` //收款方式 + Date dts.Date `json:"date"` //计划收款日期 + Remark string `json:"remark"` //备注 + + Batch *Tag `json:"batch,omitempty" gorm:"foreignKey:BatchID"` + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/product.go b/app/models/product.go new file mode 100644 index 0000000..18dfa12 --- /dev/null +++ b/app/models/product.go @@ -0,0 +1,54 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// ProductPower 产品权限 +type ProductPower int8 + +const ( + SellPower ProductPower = iota + 1 // 销售 + PurchasePower // 采购 + GiftPower // 赠送 +) + +func (p ProductPower) Valid() bool { + return p >= SellPower && p <= GiftPower +} + +// Product 产品 +type Product struct { + ID uint `json:"id" gorm:"primarykey"` // 产品编号 + Code string `json:"code" gorm:"uniqueIndex"` // 产品编码,为系统扩展和对外开放时使用 + Title string `json:"title" gorm:"uniqueIndex"` // 产品名称 + Description dts.NullString `json:"description"` // 产品描述 + Content dts.NullString `json:"content" gorm:"type:text"` // 产品内容 + CategoryID uint `json:"category_id"` // 所属分类编号 + Category *ProductCategory `json:"category,omitempty"` // 所属分类 + Brand dts.NullString `json:"brand"` // 品牌,可选 + Spec dts.NullString `json:"spec"` // 产品规格,可选 + Unit string `json:"unit"` // 计量单位 - 台、套、箱、个、件、盒、片 + CostPrice float64 `json:"cost_price" gorm:"type:numeric(8,2)"` // 成本单价,预设 + SalePrice float64 `json:"sale_price" gorm:"type:numeric(8,2)"` // 标准售价(含税),预设 + Photos []string `json:"photos" gorm:"type:text;serializer:json"` // 产品相册 + Powers dts.Slice[ProductPower] `json:"powers"` // 产品权限 + Inbound int `json:"inbound"` // 总入库数量,历次入库累加的结果 + Outbound int `json:"outbound"` // 总出库数量,历次出库累加的结果 + Stock int `json:"stock"` // 当前可用库存,与 Hold 相加之和为当前的总库存 + Hold int `json:"hold"` // 锁定(冻结)库存,与 Stock 相加之和为当前的总库存 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/product_category.go b/app/models/product_category.go new file mode 100644 index 0000000..f22009a --- /dev/null +++ b/app/models/product_category.go @@ -0,0 +1,26 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// ProductCategory 产品分类 +type ProductCategory struct { + ID uint `json:"id" gorm:"primarykey"` // 类型ID + Name string `json:"name" gorm:"uniqueIndex"` // 类型名称 + Sort int `json:"sort"` // 分类排序 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt time.Time `json:"updated_at"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/purchase_audit.go b/app/models/purchase_audit.go new file mode 100644 index 0000000..47788ae --- /dev/null +++ b/app/models/purchase_audit.go @@ -0,0 +1,36 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// PurchaseAudit 采购审核 +// +// 可以对采购申请驳回,所以一个采购申请会存在多次审核 +type PurchaseAudit struct { + ID uint `json:"id" gorm:"primaryKey"` // 审核ID + RequisitionID uint `json:"requisition_id"` // 采购单ID + AuditorID uint `json:"auditor_id"` // 审核人ID(公司员工) + DepartmentID uint `json:"department_id"` // 审批人归属部门ID + Result bool `json:"result"` // 审批结果 - true审批通过、false不通过 + FailureReason dts.NullString `json:"failure_reason"` // 不通过原因 + + Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求 + Auditor *Employee `json:"auditor,omitempty" gorm:"foreignKey:AuditorID"` // 审核人 + Department *Department `json:"department,omitempty" gorm:"foreignKey:DepartmentID"` // 审批人归属部门 + + // 数据操作 + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/purchase_execution.go b/app/models/purchase_execution.go new file mode 100644 index 0000000..6a0de55 --- /dev/null +++ b/app/models/purchase_execution.go @@ -0,0 +1,36 @@ +package models + +import ( + "database/sql" + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// PurchaseExecution 采购执行 +type PurchaseExecution struct { + ID uint `json:"id" gorm:"primaryKey"` // 审核ID + RequisitionID uint `json:"requisition_id"` // 采购单ID + ExecutorID uint `json:"executor_id"` // 采购员ID(员工) + DepartmentID uint `json:"department_id"` // 采购归属部门ID + PurchasingDate dts.NullDate `json:"purchasing_date"` // 实际采购时间 + EstimatedArrivalDate dts.NullDate `json:"estimated_arrival_date"` // 预计到货日期 + Result sql.NullString `json:"purchased_result"` // 采购结果 - purchased已采购、sufficient库存充足 + + Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求 + Executor *Employee `json:"executor,omitempty" gorm:"foreignKey:ExecutorID"` // 采购员(员工) + Department *Department `json:"department,omitempty" gorm:"foreignKey:DepartmentID"` // 采购归属部门 + + // 数据操作 + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/purchase_order.go b/app/models/purchase_order.go new file mode 100644 index 0000000..a783030 --- /dev/null +++ b/app/models/purchase_order.go @@ -0,0 +1,57 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +type PurchaseOrderType int8 + +const ( + PlannedOrder PurchaseOrderType = iota + 1 // 计划订单 + PurchaseStorage // 采购入库 + PurchaseReturn // 采购退货 +) + +// PurchaseOrder 采购订单 +type PurchaseOrder struct { + // 采购订单基础信息 + ID uint `json:"id" gorm:"primaryKey"` // 审核ID + Code string `json:"code"` // 采购订单编号 + RequisitionID uint `json:"requisition_id"` // 采购单ID + Name string `json:"name"` // 采购订单名称 + Type PurchaseOrderType `json:"type"` // 订单类型 + ContractDate dts.Date `json:"contract_date"` // 订单签订日期 + DeliveryDate dts.Date `json:"delivery_date"` // 订单交付日期 + PurchaserID uint `json:"purchaser_id"` // 采购负责人ID + DepartmentID uint `json:"department_id"` // 采购归属部门ID + WarehouseID uint `json:"warehouse_id"` // 入库仓库ID + PreferentialPrice decimal.Decimal `json:"preferential_price"` // 优惠金额/元 + RemittanceID uint `json:"remittance_id"` // 财务信息 + SettlementMethodID uint `json:"settlement_method_id"` // 结算方式 + + //Attachments []types.File `json:"attachments"` // 合同附件上传 + Products []PurchaseProduct `json:"products,omitempty" gorm:"foreignKey:OrderID"` // 采购产品明细 + Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求 + Purchaser *Employee `json:"purchaser,omitempty" gorm:"foreignKey:PurchaserID"` // 采购负责人 + Department *Department `json:"department,omitempty" gorm:"foreignKey:DepartmentID"` // 采购归属部门 + Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignKey:WarehouseID"` // 入库仓库 + DistributionPlans []DistributionPlan `json:"distribution_plans" gorm:"polymorphic:Owner;polymorphicValue:purchase"` // 到货计划 + PaymentPlans []PaymentPlan `json:"payment_plans" gorm:"polymorphic:Owner;polymorphicValue:purchase"` // 付款计划 + Remittance *Remittance `json:"remittance,omitempty" gorm:"foreignKey:RemittanceID"` // 财务信息 + SettlementMethod *Tag `json:"settlement_method" gorm:"foreignKey:SettlementMethodID"` // 结算方式 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/purchase_product.go b/app/models/purchase_product.go new file mode 100644 index 0000000..a06901b --- /dev/null +++ b/app/models/purchase_product.go @@ -0,0 +1,32 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// PurchaseProduct 管理产品 +type PurchaseProduct struct { + ID uint `json:"id" gorm:"primarykey"` // 入库ID + RequisitionID dts.NullUint `json:"requisition_id"` // 采购请求ID + OrderID dts.NullUint `json:"order_id"` // 采购订单ID + ProductID uint `json:"product_id"` // 采购的商品ID + Amount uint `json:"amount"` // 采购数量需求 + + Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求 + Order *PurchaseOrder `json:"order,omitempty" gorm:"foreignKey:OrderID"` // 采购订单 + Product *Product `json:"product,omitempty" gorm:"foreignKey:ProductID"` // 采购的商品 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/purchase_requisition.go b/app/models/purchase_requisition.go new file mode 100644 index 0000000..e0e90a6 --- /dev/null +++ b/app/models/purchase_requisition.go @@ -0,0 +1,53 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +type ArrivedAddress struct { + ProvinceID int `json:"province_id"` // 省份编号 + CityID int `json:"city_id"` // 城市编号 + CountyID int `json:"county_id"` // 区县编号 + Address string `json:"address"` // 详细地址 + + Province *District `json:"province,omitempty" gorm:"foreignKey:ProvinceID"` + City *District `json:"city,omitempty" gorm:"foreignKey:CityID"` + County *District `json:"county,omitempty" gorm:"foreignKey:CountyID"` +} + +// PurchaseRequisition 请购单 +type PurchaseRequisition struct { + ID uint `json:"id" gorm:"primaryKey"` // 入库ID + Code string `json:"code" gorm:"unique"` // 采购申请编号 + RequesterID uint `json:"requester_id"` // 申请人ID(员工) + DepartmentID uint `json:"department_id"` // 申请人归属部门ID + PlannedPurchaseDate dts.Date `json:"planned_purchase_date"` // 计划采购日期 + PlannedArrivedDate dts.Date `json:"planned_arrived_date"` // 计划到货日期 + ReasonID dts.NullUint `json:"reason_id"` // 需求原因ID - 补货、库存预警、销售需求 + WarehouseID dts.NullUint `json:"warehouse_id"` // 入库仓库ID + ArrivedTo *ArrivedAddress `json:"arrived_to" gorm:"embedded;embeddedPrefix:arrived_"` // 到货地址 + Remark string `json:"remark"` // 备注 + + Requester *Employee `json:"requester" orm:"foreignKey:RequesterID"` // 申请人(员工) + Department *Department `json:"department" orm:"foreignKey:DepartmentID"` // 申请人归属部门 + Reason *Tag `json:"reason,omitempty" gorm:"foreignKey:ReasonID"` // 需求原因 + Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignKey:WarehouseID"` // 入库仓库 + Products []PurchaseProduct `json:"products,omitempty" gorm:"foreignKey:RequisitionID"` // 采购商品列表 + Audits []PurchaseAudit `json:"audits,omitempty" gorm:"foreignKey:RequisitionID"` // 审核记录 + Executions []PurchaseExecution `json:"executions,omitempty" gorm:"foreignKey:RequisitionID"` // 执行记录 + + // 数据操作 + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/remittance.go b/app/models/remittance.go new file mode 100644 index 0000000..7ef3d6e --- /dev/null +++ b/app/models/remittance.go @@ -0,0 +1,49 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type TaxType int8 + +// 针对供应商 +const ( + _ TaxType = iota + 1 // 增值税专用发票 + _ // 增值税普通发票 +) + +// 正对消费者 +const ( + _ TaxType = iota + 3 // 纸质专票 + _ // 纸质普票 + _ // 电子普票 + _ // 电子专票 + _ // 全电专票 + _ // 全电普票 + _ // 无需开票 +) + +const ( + RemittanceForCustomer = "customer" // 消费者 + RemittanceForSupplier = "supplier" // 供应商 +) + +// Remittance 汇款信息 +type Remittance struct { + ID uint `json:"id" gorm:"primarykey"` // 汇款信息编号 + OwnerID uint `json:"owner_id"` // 所属者编号 + OwnerType string `json:"owner_type"` // 所属者类型 + InvoiceTitle string `json:"invoice_title"` // 发票抬头 + InvoiceNumber string `json:"invoice_number"` // 发票税号 + TaxType TaxType `json:"tax_type"` // 税种 + TaxRate int `json:"tax_rate"` // 增值税税率% + BlankAccount string `json:"blank_account"` // 银行账户 + BankName string `json:"blank_name"` // 开户银行 + Telephone string `json:"telephone"` // 开户电话 + Email string `json:"email"` // 收票邮箱 + CreatedAt time.Time `json:"create_time"` // 创建时间 + UpdatedAt time.Time `json:"update_time"` // 上次更新时间 + DeletedAt gorm.DeletedAt `json:"delete_time,omitempty"` // 数据删除时间 +} diff --git a/app/models/supplier.go b/app/models/supplier.go new file mode 100644 index 0000000..6c54879 --- /dev/null +++ b/app/models/supplier.go @@ -0,0 +1,44 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// Supplier 供应商 +type Supplier struct { + ID uint `json:"id" gorm:"primarykey"` // 供应商编号 + Code string `json:"code" gorm:"uniqueIndex"` // 供应商编码 + Name string `json:"name" gorm:"uniqueIndex"` // 供应商名称 + TypeID uint `json:"type_id"` // 分类编号 + LevelID dts.NullUint `json:"level_id"` // 供应商级别 + SignedAt time.Time `json:"signed_at"` // 签约时间(签约开始日期) + ExpiredAt dts.NullTime `json:"expired_at"` // 到期时间(签约结束日期) + SettlementID dts.NullUint `json:"settlement_id"` // 结算方式 + CreditLimit decimal.Decimal `json:"credit_limit"` // 信用额度 + PrincipalID dts.NullUint `json:"principal_id"` // 销售负责人编号 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt time.Time `json:"updated_at"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 + + Contracts []File `json:"contracts" gorm:"polymorphic:Owner;polymorphicValue:supplier-contract"` // 合同附件 + Address *Address `json:"address,omitempty" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 客户地址 + Remittance *Remittance `json:"remittance" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 财务信息 + Linkmen []Linkman `json:"linkmen,omitempty" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 联系人列表 + Certifications []Certification `json:"certifications" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 资质列表 + Principal *Employee `json:"principal" gorm:"foreignKey:PrincipalID"` // 销售负责人 + Settlement *Tag `json:"settlement" gorm:"foreignKey:SettlementID"` // 结算方式 + Type *Tag `json:"type,omitempty" gorm:"foreignKey:TypeID"` // 供应商分类 - 比如:成品供应商、零件供应商、集合供应商等 + Level *Tag `json:"level,omitempty" gorm:"foreignKey:LevelID"` // 供应商等级 +} diff --git a/app/models/supply_price.go b/app/models/supply_price.go new file mode 100644 index 0000000..49a8b0b --- /dev/null +++ b/app/models/supply_price.go @@ -0,0 +1,35 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// SupplyPrice 供应商价格表 +// https://www.jiandaoyun.com/index/solution_center/app/21774?utm_src=fazxjxcshseo +type SupplyPrice struct { + ID uint `json:"id" gorm:"primarykey"` // 价格编号 + SupplierID uint `json:"supplier_id"` // 供应商编号 + ProductID uint `json:"product_id"` // 供应产品编号 + ReferencePrice int `json:"reference_price"` // 标准采购单价(含税) + PurchasePriceWithTax int `json:"purchase_price_with_tax"` // 采购单价(含税) + DiscountRate int `json:"discount_rate"` // 折扣率 + PurchasePrice int `json:"purchase_price"` // 采购单价(不含税) + TaxRate int `json:"tax_rate"` // 增值税税率 + TaxValue int `json:"tax_value"` // 税额,单位分 + SubmitterID uint `json:"submitter_id"` // 提交人编号 + Submitter *Employee `json:"submitter" gorm:"foreignKey:SubmitterID"` // 提交人信息 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt time.Time `json:"updated_at"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/tag.go b/app/models/tag.go new file mode 100644 index 0000000..440e79f --- /dev/null +++ b/app/models/tag.go @@ -0,0 +1,52 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// TagType 标签类型 +type TagType int8 + +const ( + TagCustomerType TagType = iota + 1 // 客户分类,比如:潜在客户、成交客户、战略合作等; + TagCustomerLabel // 客户标签 + TagPriceStrategy // 价格策略,比如:标准售价、一级售价、二级售价等; + TagSettlementMethod // 结算方式,比如:现结、周结、月结、季结等 + TagSupplierType // 供应商分类 + TagSupplierLevel // 供应商等级 + TagWarehouseType // 仓库类型 + TagPurchaseReason // 需求原因,比如:补货、库存预警、销售需求等 + TagPaymentType // 支付方式,比如:银行转账、现金支付、微信支付、支付宝支付、银行支票等 + TagPaymentBatch // 支付批次,比如:整批货款、定金、第一批货款、第二批货款、第三批货款 + TagDistributionBatch // 配送批次,比如:整批、第一批、第二批、第三批等 + Tag_ // 在这个前面添加类型,而不用修改 Valid 方法 +) + +// Valid 是否有效 +func (t TagType) Valid() bool { + return t >= TagCustomerType && t < Tag_ +} + +// Tag 通用标签 +type Tag struct { + ID uint `json:"id" gorm:"primarykey"` // 标签编号 + Type TagType `json:"type" gorm:"uniqueIndex:uni_typed_name"` // 标签类型 + Name string `json:"name" gorm:"uniqueIndex:uni_typed_name"` // 标签名称 + Color string `json:"color" gorm:"default:null"` // 标签颜色 + Value dts.Any `json:"value"` // 绑定的值 + Sort int `json:"sort"` // 标签排序 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 +} diff --git a/app/models/tenant.go b/app/models/tenant.go new file mode 100644 index 0000000..dd1c631 --- /dev/null +++ b/app/models/tenant.go @@ -0,0 +1,21 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// Tenant 平台租户 +type Tenant struct { + ID uint `json:"id" gorm:"primaryKey;comment:租户ID"` + AccountId uint `json:"account_id" gorm:"comment:公司账户编号(创建者)"` + Name string `json:"name" gorm:"size:200;uniqueIndex;comment:'公司名称'"` + Description dts.NullString `json:"description" gorm:"size:500;comment:公司介绍"` + CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"` + UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"` + DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"comment:删除时间"` + + Account *Account `json:"account,omitempty" gorm:"foreignKey:AccountId"` // 公司账号 +} diff --git a/app/models/utils.go b/app/models/utils.go new file mode 100644 index 0000000..3c485de --- /dev/null +++ b/app/models/utils.go @@ -0,0 +1,21 @@ +package models + +// SimpleStatus 简单状态 +type SimpleStatus bool + +const ( + StatusOn SimpleStatus = true + StatusOff SimpleStatus = false +) + +// AuditableStatus 具有中间能力的状态 +type AuditableStatus int8 + +const ( + StatusDisabled AuditableStatus = iota - 1 // 被禁用 + StatusAuditing // 待审核 + StatusEnabled // 已启用 +) + +// Quality 品种 +type Quality int diff --git a/app/models/warehouse.go b/app/models/warehouse.go new file mode 100644 index 0000000..6d9a025 --- /dev/null +++ b/app/models/warehouse.go @@ -0,0 +1,90 @@ +package models + +import ( + "errors" + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// Warehouse 仓库 +type Warehouse struct { + Id uint `json:"id" gorm:"primarykey"` // 仓库ID + Name string `json:"name" gorm:"index:,unique,composite:uni_name_with_and_company"` // 仓库名称 + Code string `json:"code"` // 仓库编码 + TypeID uint `json:"type_id"` // 仓库类型编号 + Address *Address `json:"address,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 仓库地址 + Capacity int `json:"capacity"` // 仓库容量/立方 + ManagerID dts.NullUint `json:"manager_id"` // 仓库主管编号 + Status SimpleStatus `json:"status"` // 仓库状态 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 + + Type *Tag `json:"type" gorm:"foreignKey:TypeID"` // 仓库类型 + Manager *Employee `json:"manager" gorm:"foreignKey:ManagerID"` // 仓库主管 +} + +func (w *Warehouse) testType(tx *gorm.DB) error { + var typ Tag + err := tx.Unscoped().Where("id", w.TypeID).First(&typ).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("仓库分类不存在") + } + return err + } + if typ.Type != TagWarehouseType { + return errors.New("不是有效的仓库分类") + } + if typ.DeletedAt.Valid { + return errors.New("仓库分类已删除") + } + return nil +} + +func (w *Warehouse) testManager(tx *gorm.DB) error { + if !w.ManagerID.Valid { + return nil + } + var manager Employee + err := tx.Unscoped().First(&manager, "id", w.ManagerID.Uint).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("员工不存在") + } + return err + } + // TODO 验证是不是公司的员工 + if manager.DeletedAt.Valid { + return errors.New("员工已删除") + } + return nil +} + +func (w *Warehouse) test(tx *gorm.DB) error { + err := w.testType(tx) + if err != nil { + return err + } + return w.testManager(tx) +} + +// BeforeCreate 实现 callbacks.BeforeCreateInterface 接口 +func (w *Warehouse) BeforeCreate(tx *gorm.DB) error { + return w.test(tx) +} + +// BeforeUpdate 实现 callback.BeforeUpdateInterface 接口 +func (w *Warehouse) BeforeUpdate(tx *gorm.DB) error { + return w.test(tx) +} diff --git a/app/models/warehouse_location.go b/app/models/warehouse_location.go new file mode 100644 index 0000000..94b695f --- /dev/null +++ b/app/models/warehouse_location.go @@ -0,0 +1,34 @@ +package models + +import ( + "ims/util/db/dts" + "time" + + "gorm.io/gorm" +) + +// WarehouseLocation 仓位 +type WarehouseLocation struct { + Id uint `json:"id" gorm:"primarykey"` // 仓位ID + Code string `json:"code"` // 仓位编码 + WarehouseId uint `json:"warehouse_id" gorm:"index:,unique,composite:uni_code_with_warehouse_and_company"` // 所属仓库ID + Sequence string `json:"sequence" gorm:"index:,unique,composite:uni_code_with_warehouse_and_company"` // 仓位序号 + Capacity int `json:"capacity"` // 仓位容量/立方 + Remark string `json:"remark"` // 仓位备注 + ManagerId dts.NullUint `json:"manager_id"` // 仓位主管编号 + Status SimpleStatus `json:"status"` // 是否启用 + + CreatedBy uint `json:"created_by"` // 创建者(员工编号) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号) + UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间 + DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号) + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间 + + Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工 + Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工 + Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工 + + Warehouse *Warehouse `json:"warehouse,omitempty"` // 所属仓库 + Manager *Employee `json:"manager" gorm:"foreignKey:ManagerId"` // 仓位主管 +} diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..8ca4a7e --- /dev/null +++ b/database/README.md @@ -0,0 +1,13 @@ +# 使用 gormigrate 库实现数据库迁移 + +迁移工具 Github + +```text +https://github.com/go-gormigrate/gormigrate +``` + +## 迁移文件命名规范 + +```text +时间 + 顺序 + 行为 +``` \ No newline at end of file diff --git a/database/migrator.go b/database/migrator.go new file mode 100644 index 0000000..a21e157 --- /dev/null +++ b/database/migrator.go @@ -0,0 +1,39 @@ +package database + +import ( + "context" + "fmt" + "ims/database/system" + "ims/database/tenant" + "ims/util/db" + "ims/util/log" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +func Init(ctx context.Context) error { + return System(db.FromContext(ctx), func(m *db.Migrator) error { + return m.Migrate() + }) +} + +func System(tx *gorm.DB, fn func(m *db.Migrator) error) error { + return exec(tx, 0, fn, system.Migrations) +} + +func Tenant(tx *gorm.DB, tenantId uint, fn func(m *db.Migrator) error) error { + return exec(tx, tenantId, fn, tenant.Migrations) +} + +func exec(tx *gorm.DB, tenantId uint, fn func(m *db.Migrator) error, migrations []*gormigrate.Migration) error { + return fn(&db.Migrator{ + Schema: db.TenantSchema(tenantId), + TenantId: tenantId, + DB: tx, + Migrations: migrations, + Log: func(s string, a ...any) { + log.Debug(fmt.Sprintf(s, a...)) + }, + }) +} diff --git a/database/system/202409291240_01_initialize_database.go b/database/system/202409291240_01_initialize_database.go new file mode 100644 index 0000000..dfde00b --- /dev/null +++ b/database/system/202409291240_01_initialize_database.go @@ -0,0 +1,21 @@ +package system + +import ( + "ims/app/models" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var m202409291240_01_initialize_database = gormigrate.Migration{ + ID: "202409291240_01_initialize_database", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + &models.Account{}, + &models.Tenant{}, + ) + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/database/system/202409291240_02_create_super_account.go b/database/system/202409291240_02_create_super_account.go new file mode 100644 index 0000000..6a01646 --- /dev/null +++ b/database/system/202409291240_02_create_super_account.go @@ -0,0 +1,32 @@ +package system + +import ( + "ims/app/models" + "ims/util/db/dts" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var m202409291240_02_create_super_account = gormigrate.Migration{ + ID: "202409291240_02_create_super_account", + Migrate: func(tx *gorm.DB) error { + return tx.Model(&models.Account{}).FirstOrCreate( + &models.Account{ + ID: 1, + Nickname: "超级管理员", + AvatarUrl: "https://gravatar.com/userimage/256315286/98080c737d1d5c9f4d131266245ea8c9.jpeg?size=256", + Username: "admin", + RawPassword: "111111", + Email: dts.NullString{ + String: "hupeh@qq.com", + Valid: true, + }, + }, + models.Account{ID: 1}, + ).Error + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/database/system/migrations.go b/database/system/migrations.go new file mode 100644 index 0000000..363d487 --- /dev/null +++ b/database/system/migrations.go @@ -0,0 +1,10 @@ +package system + +import ( + "github.com/go-gormigrate/gormigrate/v2" +) + +var Migrations = []*gormigrate.Migration{ + &m202409291240_01_initialize_database, + &m202409291240_02_create_super_account, +} diff --git a/database/tenant/202409291240_01_initialize_database.go b/database/tenant/202409291240_01_initialize_database.go new file mode 100644 index 0000000..280387b --- /dev/null +++ b/database/tenant/202409291240_01_initialize_database.go @@ -0,0 +1,49 @@ +package tenant + +import ( + "ims/app/models" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var m202409291240_01_initialize_database = gormigrate.Migration{ + ID: "202409291240_01_initialize_database", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + &models.Address{}, + &models.Certification{}, + &models.Customer{}, + &models.CustomerTrack{}, + &models.Department{}, + &models.DistributionPlan{}, + &models.District{}, + &models.Employee{}, + &models.File{}, + &models.Inbound{}, + &models.InboundItem{}, + &models.Linkman{}, + &models.Material{}, + &models.Outbound{}, + &models.OutboundItem{}, + &models.PaymentPlan{}, + &models.Product{}, + &models.ProductCategory{}, + &models.PurchaseAudit{}, + &models.PurchaseExecution{}, + &models.PurchaseOrder{}, + &models.PurchaseProduct{}, + &models.PurchaseRequisition{}, + &models.Remittance{}, + &models.Supplier{}, + &models.SupplyPrice{}, + &models.Tag{}, + &models.Warehouse{}, + &models.WarehouseLocation{}, + ) + }, + Rollback: func(tx *gorm.DB) error { + //return tx.Migrator().DropTable("credit_cards") + return nil + }, +} diff --git a/database/tenant/202409291240_02_initialize_districts.go b/database/tenant/202409291240_02_initialize_districts.go new file mode 100644 index 0000000..dc45461 --- /dev/null +++ b/database/tenant/202409291240_02_initialize_districts.go @@ -0,0 +1,18 @@ +package tenant + +import ( + "ims/app/models" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var m202409291240_02_initialize_districts = gormigrate.Migration{ + ID: "202409291240_02_initialize_districts", + Migrate: func(tx *gorm.DB) error { + return models.InstallDistricts(tx) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("TRUNCATE TABLE " + tx.NamingStrategy.TableName("district")).Error + }, +} diff --git a/database/tenant/migrations.go b/database/tenant/migrations.go new file mode 100644 index 0000000..bc2ca67 --- /dev/null +++ b/database/tenant/migrations.go @@ -0,0 +1,10 @@ +package tenant + +import ( + "github.com/go-gormigrate/gormigrate/v2" +) + +var Migrations = []*gormigrate.Migration{ + &m202409291240_01_initialize_database, + &m202409291240_02_initialize_districts, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..43182bd --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module ims + +go 1.22.0 + +require ( + github.com/go-gormigrate/gormigrate/v2 v2.1.3 + github.com/go-redis/cache/v9 v9.0.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/jinzhu/inflection v1.0.0 + github.com/olekukonko/tablewriter v0.0.5 + github.com/redis/go-redis/v9 v9.6.1 + github.com/rs/xid v1.6.0 + github.com/shopspring/decimal v1.4.0 + golang.org/x/sync v0.8.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gorm.io/driver/mysql v1.5.7 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.12 + zestack.dev/cast v0.0.0-20240523001414-e34212374a23 + zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f + zestack.dev/env v0.0.0-20240108012311-632035163eec + zestack.dev/is v0.0.0-20240108012312-1fe7fd4da082 + zestack.dev/log v0.0.0-20240523001421-24d58305cd03 + zestack.dev/misc v0.0.0-20240815120320-73293b130b9f + zestack.dev/slim v0.0.0-20240913145858-96a68a237caf + zestack.dev/v v0.0.0-20240502170943-a14e2d946f2c +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/klauspost/compress v1.13.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/vmihailenco/go-tinylfu v0.2.2 // indirect + github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f280209 --- /dev/null +++ b/go.sum @@ -0,0 +1,269 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-gormigrate/gormigrate/v2 v2.1.3 h1:ei3Vq/rpPI/jCJY9mRHJAKg5vU+EhZyWhBAkaAomQuw= +github.com/go-gormigrate/gormigrate/v2 v2.1.3/go.mod h1:VJ9FIOBAur+NmQ8c4tDVwOuiJcgupTG105FexPFrXzA= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= +github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= +github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= +github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= +github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= +github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= +github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= +github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= +github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= +github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= +github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= +github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +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/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= +github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= +github.com/vmihailenco/msgpack/v5 v5.3.4 h1:qMKAwOV+meBw2Y8k9cVwAy7qErtYCwBzZ2ellBfvnqc= +github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +zestack.dev/cast v0.0.0-20240523001414-e34212374a23 h1:xlcaGxt2tomZX91jzN7mk4vyy+d3fImb7FQcr8hd9Aw= +zestack.dev/cast v0.0.0-20240523001414-e34212374a23/go.mod h1:Z0WKru8M3FN1fDhqVEzw+I8MNNOPo3bc9lzB6oEJW6s= +zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f h1:5tSc5qVy2f6vyG5LVRETRe8tAFeZKxcjFsl0Jy7rRUQ= +zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f/go.mod h1:lHvP85VRaqzm4E/9Q9kW/WmEnEHL4PQ9Fls5xDah3zI= +zestack.dev/env v0.0.0-20240108012311-632035163eec h1:VOz6k4dJeHHoozGrF6ISIGaR7amRXGCJfMH+0amYrf0= +zestack.dev/env v0.0.0-20240108012311-632035163eec/go.mod h1:TgAagxCpU5oBFIHQSSuVcGw3ygfNvm3CSU92t/ufEWA= +zestack.dev/is v0.0.0-20240108012312-1fe7fd4da082 h1:RXuVELrTjlZcKHWxX4+GuRcHPRWoOYIkr0u7F+EtYP8= +zestack.dev/is v0.0.0-20240108012312-1fe7fd4da082/go.mod h1:JDC6HTLLY20gU2PWx7BHYun97lOQGtYQI2acQmTccy8= +zestack.dev/log v0.0.0-20240523001421-24d58305cd03 h1:ZNnBVHIiV4R2SBypa24SLuhkrc9unlvPdXg/u0ZqSro= +zestack.dev/log v0.0.0-20240523001421-24d58305cd03/go.mod h1:1ZoaLtIYHxx/J7jFoQpNLxsCIcpBZ1NQWFjafTEBzN8= +zestack.dev/misc v0.0.0-20240815120320-73293b130b9f h1:77RL7xP7l0kV8s12EDo/JcQDbYuuwfbBO3JhYu9V8MI= +zestack.dev/misc v0.0.0-20240815120320-73293b130b9f/go.mod h1:dfX3xtBz37zBEsTnO1u/XPpXeq7ZFZHDXqoz9vllfDw= +zestack.dev/slim v0.0.0-20240913145858-96a68a237caf h1:axDVuMqH1R8PkLLeiD+B9LcjfG/07oH0ZWgOv7XixnU= +zestack.dev/slim v0.0.0-20240913145858-96a68a237caf/go.mod h1:QhpKfYJGCC13b11uqWOjxgEGLJUUwLyJ6XPsGBLcjC0= +zestack.dev/v v0.0.0-20240502170943-a14e2d946f2c h1:33nVg7TNj6lvcp8Uw3FaCAdQ62oS7Jj+vNlAPm5eLOE= +zestack.dev/v v0.0.0-20240502170943-a14e2d946f2c/go.mod h1:kebq/o/Qw/GVuDvt25DfNhXNAIi9/5U+szrGfPmekFk= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9b32630 --- /dev/null +++ b/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "ims/app" + "ims/util/log" + "os" + "os/signal" + "syscall" + + "zestack.dev/env" +) + +func main() { + ctx, cancel := context.WithCancelCause(context.Background()) + + // 读取环境变量配置 + if err := env.Init(); err != nil { + cancel(err) + log.Error("failed to initialize environ variables", "error", err) + os.Exit(1) + } + + // 启动应用 + if err := app.Start(ctx); err != nil { + cancel(err) + log.Error("failed to start the application", "error", err) + os.Exit(1) + } + + // 监听信号 + singleChannel := make(chan os.Signal, 1) + signal.Notify(singleChannel, syscall.SIGINT, syscall.SIGTERM) + select { + case <-ctx.Done(): + case <-singleChannel: + } + + // 停止应用 + if err := app.Stop(); err != nil { + cancel(err) + log.Error("failed to stop the application", "error", err) + os.Exit(1) + } + + cancel(context.Canceled) + os.Exit(0) +} diff --git a/util/backoff/backoff.go b/util/backoff/backoff.go new file mode 100644 index 0000000..477a33a --- /dev/null +++ b/util/backoff/backoff.go @@ -0,0 +1,87 @@ +package backoff + +import ( + "cmp" + "fmt" + "log" + "time" +) + +// Default configuration values. +const ( + DefaultMaxRetries = 3 + DefaultRetryInterval = 2 * time.Second + DefaultMaxInterval = 30 * time.Second +) + +// Options is the configuration options for retry operations. +type Options struct { + // MaxRetries is the maximum number of retry attempts. + // Default is 3. + MaxRetries int `json:"gmt_max_retries"` + // Interval is the initial interval between retry attempts. + // Default is 2 seconds. + Interval time.Duration `json:"gmt_retry_interval"` + // MaxInterval is the maximum interval between retry attempts. + // Default is 30 seconds. + MaxInterval time.Duration `json:"gmt_retry_max_interval"` +} + +// Option is a function that applies an option to an Options instance. +type Option func(*Options) + +func (o *Options) Apply(opts ...Option) { + for _, opt := range opts { + opt(o) + } + + o.MaxRetries = cmp.Or(max(o.MaxRetries, 0), DefaultMaxRetries) + o.Interval = cmp.Or(max(o.Interval, 0), DefaultRetryInterval) + o.MaxInterval = cmp.Or(max(o.MaxInterval, 0), DefaultMaxInterval) +} + +// WithMaxRetries sets the maximum number of retry attempts. +func WithMaxRetries(n int) Option { + return func(o *Options) { + o.MaxRetries = n + } +} + +// WithRetryInterval sets the initial interval between retry attempts. +func WithRetryInterval(i time.Duration) Option { + return func(o *Options) { + o.Interval = i + } +} + +// WithMaxInterval sets the maximum interval between retry attempts. +func WithMaxInterval(i time.Duration) Option { + return func(o *Options) { + o.MaxInterval = i + } +} + +// Retry executes the provided function with retry logic using exponential backoff. +func Retry(fn func() error, opts ...Option) error { + o := new(Options) + o.Apply(opts...) + + interval := o.Interval + for i := 0; i < o.MaxRetries; i++ { + if err := fn(); err != nil { + if i == o.MaxRetries-1 { + return fmt.Errorf("backoff: max retries (%d) exceeded: %w", o.MaxRetries, err) + } + time.Sleep(interval) + // TODO 日志 + log.Printf("backoff: retrying after %s (attempt %d of %d) due to error: %v", interval, i+1, o.MaxRetries, err) + interval *= 2 + if interval > o.MaxInterval { + interval = o.MaxInterval + } + } else { + return nil + } + } + return nil +} diff --git a/util/backoff/backoff_test.go b/util/backoff/backoff_test.go new file mode 100644 index 0000000..440a595 --- /dev/null +++ b/util/backoff/backoff_test.go @@ -0,0 +1,57 @@ +package backoff + +import ( + "errors" + "testing" + "time" +) + +func TestRetry(t *testing.T) { + tests := []struct { + name string + fn func() error + opts []Option + expectError bool + }{ + { + name: "Success on first try", + fn: func() error { + return nil + }, + opts: []Option{WithMaxRetries(3), WithRetryInterval(1 * time.Second)}, + expectError: false, + }, + { + name: "Fail all retries", + fn: func() error { + return errors.New("fail") + }, + opts: []Option{WithMaxRetries(3), WithRetryInterval(1 * time.Second)}, + expectError: true, + }, + { + name: "Success on second try", + fn: func() func() error { + static := 0 + return func() error { + static++ + if static == 2 { + return nil + } + return errors.New("fail") + } + }(), + opts: []Option{WithMaxRetries(3), WithRetryInterval(1 * time.Second)}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Retry(tt.fn, tt.opts...) + if (err != nil) != tt.expectError { + t.Errorf("expected error: %v, got: %v", tt.expectError, err) + } + }) + } +} diff --git a/util/cache/cache.go b/util/cache/cache.go new file mode 100644 index 0000000..2474185 --- /dev/null +++ b/util/cache/cache.go @@ -0,0 +1,100 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "time" + + "ims/util/rdb" + + "github.com/go-redis/cache/v9" + "github.com/redis/go-redis/v9" + "golang.org/x/sync/singleflight" + "zestack.dev/env" +) + +var ( + engine *cache.Cache + sfg singleflight.Group + + ErrCacheMiss = cache.ErrCacheMiss +) + +func Init() error { + engine = cache.New(&cache.Options{ + Redis: rdb.Redis(), + LocalCache: cache.NewTinyLFU(1000, time.Minute), + }) + + return nil +} + +func realKey(key string) string { + return fmt.Sprintf("%s:%s", env.String("CACHE_PREFIX", "cache"), key) +} + +func Get(ctx context.Context, key string, value any) error { + return engine.Get(ctx, realKey(key), value) +} + +func GetOr(ctx context.Context, key string, value any, or func(ctx context.Context, key string, value any) error) error { + _, err, _ := sfg.Do(key, func() (interface{}, error) { + err := engine.Get(ctx, realKey(key), value) + if err == nil { + return nil, nil + } + if errors.Is(err, cache.ErrCacheMiss) { + err = or(ctx, key, value) + if err == nil { + _ = Set(ctx, key, value) + } + } + if errors.Is(err, redis.Nil) { + return nil, ErrCacheMiss + } + return nil, err + }) + + return err +} + +// Set 设置缓存 默认 1 小时有效 +func Set(ctx context.Context, key string, value any) error { + return engine.Set(&cache.Item{ + Ctx: ctx, + Key: realKey(key), + Value: value, + TTL: env.Duration("CACHE_TTL", time.Hour), + }) +} + +// SetNX only sets the key if it does not already exist. +func SetNX(ctx context.Context, key string, value any) error { + return engine.Set(&cache.Item{ + Ctx: ctx, + Key: realKey(key), + Value: value, + TTL: env.Duration("CACHE_TTL", time.Hour), + SetNX: true, + }) +} + +// SetXX only sets the key if it already exists. +func SetXX(ctx context.Context, key string, value any) error { + return engine.Set(&cache.Item{ + Ctx: ctx, + Key: realKey(key), + Value: value, + TTL: env.Duration("CACHE_TTL", time.Hour), + SetXX: true, + }) +} + +func Exists(ctx context.Context, key string) bool { + return engine.Exists(ctx, realKey(key)) +} + +func Delete(ctx context.Context, key string) error { + return engine.Delete(ctx, realKey(key)) +} diff --git a/util/db/clause.go b/util/db/clause.go new file mode 100644 index 0000000..2d82ab3 --- /dev/null +++ b/util/db/clause.go @@ -0,0 +1,140 @@ +package db + +import ( + "gorm.io/gorm/clause" +) + +type null struct { + Column any +} + +func (n null) Build(builder clause.Builder) { + builder.WriteQuoted(n.Column) + builder.WriteString(" IS NULL") +} + +func (n null) NegationBuild(builder clause.Builder) { + builder.WriteQuoted(n.Column) + builder.WriteString(" IS NOT NULL") +} + +type between struct { + Column any + Less any + More any +} + +func (b between) Build(builder clause.Builder) { + b.build(builder, " BETWEEN ") +} + +func (b between) NegationBuild(builder clause.Builder) { + b.build(builder, " NOT BETWEEN ") +} + +func (b between) build(builder clause.Builder, op string) { + builder.WriteQuoted(b.Column) + builder.WriteString(op) + builder.AddVar(builder, b.Less) + builder.WriteString(" And ") + builder.AddVar(builder, b.More) +} + +type exists struct { + expr any +} + +func (e exists) Build(builder clause.Builder) { + e.build(builder, "EXISTS") +} + +func (e exists) NegationBuild(builder clause.Builder) { + e.build(builder, "NOT EXISTS") +} + +func (e exists) build(builder clause.Builder, op string) { + builder.WriteString(op) + builder.WriteString(" (") + builder.AddVar(builder, e.expr) + builder.WriteString(")") +} + +func Eq(col string, val any) clause.Expression { + return clause.Eq{Column: col, Value: val} +} + +func Neq(col string, val any) clause.Expression { + return clause.Neq{Column: col, Value: val} +} + +func Lt(col string, val any) clause.Expression { + return clause.Lt{Column: col, Value: val} +} + +func Lte(col string, val any) clause.Expression { + return clause.Lte{Column: col, Value: val} +} + +func Gt(col string, val any) clause.Expression { + return clause.Gt{Column: col, Value: val} +} + +func Gte(col string, val any) clause.Expression { + return clause.Gte{Column: col, Value: val} +} + +func Between(col string, less, more any) clause.Expression { + return between{col, less, more} +} + +func NotBetween(col string, less, more any) clause.Expression { + return clause.Not(between{col, less, more}) +} + +func IsNull(col string) clause.Expression { + return null{col} +} + +func NotNull(col string) clause.Expression { + return clause.Not(null{col}) +} + +func Like(col, tpl string) clause.Expression { + return clause.Like{Column: col, Value: "%" + tpl + "%"} +} + +func NotLike(col, tpl string) clause.Expression { + return clause.Not(clause.Like{Column: col, Value: "%" + tpl + "%"}) +} + +func HasPrefix(col, prefix string) clause.Expression { + return clause.Like{Column: col, Value: prefix + "%"} +} + +func NotPrefix(col, prefix string) clause.Expression { + return clause.Not(HasPrefix(col, prefix)) +} + +func HasSuffix(col, suffix string) clause.Expression { + return clause.Like{Column: col, Value: "%" + suffix} +} + +func NotSuffix(col, suffix string) clause.Expression { + return clause.Not(HasSuffix(col, suffix)) +} + +func In(col string, values ...any) clause.Expression { + return clause.IN{Column: col, Values: values} +} + +func NotIn(col string, values ...any) clause.Expression { + return clause.Not(clause.IN{Column: col, Values: values}) +} + +func Exists(expr any) clause.Expression { + return exists{expr} +} + +func NotExists(expr any) clause.Expression { + return clause.Not(exists{expr}) +} diff --git a/util/db/db.go b/util/db/db.go new file mode 100644 index 0000000..680f3a1 --- /dev/null +++ b/util/db/db.go @@ -0,0 +1,130 @@ +package db + +import ( + "context" + "errors" + "ims/util/backoff" + "strings" + + "gorm.io/gorm" + "zestack.dev/cast" + "zestack.dev/env" +) + +const ( + ctxKey = "ims/util/db:engine" +) + +var ( + // ErrExecSQL is returned when the migrator fails to execute SQL. + ErrExecSQL = errors.New("locking: failed to execute SQL") + // ErrAcquireLock is returned when the migrator fails to acquire an advisory lock. + ErrAcquireLock = errors.New("locking: failed to acquire advisory lock") + // ErrReleaseLock is returned when the migrator fails to release an advisory lock. + ErrReleaseLock = errors.New("locking: failed to release advisory lock") + // ErrSwitchSchema is returned when the migrator fails to switch to a schema. + ErrSwitchSchema = errors.New("db: nested schema switching") + + db *gorm.DB + schemaHelper SchemaHelper +) + +type SchemaHelper interface { + PublicSchema() string + TenantSchema(tenantId uint) string + CurrentSchema(tx *gorm.DB) string + UseSchema(tx *gorm.DB, schema string) (reset func() error, err error) + LockSchema(tx *gorm.DB, schema string, retry *backoff.Options) (unlock func() error, err error) + CreateSchema(tx *gorm.DB, schema string) error + DropSchema(tx *gorm.DB, schema string) error +} + +// Engine 获取数据库操作引擎 +func Engine() *gorm.DB { + return db +} + +func NewContext(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKey, FromContext(ctx)) +} + +func FromContext(ctx context.Context) *gorm.DB { + if ctx == nil { + return Engine() + } + engine, ok := ctx.Value(ctxKey).(*gorm.DB) + if ok && engine != nil { + return engine + } + return Engine().WithContext(ctx) +} + +// WithContext 派生出基于指定上下文的数据库操作引擎 +func WithContext(ctx context.Context) *gorm.DB { + return Engine().WithContext(ctx) +} + +func PublicSchema() string { + return schemaHelper.PublicSchema() +} + +func TenantSchema(tenantId uint) string { + return schemaHelper.TenantSchema(tenantId) +} + +// UseTenant 使用指定的租户 +// 切记:应该在事务中使用 +func UseTenant(tx *gorm.DB, tenant uint) (reset func() error, err error) { + return schemaHelper.UseSchema(tx, schemaHelper.TenantSchema(tenant)) +} + +func CurrentTenant(tx *gorm.DB) (tenant uint, ok bool) { + schema := schemaHelper.CurrentSchema(tx) + prefix := env.String("DB_TENANT_PREFIX", "tenant_") + suffix := env.String("DB_TENANT_SUFFIX") + + if schema != "" && prefix != "" && strings.HasPrefix(schema, prefix) { + schema = strings.TrimPrefix(schema, prefix) + } + if schema != "" && suffix != "" && strings.HasSuffix(schema, suffix) { + schema = strings.TrimSuffix(schema, suffix) + } + if schema == "" { + return + } + + var err error + tenant, err = cast.Uint(schema) + ok = err == nil && tenant > 0 + + return +} + +func WithTenant(tx *gorm.DB, tenant uint, fn func(tx *gorm.DB) error) error { + return tx.Transaction(func(tx *gorm.DB) error { + reset, err := UseTenant(tx, tenant) + if err != nil { + return err + } + defer reset() + + return fn(tx) + }) +} + +func LockSchema(tx *gorm.DB, schema string, opts ...backoff.Option) (unlock func() error, err error) { + if len(opts) == 0 { + return schemaHelper.LockSchema(tx, schema, nil) + } + var retry *backoff.Options + retry.Apply(opts...) + return schemaHelper.LockSchema(tx, schema, retry) +} + +func CreateSchema(tx *gorm.DB, schema string) error { + return schemaHelper.CreateSchema(tx, schema) +} + +func DropSchema(tx *gorm.DB, schema string) error { + return schemaHelper.DropSchema(tx, schema) +} diff --git a/util/db/dts/any.go b/util/db/dts/any.go new file mode 100644 index 0000000..74a3b4b --- /dev/null +++ b/util/db/dts/any.go @@ -0,0 +1,91 @@ +package dts + +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "strings" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" +) + +// Any give a generic data type for json encoded data. +type Any struct { + data any +} + +func NewAny(data any) Any { + return Any{ + data: data, + } +} + +// Data return data with generic Type T +func (j Any) Data() any { + return j.data +} + +// Value return json value, implement driver.Valuer interface +func (j Any) Value() (driver.Value, error) { + return json.Marshal(j.data) +} + +// Scan scan value into Any, implements sql.Scanner interface +func (j *Any) Scan(value any) error { + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) + } + return json.Unmarshal(bytes, &j.data) +} + +// MarshalJSON to output non base64 encoded []byte +func (j Any) MarshalJSON() ([]byte, error) { + return json.Marshal(j.data) +} + +// UnmarshalJSON to deserialize []byte +func (j *Any) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &j.data) +} + +// GormDataType gorm common data type +func (Any) GormDataType() string { + return "json" +} + +// GormDBDataType gorm db data type +func (Any) GormDBDataType(db *gorm.DB, field *schema.Field) string { + switch db.Dialector.Name() { + case "sqlite": + return "JSON" + case "mysql": + return "JSON" + case "postgres": + return "JSONB" + } + return "" +} + +func (js Any) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { + data, _ := js.MarshalJSON() + + switch db.Dialector.Name() { + case "mysql": + if v, ok := db.Dialector.(*mysql.Dialector); ok && !strings.Contains(v.ServerVersion, "MariaDB") { + return gorm.Expr("CAST(? AS JSON)", string(data)) + } + } + + return gorm.Expr("?", string(data)) +} diff --git a/util/db/dts/color.go b/util/db/dts/color.go new file mode 100644 index 0000000..5a5c66f --- /dev/null +++ b/util/db/dts/color.go @@ -0,0 +1,84 @@ +package dts + +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + "zestack.dev/is" +) + +// Color 颜色,仅支持16进制 +type Color string + +// Set 设置颜色值 +func (c *Color) Set(s string) bool { + if s == "" || is.HEXColor(s) { + *c = Color(s) + return true + } + return false +} + +func (c Color) Value() (driver.Value, error) { + if len(c) == 0 { + return nil, nil + } + return string(c), nil +} + +func (c *Color) Scan(value any) error { + if value == nil { + *c = "" + return nil + } + var ok bool + switch v := value.(type) { + case []byte: + ok = c.Set(string(v)) + case string: + ok = c.Set(v) + } + if !ok { + return errors.New(fmt.Sprint("failed to unmarshal value:", value)) + } + return nil +} + +func (c Color) MarshalJSON() ([]byte, error) { + return json.Marshal(string(c)) +} + +func (c *Color) UnmarshalJSON(b []byte) error { + var str string + err := json.Unmarshal(b, &str) + if err != nil { + return err + } + if !c.Set(str) { + return errors.New("failed to unmarshal value") + } + return nil +} + +func (c Color) String() string { + return string(c) +} + +// GormDBDataType gorm db data type +func (Color) GormDBDataType(db *gorm.DB, field *schema.Field) string { + return "varchar(7)" +} + +func (c Color) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { + if len(c) == 0 { + return gorm.Expr("NULL") + } + data, _ := c.MarshalJSON() + return gorm.Expr("?", string(data)) +} diff --git a/util/db/dts/date.go b/util/db/dts/date.go new file mode 100644 index 0000000..e136165 --- /dev/null +++ b/util/db/dts/date.go @@ -0,0 +1,42 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "time" +) + +type Date time.Time + +func NewDate(str string) (Date, error) { + value, err := time.Parse(time.DateOnly, str) + if err != nil { + return Date{}, err + } + return Date(value), nil +} + +func (date *Date) Scan(value interface{}) (err error) { + nullTime := &sql.NullTime{} + err = nullTime.Scan(value) + *date = Date(nullTime.Time) + return +} + +func (date Date) Value() (driver.Value, error) { + y, m, d := time.Time(date).Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.Time(date).Location()), nil +} + +// GormDataType gorm common data type +func (date Date) GormDataType() string { + return "date" +} + +func (date Date) MarshalJSON() ([]byte, error) { + return time.Time(date).MarshalJSON() +} + +func (date *Date) UnmarshalJSON(b []byte) error { + return (*time.Time)(date).UnmarshalJSON(b) +} diff --git a/util/db/dts/map.go b/util/db/dts/map.go new file mode 100644 index 0000000..3a44da5 --- /dev/null +++ b/util/db/dts/map.go @@ -0,0 +1,104 @@ +package dts + +import ( + "bytes" + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "strings" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" +) + +// Map defined JSON data type, need to implements driver.Valuer, sql.Scanner interface +type Map map[string]any + +func NewMap(m map[string]any) Map { + return Map(m) +} + +// Value return json value, implement driver.Valuer interface +func (m Map) Value() (driver.Value, error) { + if m == nil { + return nil, nil + } + ba, err := m.MarshalJSON() + return string(ba), err +} + +// Scan scan value into Jsonb, implements sql.Scanner interface +func (m *Map) Scan(val interface{}) error { + if val == nil { + *m = make(Map) + return nil + } + var ba []byte + switch v := val.(type) { + case []byte: + ba = v + case string: + ba = []byte(v) + default: + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", val)) + } + t := map[string]any{} + rd := bytes.NewReader(ba) + decoder := json.NewDecoder(rd) + decoder.UseNumber() + err := decoder.Decode(&t) + *m = t + return err +} + +// MarshalJSON to output non base64 encoded []byte +func (m Map) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + t := (map[string]interface{})(m) + return json.Marshal(t) +} + +// UnmarshalJSON to deserialize []byte +func (m *Map) UnmarshalJSON(b []byte) error { + t := map[string]interface{}{} + err := json.Unmarshal(b, &t) + *m = Map(t) + return err +} + +// GormDataType gorm common data type +func (m Map) GormDataType() string { + return "map" +} + +// GormDBDataType gorm db data type +func (Map) GormDBDataType(db *gorm.DB, field *schema.Field) string { + switch db.Dialector.Name() { + case "sqlite": + return "JSON" + case "mysql": + return "JSON" + case "postgres": + return "JSONB" + case "sqlserver": + return "NVARCHAR(MAX)" + } + return "" +} + +func (m Map) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { + data, _ := m.MarshalJSON() + switch db.Dialector.Name() { + case "mysql": + if v, ok := db.Dialector.(*mysql.Dialector); ok && !strings.Contains(v.ServerVersion, "MariaDB") { + return gorm.Expr("CAST(? AS JSON)", string(data)) + } + } + return gorm.Expr("?", string(data)) +} diff --git a/util/db/dts/null_date.go b/util/db/dts/null_date.go new file mode 100644 index 0000000..3ce19a1 --- /dev/null +++ b/util/db/dts/null_date.go @@ -0,0 +1,64 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "time" +) + +type NullDate struct { + Date time.Time + Valid bool // Valid is true if Date is not NULL +} + +func (date NullDate) Any() any { + if date.Valid { + return date.Date + } + return nil +} + +func (nd *NullDate) Scan(value any) error { + if value == nil { + nd.Date, nd.Valid = time.Time{}, false + return nil + } + nt := new(sql.NullTime) + err := nt.Scan(value) + nd.Date = nt.Time + nd.Valid = nt.Valid + return err +} + +func (nd NullDate) Value() (driver.Value, error) { + if !nd.Valid { + return nil, nil + } + y, m, d := nd.Date.Date() + return time.Date(y, m, d, 0, 0, 0, 0, nd.Date.Location()), nil +} + +// GormDataType gorm common data type +func (nd NullDate) GormDataType() string { + return "date" +} + +func (nd NullDate) MarshalJSON() ([]byte, error) { + if nd.Valid { + return nd.Date.MarshalJSON() + } + return json.Marshal(nil) +} + +func (nd *NullDate) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + nd.Valid = false + return nil + } + err := nd.Date.UnmarshalJSON(b) + if err == nil { + nd.Valid = true + } + return err +} diff --git a/util/db/dts/null_string.go b/util/db/dts/null_string.go new file mode 100644 index 0000000..3660632 --- /dev/null +++ b/util/db/dts/null_string.go @@ -0,0 +1,62 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" +) + +// NullString represents a string that may be null. +// NullString implements the [Scanner] interface so +// it can be used as a scan destination: +// +// var s NullString +// err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&s) +// ... +// if s.Valid { +// // use s.String +// } else { +// // NULL value +// } +type NullString struct { + String string + Valid bool // Valid is true if String is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullString) Scan(value any) error { + if value == nil { + ns.String, ns.Valid = "", false + return nil + } + ss := new(sql.NullString) + err := ss.Scan(value) + ns.String = ss.String + ns.Valid = ss.Valid + return err +} + +// Value implements the driver Valuer interface. +func (ns NullString) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return ns.String, nil +} + +func (ns NullString) MarshalJSON() ([]byte, error) { + if ns.Valid { + return json.Marshal(ns.String) + } + return json.Marshal(nil) +} + +func (ns *NullString) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + ns.Valid = false + return nil + } + err := json.Unmarshal(b, &ns.String) + ns.Valid = err == nil + return err +} diff --git a/util/db/dts/null_time.go b/util/db/dts/null_time.go new file mode 100644 index 0000000..9a829d3 --- /dev/null +++ b/util/db/dts/null_time.go @@ -0,0 +1,53 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "time" +) + +// NullTime represents a [time.Time] that may be null. +// NullTime implements the [Scanner] interface so +// it can be used as a scan destination, similar to [NullString]. +type NullTime struct { + Time time.Time + Valid bool // Valid is true if Time is not NULL +} + +// Scan implements the Scanner interface. +func (nt *NullTime) Scan(value any) error { + if value == nil { + nt.Time, nt.Valid = time.Time{}, false + return nil + } + st := new(sql.NullTime) + err := st.Scan(value) + nt.Time = st.Time + nt.Valid = st.Valid + return err +} + +// Value implements the driver Valuer interface. +func (nt NullTime) Value() (driver.Value, error) { + if !nt.Valid { + return nil, nil + } + return nt.Time, nil +} + +func (nt *NullTime) UnmarshalJSON(bytes []byte) error { + err := nt.Time.UnmarshalJSON(bytes) + if err != nil { + return err + } + nt.Valid = !nt.Time.IsZero() + return nil +} + +func (nt NullTime) MarshalJSON() ([]byte, error) { + if !nt.Valid { + return json.Marshal(nil) + } + return nt.Time.MarshalJSON() +} diff --git a/util/db/dts/null_uint.go b/util/db/dts/null_uint.go new file mode 100644 index 0000000..27c20e7 --- /dev/null +++ b/util/db/dts/null_uint.go @@ -0,0 +1,74 @@ +package dts + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" +) + +type NullUint struct { + Uint uint + Valid bool // Valid is true if Uint is not NULL +} + +func NewNullUint(n uint) NullUint { + return NullUint{ + Uint: n, + Valid: n > 0, + } +} + +func (n NullUint) Any() any { + if n.Valid { + return n.Uint + } + return nil +} + +// Scan implements the Scanner interface. +func (n *NullUint) Scan(value any) error { + if value == nil { + n.Uint, n.Valid = 0, false + return nil + } + ni := new(sql.NullInt64) + err := ni.Scan(value) + if err != nil { + return err + } + if !ni.Valid { + n.Uint, n.Valid = 0, false + return nil + } + n.Uint = uint(ni.Int64) + n.Valid = true + return nil +} + +// Value implements the driver Valuer interface. +func (n NullUint) Value() (driver.Value, error) { + if !n.Valid { + return nil, nil + } + // 注意:driver.Value 不支持 unit + return int64(n.Uint), nil +} + +func (n NullUint) MarshalJSON() ([]byte, error) { + if n.Valid { + return json.Marshal(n.Uint) + } + return json.Marshal(nil) +} + +func (n *NullUint) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + n.Valid = false + return nil + } + err := json.Unmarshal(b, &n.Uint) + if err == nil { + n.Valid = true + } + return err +} diff --git a/util/db/dts/slice.go b/util/db/dts/slice.go new file mode 100644 index 0000000..6a7e592 --- /dev/null +++ b/util/db/dts/slice.go @@ -0,0 +1,72 @@ +package dts + +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "strings" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" +) + +// Slice give a generic data type for json encoded slice data. +type Slice[T any] []T + +func NewSlice[T any](s []T) Slice[T] { + return s +} + +// Value return json value, implement driver.Valuer interface +func (j Slice[T]) Value() (driver.Value, error) { + return json.Marshal(j) +} + +// Scan scan value into Slice[T], implements sql.Scanner interface +func (j *Slice[T]) Scan(value interface{}) error { + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) + } + return json.Unmarshal(bytes, &j) +} + +// GormDataType gorm common data type +func (Slice[T]) GormDataType() string { + return "slices" +} + +// GormDBDataType gorm db data type +func (Slice[T]) GormDBDataType(db *gorm.DB, field *schema.Field) string { + switch db.Dialector.Name() { + case "sqlite": + return "JSON" + case "mysql": + return "JSON" + case "postgres": + return "JSONB" + } + return "" +} + +func (j Slice[T]) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { + data, _ := json.Marshal(j) + + switch db.Dialector.Name() { + case "mysql": + if v, ok := db.Dialector.(*mysql.Dialector); ok && !strings.Contains(v.ServerVersion, "MariaDB") { + return gorm.Expr("CAST(? AS JSON)", string(data)) + } + } + + return gorm.Expr("?", string(data)) +} diff --git a/util/db/dts/url.go b/util/db/dts/url.go new file mode 100644 index 0000000..e5a3368 --- /dev/null +++ b/util/db/dts/url.go @@ -0,0 +1,70 @@ +package dts + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +type URL url.URL + +func NewURL(url url.URL) URL { + return URL(url) +} + +func (u URL) Value() (driver.Value, error) { + return u.String(), nil +} + +func (u *URL) Scan(value interface{}) error { + var us string + switch v := value.(type) { + case []byte: + us = string(v) + case string: + us = v + default: + return errors.New(fmt.Sprint("Failed to parse URL:", value)) + } + uu, err := url.Parse(us) + if err != nil { + return err + } + *u = URL(*uu) + return nil +} + +func (URL) GormDataType() string { + return "url" +} + +func (URL) GormDBDataType(db *gorm.DB, field *schema.Field) string { + return "TEXT" +} + +func (u *URL) String() string { + return (*url.URL)(u).String() +} + +func (u URL) MarshalJSON() ([]byte, error) { + return json.Marshal(u.String()) +} + +func (u *URL) UnmarshalJSON(data []byte) error { + // ignore null + if string(data) == "null" { + return nil + } + uu, err := url.Parse(strings.Trim(string(data), `"'`)) + if err != nil { + return err + } + *u = URL(*uu) + return nil +} diff --git a/util/db/init.go b/util/db/init.go new file mode 100644 index 0000000..f126629 --- /dev/null +++ b/util/db/init.go @@ -0,0 +1,120 @@ +package db + +import ( + "database/sql" + "fmt" + "time" + + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/schema" + "zestack.dev/env" +) + +func Init() error { + var dialector gorm.Dialector + switch driver := env.String("DB_DRIVER", "postgres"); driver { + case "mysql": + schemaHelper = &mysqlSchemaHelper{} + dialector = openMysql() + case "postgres": + schemaHelper = &pgsqlSchemaHelper{} + dialector = openPostgres() + default: + return fmt.Errorf("unsupported database driver: %s", driver) + } + + var err error + db, err = gorm.Open(dialector, &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: env.String("DB_PREFIX"), + SingularTable: env.Bool("DB_SINGULAR_TABLE", false), + IdentifierMaxLength: env.Int("DB_IDENTIFIER_MAX_LENGTH", 0), + }, + Logger: &dbLogger{200 * time.Millisecond}, + // 在我们使用模型查询时,使用模型字段而不是通配符来查询。 + // https://gorm.io/docs/advanced_query.html#Smart-Select-Fields + QueryFields: env.Bool("DB_QUERY_FIELDS", true), + // 在 AutoMigrate 或 CreateTable 时,GORM 会自动创建外键约束, + // 若要禁用该特性,可将其设置为 true 。 + // https://gorm.io/docs/migration.html + DisableForeignKeyConstraintWhenMigrating: env.Bool("DB_DISABLE_FOREIGN_KEY_CONSTRAINT", false), + // 在 AutoMigrate 或 CreateTable 时,GORM 会自动创建索引, + // 若要禁用该特性,可将其设置为 true 。 + IgnoreRelationshipsWhenMigrating: env.Bool("DB_IGNORE_RELATIONSHIPS", false), + // 开启方言错误转换,GORM 会将不同数据库的特定错误转换为常见的 GORM 错误类型, + // 这样,方便我们对错误进行统一处理。 + // https://gorm.io/docs/error_handling.html#Dialect-Translated-Errors + // https://github.com/go-gorm/gorm/blob/master/errors.go + TranslateError: true, + }) + if err != nil { + return err + } + + // 如果使用的是 MySQL 数据库,则启用 InnoDB 引擎。 + if db.Dialector.Name() == "mysql" { + db.Set("gorm:table_options", "ENGINE=InnoDB") + } + + // 配置连接池 + var raw *sql.DB + if raw, err = db.DB(); err == nil { + err = configRawDB(raw) + } + + return err +} + +func openMysql() gorm.Dialector { + return mysql.New(mysql.Config{ + DSN: fmt.Sprintf( + "%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=%s", + env.String("DB_USER", "root"), + env.String("DB_AUTH", "password"), + env.String("DB_HOST", "localhost"), + env.Int("DB_PORT", 3306), + env.String("DB_NAME", "test"), + env.String("DB_CHARSET", "utf8mb4"), + time.Local.String(), + ), + // string 类型字段的默认长度 + DefaultStringSize: uint(env.Int("DB_STRING_SIZE", 256)), + // 禁用 datetime 精度,MySQL 5.6 之前的数据库不支持 + DisableDatetimePrecision: true, + // 重命名索引时采用删除并新建的方式,MySQL 5.7 之前的数据库和 MariaDB 不支持重命名索引 + DontSupportRenameIndex: true, + // 用 `change` 重命名列,MySQL 8 之前的数据库和 MariaDB 不支持重命名列 + DontSupportRenameColumn: true, + }) +} + +func openPostgres() gorm.Dialector { + return postgres.Open(fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=%s", + env.String("DB_HOST", "localhost"), + env.String("DB_USER", "postgres"), + env.String("DB_AUTH", "password"), + env.String("DB_NAME", "postgres"), + env.Int("DB_PORT", 5432), + env.String("DB_SSLMODE", "disable"), + time.Local.String(), + )) +} + +func configRawDB(db *sql.DB) error { + // 用于设置连接池中空闲连接的最大数量。 + if maxIdleConns := env.Int("DB_MAX_IDLE_CONNS", 0); maxIdleConns > 0 { + db.SetMaxIdleConns(maxIdleConns) + } + // 设置打开数据库连接的最大数量。 + if maxOpenConns := env.Int("DB_MAX_OPEN_CONNS", 0); maxOpenConns > 0 { + db.SetMaxOpenConns(maxOpenConns) + } + // 设置了连接可复用的最大时间。 + if connMaxLifetime := env.Duration("DB_CONN_MAX_LIFETIME", 0); connMaxLifetime > 0 { + db.SetConnMaxLifetime(connMaxLifetime) + } + return db.Ping() +} diff --git a/util/db/logger.go b/util/db/logger.go new file mode 100644 index 0000000..60e320e --- /dev/null +++ b/util/db/logger.go @@ -0,0 +1,84 @@ +package db + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "gorm.io/gorm/logger" + "zestack.dev/slim" +) + +// TODO 支持租户隔离 +func getLogger(ctx context.Context) *slog.Logger { + if ctx != nil { + l, ok := ctx.Value("db:logger").(*slim.Logger) + if ok && l != nil { + return l.Logger + } + k, ok := ctx.Value("db:logger").(*slog.Logger) + if ok && k != nil { + return k + } + } + return slog.Default() +} + +type dbLogger struct { + SlowThreshold time.Duration +} + +// LogMode log mode +func (l *dbLogger) LogMode(logger.LogLevel) logger.Interface { + return l +} + +// Info print info +func (l *dbLogger) Info(ctx context.Context, msg string, data ...any) { + getLogger(ctx).Info(fmt.Sprintf(msg, data...)) +} + +// Warn print warn messages +func (l *dbLogger) Warn(ctx context.Context, msg string, data ...any) { + getLogger(ctx).Warn(fmt.Sprintf(msg, data...)) +} + +// Error print error messages +func (l *dbLogger) Error(ctx context.Context, msg string, data ...any) { + getLogger(ctx).Error(fmt.Sprintf(msg, data...)) +} + +// Trace print sql message +func (l *dbLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + elapsed := time.Since(begin) + switch { + case err != nil && !errors.Is(err, logger.ErrRecordNotFound): + sql, rows := fc() + if rows == -1 { + l.Error(ctx, "%s [rows:%v] %s [%.3fms]", err, "-", sql, float64(elapsed.Nanoseconds())/1e6) + } else { + l.Error(ctx, "%s [rows:%v] %s [%.3fms]", err, rows, sql, float64(elapsed.Nanoseconds())/1e6) + } + case elapsed > l.SlowThreshold && l.SlowThreshold != 0: + sql, rows := fc() + slowLog := fmt.Sprintf("SLOW SQL >= %v", l.SlowThreshold) + if rows == -1 { + l.Warn(ctx, "%s [rows:%v] %s [%.3fms]", slowLog, "-", sql, float64(elapsed.Nanoseconds())/1e6) + } else { + l.Warn(ctx, "%s [rows:%v] %s [%.3fms]", slowLog, rows, sql, float64(elapsed.Nanoseconds())/1e6) + } + default: + sql, rows := fc() + if rows == -1 { + l.Info(ctx, "[rows:%v] %s [%.3fms]", "-", sql, float64(elapsed.Nanoseconds())/1e6) + } else { + l.Info(ctx, "[rows:%v] %s [%.3fms]", rows, sql, float64(elapsed.Nanoseconds())/1e6) + } + } +} + +func (l *dbLogger) ParamsFilter(_ context.Context, sql string, params ...any) (string, []any) { + return sql, params +} diff --git a/util/db/migrator.go b/util/db/migrator.go new file mode 100644 index 0000000..219118f --- /dev/null +++ b/util/db/migrator.go @@ -0,0 +1,162 @@ +package db + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +type Migrator struct { + Schema string + TenantId uint + DB *gorm.DB + Migrations []*gormigrate.Migration + Log func(string, ...any) +} + +func (m *Migrator) Migrate() error { + return m.migrate(func(migrator *gormigrate.Gormigrate) error { + return migrator.Migrate() + }) +} + +func (m *Migrator) MigrateTo(migrationID string) error { + return m.migrate(func(migrator *gormigrate.Gormigrate) error { + return migrator.MigrateTo(migrationID) + }) +} + +func (m *Migrator) migrate(fn func(migrator *gormigrate.Gormigrate) error) error { + if m.TenantId > 0 { + m.Log("⏳ migrating tables for tenant %d", m.TenantId) + } else { + m.Log("⏳ migrating public tables") + } + + if err := CreateSchema(m.DB, m.Schema); err != nil { + if m.TenantId > 0 { + m.Log("❌ failed to create schema for tenant %d: %s", m.TenantId, err) + } else { + m.Log("❌ failed to create public schema: %s", err) + } + return err + } + + tx := m.DB.Begin() + defer func() { + if tx.Error == nil { + tx.Commit() + if m.TenantId > 0 { + m.Log("✅ private tables migrated for tenant %d", m.TenantId) + } else { + m.Log("✅ public tables migrated for all tenants") + } + } else { + tx.Rollback() + } + }() + + unlock, err := LockSchema(tx, m.Schema) + if err != nil { + m.Log("❌ failed to acquire advisory lock: %w", err) + return err + } + defer unlock() + + reset := func() error { return nil } + if m.TenantId > 0 { + reset, err = UseTenant(tx, m.TenantId) + if err != nil { + m.Log("❌ failed to switch schema for tenant %d: %w", m.TenantId, err) + return err + } + } + defer reset() + + err = fn(gormigrate.New(tx, &gormigrate.Options{ + TableName: tx.NamingStrategy.TableName("migrations"), + }, m.Migrations)) + if err != nil { + if m.TenantId > 0 { + m.Log("❌ failed to call migrate for tenant %d: %w", m.TenantId, err) + } else { + m.Log("❌ failed to migrate public tables: %w", err) + } + return err + } + + return nil +} + +func (m *Migrator) RollbackLast() error { + return m.rollback(func(migrator *gormigrate.Gormigrate) error { + return migrator.RollbackLast() + }) +} + +func (m *Migrator) RollbackTo(migrationID string) error { + return m.rollback(func(migrator *gormigrate.Gormigrate) error { + return migrator.MigrateTo(migrationID) + }) +} + +func (m *Migrator) rollback(fn func(migrator *gormigrate.Gormigrate) error) error { + if m.TenantId > 0 { + m.Log("⏳ rollbacking tables for tenant %d", m.TenantId) + } else { + m.Log("⏳ rollbacking public tables") + } + + if err := CreateSchema(m.DB, m.Schema); err != nil { + if m.TenantId > 0 { + m.Log("❌ failed to create schema for tenant %d: %s", m.TenantId, err) + } else { + m.Log("❌ failed to create public schema: %s", err) + } + return err + } + + tx := m.DB.Begin() + defer func() { + if tx.Error == nil { + tx.Commit() + if m.TenantId > 0 { + m.Log("✅ private tables rollbacked for tenant %d", m.TenantId) + } else { + m.Log("✅ public tables rollbacked for all tenants") + } + } else { + tx.Rollback() + } + }() + + unlock, err := LockSchema(tx, m.Schema) + if err != nil { + m.Log("❌ failed to acquire advisory lock: %w", err) + return err + } + defer unlock() + + reset := func() error { return nil } + if m.TenantId > 0 { + reset, err = UseTenant(tx, m.TenantId) + if err != nil { + m.Log("❌ failed to switch schema for tenant %d: %w", m.TenantId, err) + return err + } + } + defer reset() + + err = fn(gormigrate.New(tx, &gormigrate.Options{ + TableName: tx.NamingStrategy.TableName("migrations"), + }, m.Migrations)) + if err != nil { + if m.TenantId > 0 { + m.Log("❌ failed to call rollback for tenant %d: %w", m.TenantId, err) + } else { + m.Log("❌ failed to rollback public tables: %w", err) + } + return err + } + + return nil +} diff --git a/util/db/mysql/driver.go b/util/db/mysql/driver.go new file mode 100644 index 0000000..a55cb67 --- /dev/null +++ b/util/db/mysql/driver.go @@ -0,0 +1,57 @@ +package mysql + +import ( + "strings" + + "gorm.io/gorm" + "zestack.dev/cast" +) + +// func init() { +// db.RegisterDriver("mysql", func(opts *db.DriverOptions) db.Driver { +// return &Driver{ +// Migrator: Migrator{ +// DB: opts.DB, +// Log: opts.Log, +// Retry: opts.Retry, +// }, +// } +// }) +// } + +type Driver struct { + Migrator +} + +func (*Driver) UseTenant(tx *gorm.DB, tenant uint) (reset func() error, err error) { + return UseDatabase(tx, TenantDatabase(tenant)) +} + +func (*Driver) CurrentTenant(tx *gorm.DB) (tenant uint, ok bool) { + databse := CurrentDatabase(tx) + basename := TenantBasename() + + if databse != "" && strings.HasPrefix(databse, basename) { + var err error + tenant, err = cast.Uint(databse[len(basename):]) + ok = err == nil && tenant > 0 + } + + return +} + +func createSharedSchema(tx *gorm.DB) error { + database := PublicDatabase() + sql := "CREATE DATABASE IF NOT EXISTS " + tx.Statement.Quote(database) + return tx.Exec(sql).Error +} + +func (d *Driver) CreateTenantSchema(tx *gorm.DB, tenantId uint) error { + database := TenantDatabase(tenantId) + sql := "CREATE DATABASE IF NOT EXISTS " + tx.Statement.Quote(database) + return tx.Exec(sql).Error +} + +func (d *Driver) DropSchemaForTenant(tenant uint) error { + return d.DropDatabaseForTenant(tenant) +} diff --git a/util/db/mysql/lock.go b/util/db/mysql/lock.go new file mode 100644 index 0000000..146a8f4 --- /dev/null +++ b/util/db/mysql/lock.go @@ -0,0 +1,93 @@ +package mysql + +import ( + "database/sql" + "fmt" + "ims/util/backoff" + "ims/util/db" + + "gorm.io/gorm" +) + +// SQL statements for MySQL advisory locks. +// https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html +const ( + // GET_LOCK(str, timeout) → int (1: lock acquired, 0: lock not acquired, NULL: an error occurred). + sqlGetLock = "SELECT GET_LOCK('%s', 0)" + + // RELEASE_LOCK(str) → int (1: lock released, 0: lock not released, NULL: lock does not exist). + sqlReleaseLock = "SELECT RELEASE_LOCK('%s')" +) + +// lock represents a MySQL advisory lock. +type lock struct { + tx *gorm.DB + lockKey string +} + +func (a *lock) execute(sqlstr string) (bool, error) { + var result sql.NullInt64 + if err := a.tx.Raw(sqlstr).Scan(&result).Error; err == nil { + if !result.Valid { + return false, db.ErrAcquireLock + } + switch result.Int64 { + case 1: + return true, nil + case 0: + return false, db.ErrAcquireLock + } + } + return false, fmt.Errorf("%w: %s", db.ErrExecSQL, sqlstr) +} + +func (a *lock) acquire() (func() error, error) { + sqlstr := fmt.Sprintf(sqlGetLock, a.lockKey) + ok, err := a.execute(sqlstr) + if err != nil || !ok { + return nil, fmt.Errorf("%w for key %s: %v", db.ErrAcquireLock, a.lockKey, err) + } + + return a.release, nil +} + +func (a *lock) release() error { + sqlstr := fmt.Sprintf(sqlReleaseLock, a.lockKey) + success, err := a.execute(sqlstr) + if err != nil || !success { + return fmt.Errorf("%w for key %s: %v", db.ErrReleaseLock, a.lockKey, err) + } + return nil +} + +// acquire acquires a MySQL advisory lock. +func acquire(tx *gorm.DB, lockKey string) (func() error, error) { + lock := &lock{tx: tx, lockKey: lockKey} + return lock.acquire() +} + +// Acquire acquires a MySQL advisory lock. +// Returns a release function and an error. +// +// It's the responsibility of the caller to release the lock by calling the release function. +func AcquireLock(tx *gorm.DB, lockKey string, options *backoff.Options) (release func() error, err error) { + if options == nil { + release, err = acquire(tx, lockKey) + return + } + + acquireFunc := func() error { + releaseFn, acquireErr := acquire(tx, lockKey) + if acquireErr != nil { + return acquireErr + } + release = releaseFn + return nil + } + + err = backoff.Retry(acquireFunc, func(o *backoff.Options) { + *o = *options + }) + + return release, err +} diff --git a/util/db/mysql/migrate.go b/util/db/mysql/migrate.go new file mode 100644 index 0000000..859f0ad --- /dev/null +++ b/util/db/mysql/migrate.go @@ -0,0 +1,135 @@ +package mysql + +import ( + "errors" + "ims/util/backoff" + + "gorm.io/gorm" +) + +type Migrator struct { + DB *gorm.DB + Retry *backoff.Options + Log func(format string, args ...any) +} + +func (m Migrator) retry(fn func() error) error { + if m.Retry == nil { + return fn() + } + return backoff.Retry(fn, func(o *backoff.Options) { + *o = *m.Retry + }) +} + +func (m Migrator) acquireLock(tx *gorm.DB, database string) (func() error, error) { + return AcquireLock(tx, database, m.Retry) +} + +// MigrateTenantModels creates a database for a specific tenant and migrates the tenant tables. +func (m Migrator) MigrateTenantModels(tenantId uint, models []any) (err error) { + m.Log("⏳ migrating tables for tenant %d", tenantId) + + if len(models) == 0 { + err = errors.New("no tenant tables to migrate") + return + } + + tx := m.DB.Session(&gorm.Session{}) + database := TenantDatabase(tenantId) + sql := "CREATE DATABASE IF NOT EXISTS " + tx.Statement.Quote(database) + if err = tx.Exec(sql).Error; err != nil { + m.Log("❌ failed to create database '%s': %w", database, err) + return + } + + unlock, lockErr := m.acquireLock(tx, database) + if lockErr != nil { + m.Log("❌ failed to acquire advisory lock for tenant %d: %w", tenantId, lockErr) + return lockErr + } + defer unlock() + + err = tx.Transaction(func(tx *gorm.DB) error { + reset, err := UseDatabase(tx, database) + if err != nil { + m.Log("❌ failed to switch to tenant database %d: %w", tenantId, err) + return err + } + defer reset() + + if err = tx.AutoMigrate(models...); err != nil { + m.Log("❌ failed to migrate tables for tenant %d: %w", tenantId, err) + return err + } + m.Log("✅ private tables migrated for tenant %d", tenantId) + return nil + }) + + return +} + +// MigrateSharedModels migrates the shared tables in the database. +func (m Migrator) MigrateSharedModels(models []any) error { + m.Log("⏳ migrating public tables") + + if len(models) == 0 { + return errors.New("no public tables to migrate") + } + + db := m.DB.Session(&gorm.Session{}) + database := PublicDatabase() + sql := "CREATE DATABASE IF NOT EXISTS " + db.Statement.Quote(database) + if err := db.Exec(sql).Error; err != nil { + m.Log("❌ failed to create public database '%s': %w", database, err) + return err + } + + unlock, lockErr := m.acquireLock(m.DB, database) + if lockErr != nil { + m.Log("❌ failed to acquire advisory lock: %w", lockErr) + return lockErr + } + defer unlock() + + tx := db.Begin() + defer func() { + if tx.Error == nil { + tx.Commit() + m.Log("✅ public tables migrated") + } else { + tx.Rollback() + } + }() + + sql = "USE " + tx.Statement.Quote(database) + if err := tx.Exec(sql).Error; err != nil { + m.Log("❌ failed to switch to public database '%s': %w", database, err) + return err + } + + if err := tx.AutoMigrate(models...); err != nil { + m.Log("❌ failed to migrate public tables: %w", err) + return err + } + + return nil +} + +// DropDatabaseForTenant drops the database for a specific tenant. +func (m Migrator) DropDatabaseForTenant(tenantId uint) error { + m.Log("⏳ dropping database for tenant %d", tenantId) + + tx := m.DB.Session(&gorm.Session{}) + database := TenantDatabase(tenantId) + + return m.retry(func() error { + sql := "DROP DATABASE IF EXISTS " + tx.Statement.Quote(database) + if err := tx.Exec(sql).Error; err != nil { + m.Log("❌ failed to drop database '%s' for tenant %d: %w", database, tenantId, err) + return err + } + m.Log("✅ database dropped for tenant %d", tenantId) + return nil + }) +} diff --git a/util/db/mysql/schema.go b/util/db/mysql/schema.go new file mode 100644 index 0000000..dae60f0 --- /dev/null +++ b/util/db/mysql/schema.go @@ -0,0 +1,60 @@ +package mysql + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + "zestack.dev/env" +) + +func PublicDatabase() string { + return env.String("DB_NAME") +} + +func TenantBasename() string { + return env.String("TENANT_BASENAME", "tenant_") +} + +func TenantDatabase(tenant uint) string { + if tenant == 0 { + return PublicDatabase() + } + return fmt.Sprintf("%s%d", TenantBasename(), tenant) +} + +func UseDatabase(tx *gorm.DB, database string) (reset func() error, err error) { + if database == "" { + err = errors.New("database name is empty") + tx.AddError(err) + return + } + + if database == CurrentDatabase(tx) { + reset = func() error { return nil } + return + } + + sql := "USE " + tx.Statement.Quote(database) + if execErr := tx.Exec(sql).Error; execErr != nil { + err = fmt.Errorf("failed to set database %q: %w", database, execErr) + tx.AddError(err) + return + } + + reset = func() error { + publicDatabase := PublicDatabase() + if database == publicDatabase { + return nil + } + return tx.Exec("USE " + tx.Statement.Quote(publicDatabase)).Error + } + + return +} + +func CurrentDatabase(tx *gorm.DB) string { + var database string + tx.Raw("SELECT DATABASE()").Row().Scan(&database) + return database +} diff --git a/util/db/mysql_lock.go b/util/db/mysql_lock.go new file mode 100644 index 0000000..13b1d51 --- /dev/null +++ b/util/db/mysql_lock.go @@ -0,0 +1,89 @@ +package db + +import ( + "database/sql" + "fmt" + "ims/util/backoff" + + "gorm.io/gorm" +) + +// SQL statements for MySQL advisory locks. +// https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html +const ( + // GET_LOCK(str, timeout) → int (1: lock acquired, 0: lock not acquired, NULL: an error occurred). + mysqlGetLock = "SELECT GET_LOCK('%s', 0)" + + // RELEASE_LOCK(str) → int (1: lock released, 0: lock not released, NULL: lock does not exist). + mysqlReleaseLock = "SELECT RELEASE_LOCK('%s')" +) + +// mysqlLock represents a MySQL advisory mysqlLock. +type mysqlLock struct { + tx *gorm.DB + lockKey string +} + +func (a *mysqlLock) execute(sqlstr string) (bool, error) { + var result sql.NullInt64 + if err := a.tx.Raw(sqlstr).Scan(&result).Error; err == nil { + if !result.Valid { + return false, ErrAcquireLock + } + switch result.Int64 { + case 1: + return true, nil + case 0: + return false, ErrAcquireLock + } + } + return false, fmt.Errorf("%w: %s", ErrExecSQL, sqlstr) +} + +func (a *mysqlLock) acquire() (func() error, error) { + sqlstr := fmt.Sprintf(mysqlGetLock, a.lockKey) + if ok, err := a.execute(sqlstr); err != nil || !ok { + return nil, fmt.Errorf("%w for key %s: %v", ErrAcquireLock, a.lockKey, err) + } + return a.release, nil +} + +func (a *mysqlLock) release() error { + sqlstr := fmt.Sprintf(mysqlReleaseLock, a.lockKey) + if success, err := a.execute(sqlstr); err != nil || !success { + return fmt.Errorf("%w for key %s: %v", ErrReleaseLock, a.lockKey, err) + } + return nil +} + +// acquire acquires a MySQL advisory lock. +func acquire(tx *gorm.DB, lockKey string) (func() error, error) { + lock := &mysqlLock{tx: tx, lockKey: lockKey} + return lock.acquire() +} + +// Acquire acquires a MySQL advisory lock. +// Returns a release function and an error. +// +// It's the responsibility of the caller to release the lock by calling the release function. +func AcquireLock(tx *gorm.DB, lockKey string, options *backoff.Options) (release func() error, err error) { + if options == nil { + release, err = acquire(tx, lockKey) + return + } + + acquireFunc := func() error { + releaseFn, acquireErr := acquire(tx, lockKey) + if acquireErr != nil { + return acquireErr + } + release = releaseFn + return nil + } + + err = backoff.Retry(acquireFunc, func(o *backoff.Options) { + *o = *options + }) + + return release, err +} diff --git a/util/db/mysql_schema.go b/util/db/mysql_schema.go new file mode 100644 index 0000000..9014051 --- /dev/null +++ b/util/db/mysql_schema.go @@ -0,0 +1,99 @@ +package db + +import ( + "errors" + "fmt" + "ims/util/backoff" + + "gorm.io/gorm" + "zestack.dev/env" +) + +type mysqlSchemaHelper struct{} + +func (s *mysqlSchemaHelper) PublicSchema() string { + return env.String("DB_NAME") +} + +func (s *mysqlSchemaHelper) TenantSchema(tenantId uint) string { + if tenantId == 0 { + return s.PublicSchema() + } + return fmt.Sprintf( + "%s%d%s", + env.String("DB_TENANT_PREFIX", "tenant_"), + tenantId, + env.String("DB_TENANT_SUFFIX", ""), + ) +} + +func (s *mysqlSchemaHelper) CurrentSchema(tx *gorm.DB) string { + var schema string + tx.Raw("SELECT DATABASE()").Row().Scan(&schema) + return schema +} + +func (s *mysqlSchemaHelper) UseSchema(tx *gorm.DB, schema string) (func() error, error) { + if schema == "" { + err := errors.New("schema name is empty") + tx.AddError(err) + return nil, err + } + + currentSchema := s.CurrentSchema(tx) + publicSchema := s.PublicSchema() + + // 当前 schema 与目标 schema 相同,无需切换 + if schema == currentSchema { + reset := func() error { return nil } + return reset, nil + } + + // 不支持租户切换 + if currentSchema != publicSchema && schema != publicSchema { + err := fmt.Errorf( + "failed to switch schema %s from current schema %s: %w", + schema, + currentSchema, + ErrSwitchSchema, + ) + tx.AddError(err) + return nil, err + } + + sqlstr := "USE " + tx.Statement.Quote(schema) + if execErr := tx.Exec(sqlstr).Error; execErr != nil { + err := fmt.Errorf("failed to set database %q: %w", schema, execErr) + tx.AddError(err) + return nil, err + } + + if schema == publicSchema { + reset := func() error { return nil } + return reset, nil + } + + reset := func() error { + return tx.Exec("USE " + tx.Statement.Quote(publicSchema)).Error + } + + return reset, nil +} + +func (s *mysqlSchemaHelper) LockSchema(tx *gorm.DB, schema string, retry *backoff.Options) (func() error, error) { + return AcquireLock(tx, schema, retry) +} + +func (s *mysqlSchemaHelper) CreateSchema(tx *gorm.DB, schema string) error { + sql := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", tx.Statement.Quote(schema)) + return tx.Exec(sql).Error +} + +func (s *mysqlSchemaHelper) DropSchema(tx *gorm.DB, schema string) error { + sql := fmt.Sprintf("DROP DATABASE IF EXISTS %s", tx.Statement.Quote(schema)) + return tx.Exec(sql).Error +} + +func (s *mysqlSchemaHelper) String() string { + return "mysql" +} diff --git a/util/db/pgsql/driver.go b/util/db/pgsql/driver.go new file mode 100644 index 0000000..ccfbfb2 --- /dev/null +++ b/util/db/pgsql/driver.go @@ -0,0 +1,41 @@ +package pgsql + +import ( + "strings" + + "gorm.io/gorm" + "zestack.dev/cast" +) + +// func init() { +// db.RegisterDriver("pgsql", func(opts *db.DriverOptions) db.Driver { +// return &Driver{ +// Migrator: Migrator{ +// DB: opts.DB, +// Log: opts.Log, +// Retry: opts.Retry, +// }, +// } +// }) +// } + +type Driver struct { + Migrator +} + +func (*Driver) UseTenant(tx *gorm.DB, tenantId uint) (reset func() error, err error) { + return SetSearchPath(tx, TenantSchema(tenantId)) +} + +func (*Driver) CurrentTenant(tx *gorm.DB) (tenant uint, ok bool) { + databse := CurrentSearchPath(tx) + basename := TenantBasename() + + if databse != "" && strings.HasPrefix(databse, basename) { + var err error + tenant, err = cast.Uint(databse[len(basename):]) + ok = err == nil && tenant > 0 + } + + return +} diff --git a/util/db/pgsql/lock.go b/util/db/pgsql/lock.go new file mode 100644 index 0000000..bc13b6a --- /dev/null +++ b/util/db/pgsql/lock.go @@ -0,0 +1,63 @@ +package pgsql + +import ( + "fmt" + "hash/fnv" + "ims/util/backoff" + "ims/util/db" + + "gorm.io/gorm" +) + +// SQL statements for PostgreSQL advisory locks. +// https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +const ( + // pg_try_advisory_xact_lock ( key bigint ) → boolean. + sqlTryAdvisoryXactLock = "SELECT pg_try_advisory_xact_lock(?)" +) + +// lock represents a PostgreSQL transaction-level advisory lock. +type lock struct { + tx *gorm.DB + lockKey string +} + +func (p *lock) execute(sqlstr string, args ...any) (bool, error) { + var result bool + if err := p.tx.Raw(sqlstr, args...).Scan(&result).Error; err == nil && result { + return true, nil + } + return false, fmt.Errorf("%w: %s", db.ErrExecSQL, sqlstr) +} + +func (p *lock) acquire() error { + key := GenerateLockKey(p.lockKey) + ok, err := p.execute(sqlTryAdvisoryXactLock, key) + if err != nil || !ok { + return fmt.Errorf("%w for key %s: %v", db.ErrAcquireLock, p.lockKey, err) + } + return nil +} + +func GenerateLockKey(s string) int64 { + hasher := fnv.New64a() + hasher.Write([]byte(s)) + hash := hasher.Sum64() + return int64(hash) +} + +// AcquireXact acquires a PostgreSQL transaction-level advisory lock. +// The caller is responsible for ensuring that a transaction is active, +// and that the lock is released after use. +func AcquireXact(tx *gorm.DB, lockKey string, opts *backoff.Options) error { + l := &lock{tx: tx, lockKey: lockKey} + + if opts == nil { + return l.acquire() + } + + return backoff.Retry( + func() error { return l.acquire() }, + func(o *backoff.Options) { *o = *opts }, + ) +} diff --git a/util/db/pgsql/migrate.go b/util/db/pgsql/migrate.go new file mode 100644 index 0000000..97b7738 --- /dev/null +++ b/util/db/pgsql/migrate.go @@ -0,0 +1,119 @@ +package pgsql + +import ( + "errors" + "ims/util/backoff" + + "gorm.io/gorm" +) + +type Migrator struct { + DB *gorm.DB + Retry *backoff.Options + Log func(format string, args ...any) +} + +func (m Migrator) retry(fn func() error) error { + if m.Retry == nil { + return fn() + } + return backoff.Retry(fn, func(o *backoff.Options) { + *o = *m.Retry + }) +} + +func (m Migrator) acquireXact(tx *gorm.DB, lockKey string) error { + return AcquireXact(tx, lockKey, m.Retry) +} + +func (m Migrator) MigrateTenantModels(tenantId uint, models []any) error { + m.Log("⏳ migrating tables for tenant %d", tenantId) + + if len(models) == 0 { + m.Log("😭 no tenant tables to migrate") + return errors.New("no tenant tables to migrate") + } + + tx := m.DB.Session(&gorm.Session{}) + schema := TenantSchema(tenantId) + sql := "CREATE SCHEMA IF NOT EXISTS " + tx.Statement.Quote(schema) + if err := tx.Exec(sql).Error; err != nil { + m.Log("❌ failed to create schema for tenant %d: %s", tenantId, err) + return err + } + + err := tx.Transaction(func(tx *gorm.DB) error { + err := m.acquireXact(tx, schema) + if err != nil { + m.Log("❌ failed to acquire advisory lock for tenant %d: %w", tenantId, err) + return err + } + reset, searchPathErr := SetSearchPath(tx, schema) + if searchPathErr != nil { + m.Log("❌ failed to set search path '%s' to tenant %d: %w", schema, tenantId, searchPathErr) + return searchPathErr + } + defer reset() + + if err := tx.AutoMigrate(models...); err != nil { + m.Log("❌ failed to migrate tenant tables for tenant %d: %w", tenantId, err) + return err + } + m.Log("✅ private tables migrated for tenant %d", tenantId) + return nil + }) + if err != nil { + return err + } + return nil + +} + +// MigrateSharedModels migrates the public tables in the database. +func (m Migrator) MigrateSharedModels(models []any) error { + m.Log("⏳ migrating public tables") + + if len(models) == 0 { + m.Log("😭 no public tables to migrate") + return errors.New("no public tables to migrate") + } + + tx := m.DB.Begin() + defer func() { + if tx.Error == nil { + tx.Commit() + m.Log("✅ public tables migrated for all tenants") + } else { + tx.Rollback() + } + }() + + if err := m.acquireXact(tx, PublicSchema()); err != nil { + m.Log("❌ failed to acquire advisory lock: %w", err) + return err + } + + if err := tx.AutoMigrate(models...); err != nil { + m.Log("❌ failed to migrate public tables: %w", err) + return err + } + + return nil +} + +func (m Migrator) DropSchemaForTenant(tenantId uint) error { + m.Log("⏳ dropping schema for tenant %d", tenantId) + + tx := m.DB.Session(&gorm.Session{}) + schema := TenantSchema(tenantId) + + return m.retry(func() error { + sql := "DROP SCHEMA IF EXISTS " + tx.Statement.Quote(schema) + " CASCADE" + if err := tx.Exec(sql).Error; err != nil { + m.Log("❌ failed to drop schema for tenant %d: %s", tenantId, err) + return err + } + m.Log("✅ schema dropped for tenant %d", tenantId) + return nil + }) +} diff --git a/util/db/pgsql/schema.go b/util/db/pgsql/schema.go new file mode 100644 index 0000000..ed43c94 --- /dev/null +++ b/util/db/pgsql/schema.go @@ -0,0 +1,65 @@ +package pgsql + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + "zestack.dev/env" +) + +func PublicSchema() string { + return env.String("DB_PUBLIC_SCHEMA", "public") +} + +func TenantBasename() string { + return env.String("TENANT_BASENAME", "tenant_") +} + +func TenantSchema(tenant uint) string { + if tenant == 0 { + return PublicSchema() + } + return fmt.Sprintf("%s%d", TenantBasename(), tenant) +} + +func SetSearchPath(tx *gorm.DB, schema string) (reset func() error, err error) { + // tx = tx.Session(&gorm.Session{}) + if schema == "" { + err = errors.New("schema name is empty") + tx.AddError(err) + return + } + + if schema == CurrentSearchPath(tx) { + reset = func() error { return nil } + return + } + + sqlstr := "SET search_path TO " + tx.Statement.Quote(schema) + if execErr := tx.Exec(sqlstr).Error; execErr != nil { + err = fmt.Errorf("failed to set search path %q: %w", schema, execErr) + tx.AddError(err) + return + } + + reset = func() error { + publicSchema := PublicSchema() + if schema == publicSchema { + return nil + } + return tx.Exec("SET search_path TO " + tx.Statement.Quote(publicSchema)).Error + } + + return +} + +func CurrentSearchPath(tx *gorm.DB) string { + // tx = tx.Session(&gorm.Session{}) + var searchPath string + tx.Raw("SHOW search_path").Scan(&searchPath) + if searchPath == `"$user", public` { + return "public" + } + return searchPath +} diff --git a/util/db/pgsql_lock.go b/util/db/pgsql_lock.go new file mode 100644 index 0000000..340259c --- /dev/null +++ b/util/db/pgsql_lock.go @@ -0,0 +1,68 @@ +package db + +import ( + "fmt" + "hash/fnv" + "ims/util/backoff" + + "gorm.io/gorm" +) + +// SQL statements for PostgreSQL advisory locks. +// https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +const ( + // pg_try_advisory_xact_lock ( key bigint ) → boolean. + pgsqlTryAdvisoryXactLock = "SELECT pg_try_advisory_xact_lock(?)" +) + +// pgsqlLock represents a PostgreSQL transaction-level advisory pgsqlLock. +type pgsqlLock struct { + tx *gorm.DB + lockKey string +} + +func (p *pgsqlLock) execute(sqlstr string, args ...any) (bool, error) { + var result bool + if err := p.tx.Raw(sqlstr, args...).Scan(&result).Error; err == nil && result { + return true, nil + } + return false, fmt.Errorf("%w: %s", ErrExecSQL, sqlstr) +} + +func (p *pgsqlLock) acquire() error { + key := generateLockKey(p.lockKey) + ok, err := p.execute(pgsqlTryAdvisoryXactLock, key) + if err != nil || !ok { + return fmt.Errorf("%w for key %s: %v", ErrAcquireLock, p.lockKey, err) + } + return nil +} + +func generateLockKey(s string) int64 { + hasher := fnv.New64a() + hasher.Write([]byte(s)) + hash := hasher.Sum64() + return int64(hash) +} + +// AcquireXact acquires a PostgreSQL transaction-level advisory lock. +// The caller is responsible for ensuring that a transaction is active, +// and that the lock is released after use. +func AcquireXact(tx *gorm.DB, lockKey string, opts *backoff.Options) (release func() error, err error) { + l := &pgsqlLock{tx: tx, lockKey: lockKey} + + if opts == nil { + err = l.acquire() + } else { + err = backoff.Retry( + func() error { return l.acquire() }, + func(o *backoff.Options) { *o = *opts }, + ) + } + + release = func() error { + return nil + } + + return +} diff --git a/util/db/pgsql_schema.go b/util/db/pgsql_schema.go new file mode 100644 index 0000000..b316e24 --- /dev/null +++ b/util/db/pgsql_schema.go @@ -0,0 +1,107 @@ +package db + +import ( + "errors" + "fmt" + "ims/util/backoff" + + "gorm.io/gorm" + "zestack.dev/env" +) + +type pgsqlSchemaHelper struct{} + +func (s *pgsqlSchemaHelper) PublicSchema() string { + return env.String("DB_PUBLIC_SCHEMA", "public") +} + +func (s *pgsqlSchemaHelper) TenantSchema(tenantId uint) string { + if tenantId == 0 { + return s.PublicSchema() + } + return fmt.Sprintf( + "%s%d%s", + env.String("DB_TENANT_PREFIX", "tenant_"), + tenantId, + env.String("DB_TENANT_SUFFIX", ""), + ) +} + +func (s *pgsqlSchemaHelper) CurrentSchema(tx *gorm.DB) string { + // tx = tx.Session(&gorm.Session{}) + var schema string + tx.Raw("SHOW search_path").Scan(&schema) + if schema == `"$user", public` { + return "public" + } + return schema +} + +func (s *pgsqlSchemaHelper) UseSchema(tx *gorm.DB, schema string) (func() error, error) { + // tx = tx.Session(&gorm.Session{}) + if schema == "" { + err := errors.New("schema name is empty") + tx.AddError(err) + return nil, err + } + + currentSchema := s.CurrentSchema(tx) + publicSchema := s.PublicSchema() + + // 当前 schema 与目标 schema 相同,无需切换 + if schema == s.CurrentSchema(tx) { + reset := func() error { return nil } + return reset, nil + } + + // 不支持租户切换 + if currentSchema != publicSchema && schema != publicSchema { + err := fmt.Errorf( + "failed to switch schema %s from current schema %s: %w", + schema, + currentSchema, + ErrSwitchSchema, + ) + tx.AddError(err) + return nil, err + } + + sqlstr := "SET search_path TO " + tx.Statement.Quote(schema) + if execErr := tx.Exec(sqlstr).Error; execErr != nil { + err := fmt.Errorf("failed to set search path %q: %w", schema, execErr) + tx.AddError(err) + return nil, err + } + + if schema == publicSchema { + reset := func() error { return nil } + return reset, nil + } + + reset := func() error { + return tx.Exec("SET search_path TO " + tx.Statement.Quote(publicSchema)).Error + } + + return reset, nil +} + +// AcquireXact acquires a PostgreSQL transaction-level advisory lock. +// The caller is responsible for ensuring that a transaction is active, +// and that the lock is released after use. +func (s *pgsqlSchemaHelper) LockSchema(tx *gorm.DB, schema string, retry *backoff.Options) (func() error, error) { + return AcquireXact(tx, schema, retry) +} + +func (s *pgsqlSchemaHelper) CreateSchema(tx *gorm.DB, schema string) error { + sql := "CREATE SCHEMA IF NOT EXISTS " + tx.Statement.Quote(schema) + return tx.Exec(sql).Error +} + +func (s *pgsqlSchemaHelper) DropSchema(tx *gorm.DB, schema string) error { + sql := "DROP SCHEMA IF EXISTS " + tx.Statement.Quote(schema) + " CASCADE" + return tx.Exec(sql).Error +} + +func (s *pgsqlSchemaHelper) String() string { + return "pgsql" +} diff --git a/util/evio/default.go b/util/evio/default.go new file mode 100644 index 0000000..ffdfe64 --- /dev/null +++ b/util/evio/default.go @@ -0,0 +1,46 @@ +package evio + +import ( + "context" + "sync/atomic" +) + +var defaultEvio atomic.Pointer[simple] + +func init() { + evio := New(NewMatcher).(*simple) + defaultEvio.Store(evio) +} + +func Default() Evio { + return defaultEvio.Load() +} + +func Call(topic string, data any) { + Default().Call(topic, data) +} + +func Publish(topic string, data any) error { + return Default().Publish(topic, data) +} + +func Subscribe(topic string, handler Handler, opts ...SubscribeOption) (func() bool, error) { + return Default().Subscribe(topic, handler, opts...) +} + +func Listen(topic string) (stop func(), data <-chan any, err error) { + return Default().Listen(topic) +} + +func Start(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return Default().Start() + } +} + +func Stop() error { + return Default().Stop() +} diff --git a/util/evio/evio.go b/util/evio/evio.go new file mode 100644 index 0000000..63ac8eb --- /dev/null +++ b/util/evio/evio.go @@ -0,0 +1,102 @@ +package evio + +import "errors" + +var ErrClosed = errors.New("evio was closed") + +// Handler 事件处理器 +type Handler interface { + // Handle 事件处理函数 + Handle(any) error +} + +type HandlerFunc func(any) error + +func (h HandlerFunc) Handle(data any) error { + return h(data) +} + +func Priority(priority int) SubscribeOption { + return func(h Handler) { + if x, ok := h.(interface{ SetPriority(int) }); ok { + x.SetPriority(priority) + } + } +} + +func Once(once bool) SubscribeOption { + return func(h Handler) { + if x, ok := h.(interface{ SetOnce(bool) }); ok { + x.SetOnce(once) + } + } +} + +// 订阅器初始化接口 +type Initializer interface { + // Init 初始化订阅器 + Init(Evio) +} + +// Oncer 一次性订阅器接口,使订阅器只会被执行一次 +type Oncer interface { + // Once 订阅器表示是否只执行一次 + Once() bool +} + +// Prioritizer 优先级接口,订阅器执行顺序, +type Prioritizer interface { + // Priority 返回优先级,值越小越先执行 + // 如果订阅器为实现该接口,其优先级为 0 + Priority() int +} + +type Matcher interface { + Match(topic string) bool +} + +type SubscribeOption func(Handler) + +// Unsubscriber 订阅取消函数 +type Unsubscriber func() bool + +// Evio 时间管理接口 +type Evio interface { + // Call 调用事件,是 Publish 的同步实现 + // + // * "foo.bar" 具体事件 + // * "*.bar" 后缀相同 + // * "foo.*" 前缀相同 + // * *.bar.* 中间包含 + // + // 注意:前缀匹配必须包含符号 `.`。 + Call(topic string, data any) + // Publish 发布事件,参数 topic 是模式。 + Publish(topic string, data any) error + // Subscribe 订阅主题 + Subscribe(topic string, handler Handler, opts ...SubscribeOption) (Unsubscriber, error) + // Listen 监听主题值 + Listen(topic string) (stop func(), data <-chan any, err error) + // Start 启动事件服务 + Start() error + // Stop 停止 + Stop() error +} + +type Error struct { + err error + topic string + data any +} + +func (e *Error) Error() string { + return e.err.Error() +} + +func (e *Error) Topic() string { + return e.topic +} + +func (e *Error) Data() any { + return e.data +} diff --git a/util/evio/simple.go b/util/evio/simple.go new file mode 100644 index 0000000..b22e9d6 --- /dev/null +++ b/util/evio/simple.go @@ -0,0 +1,257 @@ +package evio + +import ( + "slices" + "sync" + "sync/atomic" +) + +type subscription struct { + id int + priority null[int] + once null[bool] + topic string + evio *simple + unsubscribed int32 + handler Handler +} + +func (s *subscription) SetPriority(priority int) { + s.priority.val = priority + s.priority.ok = true +} + +func (s *subscription) SetOnce(once bool) { + s.once.val = once + s.once.ok = true +} + +func (s *subscription) Priority() int { + if p, ok := s.handler.(Prioritizer); ok { + return p.Priority() + } + if s.priority.ok { + return s.priority.val + } + return 0 +} + +func (s *subscription) Once() bool { + if o, ok := s.handler.(Oncer); ok { + return o.Once() + } + if s.once.ok { + return s.once.val + } + return false +} + +func (s *subscription) Handle(data any) error { + if atomic.LoadInt32(&s.unsubscribed) > 0 { + return nil + } + return s.handler.Handle(data) +} + +func unsubscribe(s *subscription) bool { + return atomic.SwapInt32(&s.unsubscribed, 1) == 0 +} + +type event struct { + topic string + data any +} + +type simple struct { + mutex sync.RWMutex + pool sync.Pool + idgen int32 + subs []*subscription + events chan event + closed chan struct{} + newMatcher func(topic string) Matcher +} + +func New(newMatcher func(string) Matcher) Evio { + return &simple{ + pool: sync.Pool{ + New: func() any { + return make(chan any) + }, + }, + events: make(chan event, 1024), + closed: make(chan struct{}), + newMatcher: newMatcher, + } +} + +type mistaker struct { + defers []func(data any) +} + +func (m *mistaker) captch(s *simple, err error, topic string) { + m.defers = append(m.defers, func(data any) { + switch x := err.(type) { + case *Error: + s.Call("error", x) + case interface{ Unwrap() error }: + if err = x.Unwrap(); err != nil { + s.Call("error", &Error{err, topic, data}) + } + case interface{ Unwrap() []error }: + for _, err := range x.Unwrap() { + s.Call("error", &Error{err, topic, data}) + } + default: + s.Call("error", &Error{err, topic, data}) + } + }) +} + +func (m *mistaker) flush(data any) { + for _, deferFunc := range m.defers { + deferFunc(data) + } +} + +func (s *simple) Call(topic string, data any) { + matcher := s.newMatcher(topic) + + s.mutex.RLock() + var subs []*subscription + var bads []*subscription + for _, sub := range s.subs { + if atomic.LoadInt32(&sub.unsubscribed) > 0 { + bads = append(bads, sub) + continue + } + if matcher.Match(sub.topic) { + subs = append(subs, sub) + } + } + slices.SortFunc(subs, func(a, b *subscription) int { + if n := a.Priority() - b.Priority(); n != 0 { + return n + } + return a.id - b.id + }) + s.mutex.RUnlock() + + // 删除失效的 + if len(bads) > 0 { + s.mutex.Lock() + for _, sub := range bads { + s.subs = slices.DeleteFunc(s.subs, func(s *subscription) bool { + return sub.id == s.id + }) + } + s.mutex.Unlock() + } + + var m mistaker + defer m.flush(data) + + // 广播 + for _, sub := range subs { + if sub.Once() { + unsubscribe(sub) + } + if err := sub.Handle(data); err != nil { + m.captch(s, err, sub.topic) + } + } +} + +func (s *simple) Publish(topic string, data any) error { + select { + case <-s.closed: + return ErrClosed + case s.events <- event{topic, data}: + return nil + } +} + +func (s *simple) Subscribe(topic string, handler Handler, opts ...SubscribeOption) (Unsubscriber, error) { + select { + case <-s.closed: + return nil, ErrClosed + default: + } + + var sub subscription + sub.id = int(atomic.AddInt32(&s.idgen, 1)) + sub.topic = topic + sub.evio = s + sub.handler = handler + for _, option := range opts { + option(&sub) + } + + s.mutex.Lock() + s.subs = append(s.subs, &sub) + s.mutex.Unlock() + + if i, ok := handler.(Initializer); ok { + go i.Init(s) + } + + return func() bool { return unsubscribe(&sub) }, nil +} + +func (s *simple) Listen(topic string) (func(), <-chan any, error) { + var stopped int32 + ch := s.pool.Get().(chan any) + + unsubscribe, err := s.Subscribe(topic, HandlerFunc(func(data any) error { + if atomic.LoadInt32(&stopped) == 0 { + ch <- data + } + return nil + })) + if err != nil { + s.pool.Put(ch) + return nil, nil, err + } + + stop := func() { + if atomic.CompareAndSwapInt32(&stopped, 0, 1) { + select { + case <-ch: + default: + } + s.pool.Put(ch) + unsubscribe() + } + } + + return stop, ch, err +} + +func (s *simple) Start() error { + go s.start() + return nil +} + +func (s *simple) start() { + for { + select { + case <-s.closed: + for n := len(s.events); n > 0; n-- { + evt := <-s.events + s.Call(evt.topic, evt.data) + } + return + case evt := <-s.events: + s.Call(evt.topic, evt.data) + } + } +} + +func (s *simple) Stop() error { + select { + case <-s.closed: + default: + close(s.closed) + } + return nil +} diff --git a/util/evio/utils.go b/util/evio/utils.go new file mode 100644 index 0000000..2b8263c --- /dev/null +++ b/util/evio/utils.go @@ -0,0 +1,54 @@ +package evio + +import "strings" + +type null[T any] struct { + val T + ok bool +} + +type matcher struct { + rawPattern string + pattern string + matchPrefix bool + matchSuffix bool +} + +// TODO 优化,使用缓存,只处理一次 +func NewMatcher(pattern string) Matcher { + m := matcher{ + rawPattern: pattern, + pattern: pattern, + matchPrefix: strings.HasSuffix(pattern, ".*"), // foo.* + matchSuffix: strings.HasPrefix(pattern, ".*"), // *.bar + } + if m.matchPrefix { + m.pattern = pattern[:len(pattern)-1] + } + if m.matchSuffix { + m.pattern = pattern[1:] + } + return &m +} + +func (m *matcher) Match(topic string) bool { + if m.rawPattern == topic { + return true + } + hasPrefix := strings.HasPrefix(topic, m.pattern) + hasSuffix := strings.HasSuffix(topic, m.pattern) + hasMiddle := strings.Contains(topic, m.pattern) + // *.bar.* + if !m.matchPrefix && !m.matchSuffix { + return hasMiddle && !hasPrefix && !hasSuffix + } + // foo.* + if m.matchPrefix { + return hasPrefix + } + // *.bar + if m.matchSuffix { + return hasSuffix + } + return false +} diff --git a/util/init.go b/util/init.go new file mode 100644 index 0000000..b5d6ebe --- /dev/null +++ b/util/init.go @@ -0,0 +1,52 @@ +package util + +import ( + "context" + "ims/util/cache" + "ims/util/db" + "ims/util/jwt" + "ims/util/rdb" + "os" + "time" + + "github.com/shopspring/decimal" + "zestack.dev/env" +) + +func Init(_ context.Context) error { + // 设置 decimal 的序列化结果为不带引号的字符串, + // 也就是 json 数字为非字符串。 + decimal.MarshalJSONWithoutQuotes = true + + var err error + call := func(fn func() error) { + if err == nil { + err = fn() + } + } + call(initTimeZone) + call(db.Init) + call(rdb.Init) + call(cache.Init) + call(jwt.Init) + return err +} + +// 初始化时区 +func initTimeZone() error { + timeZone := env.String("TIME_ZONE", "Asia/Shanghai") + if tzdata := env.String("TZDATA_PATH"); tzdata != "" { + if data, err := os.ReadFile(tzdata); err != nil { + return err + } else if local, err := time.LoadLocationFromTZData(timeZone, data); err != nil { + return err + } else { + time.Local = local + } + } else if local, err := time.LoadLocation(timeZone); err != nil { + return err + } else { + time.Local = local + } + return nil +} diff --git a/util/jwt/auth.go b/util/jwt/auth.go new file mode 100644 index 0000000..11de2e1 --- /dev/null +++ b/util/jwt/auth.go @@ -0,0 +1,54 @@ +package jwt + +import ( + "errors" + + "zestack.dev/slim" +) + +type AuthConfig struct { + Skipper func(c slim.Context) bool + Finder Finder + Anonymously bool + Claims func(c slim.Context, token string, claims *Claims) error +} + +func (config AuthConfig) ToMiddleware() slim.MiddlewareFunc { + if config.Finder == nil { + config.Finder = DefaultFinder + } + return func(c slim.Context, next slim.HandlerFunc) error { + if config.Skipper != nil && config.Skipper(c) { + return next(c) + } + token := config.Finder(c) + if token == "" { + if config.Anonymously { + return next(c) + } + return ErrTokenNotFound + } + claims, err := Verify(token) + if errors.Is(err, ErrTokenExpired) { + c.SetHeader("X-Token-Expired", "true") + } else if err != nil { + return err + } + if config.Claims != nil { + err = config.Claims(c, token, claims) + if err != nil { + return err + } + } + if c.Written() { + return nil + } + c.Set("jwt:token", token) + c.Set("jwt:claims", claims) + return next(c) + } +} + +func Auth(c AuthConfig) slim.MiddlewareFunc { + return c.ToMiddleware() +} diff --git a/util/jwt/finder.go b/util/jwt/finder.go new file mode 100644 index 0000000..46291b6 --- /dev/null +++ b/util/jwt/finder.go @@ -0,0 +1,51 @@ +package jwt + +import ( + "strings" + + "zestack.dev/slim" +) + +type Finder func(c slim.Context) string + +func DefaultFinder(c slim.Context) string { + if s := FromQuery(c); s != "" { + return s + } + if s := FromHeader(c); s != "" { + return s + } + return FromCookie(c) +} + +func FromCookie(c slim.Context, keys ...string) string { + for _, key := range keys { + cookie, err := c.Cookie(key) + if err == nil { + return cookie.Value + } + } + cookie, err := c.Cookie("jwt") + if err != nil { + return "" + } + return cookie.Value +} + +func FromHeader(c slim.Context) string { + bearer := c.Header("Authorization") + if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" { + return bearer[7:] + } + return "" +} + +func FromQuery(c slim.Context, keys ...string) string { + for _, key := range keys { + s := c.QueryParam(key) + if s != "" { + return s + } + } + return c.QueryParam("jwt") +} diff --git a/util/jwt/generate.go b/util/jwt/generate.go new file mode 100644 index 0000000..2daedb9 --- /dev/null +++ b/util/jwt/generate.go @@ -0,0 +1,36 @@ +package jwt + +import ( + "encoding/base64" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/rs/xid" + "zestack.dev/env" +) + +// Generate 生成令牌 +func Generate(claims Claims) (string, error) { + if claims.ID == "" { + claims.ID = xid.New().String() + } + if claims.Issuer == "" { + claims.Issuer = env.String("JWT_ISSUER") + } + if claims.Subject == "" { + claims.Issuer = env.String("JWT_SUBJECT") + } + if claims.Audience == nil { + claims.Audience = env.List("JWT_AUDIENCE", []string{"*"}) + } + if claims.ExpiresAt == nil { + ttl := env.Duration("JWT_TTL", time.Hour) + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(ttl)) + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(privateKey) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString([]byte(signedToken)), nil +} diff --git a/util/jwt/jwt.go b/util/jwt/jwt.go new file mode 100644 index 0000000..a075de2 --- /dev/null +++ b/util/jwt/jwt.go @@ -0,0 +1,36 @@ +package jwt + +import ( + "crypto/rsa" + "errors" + + "github.com/golang-jwt/jwt/v5" + "zestack.dev/env" +) + +var ( + ErrUnauthorized = errors.New("jwt: unauthorized") + ErrForbidden = errors.New("jwt: forbidden") + ErrInvalidToken = errors.New("jwt: invalid token") + ErrTokenExpired = errors.New("jwt: token is expired") + ErrTokenNotFound = errors.New("jwt: token not found") + + publicKey *rsa.PublicKey + privateKey *rsa.PrivateKey +) + +type Claims = jwt.RegisteredClaims + +func Init() (err error) { + publicKey, err = jwt.ParseRSAPublicKeyFromPEM(env.Bytes("JWT_PUBLIC_KEY")) + if err != nil { + return err + } + + privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(env.Bytes("JWT_PRIVATE_KEY")) + if err != nil { + return + } + + return +} diff --git a/util/jwt/verify.go b/util/jwt/verify.go new file mode 100644 index 0000000..8003997 --- /dev/null +++ b/util/jwt/verify.go @@ -0,0 +1,33 @@ +package jwt + +import ( + "encoding/base64" + "errors" + + "github.com/golang-jwt/jwt/v5" +) + +// Verify 验证令牌 +func Verify(tokenString string) (*Claims, error) { + bts, err := base64.RawURLEncoding.DecodeString(tokenString) + if err != nil { + return nil, err + } + var claims Claims + token, err := jwt.ParseWithClaims(string(bts), &claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, ErrInvalidToken + } + return publicKey, nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return &claims, ErrTokenExpired + } + return nil, err + } + if !token.Valid { + return nil, ErrInvalidToken + } + return &claims, nil +} diff --git a/util/log/handler.go b/util/log/handler.go new file mode 100644 index 0000000..969db2c --- /dev/null +++ b/util/log/handler.go @@ -0,0 +1,256 @@ +package log + +import ( + "context" + "fmt" + "io" + "log/slog" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "time" + + "zestack.dev/color" +) + +type TextHandler struct { + opts slog.HandlerOptions + preformatted []byte // data from WithGroup and WithAttrs + groups []string // all groups started from WithGroup + mu *sync.Mutex + out color.Writer +} + +func NewTextHandler(out io.Writer, opts *slog.HandlerOptions) *TextHandler { + w, ok := out.(color.Writer) + if !ok { + w = color.NewWriter(out) + } + h := &TextHandler{out: w, mu: &sync.Mutex{}} + if opts != nil { + h.opts = *opts + } + if h.opts.Level == nil { + h.opts.Level = slog.LevelInfo + } + return h +} + +func (h *TextHandler) clone() TextHandler { + return TextHandler{ + opts: h.opts, + preformatted: h.preformatted[:], + groups: h.groups[:], + mu: h.mu, + out: h.out, + } +} + +func (h *TextHandler) Enabled(_ context.Context, level slog.Level) bool { + minLevel := slog.LevelInfo + if h.opts.Level != nil { + minLevel = h.opts.Level.Level() + } + return level >= minLevel +} + +func (h *TextHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + h2 := h.clone() + // Add an unopened group to h2 without modifying h. + h2.groups = make([]string, len(h.groups)+1) + copy(h2.groups, h.groups) + h2.groups[len(h2.groups)-1] = name + return &h2 +} + +func (h *TextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + h2 := *h + // Force an append to copy the underlying array. + h2.preformatted = slices.Clip(h.preformatted) + h2.groups = slices.Clip(h.groups) + // Pre-format the attributes. + for _, a := range attrs { + h2.preformatted = h2.appendAttr(h2.preformatted, a) + } + return &h2 +} + +func (h *TextHandler) Handle(_ context.Context, r slog.Record) error { + bufp := allocBuf() + buf := *bufp + defer func() { + *bufp = buf + freeBuf(bufp) + }() + if !r.Time.IsZero() { + buf = h.appendAttr(buf, slog.Time(slog.TimeKey, r.Time)) + } + buf = h.appendAttr(buf, slog.Any(slog.LevelKey, r.Level)) + buf = h.appendAttr(buf, slog.String(slog.MessageKey, r.Message)) + if h.opts.AddSource { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + // Optimize to minimize allocation. + srcbufp := allocBuf() + defer freeBuf(srcbufp) + *srcbufp = append(*srcbufp, f.File...) + *srcbufp = append(*srcbufp, ':') + *srcbufp = strconv.AppendInt(*srcbufp, int64(f.Line), 10) + if strings.Contains(r.Message, "\n") { + buf = append(buf, ' ') + } + buf = h.appendAttr(buf, slog.String(slog.SourceKey, string(*srcbufp))) + } + if h.opts.AddSource && strings.Contains(r.Message, "\n") { + buf = append(buf, "\n "...) + } + buf = append(buf, sDim...) + // Insert preformatted attributes just after built-in ones. + buf = append(buf, h.preformatted...) + if r.NumAttrs() > 0 { + r.Attrs(func(a slog.Attr) bool { + buf = h.appendAttr(buf, a) + return true + }) + } + buf = append(buf, cReset...) + buf = append(buf, "\n"...) + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.out.Write(buf) + return err +} + +var ( + cHour = color.New(color.FgBlue) + cYear = color.New(color.FgMagenta) + cDim = color.New(color.FgHiBlack) + sDefault = color.Bytes(color.FgHiWhite) + sDim = color.Bytes(color.FgHiBlack) + cReset = color.Bytes(color.Reset) +) + +func (h *TextHandler) appendAttr(buf []byte, a slog.Attr) []byte { + // Resolve the Attr's value before doing anything else. + a.Value = a.Value.Resolve() + if rep := h.opts.ReplaceAttr; rep != nil && a.Value.Kind() != slog.KindGroup { + var gs []string + if h.groups != nil { + gs = h.groups[:] + } + // a.Value is resolved before calling ReplaceAttr, so the user doesn't have to. + a = rep(gs, a) + // The ReplaceAttr function may return an unresolved Attr. + a.Value = a.Value.Resolve() + } + // Ignore empty Attrs. + if a.Equal(slog.Attr{}) { + return buf + } + switch a.Key { + case slog.TimeKey: + ts := strings.SplitN(a.Value.Time().Format(time.DateTime), " ", 2) + buf = fmt.Appendf(buf, "%s %s", cYear.Wrap(ts[0]), cHour.Wrap(ts[1])) + buf = append(buf, ' ') + return buf + case slog.LevelKey: + level, prepend := levelToColor(a.Value.Any().(slog.Level)) + buf = fmt.Appendf(buf, "%s %s%s %s", cDim.Wrap("|"), prepend, level, cDim.Wrap("|")) + buf = append(buf, ' ') + return buf + case slog.MessageKey: + msgbufp := allocBuf() + defer freeBuf(msgbufp) + var prepend []byte + var lines int + msg := a.Value.String() + buf = append(buf, sDefault...) + for { + if lines == 1 { + buf = fmt.Appendf(buf, "%s\n", cDim.Wrap("↲")) + prepend = append(append(sDim, []byte(" > ")...), cReset...) + *msgbufp = append(prepend, *msgbufp...) + } + *msgbufp = append(*msgbufp, prepend...) + index := strings.IndexByte(msg, '\n') + if index == -1 { + if lines > 1 { + msg = strings.TrimSpace(msg) + } + *msgbufp = append(*msgbufp, msg...) + if lines > 1 { + *msgbufp = append(*msgbufp, '\n') + } else { + *msgbufp = append(*msgbufp, ' ') + } + break + } else { + *msgbufp = append(*msgbufp, strings.TrimSpace(msg[:index])...) + *msgbufp = append(*msgbufp, '\n') + msg = msg[index+1:] + } + lines++ + } + buf = append(buf, *msgbufp...) + buf = append(buf, cReset...) + return buf + case slog.SourceKey: + buf = append(buf, cDim.Wrap(a.Key+"=\"").Bytes()...) + buf = append(buf, color.Namespace(a.Value.String()).Bytes()...) + buf = append(buf, cDim.Wrap("\"").Bytes()...) + buf = append(buf, ' ') + return buf + default: + if a.Value.Kind() != slog.KindGroup { + for _, g := range h.groups { + buf = fmt.Appendf(buf, "%s.", g) + } + } + } + switch a.Value.Kind() { + case slog.KindString: + // Quote string values, to make them easy to parse. + buf = append(buf, a.Key...) + buf = append(buf, "="...) + buf = strconv.AppendQuote(buf, a.Value.String()) + buf = append(buf, ' ') + case slog.KindTime: + // Write times in a standard way, without the monotonic time. + buf = append(buf, a.Key...) + buf = append(buf, "="...) + buf = a.Value.Time().AppendFormat(buf, time.RFC3339Nano) + buf = append(buf, ' ') + case slog.KindGroup: + attrs := a.Value.Group() + // Ignore empty groups. + if len(attrs) == 0 { + return buf + } + // If the key is non-empty, write it out and indent the rest of the attrs. + // Otherwise, inline the attrs. + prefix := a.Key + if a.Key != "" { + prefix += "." + } + for _, ga := range attrs { + buf = h.appendAttr(buf, slog.Attr{ + Key: prefix + ga.Key, + Value: ga.Value, + }) + } + default: + buf = append(buf, a.Key...) + buf = append(buf, "="...) + buf = append(buf, a.Value.String()...) + buf = append(buf, ' ') + } + return buf +} diff --git a/util/log/log.go b/util/log/log.go new file mode 100644 index 0000000..c86ba0b --- /dev/null +++ b/util/log/log.go @@ -0,0 +1,95 @@ +package log + +import ( + "context" + "io" + "log/slog" + "os" + "strings" + "sync" + "sync/atomic" + + "gopkg.in/natefinch/lumberjack.v2" + "zestack.dev/env" + "zestack.dev/slim" +) + +var defaultLogger atomic.Pointer[slim.Logger] +var loggers sync.Map + +func Init(_ context.Context) error { + l := slim.NewLogger(&slim.LoggerOptions{ + Output: os.Stderr, + AddSource: env.Bool("LOG_ADD_SOURCE", false), + Level: slog.Level(env.Int("LOG_LEVEL", 0)), + NewHandler: func(w io.Writer, opts *slog.HandlerOptions) slog.Handler { + return NewMultiHandler( + NewTextHandler(w, opts), + slog.NewJSONHandler(&lumberjack.Logger{ + Filename: env.Path("storage/logs/default.log"), // + MaxSize: env.Int("LOG_MAX_SIZE", 500), // megabytes + MaxBackups: env.Int("LOG_MAX_BACKUPS", 3), // + MaxAge: env.Int("LOG_MAX_AGE", 28), // days + Compress: env.Bool("LOG_COMPRESS", true), // enabled by default + }, opts), + ) + }, + }) + slog.SetDefault(l.Logger) + defaultLogger.Store(l) + return nil +} + +func Channel(name string) *slim.Logger { + if name != "default" && name != "" { + return Default() + } + if l, ok := loggers.Load(name); ok { + return l.(*slim.Logger) + } + s := env.Signed("LOG", strings.ToUpper(name)) + l := slim.NewLogger(&slim.LoggerOptions{ + Output: &lumberjack.Logger{ + Filename: env.Path("storage/logs/" + name + ".log"), + MaxSize: s.Int("MAX_SIZE", 500), // megabytes + MaxBackups: s.Int("MAX_BACKUPS", 3), + MaxAge: s.Int("MAX_AGE", 28), // days + Compress: s.Bool("COMPRESS", true), // enabled by default + }, + AddSource: s.Bool("ADD_SOURCE", false), + Level: slog.Level(s.Int("LEVEL", 0)), + NewHandler: func(w io.Writer, opts *slog.HandlerOptions) slog.Handler { + return slog.NewJSONHandler(w, opts) + }, + }) + loggers.Store(name, l) + return l +} + +func Default() *slim.Logger { + return defaultLogger.Load() +} + +func With(args ...any) *slim.Logger { + return Default().With(args...) +} + +func WithGroup(name string) *slim.Logger { + return Default().WithGroup(name) +} + +func Debug(msg string, args ...any) { + Default().Debug(msg, args...) +} + +func Info(msg string, args ...any) { + Default().Info(msg, args...) +} + +func Warn(msg string, args ...any) { + Default().Warn(msg, args...) +} + +func Error(msg string, args ...any) { + Default().Error(msg, args...) +} diff --git a/util/log/utils.go b/util/log/utils.go new file mode 100644 index 0000000..0651a89 --- /dev/null +++ b/util/log/utils.go @@ -0,0 +1,51 @@ +package log + +import ( + "log/slog" + "sync" + + "zestack.dev/color" +) + +var ( + cError = color.New(color.FgHiRed, color.Bold) + cInfo = color.New(color.FgHiGreen, color.Bold) + cWarn = color.New(color.FgHiYellow, color.Bold) + cDebug = color.New(color.FgHiCyan, color.Bold) + // cFatal = color.New(color.FgHiBlue, color.Bold) + // cPanic = color.New(color.FgHiMagenta, color.Bold) + // cTrace = color.New(color.FgHiCyan, color.Bold) +) + +func levelToColor(l slog.Level) (*color.Value, string) { + switch { + case l < slog.LevelInfo: + return cDebug.Wrap("DEBUG"), "" + case l < slog.LevelWarn: + return cInfo.Wrap("INFO"), " " + case l < slog.LevelError: + return cWarn.Wrap("WARN"), " " + default: + return cError.Wrap("ERROR"), "" + } +} + +var bufPool = sync.Pool{ + New: func() any { + b := make([]byte, 0, 1024) + return &b + }, +} + +func allocBuf() *[]byte { + return bufPool.Get().(*[]byte) +} + +func freeBuf(b *[]byte) { + // To reduce peak allocation, return only smaller buffers to the pool. + const maxBufferSize = 16 << 10 + if cap(*b) <= maxBufferSize { + *b = (*b)[:0] + bufPool.Put(b) + } +} diff --git a/util/log/writer.go b/util/log/writer.go new file mode 100644 index 0000000..89a0e70 --- /dev/null +++ b/util/log/writer.go @@ -0,0 +1,60 @@ +package log + +import ( + "context" + "errors" + "log/slog" +) + +type MultiHandler struct { + handlers []slog.Handler +} + +func NewMultiHandler(handlers ...slog.Handler) slog.Handler { + return &MultiHandler{handlers} +} + +func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, handler := range h.handlers { + if handler.Enabled(ctx, level) { + return true + } + } + return false +} + +func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error { + var errs []error + for _, handler := range h.handlers { + if handler.Enabled(ctx, record.Level) { + ex := handler.Handle(ctx, record) + if ex != nil { + errs = append(errs, ex) + } + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + handlers := make([]slog.Handler, len(h.handlers)) + for i, handler := range h.handlers { + handlers[i] = handler.WithAttrs(attrs) + } + return &MultiHandler{ + handlers: handlers, + } +} + +func (h *MultiHandler) WithGroup(name string) slog.Handler { + handlers := make([]slog.Handler, len(h.handlers)) + for i, handler := range h.handlers { + handlers[i] = handler.WithGroup(name) + } + return &MultiHandler{ + handlers: handlers, + } +} diff --git a/util/rdb/rdb.go b/util/rdb/rdb.go new file mode 100644 index 0000000..4134684 --- /dev/null +++ b/util/rdb/rdb.go @@ -0,0 +1,26 @@ +package rdb + +import ( + "fmt" + + "github.com/redis/go-redis/v9" + "zestack.dev/env" +) + +var rdb *redis.Client + +func Init() error { + rdb = redis.NewClient(&redis.Options{ + Network: env.String("REDIS_NETWORK", "tcp"), + Addr: fmt.Sprintf("%s:%d", env.String("REDIS_HOST"), env.Int("REDIS_PORT", 6379)), + DB: env.Int("REDIS_DB", 0), + Username: env.String("REDIS_USER"), + Password: env.String("REDIS_AUTH"), + }) + + return nil +} + +func Redis() *redis.Client { + return rdb +} diff --git a/util/rsp/error.go b/util/rsp/error.go new file mode 100644 index 0000000..1d9b4d7 --- /dev/null +++ b/util/rsp/error.go @@ -0,0 +1,112 @@ +package rsp + +var ( + ErrOK = NewError(200, "OK", "ok") // 表示没有任何错误。 + ErrInternal = NewError(500, "InternalError", "系统内部错误") // 客户端请求有效,但服务器处理时发生了意外。 + ErrMethodNotAllowed = NewError(405, "MethodNotAllowed", "网络请求方法错误") + ErrNotFound = NewError(404, "NotFound", "请求资源不存在") + ErrLimitExecuted = NewError(403, "LimitExecuted", "访问频次超过限制") + ErrLimitDevice = NewError(400, "LimitDevice", "设备数超过限制") + ErrUnknownError = NewError(400, "UnknownError", "未知错误") + ErrBadParams = NewError(400, "BadParams", "提交的参数不符合要求") + ErrRecordNotFound = NewError(404, "RecordNotFound", "访问的数据不存在") + ErrRecordAlreadyExists = NewError(500, "RecordAlreadyExists", "数据已经存在") + ErrPermissionDenied = NewError(403, "PermissionDenied", "没有操作权限") + ErrServiceUnavailable = NewError(503, "ServiceUnavailable", "系统维护中") + ErrUnauthorized = NewError(401, "Unauthorized", "缺少身份验证凭据或身份凭据错误") // 用户未提供身份验证凭据,或者没有通过身份验证。 + ErrForbidden = NewError(403, "Forbidden", "没有资源访问权限") // 用户通过了身份验证,但是不具有访问资源所需的权限。 + ErrGone = NewError(410, "Gone", "访问的资源不存在") // 所请求的资源已从这个地址转移,不再可用。 + ErrUnsupportedMediaType = NewError(415, "UnsupportedMediaType", "提交的数据格式错误") // 客户端要求的返回格式不支持,比如,API 只能返回 JSON 格式,但是客户端要求返回 XML 格式。 + ErrUnprocessableEntity = NewError(422, "UnprocessableEntity", "上传附件错误") // 无法处理客户端上传的附件,导致请求失败。 + ErrAlreadyAssociated = NewError(433, "AlreadyAssociated", "数据已被使用") + ErrBadRequest = NewError(400, "BadRequest", "请求无效") // 服务器不理解客户端的请求。 + ErrDatabaseException = NewError(500, "DatabaseException", "数据库异常") + ErrCreateFailed = NewError(500, "CreateFailed", "创建数据失败") + ErrUpdateFailed = NewError(500, "UpdateFailed", "更新数据失败") + ErrDeleteFailed = NewError(500, "DeleteFailed", "删除数据失败") + ErrUnimplemented = NewError(500, "Unimplemented", "接口尚未实现") +) + +type Error struct { + internal error // 包装的其它错误 + status int // HTTP 状态码 + code string // 请求错误码 + text string // 响应提示消息 + data any // 错误携带的响应数据 +} + +func NewError(status int, code, text string) *Error { + return &Error{nil, status, code, text, nil} +} + +func (e *Error) Status() int { + return e.status +} + +func (e *Error) Code() string { + return e.code +} + +func (e *Error) Text() string { + return e.Text() +} + +func (e *Error) Data() any { + return e.data +} + +func (e *Error) Internal() error { + return e.internal +} + +func (e *Error) Unwrap() error { + return e.Internal() +} + +func (e *Error) String() string { + return e.text +} + +func (e *Error) Error() string { + return e.text +} + +func (e *Error) WithInternal(err error) *Error { + c := *e + c.internal = err + return &c +} + +func (e *Error) WithText(text ...string) *Error { + if len(text) == 0 || text[0] == e.text { + return e + } + c := *e + c.text = text[0] + return &c +} + +func (e *Error) WithData(data any) *Error { + if e.data == data { + return e + } + c := *e + c.data = data + return &c +} + +type Problem struct { + Label string `json:"-" xml:"-"` + Code string `json:"code" xml:"code"` + Message string `json:"message" xml:"message"` + Problems map[string][]*Problem `json:"problems,omitempty" xml:"problems,omitempty"` +} + +type Problems map[string][]*Problem + +func (p Problems) Add(problem *Problem) { + if _, ok := p[problem.Label]; !ok { + p[problem.Label] = make([]*Problem, 0) + } + p[problem.Label] = append(p[problem.Label], problem) +} diff --git a/util/rsp/response.go b/util/rsp/response.go new file mode 100644 index 0000000..b9e66a9 --- /dev/null +++ b/util/rsp/response.go @@ -0,0 +1,158 @@ +package rsp + +import ( + "errors" + "fmt" + "net/http" + "runtime" + + "zestack.dev/slim" + "zestack.dev/v" +) + +type response struct { + status int + headers map[string]string + cookies []*http.Cookie + err error + message string + data any +} + +type Option func(o *response) + +func StatusCode(status int) Option { + return func(o *response) { + o.status = status + } +} + +func Header(key, value string) Option { + return func(o *response) { + if o.headers == nil { + o.headers = make(map[string]string) + } + o.headers[key] = value + } +} + +func Cookie(cookie *http.Cookie) Option { + return func(o *response) { + if o.cookies != nil { + for i, h := range o.cookies { + if h.Name == cookie.Name { + o.cookies[i] = cookie + return + } + } + } + o.cookies = append(o.cookies, cookie) + } +} + +func Message(msg string) Option { + return func(o *response) { + o.message = msg + } +} + +func Data(data any) Option { + return func(o *response) { + o.data = data + } +} + +func (r *response) result(c slim.Context) (m map[string]any, status int) { + status = r.status + m = map[string]any{ + "code": nil, + "success": false, + "message": r.message, + } + if r.err == nil { + m["code"] = ErrOK.code + m["message"] = ErrOK.text + m["success"] = true + if r.data != nil { + m["data"] = r.data + } + if m["message"] == "" { + m["message"] = http.StatusText(status) + } + return + } + + var verr *v.Errors + var rerr *Error + var err error + if errors.As(r.err, &verr) { + err = nil + problems := make(Problems) + for _, e := range verr.All() { + message := e.String() + if message == "" { + message = e.Error() + } + problems.Add(&Problem{ + Label: e.Field(), + Code: e.Code(), + Message: message, + Problems: nil, + }) + } + m["code"] = ErrBadParams.code + m["message"] = ErrBadParams.text + m["problems"] = problems + if status < 400 { + status = ErrBadParams.status + } + } else if errors.As(r.err, &rerr) { + m["code"] = rerr.code + m["success"] = errors.Is(rerr, ErrOK) + m["message"] = rerr.text + if data := rerr.Data(); data != nil { + m["data"] = data + } + if status < 400 { + status = rerr.status + } + err = rerr.internal + } else { + var he *slim.HTTPError + if errors.As(r.err, &he) { + status = he.StatusCode + err = he.Internal + m["message"] = he.Message + } else { + m["message"] = r.err.Error() + } + m["code"] = ErrInternal.Code() + if status < 400 { + status = ErrInternal.status + } + } + if c.Slim().Debug && m["success"] != true { + if err != nil { + m["error"] = err.Error() + } + m["stack"] = relevantCaller() + } + if m["message"] == "" { + m["message"] = http.StatusText(status) + } + return +} + +func relevantCaller() []string { + pc := make([]uintptr, 16) + n := runtime.Callers(4, pc) + frames := runtime.CallersFrames(pc[:n]) + var traces []string + for { + frame, more := frames.Next() + traces = append(traces, fmt.Sprintf("%s:%s:%d", frame.File, frame.Func.Name(), frame.Line)) + if !more { + return traces + } + } +} diff --git a/util/rsp/rsp.go b/util/rsp/rsp.go new file mode 100644 index 0000000..ca7c263 --- /dev/null +++ b/util/rsp/rsp.go @@ -0,0 +1,143 @@ +package rsp + +import ( + "bytes" + "encoding/json" + "net/http" + + "zestack.dev/slim" +) + +var ( + // TextMarshaller 将 Map 转化成文本格式用于响应给请求者 + TextMarshaller func(map[string]any) (string, error) + // HtmlMarshaller 将 Map 转换成超文本格式用于响应给请求者 + HtmlMarshaller func(map[string]any) (string, error) + // JsonpCallbacks 默认 JSONP 查询的字段 + JsonpCallbacks []string + // DefaultJsonpCallback 默认查询的 JSONP 函数字段名 + DefaultJsonpCallback string +) + +func init() { + TextMarshaller = toText + HtmlMarshaller = toText + JsonpCallbacks = []string{"callback", "cb", "jsonp"} + DefaultJsonpCallback = "callback" +} + +func toText(m map[string]any) (string, error) { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(true) + if err := enc.Encode(m); err != nil { + return "", err + } + return buf.String(), nil +} + +func respond(c slim.Context, o *response) (err error) { + defer func() { + if err != nil { + c.Logger().Error(err.Error()) + } + }() + // 如果已经输出过,就忽略 + if c.Written() { + return nil + } + // 设置报头 + if o.headers != nil { + for key, value := range o.headers { + c.SetHeader(key, value) + } + } + // 设置 cookie + if o.cookies != nil { + for _, cookie := range o.cookies { + c.SetCookie(cookie) + } + } + m, status := o.result(c) + // HEAD 请求没有结果 + r := c.Request() + if r.Method == http.MethodHead { + return c.NoContent(status) + } + // 根据报头响应不同的格式 + switch c.Accepts("html", "json", "jsonp", "xml", "text", "text/*") { + case "html": + var html string + if html, err = HtmlMarshaller(m); err == nil { + err = c.HTML(status, html) + } + case "json": + err = c.JSON(status, m) + case "jsonp": + qs := c.Request().URL.Query() + for _, name := range JsonpCallbacks { + if cb := qs.Get(name); cb != "" { + err = c.JSONP(status, cb, m) + return + } + } + err = c.JSONP(status, DefaultJsonpCallback, m) + case "xml": + err = c.XML(status, m) + case "text", "text/*": + var text string + if text, err = TextMarshaller(m); err == nil { + err = c.String(status, text) + } + default: + err = c.JSON(status, m) + } + return +} + +// Respond 自定义响应 +func Respond(c slim.Context, opts ...Option) error { + o := response{status: http.StatusOK} + for _, option := range opts { + option(&o) + } + return respond(c, &o) +} + +// Ok 响应成功请求 +func Ok(c slim.Context, data any) error { + return Respond(c, Data(data)) +} + +// Created 表示数据创建成功 +func Created(c slim.Context, data any) error { + return Respond(c, Data(data), StatusCode(http.StatusCreated)) +} + +// Deleted 表示数据删除成功 +func Deleted(c slim.Context, data ...any) error { + if len(data) > 0 { + return Respond(c, StatusCode(http.StatusOK), Data(data[0])) + } + return Respond(c, StatusCode(http.StatusOK)) +} + +// NotFound 响应访问的资源不存在 +func NotFound(c slim.Context) error { + return Respond(c, StatusCode(http.StatusNotFound)) +} + +// Accepted 响应一个异步操作,比如任务调度等 +func Accepted(c slim.Context, data any) error { + return Respond(c, Data(data), StatusCode(http.StatusAccepted)) +} + +// Fail 响应一个错误 +func Fail(c slim.Context, err error, opts ...Option) error { + o := response{status: http.StatusInternalServerError} + for _, option := range opts { + option(&o) + } + o.err = err + return respond(c, &o) +}