Repository: issue9/cnregion Branch: master Commit: 692b4fb3ec65 Files: 23 Total size: 34.4 KB Directory structure: gitextract_8k6qygrr/ ├── .github/ │ └── workflows/ │ ├── codeql-analysis.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── cnregion.go ├── cnregion_test.go ├── data/ │ ├── embed.go │ └── embed_test.go ├── db.go ├── db_test.go ├── districts.go ├── districts_test.go ├── go.mod ├── go.sum ├── id/ │ ├── id.go │ └── id_test.go ├── region.go ├── region_test.go ├── search.go ├── search_test.go └── version/ ├── version.go └── version_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. name: "CodeQL" on: push: branches: [master] pull_request: # The branches below must be a subset of the branches above branches: [master] schedule: - cron: '0 16 * * 5' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: # Override automatic language detection by changing the below list # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] language: ['go'] # Learn more... # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - name: Checkout repository uses: actions/checkout@v4 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: [push, pull_request] jobs: test: name: Test runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] go: ['1.21.x', '1.23.x'] steps: - name: Set git to use LF run: | git config --global core.autocrlf false git config --global core.eol lf - name: Check out code into the Go module directory uses: actions/checkout@v4 - name: Set up Go ${{ matrix.go }} uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} id: go - name: Vet run: go vet -v ./... - name: Test run: go test -v -coverprofile='coverage.txt' -covermode=atomic ./... - name: Upload Coverage report uses: codecov/codecov-action@v4 with: token: ${{secrets.CODECOV_TOKEN}} file: ./coverage.txt ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ .vscode/ .idea/ *.swp .DS_Store ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 caixw Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # cnregion [![Test](https://github.com/issue9/cnregion/workflows/Test/badge.svg)](https://github.com/issue9/cnregion/actions?query=workflow%3ATest) [![Go version](https://img.shields.io/github/go-mod/go-version/issue9/cnregion)](https://golang.org) [![PkgGoDev](https://pkg.go.dev/badge/github.com/issue9/cnregion)](https://pkg.go.dev/github.com/issue9/cnregion/v2) [![codecov](https://codecov.io/gh/issue9/cnregion/branch/master/graph/badge.svg)](https://codecov.io/gh/issue9/cnregion) ![License](https://img.shields.io/github/license/issue9/cnregion) 历年统计用区域和城乡划分代码,数据来源于 。 符合国家标准 GB/T 2260 与 GB/T 10114。 关于版本号,主版本号代码不兼容性更改,次版本号代码最后一次生成的数据年份,BUG 修正和兼容性的功能增加则增加修订版本号。 ```go v, err := cnregion.LoadFile("./data/regions.db", "-", 2020) p := v.Provinces() // 返回所有省列表 cities := p[0].Items() // 返回该省下的所有市 counties := cities[0].Items() // 返回该市下的所有县 towns := counties[0].Items() // 返回所有镇 villages := towns[0].Items() // 所有村和街道信息 d := v.Districts() // 按以前的行政大区进行划分 provinces := d[0].Items() // 该大区下的所有省份 list := v.Search(&SearchOptions{Text: "温州"}) // 按索地名中带温州的区域列表 ``` 对采集的数据进行了一定的加工,以减少文件的体积,文件保存在 `./data/regions.db` 中。 ## 安装 ```shell go get github.com/issue9/cnregion/v2 ``` ## 版权 本项目采用 [MIT](https://opensource.org/licenses/MIT) 开源授权许可证,完整的授权说明可在 [LICENSE](LICENSE) 文件中找到。 ================================================ FILE: cnregion.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT // Package cnregion 中国区域划分代码 // // 中国行政区域五级划分代码,包含了省、市、县、乡和村五个级别。 // [数据规则]以及[数据来源]。 // // [数据规则]: http://www.stats.gov.cn/tjsj/tjbz/200911/t20091125_8667.html // [数据来源]: http://www.stats.gov.cn/tjsj/tjbz/tjyqhdmhcxhfdm/ package cnregion import ( "bytes" "compress/gzip" "io" "io/fs" "os" ) // LoadFS 从数据文件加载数据 func LoadFS(f fs.FS, file, separator string, compress bool, version ...int) (*DB, error) { data, err := fs.ReadFile(f, file) if err != nil { return nil, err } return Load(data, separator, compress, version...) } // Load 将数据内容加载至 DB 对象 // // version 仅加载指定年份的数据,如果为空,则加载所有数据; func Load(data []byte, separator string, compress bool, version ...int) (*DB, error) { if compress { rd, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { return nil, err } data, err = io.ReadAll(rd) if err != nil { return nil, err } } db := &DB{ fullNameSeparator: separator, filters: version, } if err := db.unmarshal(data); err != nil { return nil, err } db.initDistricts() return db, nil } // LoadFile 从数据文件加载数据 func LoadFile(file, separator string, compress bool, version ...int) (*DB, error) { data, err := os.ReadFile(file) if err != nil { return nil, err } return Load(data, separator, compress, version...) } // Dump 输出到文件 func (db *DB) Dump(file string, compress bool) error { data, err := db.marshal() if err != nil { return err } if compress { buf := new(bytes.Buffer) w := gzip.NewWriter(buf) if _, err = w.Write(data); err != nil { return err } if err = w.Close(); err != nil { return err } data = buf.Bytes() } return os.WriteFile(file, data, os.ModePerm) } ================================================ FILE: cnregion_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "os" "path/filepath" "testing" "github.com/issue9/assert/v4" "github.com/issue9/cnregion/v2/id" "github.com/issue9/cnregion/v2/version" ) func TestLoad(t *testing.T) { a := assert.New(t, false) o1, err := Load(data, "-", false) a.NotError(err). Equal(o1.fullNameSeparator, obj.fullNameSeparator). Equal(o1.versions, obj.versions). Equal(len(o1.root.items), len(obj.root.items)). Equal(o1.root.items[0].id, obj.root.items[0].id). Equal(o1.root.items[0].fullID, obj.root.items[0].fullID). Equal(o1.root.items[0].items[0].id, obj.root.items[0].items[0].id). Equal(o1.root.items[1].items[0].id, obj.root.items[1].items[0].id). Equal(o1.root.items[1].items[0].fullID, obj.root.items[1].items[0].fullID). Equal(o1.root.items[1].items[1].fullID, obj.root.items[1].items[1].fullID). NotEqual(o1.root.items[1].items[1].fullID, obj.root.items[1].items[0].fullID) d1, err := obj.marshal() a.NotError(err).NotNil(d1) a.Equal(string(d1), string(data)) _, err = Load([]byte("100:[2020]:::1:0{}"), "-", false) a.Equal(err, ErrIncompatible) o1, err = Load(data, "-", false, 2019) a.NotError(err). Equal(0, len(o1.root.items)) } func TestDB_LoadDump(t *testing.T) { a := assert.New(t, false) path := filepath.Join(os.TempDir(), "cnregion_db.dict") a.NotError(obj.Dump(path, false)) d, err := LoadFile(path, "-", false) a.NotError(err).NotNil(d) path = filepath.Join(os.TempDir(), "cnregion_db_compress.dict") a.NotError(obj.Dump(path, true)) d, err = LoadFile(path, "-", true) a.NotError(err).NotNil(d) } func TestLoadFS(t *testing.T) { a := assert.New(t, false) obj, err := LoadFS(os.DirFS("./data"), "regions.db", "-", true) a.NotError(err).NotNil(obj) a.Equal(obj.versions, version.All()). Equal(obj.fullNameSeparator, "-"). True(len(obj.root.items) > 0). Equal(obj.root.items[0].level, id.Province). Equal(obj.root.items[0].items[0].level, id.City). Equal(obj.root.items[0].items[0].items[0].level, id.County). Equal(obj.root.items[1].level, id.Province). Equal(obj.root.items[2].items[0].level, id.City) } ================================================ FILE: data/embed.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package data import ( "embed" "github.com/issue9/cnregion/v2" ) //go:embed regions.db var data embed.FS // Embed 将 regions.db 的内容嵌入到程序中 // // 这样可以让程序不依赖外部文件,但同时也会增加编译后程序的大小。 func Embed(separator string, version ...int) (*cnregion.DB, error) { return cnregion.LoadFS(data, "regions.db", separator, true, version...) } ================================================ FILE: data/embed_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package data import ( "testing" "github.com/issue9/assert/v4" ) func TestEmbed(t *testing.T) { a := assert.New(t, false) v, err := Embed(">", 2021) a.NotError(err).NotNil(v) r := v.Find("330305000000") a.NotNil(r). Equal(r.ID(), "05"). Equal(r.FullID(), "330305000000"). Equal(r.Name(), "洞头区"). Equal(r.FullName(), "浙江省>温州市>洞头区") } ================================================ FILE: db.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "bytes" "errors" "fmt" "slices" "strconv" "strings" "github.com/issue9/errwrap" "github.com/issue9/cnregion/v2/id" ) // Version 数据文件的版本号 const Version = 1 // ErrIncompatible 数据文件版本不兼容 // // 当数据文件中指定的版本号与当前的 Version 不相等时,返回此错误。 var ErrIncompatible = errors.New("数据文件版本不兼容") // DB 区域数据库信息 // // 数据格式: // // 1:[versions]:{id:name:yearIndex:size{}} // // - 1 表示数据格式的版本,采用当前包的 Version 常量; // - versions 表示当前数据文件中的数据支持的年份列表,以逗号分隔; // - id 当前区域的 ID; // - name 当前区域的名称; // - yearIndex 此条数据支持的年份列表,每一个位表示一个年份在 versions 中的索引值; // - size 表示子元素的数量; type DB struct { root *Region versions []int // 支持的版本 // 以下数据不会写入数据文件中 fullNameSeparator string districts []*Region // Load 指定的过滤版本,仅在 unmarshal 过程中使用, // 在完成 unmarshal 之的清空。 filters []int } // NewDB 返回空的 [DB] 对象 func NewDB() *DB { db := &DB{versions: []int{}} db.root = &Region{db: db} return db } // Version 当前这份数据支持的年份列表 func (db *DB) Versions() []int { return db.versions } // AddVersion 添加新的版本号 func (db *DB) AddVersion(ver int) (ok bool) { if slices.Index(db.versions, ver) > -1 { // 检测 ver 是否已经存在 return false } db.versions = append(db.versions, ver) return true } // Find 查找指定 ID 对应的信息 func (db *DB) Find(regionID string) *Region { return db.root.findItem(id.SplitFilter(regionID)...) } var levelIndex = []id.Level{id.Province, id.City, id.County, id.Town, id.Village} // AddItem 添加一条子项 func (db *DB) AddItem(regionID, name string, ver int) error { list := id.SplitFilter(regionID) item := db.root.findItem(list...) if item == nil { items := list[:len(list)-1] // 上一级 item = db.root.findItem(items...) level := levelIndex[len(items)] return item.addItem(list[len(list)-1], name, level, ver) } return item.setSupported(ver) } func (db *DB) marshal() ([]byte, error) { versions := make([]string, 0, len(db.versions)) for _, v := range db.versions { versions = append(versions, strconv.Itoa(v)) } buf := errwrap.Buffer{Buffer: bytes.Buffer{}} buf.WString(strconv.Itoa(Version)).WByte(':') buf.WByte('[') buf.WString(strings.Join(versions, ",")) buf.WByte(']').WByte(':') err := db.root.marshal(&buf) if err != nil { return nil, err } if buf.Err != nil { return nil, err } return buf.Bytes(), nil } func (db *DB) unmarshal(data []byte) error { data, val := indexBytes(data, ':') ver, err := strconv.Atoi(val) if err != nil { return err } if ver != Version { return ErrIncompatible } data, val = indexBytes(data, ':') versions := strings.Split(strings.Trim(val, "[]"), ",") db.versions = make([]int, 0, len(versions)) for _, version := range versions { v, err := strconv.Atoi(version) if err != nil { return err } db.versions = append(db.versions, v) } if len(db.filters) == 0 { db.filters = db.versions } else { LOOP: for _, v := range db.filters { for _, v2 := range db.versions { if v2 == v { continue LOOP } } return fmt.Errorf("当前数据文件没有 %d 年份的数据", v) } } defer func() { db.versions = db.filters db.filters = db.filters[:0] }() db.root = &Region{db: db} return db.root.unmarshal(data, "", "", 0) } func (db *DB) filterVersions(versions []int) []int { vers := make([]int, 0, len(versions)) LOOP: for _, v := range versions { for _, v2 := range db.filters { if v2 == v { vers = append(vers, v) continue LOOP } } } return vers } ================================================ FILE: db_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "testing" "github.com/issue9/assert/v4" "github.com/issue9/cnregion/v2/id" "github.com/issue9/cnregion/v2/version" ) var data = []byte(`1:[2020,2019]:::1:2{33:浙江:1:1{01:温州:3:0{}}34:安徽:1:3{01:合肥:3:0{}02:芜湖:1:0{}03:芜湖-2:1:0{}}}`) var obj = &DB{ versions: []int{2020, 2019}, fullNameSeparator: "-", root: &Region{ name: "", versions: []int{2020}, items: []*Region{ { id: "33", name: "浙江", versions: []int{2020}, fullName: "浙江", fullID: "330000000000", level: id.Province, items: []*Region{ { id: "01", name: "温州", versions: []int{2020, 2019}, fullName: "浙江-温州", fullID: "330100000000", level: id.City, }, }, }, { id: "34", name: "安徽", fullName: "安徽", fullID: "340000000000", versions: []int{2020}, level: id.Province, items: []*Region{ { id: "01", name: "合肥", versions: []int{2020, 2019}, fullName: "安徽-合肥", fullID: "340100000000", level: id.City, }, { id: "02", name: "芜湖", versions: []int{2020}, fullName: "安徽-芜湖", fullID: "340200000000", level: id.City, }, { id: "03", name: "芜湖-2", versions: []int{2020}, fullName: "安徽-芜湖-2", fullID: "340300000000", level: id.City, }, }, }, }, }, } func init() { setRegionDB(obj.root, obj) } func setRegionDB(r *Region, db *DB) { r.db = db for _, i := range r.items { setRegionDB(i, db) } } func TestDB_Find(t *testing.T) { a := assert.New(t, false) // 2020 db, err := LoadFile("./data/regions.db", ">", true, 2020) a.NotError(err).NotNil(db) r := db.Find("330305000000") a.NotNil(r). Equal(r.ID(), "05"). Equal(r.FullID(), "330305000000"). Equal(r.Name(), "洞头区"). Equal(r.FullName(), "浙江省>温州市>洞头区"). Equal(r.Versions(), []int{2020}) r = db.Find("330322000000") // 洞头县,已改为洞头区 a.Nil(r) // 2009 db, err = LoadFile("./data/regions.db", ">", true, 2009) a.NotError(err).NotNil(db) r = db.Find("330322000000") a.NotNil(r). Equal(r.ID(), "22"). Equal(r.FullID(), "330322000000"). Equal(r.Name(), "洞头县"). Equal(r.FullName(), "浙江省>温州市>洞头县"). Equal(r.Versions(), []int{2009}) r = db.Find("330305000000") a.Nil(r) // 所有年份的数据 db, err = LoadFile("./data/regions.db", ">", true, version.Range(2009, 2020)...) a.NotError(err).NotNil(db) r = db.Find("330322000000") a.NotNil(r). Equal(r.ID(), "22"). Equal(r.Versions(), []int{2014, 2013, 2012, 2011, 2010, 2009}) r = db.Find("330305000000") a.NotNil(r). Equal(r.ID(), "05"). Contains(r.Versions(), []int{2018, 2017, 2016, 2015}) } ================================================ FILE: districts.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import "github.com/issue9/cnregion/v2/id" // Districts 按行政大区划分 // // NOTE: 大区划分并不统一,按照各个省份的第一个数字进行划分。 func (db *DB) Districts() []*Region { return db.districts } func (db *DB) initDistricts() { db.districts = make([]*Region, 0, len(districtsMap)) for index, name := range districtsMap { items := make([]*Region, 0, 10) for _, p := range db.Provinces() { if p.ID()[0] == index { items = append(items, p) } } db.districts = append(db.districts, &Region{ id: string(index), fullID: id.Fill(string(index), id.Village), name: name, fullName: name, items: items, }) } } var districtsMap = map[byte]string{ '1': "华北地区", '2': "东北地区", '3': "华东地区", '4': "中南地区", '5': "西南地区", '6': "西北地区", } ================================================ FILE: districts_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "testing" "github.com/issue9/assert/v4" ) func TestDB_Districts(t *testing.T) { a := assert.New(t, false) db, err := LoadFile("./data/regions.db", ">", true, 2020) a.NotError(err).NotNil(db) a.Length(db.Districts(), len(districtsMap)) for _, d := range db.Districts() { if d.ID() == "1" { a.Equal(d.Name(), "华北地区").Equal(5, len(d.Items())) } } } ================================================ FILE: go.mod ================================================ module github.com/issue9/cnregion/v2 go 1.21 require ( github.com/issue9/assert/v4 v4.3.1 github.com/issue9/errwrap v0.3.2 ) ================================================ FILE: go.sum ================================================ github.com/issue9/assert/v4 v4.1.1/go.mod h1:v7qDRXi7AsaZZNh8eAK2rkLJg5/clztqQGA1DRv9Lv4= github.com/issue9/assert/v4 v4.3.1 h1:dHYODk1yV7j/1baIB6K6UggI4r1Hfuljqic7PaDbwLg= github.com/issue9/assert/v4 v4.3.1/go.mod h1:v7qDRXi7AsaZZNh8eAK2rkLJg5/clztqQGA1DRv9Lv4= github.com/issue9/errwrap v0.3.2 h1:7KEme9Pfe75M+sIMcPCn/DV90wjnOcRbO4DXVAHj3Fw= github.com/issue9/errwrap v0.3.2/go.mod h1:KcCLuUGiffjooLCUjL89r1cyO8/HT/VRcQrneO53N3A= ================================================ FILE: id/id.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT // Package id 针对 ID 的一些操作函数 package id import ( "fmt" "strings" ) // Level 表示区域的级别 type Level uint8 // 对区域级别的定义 const ( Village Level = 1 << iota Town County City Province AllLevel = Village + Town + County + City + Province ) var lengths = map[Level]int{ Village: 12, Town: 9, County: 6, City: 4, Province: 2, } // Length 获取各个类型 ID 的有效果长度 func Length(level Level) int { if _, found := lengths[level]; !found { panic("无效的 level 参数") } return lengths[level] } // Split 将一个区域 ID 按区域进行划分 func Split(id string) (province, city, county, town, village string) { if len(id) != Length(Village) { panic(fmt.Sprintf("id 的长度只能为 %d,当前为 %s", Length(Village), id)) } return id[:Length(Province)], id[Length(Province):Length(City)], id[Length(City):Length(County)], id[Length(County):Length(Town)], id[Length(Town):Length(Village)] } // SplitFilter 将 id 按区域进行划分且过滤掉零值的区域 // // 330312123000 => 33 03 12 123 // // 如果传递的是零值,则返回空数组。 func SplitFilter(id string) []string { province, city, county, town, village := Split(id) return filterZero(province, city, county, town, village) } func filterZero(id ...string) []string { for index, i := range id { // 过滤掉数组中的零值 if isZero(i) { id = id[:index] break } } return id } // Parent 获取 id 的上一级行政区域的 ID // // 330312123456 => 330312123 func Parent(id string) string { list := SplitFilter(id) return strings.Join(list[:len(list)-1], "") } // Prefix 获取 ID 的非零前缀 // // 330312123456 => 330312123456 // 330312123000 => 330312123 func Prefix(id string) string { return strings.Join(SplitFilter(id), "") } // Fill 为 id 填充后缀的 0 // // id 为原始值; // level 为需要达到的行政级别,最终的长度为 Length(level)。 func Fill(id string, level Level) string { rem := Length(level) - len(id) switch { case rem == 0: return id case rem > Length(level) || rem < 2: panic(fmt.Sprintf("无效的 id %s,无法为其填充 0", id)) default: return id + strings.Repeat("0", rem) } } // isZero 判断一组字符串是否都由 0 组成 func isZero(id string) bool { for _, r := range id { if r != '0' { return false } } return true } ================================================ FILE: id/id_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package id import ( "testing" "github.com/issue9/assert/v4" ) func TestSplit(t *testing.T) { a := assert.New(t, false) province, city, county, town, village := Split("330203103233") a.Equal(province, "33"). Equal(city, "02"). Equal(county, "03"). Equal(town, "103"). Equal(village, "233") a.Panic(func() { Split("3303") }) } func TestSplitFilter(t *testing.T) { a := assert.New(t, false) list := SplitFilter("330203103000") a.Equal(4, len(list)). Equal(list[0], "33"). Equal(list[1], "02"). Equal(list[2], "03"). Equal(list[3], "103") // 碰到第一个零值,即结果后续的判断 list = SplitFilter("330003103000") a.Equal(1, len(list)).Equal(list[0], "33") list = SplitFilter("000000000000") a.Empty(list) } func TestParent(t *testing.T) { a := assert.New(t, false) a.Equal(Parent("330300000000"), "33") a.Equal(Parent("330302111000"), "330302") } func TestPrefix(t *testing.T) { a := assert.New(t, false) a.Equal(Prefix("330301001001"), "330301001001") a.Equal(Prefix("330300000000"), "3303") a.Equal(Prefix("330302000000"), "330302") } func TestFill(t *testing.T) { a := assert.New(t, false) a.Equal(Fill("34", Village), "340000000000") a.Equal(Fill("3", Village), "300000000000") a.Equal(Fill("34", Province), "34") a.Equal(Fill("34", City), "3400") a.Equal(Fill("341234666777", Village), "341234666777") a.Panic(func() { Fill("34112233444332", Village) }) } func TestIsZero(t *testing.T) { a := assert.New(t, false) a.True(isZero("000")) a.False(isZero("00x")) a.True(isZero("")) } ================================================ FILE: region.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "bytes" "errors" "fmt" "slices" "strconv" "github.com/issue9/errwrap" "github.com/issue9/cnregion/v2/id" ) // Region 表示单个区域 type Region struct { id string name string items []*Region versions []int // 支持的版本号列表 // 以下数据不会写入数据文件中 fullName string // 全名 fullID string db *DB level id.Level } // Provinces 省份列表 func (db *DB) Provinces() []*Region { return db.root.items } func (r *Region) ID() string { return r.id } // 区域的 ID,不包括后缀 0 和上一级的 ID func (r *Region) Name() string { return r.name } // 区域的名称 func (r *Region) FullName() string { return r.fullName } // 区域的全称,包括上一级的名称 func (r *Region) FullID() string { return r.fullID } // 区域的 ID,包括后缀的 0 以及上一级的 ID,长度为 12 func (r *Region) Versions() []int { return r.versions } // 支持的年份版本 func (r *Region) Items() []*Region { return r.items } // 子项 // IsSupported 当前数据是否支持该年份 func (r *Region) IsSupported(ver int) bool { return slices.Index(r.versions, ver) > -1 } func (reg *Region) addItem(id, name string, level id.Level, ver int) error { if slices.Index(reg.db.versions, ver) == -1 { return fmt.Errorf("不支持该年份 %d 的数据", ver) } for _, item := range reg.items { if item.id == id { return fmt.Errorf("已经存在相同 ID 的数据项:%s", id) } } reg.items = append(reg.items, &Region{ id: id, name: name, db: reg.db, level: level, versions: []int{ver}, }) return nil } func (reg *Region) setSupported(ver int) error { if slices.Index(reg.db.versions, ver) == -1 { return fmt.Errorf("不存在该年份 %d 的数据", ver) } if !reg.IsSupported(ver) { reg.versions = append(reg.versions, ver) } return nil } func (reg *Region) findItem(regionID ...string) *Region { if len(regionID) == 0 { return reg } for _, item := range reg.items { if item.id == regionID[0] { return item.findItem(regionID[1:]...) } } return nil } func (reg *Region) marshal(buf *errwrap.Buffer) error { supported := 0 for _, ver := range reg.versions { index := slices.Index(reg.db.versions, ver) if index == -1 { return fmt.Errorf("无效的年份 %d 位于 %s", ver, reg.fullName) } supported += 1 << index } buf.Printf("%s:%s:%d:%d{", reg.id, reg.name, supported, len(reg.items)) for _, item := range reg.items { err := item.marshal(buf) if err != nil { return err } } buf.WByte('}') return nil } func (reg *Region) unmarshal(data []byte, parentName, parentID string, level id.Level) error { reg.level = level data, reg.id = indexBytes(data, ':') data, reg.name = indexBytes(data, ':') reg.fullName = reg.name if parentName != "" { reg.fullName = parentName + reg.db.fullNameSeparator + reg.name } parentID += reg.id reg.fullID = id.Fill(parentID, id.Village) // Versions data, val := indexBytes(data, ':') supported, err := strconv.Atoi(val) if err != nil { return err } versions := make([]int, 0, len(reg.db.versions)) for i, v := range reg.db.versions { if flag := 1 << i; flag&supported == flag { versions = append(versions, v) } } reg.versions = reg.db.filterVersions(versions) data, val = indexBytes(data, '{') size, err := strconv.Atoi(val) if err != nil { return err } if size > 0 { for i := 0; i < size; i++ { index := findEnd(data) if index < 0 { return errors.New("未找到结束符号 }") } // 下一级的 Level var next id.Level if level == 0 { next = id.Province } else { next = level >> 1 } item := &Region{db: reg.db} if err := item.unmarshal(data[:index], reg.fullName, parentID, next); err != nil { return err } if len(item.versions) > 0 { // 表示该条数据不支持所有的年份 reg.items = append(reg.items, item) } data = data[index+1:] } } return nil } func indexBytes(data []byte, b byte) ([]byte, string) { index := bytes.IndexByte(data, b) if index == -1 { panic(fmt.Sprintf("在%s未找到:%s", string(data), string(b))) } return data[index+1:], string(data[:index]) } func findEnd(data []byte) int { deep := 0 for i, b := range data { switch b { case '{': deep++ case '}': deep-- if deep == 0 { return i } } } return 0 } ================================================ FILE: region_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "testing" "github.com/issue9/assert/v4" "github.com/issue9/cnregion/v2/id" ) func TestRegion_IsSupported(t *testing.T) { a := assert.New(t, false) obj := &DB{versions: []int{2020, 2019, 2018}} obj.root = &Region{items: []*Region{ {versions: []int{2020, 2019}, name: "test", db: obj}, }, db: obj} a.True(obj.root.items[0].IsSupported(2020)) a.True(obj.root.items[0].IsSupported(2019)) a.False(obj.root.items[0].IsSupported(2018)) // 不支持 a.False(obj.root.items[0].IsSupported(2009)) // 不存在于 db } func TestRegion_addItem(t *testing.T) { a := assert.New(t, false) obj := &DB{versions: []int{2020, 2019, 2018}} obj.root = &Region{items: []*Region{}, db: obj} a.ErrorString(obj.root.addItem("33", "浙江", id.Province, 2001), "不支持该年份") a.NotError(obj.root.addItem("44", "广东", id.Province, 2020)) a.Equal(obj.root.items[0].id, "44"). NotNil(obj.root.items[0].db). True(obj.root.items[0].IsSupported(2020)). False(obj.root.items[0].IsSupported(2019)) a.ErrorString(obj.root.addItem("44", "广东", id.Province, 2020), "存在相同") } func TestRegion_SetSupported(t *testing.T) { a := assert.New(t, false) obj := &DB{versions: []int{2020, 2019, 2018}} obj.root = &Region{items: []*Region{{db: obj}}, db: obj} a.NotError(obj.root.addItem("33", "浙江", id.Province, 2020)) a.NotError(obj.root.items[0].setSupported(2020)). Equal(obj.root.items[0].versions, []int{2020}) a.NotError(obj.root.items[0].setSupported(2019)). Equal(obj.root.items[0].versions, []int{2020, 2019}) a.ErrorString(obj.root.items[0].setSupported(2001), "不存在该年份") } func TestFindEnd(t *testing.T) { a := assert.New(t, false) data := []byte("0123{56}") a.Equal(findEnd(data), 7) } func TestDB_Provinces(t *testing.T) { a := assert.New(t, false) v, err := LoadFile("./data/regions.db", ">", true, 2020) a.NotError(err).NotNil(v) for _, p := range v.Provinces() { if p.ID() == "33" { a.Equal(p.Name(), "浙江省") } } } func TestRegion_Items(t *testing.T) { a := assert.New(t, false) // 2020 var x05, x22 bool v, err := LoadFile("./data/regions.db", ">", true, 2020) a.NotError(err).NotNil(v) r := v.Find("330300000000") for _, item := range r.Items() { if item.ID() == "05" { x05 = true } if item.ID() == "22" { x22 = true } } a.True(x05).False(x22) // 2009 x05 = false x22 = false v, err = LoadFile("./data/regions.db", ">", true, 2009) a.NotError(err).NotNil(v) r = v.Find("330300000000") for _, item := range r.Items() { if item.ID() == "05" { x05 = true } if item.ID() == "22" { x22 = true } } a.False(x05).True(x22) //2020 + 2009 x05 = false x22 = false v, err = LoadFile("./data/regions.db", ">", true, 2009, 2020) a.NotError(err).NotNil(v) r = v.Find("330300000000") for _, item := range r.Items() { if item.ID() == "05" { x05 = true } if item.ID() == "22" { x22 = true } } a.True(x05).True(x22) } func TestRegion_findItem(t *testing.T) { a := assert.New(t, false) r := obj.root.findItem("34", "01") a.NotNil(r).Equal(r.name, "合肥").Equal(r.fullName, "安徽-合肥") r = obj.root.findItem("34", "01", "00") a.Nil(r) r = obj.root.findItem("34") a.NotNil(r).Equal(r.name, "安徽").Equal(r.fullName, "安徽") r = obj.root.findItem() a.NotNil(r).Equal(r.name, "").Equal(r.fullName, "").Equal(2, len(r.items)) // 不存在于 obj a.Nil(obj.root.findItem("99")) a.Nil(obj.root.findItem("")) } ================================================ FILE: search.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "strings" "github.com/issue9/cnregion/v2/id" ) // Options 搜索选项 type Options struct { // 表示你需要搜索的地名需要包含的内容 // // 不能是多个名称的组合,比如"浙江温州",直接写"温州"就可以。 // 也不要提供类似于"居委会"这种无实际意义的地名; Text string // 上一级的区域 ID // // 为空表示不限制。 Parent string // 搜索的城市类型 // // 该值取值于 github.com/issue9/cnregion/v2/id.Level 类型。 多个值可以通过或运算叠加。 // 0 表示所有类型。 Level id.Level // 最大的搜索数量。0 表示不限制数量。 Max int unlimited bool } func (o *Options) isEmpty() bool { return o.Text == "" && (o.Parent == "" || o.Parent == "000000000000") && o.Level == 0 && o.Max == 0 } // Search 简单的搜索功能 func (db *DB) Search(opt *Options) []*Region { if opt == nil || opt.isEmpty() { panic("参数 opt 不能为空值") } r := db.root if opt.Parent != "" { r = db.Find(opt.Parent) } if r == nil { // 不存在 opt.Parent 指定的数据 return nil } if opt.Level == 0 { opt.Level = id.AllLevel } opt.unlimited = opt.Max == 0 size := 100 if !opt.unlimited { size = opt.Max } list := make([]*Region, 0, size) return r.search(opt, list) } func (reg *Region) search(opt *Options, list []*Region) []*Region { if strings.Contains(reg.name, opt.Text) && (reg.level&opt.Level == reg.level) && reg.level != 0 { // level == 0 只有根元素才有 list = append(list, reg) opt.Max-- } if !opt.unlimited && opt.Max <= 0 { return list } for _, item := range reg.items { list = item.search(opt, list) } return list } ================================================ FILE: search_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package cnregion import ( "os" "strings" "testing" "github.com/issue9/assert/v4" "github.com/issue9/cnregion/v2/id" ) func TestDB_Search(t *testing.T) { a := assert.New(t, false) rs := obj.Search(&Options{Text: "合肥"}) a.Equal(1, len(rs)). Equal(rs[0].name, "合肥") rs = obj.Search(&Options{Parent: "340000000000", Text: "合肥"}) a.Equal(1, len(rs)). Equal(rs[0].name, "合肥") rs = obj.Search(&Options{Parent: "000000000000", Text: "合肥"}) a.Equal(1, len(rs)). Equal(rs[0].name, "合肥") // 限定 level 只能是省以及 parent 为 34 开头 rs = obj.Search(&Options{Parent: "340000000000", Level: id.Province, Text: "合肥"}) a.Equal(0, len(rs)) // 未限定 parent 且 level 正确 rs = obj.Search(&Options{Level: id.City, Text: "合肥"}) a.Equal(1, len(rs)) rs = obj.Search(&Options{Level: id.City, Text: "湖"}) a.Equal(2, len(rs)) rs = obj.Search(&Options{Level: id.City, Parent: "340000000000", Text: "湖"}) a.Equal(2, len(rs)) // parent = 浙江 rs = obj.Search(&Options{Parent: "330000000000", Text: "合肥"}) a.Equal(0, len(rs)) // parent 不存在 rs = obj.Search(&Options{Parent: "110000000000", Text: "合肥"}) a.Equal(0, len(rs)) // 只有 Level rs = obj.Search(&Options{Level: id.City}) a.Equal(4, len(rs)) for _, r := range rs { a.True(strings.HasSuffix(r.fullID, "00000000")) } // 只有 Level rs = obj.Search(&Options{Level: id.City + id.Town}) a.Equal(4, len(rs)) // 只有 Level rs = obj.Search(&Options{Level: id.City + id.Province}) a.Equal(6, len(rs)) } func TestDB_SearchWithData(t *testing.T) { a := assert.New(t, false) obj, err := LoadFS(os.DirFS("./data"), "regions.db", "-", true) a.NotError(err).NotNil(obj) got := obj.Search(&Options{Text: "温州"}) a.NotEmpty(got) // Level 不匹配 got = obj.Search(&Options{Text: "温州", Level: id.Province}) a.Empty(got) } ================================================ FILE: version/version.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT // Package version 提供版本的相关信息 // // 依据 https://www.stats.gov.cn/sj/tjbz/tjyqhdmhcxhfdm/ 提供的数据, // 以年作为单位进行更新,同时也以四位的年份作为版本号。 package version import "fmt" // ErrInvalidYear 无效的年份版本 // // 年份只能介于 [2009, 当前年份-1) 的区间之间。 var ErrInvalidYear = fmt.Errorf("无效的版本号,必须是介于 [%d,%d] 之间的整数", start, latest) // 起始版本号,即提供的数据的起始年份。 const start = 2009 // 最新的有效年份,每次更新数据之后,需要手动更新此值。 var latest = 2023 // All 返回支持的版本号列表 func All() []int { return Range(start, latest) } // IsValid 验证年份是否为一个有效的版本号 func IsValid(year int) bool { return year >= start && year <= latest } // BeginWith 从 begin 开始直到最新年份 func BeginWith(begin int) []int { return Range(begin, latest) } // Range 获取指定范围内的版本号 func Range(begin, end int) []int { if !IsValid(begin) { panic(ErrInvalidYear) } if !IsValid(end) { panic(ErrInvalidYear) } if begin > end { panic(ErrInvalidYear) } years := make([]int, 0, end-begin+1) for year := end; year >= begin; year-- { years = append(years, year) } return years } ================================================ FILE: version/version_test.go ================================================ // SPDX-FileCopyrightText: 2021-2024 caixw // // SPDX-License-Identifier: MIT package version import ( "testing" "github.com/issue9/assert/v4" ) func TestAll(t *testing.T) { a := assert.New(t, false) all := All() // 保证从大到小 a.Equal(all[0], latest). Equal(all[len(all)-1], start) } func TestBeginWith(t *testing.T) { a := assert.New(t, false) list := BeginWith(latest) a.Equal(1, len(list)).Equal(list[0], latest) a.Panic(func() { BeginWith(start - 1) }) }