This commit is contained in:
2026-09-03 17:55:42 +08:00
parent 7c38e84e30
commit c80a3d4a2d
35 changed files with 6795 additions and 597 deletions
+23 -8
View File
@@ -1,6 +1,7 @@
package main
import (
"flag"
"log"
"photowall/internal/config"
"photowall/internal/database"
@@ -16,11 +17,17 @@ import (
)
func main() {
migrateFlag := flag.Bool("migrate", false, "执行数据库迁移与种子数据初始化 (AutoMigrate)")
flag.Parse()
// 1. 加载配置
cfg := config.Load()
log.Printf("[config] port=%s db=%s@%s:%s/%s", cfg.Port, cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName)
if *migrateFlag {
cfg.DBAutoMigrate = true
}
log.Printf("[config] port=%s db=%s@%s:%s/%s auto_migrate=%v", cfg.Port, cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName, cfg.DBAutoMigrate)
// 2. 初始化数据库(连接 + 自动迁移 + 管理员 + 种子数据)
// 2. 初始化数据库(连接 MySQL;仅在开启 auto_migrate 时迁移和播种)
if err := database.Init(); err != nil {
log.Fatalf("[database] init failed: %v", err)
}
@@ -38,9 +45,14 @@ func main() {
// 4. 初始化 Service
configSvc := service.NewConfigService(database.DB)
// 初始化系统默认配置
if err := configSvc.InitDefaults(); err != nil {
log.Printf("[config] init defaults failed: %v", err)
if cfg.DBAutoMigrate {
// 初始化系统默认配置(逐条检测并补齐)
if err := configSvc.InitDefaults(); err != nil {
log.Printf("[config] init defaults failed: %v", err)
}
} else {
// 快速预热配置缓存(单次全量查询,跳过逐条校验)
configSvc.WarmUpCache()
}
authSvc := service.NewAuthService(database.DB, jm, configSvc)
@@ -50,9 +62,12 @@ func main() {
classSvc := service.NewClassService(database.DB, configSvc)
adminSvc := service.NewAdminService(database.DB)
menuSvc := service.NewMenuService(database.DB)
// 初始化默认菜单
if err := menuSvc.InitDefaults(); err != nil {
log.Printf("[menu] init defaults failed: %v", err)
if cfg.DBAutoMigrate {
// 初始化默认菜单(逐条检测并补齐)
if err := menuSvc.InitDefaults(); err != nil {
log.Printf("[menu] init defaults failed: %v", err)
}
}
// 初始化敏感词过滤器
sensitive.Init(database.DB)
+17 -6
View File
@@ -14,12 +14,13 @@ type Config struct {
UploadDir string
// MySQL
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBCharset string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBCharset string
DBAutoMigrate bool
// 平台管理员账号(首次启动自动创建)
AdminUsername string
@@ -48,6 +49,7 @@ func Load() *Config {
DBPassword: getEnv("DB_PASSWORD", "Dfn47yeKpyJfwz8n"),
DBName: getEnv("DB_NAME", "photowall"),
DBCharset: getEnv("DB_CHARSET", "utf8mb4"),
DBAutoMigrate: getEnvBool("DB_AUTO_MIGRATE", false),
AdminUsername: getEnv("ADMIN_USERNAME", "hero920103"),
AdminPassword: getEnv("ADMIN_PASSWORD", "920103"),
// Redis
@@ -80,3 +82,12 @@ func getEnvInt(key string, def int) int {
}
return def
}
func getEnvBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}
+17
View File
@@ -45,6 +45,19 @@ func Init() error {
DB = db
if !cfg.DBAutoMigrate {
log.Println("[database] mysql connected (auto migrate skipped)")
return nil
}
log.Println("[database] running auto migration and seeding...")
// 增量保护:确保花名册新表及 row_count 字段迁移
_ = db.AutoMigrate(&model.ClassRoster{})
if !db.Migrator().HasColumn(&model.Class{}, "row_count") {
_ = db.Migrator().AddColumn(&model.Class{}, "row_count")
}
// 数据迁移:旧版 bcrypt 密码(60字符) 超过新版 md5(32字符) 列宽,
// 在 AutoMigrate 缩列前先清空旧密码,后续 ensureAdmin 会重新设置。
// 注意:salt 列此时可能还不存在,只更新 password_hash。
@@ -55,6 +68,9 @@ func Init() error {
return fmt.Errorf("auto migrate: %w", err)
}
// 历史数据回填:school_types 多学段字段由原单一 school_type 初始化
db.Exec("UPDATE yz_pw_schools SET school_types = school_type WHERE (school_types IS NULL OR school_types = '') AND school_type <> ''")
// 创建平台管理员
if err := ensureAdmin(db); err != nil {
return fmt.Errorf("ensure admin: %w", err)
@@ -79,6 +95,7 @@ func autoMigrate(db *gorm.DB) error {
&model.College{},
&model.Class{},
&model.ClassMember{},
&model.ClassRoster{},
&model.ClassPhoto{},
&model.Invitation{},
&model.AuditLog{},
+29 -15
View File
@@ -10,6 +10,11 @@ import (
// 全球国家/省/区县数据量巨大,此处仅写入代表性示例,平台管理员可通过
// 管理接口批量录入完整数据(或导入民政部/GeoNames 数据源)。
func seedRegions(db *gorm.DB) error {
var count int64
if err := db.Model(&model.Region{}).Limit(1).Count(&count).Error; err == nil && count > 0 {
return nil
}
// 国家
china := upsertRegion(db, model.Region{Name: "中国", NameEn: "China", Level: model.LevelCountry, Code: "CN", SortOrder: 1})
usa := upsertRegion(db, model.Region{Name: "美国", NameEn: "United States", Level: model.LevelCountry, Code: "US", SortOrder: 2})
@@ -23,22 +28,31 @@ func seedRegions(db *gorm.DB) error {
shanghai := upsertRegion(db, model.Region{Name: "上海市", NameEn: "Shanghai", Level: model.LevelProvince, ParentID: china.ID, Code: "310000"})
zhejiang := upsertRegion(db, model.Region{Name: "浙江省", NameEn: "Zhejiang", Level: model.LevelProvince, ParentID: china.ID, Code: "330000"})
// 中国 - 地级市/直辖市市级
nanjing := upsertRegion(db, model.Region{Name: "南京市", NameEn: "Nanjing", Level: model.LevelCity, ParentID: jiangsu.ID, Code: "320100"})
lianyungang := upsertRegion(db, model.Region{Name: "连云港市", NameEn: "Lianyungang", Level: model.LevelCity, ParentID: jiangsu.ID, Code: "320700"})
beijingCity := upsertRegion(db, model.Region{Name: "北京市", NameEn: "Beijing", Level: model.LevelCity, ParentID: beijing.ID, Code: "110100"})
guangzhou := upsertRegion(db, model.Region{Name: "广州市", NameEn: "Guangzhou", Level: model.LevelCity, ParentID: guangdong.ID, Code: "440100"})
shenzhen := upsertRegion(db, model.Region{Name: "深圳市", NameEn: "Shenzhen", Level: model.LevelCity, ParentID: guangdong.ID, Code: "440300"})
shanghaiCity := upsertRegion(db, model.Region{Name: "上海市", NameEn: "Shanghai", Level: model.LevelCity, ParentID: shanghai.ID, Code: "310100"})
hangzhou := upsertRegion(db, model.Region{Name: "杭州市", NameEn: "Hangzhou", Level: model.LevelCity, ParentID: zhejiang.ID, Code: "330100"})
// 中国 - 区县(最低级)
upsertRegion(db, model.Region{Name: "海州区", NameEn: "Haizhou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320706"})
upsertRegion(db, model.Region{Name: "连云区", NameEn: "Lianyun", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320703"})
upsertRegion(db, model.Region{Name: "赣榆区", NameEn: "Ganyu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320707"})
upsertRegion(db, model.Region{Name: "玄武区", NameEn: "Xuanwu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320102"})
upsertRegion(db, model.Region{Name: "鼓楼区", NameEn: "Gulou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320106"})
upsertRegion(db, model.Region{Name: "海淀区", NameEn: "Haidian", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110108"})
upsertRegion(db, model.Region{Name: "朝阳区", NameEn: "Chaoyang", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110105"})
upsertRegion(db, model.Region{Name: "西城区", NameEn: "Xicheng", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110102"})
upsertRegion(db, model.Region{Name: "天河区", NameEn: "Tianhe", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440106"})
upsertRegion(db, model.Region{Name: "南山区", NameEn: "Nanshan", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440305"})
upsertRegion(db, model.Region{Name: "福田区", NameEn: "Futian", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440304"})
upsertRegion(db, model.Region{Name: "浦东新区", NameEn: "Pudong", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310115"})
upsertRegion(db, model.Region{Name: "黄浦区", NameEn: "Huangpu", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310101"})
upsertRegion(db, model.Region{Name: "西湖区", NameEn: "Xihu", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330106"})
upsertRegion(db, model.Region{Name: "滨江区", NameEn: "Binjiang", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330108"})
upsertRegion(db, model.Region{Name: "连云区", NameEn: "Lianyun", Level: model.LevelCounty, ParentID: lianyungang.ID, Code: "320703"})
upsertRegion(db, model.Region{Name: "海州区", NameEn: "Haizhou", Level: model.LevelCounty, ParentID: lianyungang.ID, Code: "320706"})
upsertRegion(db, model.Region{Name: "赣榆区", NameEn: "Ganyu", Level: model.LevelCounty, ParentID: lianyungang.ID, Code: "320707"})
upsertRegion(db, model.Region{Name: "玄武区", NameEn: "Xuanwu", Level: model.LevelCounty, ParentID: nanjing.ID, Code: "320102"})
upsertRegion(db, model.Region{Name: "鼓楼区", NameEn: "Gulou", Level: model.LevelCounty, ParentID: nanjing.ID, Code: "320106"})
upsertRegion(db, model.Region{Name: "海淀区", NameEn: "Haidian", Level: model.LevelCounty, ParentID: beijingCity.ID, Code: "110108"})
upsertRegion(db, model.Region{Name: "朝阳区", NameEn: "Chaoyang", Level: model.LevelCounty, ParentID: beijingCity.ID, Code: "110105"})
upsertRegion(db, model.Region{Name: "西城区", NameEn: "Xicheng", Level: model.LevelCounty, ParentID: beijingCity.ID, Code: "110102"})
upsertRegion(db, model.Region{Name: "天河区", NameEn: "Tianhe", Level: model.LevelCounty, ParentID: guangzhou.ID, Code: "440106"})
upsertRegion(db, model.Region{Name: "南山区", NameEn: "Nanshan", Level: model.LevelCounty, ParentID: shenzhen.ID, Code: "440305"})
upsertRegion(db, model.Region{Name: "福田区", NameEn: "Futian", Level: model.LevelCounty, ParentID: shenzhen.ID, Code: "440304"})
upsertRegion(db, model.Region{Name: "浦东新区", NameEn: "Pudong", Level: model.LevelCounty, ParentID: shanghaiCity.ID, Code: "310115"})
upsertRegion(db, model.Region{Name: "黄浦区", NameEn: "Huangpu", Level: model.LevelCounty, ParentID: shanghaiCity.ID, Code: "310101"})
upsertRegion(db, model.Region{Name: "西湖区", NameEn: "Xihu", Level: model.LevelCounty, ParentID: hangzhou.ID, Code: "330106"})
upsertRegion(db, model.Region{Name: "滨江区", NameEn: "Binjiang", Level: model.LevelCounty, ParentID: hangzhou.ID, Code: "330108"})
// 美国 - 州
california := upsertRegion(db, model.Region{Name: "加利福尼亚州", NameEn: "California", Level: model.LevelProvince, ParentID: usa.ID, Code: "CA"})
+17 -1
View File
@@ -104,7 +104,11 @@ func (h *AdminHandler) Delete(c *gin.Context) {
// AuditLogs GET /api/admin/classes/:id/logs
func (h *AdminHandler) AuditLogs(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的班级ID")
return
}
logs, err := h.svc.AuditLogs(uint(id))
if err != nil {
response.Internal(c, err.Error())
@@ -154,6 +158,18 @@ func (h *AdminHandler) UnbanUser(c *gin.Context) {
response.OK(c, gin.H{"message": "已解封"})
}
// ListUsers GET /api/admin/users 用户列表(支持状态与搜索)
func (h *AdminHandler) ListUsers(c *gin.Context) {
status := c.Query("status")
keyword := c.Query("keyword")
list, err := h.svc.ListUsers(status, keyword)
if err != nil {
response.Internal(c, err.Error())
return
}
response.OK(c, list)
}
// BannedUsers GET /api/admin/users/banned 封禁用户列表
func (h *AdminHandler) BannedUsers(c *gin.Context) {
list, err := h.svc.ListBannedUsers()
+5 -1
View File
@@ -35,7 +35,11 @@ func (h *ClassHandler) Create(c *gin.Context) {
// Detail GET /api/classes/:id
func (h *ClassHandler) Detail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的班级ID")
return
}
userID := middleware.CurrentUserID(c)
isSuperAdmin := middleware.CurrentRole(c) == "admin"
detail, err := h.svc.GetDetail(uint(id), userID, isSuperAdmin)
+40 -14
View File
@@ -6,6 +6,7 @@ import (
"photowall/internal/service"
"photowall/pkg/response"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
@@ -29,14 +30,14 @@ func (h *MenuHandler) GetUserMenus(c *gin.Context) {
response.OK(c, tree)
}
// GetAll GET /api/admin/menus 获取所有菜单(管理用)
// GetAll GET /api/admin/menus 获取所有菜单树结构(管理用)
func (h *MenuHandler) GetAll(c *gin.Context) {
list, err := h.menu.GetAll()
tree, err := h.menu.GetAllTree()
if err != nil {
response.Internal(c, "获取菜单失败")
response.Internal(c, "获取菜单失败: "+err.Error())
return
}
response.OK(c, list)
response.OK(c, tree)
}
// Create POST /api/admin/menus 创建菜单
@@ -46,34 +47,59 @@ func (h *MenuHandler) Create(c *gin.Context) {
response.BadRequest(c, "参数错误:"+err.Error())
return
}
if strings.TrimSpace(m.Name) == "" {
response.BadRequest(c, "路由名称(name)不能为空")
return
}
if strings.TrimSpace(m.Title) == "" {
response.BadRequest(c, "菜单标题(title)不能为空")
return
}
if err := h.menu.Create(&m); err != nil {
response.Internal(c, "创建菜单失败")
response.Internal(c, "创建菜单失败: "+err.Error())
return
}
response.Created(c, m)
}
// Update PUT /api/admin/menus/:id 更新菜单
// Update PUT /api/admin/menus/:id 更新菜单(支持全量或部分字段更新)
func (h *MenuHandler) Update(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var m model.Menu
if err := c.ShouldBindJSON(&m); err != nil {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的菜单ID")
return
}
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, "参数错误:"+err.Error())
return
}
m.ID = uint(id)
if err := h.menu.Update(&m); err != nil {
response.Internal(c, "更新菜单失败")
// 兼容 sort / sort_order
if v, ok := body["sort_order"]; ok {
body["sort"] = v
delete(body, "sort_order")
}
delete(body, "id")
delete(body, "children") // 过滤可能携带的子项数组,避免 GORM 尝试保存未知列
delete(body, "created_at")
delete(body, "updated_at")
if err := h.menu.UpdateFields(uint(id), body); err != nil {
response.Internal(c, "更新菜单失败: "+err.Error())
return
}
response.OK(c, m)
response.OK(c, gin.H{"message": "更新成功"})
}
// Delete DELETE /api/admin/menus/:id 删除菜单
func (h *MenuHandler) Delete(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id == 0 {
response.BadRequest(c, "无效的菜单ID")
return
}
if err := h.menu.Delete(uint(id)); err != nil {
response.Internal(c, "删除菜单失败")
response.Internal(c, "删除菜单失败: "+err.Error())
return
}
response.OK(c, gin.H{"message": "删除成功"})
+98 -4
View File
@@ -1,6 +1,7 @@
package handler
import (
"photowall/internal/model"
"photowall/internal/service"
"photowall/pkg/response"
"strconv"
@@ -16,10 +17,11 @@ func NewRegionHandler(svc *service.RegionService) *RegionHandler {
return &RegionHandler{svc: svc}
}
// List GET /api/regions?parent_id=0
// List GET /api/regions?parent_id=0&level=city
func (h *RegionHandler) List(c *gin.Context) {
parentID, _ := strconv.ParseUint(c.DefaultQuery("parent_id", "0"), 10, 64)
list, err := h.svc.ListByParent(uint(parentID))
level := c.Query("level")
list, err := h.svc.ListByParent(uint(parentID), model.RegionLevel(level))
if err != nil {
response.Internal(c, err.Error())
return
@@ -39,7 +41,11 @@ func (h *RegionHandler) Countries(c *gin.Context) {
// Path GET /api/regions/:id/path
func (h *RegionHandler) Path(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的地区ID")
return
}
path, err := h.svc.GetPath(uint(id))
if err != nil {
response.NotFound(c, "地区不存在")
@@ -55,10 +61,98 @@ func (h *RegionHandler) Search(c *gin.Context) {
response.BadRequest(c, "请输入搜索关键词")
return
}
list, err := h.svc.Search(keyword, 20)
list, err := h.svc.Search(keyword, 50)
if err != nil {
response.Internal(c, err.Error())
return
}
response.OK(c, list)
}
// Tree GET /api/admin/regions/tree
func (h *RegionHandler) Tree(c *gin.Context) {
parentID, _ := strconv.ParseUint(c.DefaultQuery("parent_id", "0"), 10, 64)
tree, err := h.svc.GetTree(uint(parentID))
if err != nil {
response.Internal(c, err.Error())
return
}
response.OK(c, tree)
}
// Create POST /api/admin/regions
func (h *RegionHandler) Create(c *gin.Context) {
var r model.Region
if err := c.ShouldBindJSON(&r); err != nil {
response.BadRequest(c, "参数错误:"+err.Error())
return
}
if err := h.svc.Create(&r); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, r)
}
// Update PUT /api/admin/regions/:id
func (h *RegionHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的地区ID")
return
}
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, "参数错误:"+err.Error())
return
}
updates := map[string]interface{}{}
if v, ok := body["name"]; ok {
updates["name"] = v
}
if v, ok := body["name_en"]; ok {
updates["name_en"] = v
}
if v, ok := body["level"]; ok {
updates["level"] = v
}
if v, ok := body["code"]; ok {
updates["code"] = v
}
if v, ok := body["sort_order"]; ok {
updates["sort_order"] = v
}
if err := h.svc.Update(uint(id), updates); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"message": "更新成功"})
}
// Delete DELETE /api/admin/regions/:id
func (h *RegionHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的地区ID")
return
}
if err := h.svc.Delete(uint(id)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"message": "删除成功"})
}
// InitChina POST /api/admin/regions/init-china
func (h *RegionHandler) InitChina(c *gin.Context) {
created, total, err := h.svc.InitChinaRegions()
if err != nil {
response.Internal(c, "初始化失败: "+err.Error())
return
}
response.OK(c, gin.H{
"message": "初始化中国行政区划成功",
"created_count": created,
"total_count": total,
})
}
+107 -7
View File
@@ -1,10 +1,12 @@
package handler
import (
"photowall/internal/middleware"
"photowall/internal/model"
"photowall/internal/service"
"photowall/pkg/response"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
@@ -17,7 +19,7 @@ func NewSchoolHandler(svc *service.SchoolService) *SchoolHandler {
return &SchoolHandler{svc: svc}
}
// List GET /api/schools
// List GET /api/schools or /api/admin/schools
func (h *SchoolHandler) List(c *gin.Context) {
var q service.SchoolQuery
if err := c.ShouldBindQuery(&q); err != nil {
@@ -32,13 +34,51 @@ func (h *SchoolHandler) List(c *gin.Context) {
response.OK(c, result)
}
// Create POST /api/schools
// Stats GET /api/admin/schools/stats
func (h *SchoolHandler) Stats(c *gin.Context) {
stats, err := h.svc.Stats()
if err != nil {
response.Internal(c, err.Error())
return
}
response.OK(c, stats)
}
// Create POST /api/schools or /api/admin/schools
func (h *SchoolHandler) Create(c *gin.Context) {
var school model.School
if err := c.ShouldBindJSON(&school); err != nil {
var body struct {
RegionID uint `json:"region_id"`
Name string `json:"name"`
NameEn string `json:"name_en"`
SchoolType model.SchoolType `json:"school_type"` // 兼容旧的单选提交
SchoolTypes []string `json:"school_types"` // 新的多选提交
Address string `json:"address"`
Status model.SchoolStatus `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, "参数错误")
return
}
types := model.NormalizeSchoolTypes(body.SchoolTypes)
if len(types) == 0 && body.SchoolType != "" {
types = model.NormalizeSchoolTypes([]string{string(body.SchoolType)})
}
if len(types) == 0 {
response.BadRequest(c, "请选择学校类型")
return
}
school := model.School{
RegionID: body.RegionID,
Name: strings.TrimSpace(body.Name),
NameEn: strings.TrimSpace(body.NameEn),
SchoolType: types[0],
SchoolTypes: model.JoinSchoolTypes(types),
Address: strings.TrimSpace(body.Address),
Status: body.Status,
}
if uid := middleware.CurrentUserID(c); uid > 0 {
school.CreatedBy = uid
}
if err := h.svc.Create(&school); err != nil {
response.BadRequest(c, err.Error())
return
@@ -67,7 +107,6 @@ func (h *SchoolHandler) Update(c *gin.Context) {
response.BadRequest(c, "参数错误")
return
}
// 只允许更新部分字段
updates := map[string]interface{}{}
if v, ok := body["name"]; ok {
updates["name"] = v
@@ -75,12 +114,38 @@ func (h *SchoolHandler) Update(c *gin.Context) {
if v, ok := body["name_en"]; ok {
updates["name_en"] = v
}
if v, ok := body["school_type"]; ok {
updates["school_type"] = v
// 多学段:优先取数组形式的 school_types
if v, ok := body["school_types"]; ok {
switch raw := v.(type) {
case []interface{}:
items := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok {
items = append(items, s)
}
}
updates["school_types"] = model.JoinSchoolTypes(model.NormalizeSchoolTypes(items))
case string:
updates["school_types"] = raw
}
} else if v, ok := body["school_type"]; ok {
// 兼容旧的单选提交:同步写入两个字段
if s, ok := v.(string); ok {
updates["school_types"] = s
}
}
if v, ok := body["address"]; ok {
updates["address"] = v
}
if v, ok := body["region_id"]; ok {
updates["region_id"] = v
}
if v, ok := body["status"]; ok {
updates["status"] = v
}
if v, ok := body["reject_reason"]; ok {
updates["reject_reason"] = v
}
if err := h.svc.Update(uint(id), updates); err != nil {
response.BadRequest(c, err.Error())
return
@@ -88,6 +153,41 @@ func (h *SchoolHandler) Update(c *gin.Context) {
response.OK(c, gin.H{"message": "更新成功"})
}
// Approve POST /api/admin/schools/:id/approve
func (h *SchoolHandler) Approve(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的学校ID")
return
}
if err := h.svc.Approve(uint(id)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"message": "审核通过"})
}
// Reject POST /api/admin/schools/:id/reject
func (h *SchoolHandler) Reject(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "无效的学校ID")
return
}
var body struct {
Reason string `json:"reason" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Reason) == "" {
response.BadRequest(c, "请输入打回原因")
return
}
if err := h.svc.Reject(uint(id), strings.TrimSpace(body.Reason)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"message": "打回成功"})
}
// Delete DELETE /api/schools/:id
func (h *SchoolHandler) Delete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+17 -2
View File
@@ -38,10 +38,15 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
return
}
if err := h.svc.UpdateProfile(userID, &req); err != nil {
response.Internal(c, err.Error())
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"message": "更新成功"})
u, err := h.svc.GetUserByID(userID)
if err != nil {
response.OK(c, gin.H{"message": "更新成功"})
return
}
response.OK(c, u)
}
// AddContact POST /api/user/contacts
@@ -149,11 +154,21 @@ func (h *UserHandler) HasEducation(c *gin.Context) {
// PublicProfile GET /api/user/:id 公开信息(班级成员查看)
func (h *UserHandler) PublicProfile(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
currentUserID := middleware.CurrentUserID(c)
user, err := h.svc.GetUserByID(uint(id))
if err != nil {
response.NotFound(c, err.Error())
return
}
// 隐私脱敏保护:非本人且非管理员,对身份证号码脱敏
isAdmin := middleware.CurrentRole(c) == "admin"
if currentUserID != user.ID && !isAdmin {
if len(user.IdCard) == 18 {
user.IdCard = user.IdCard[:6] + "********" + user.IdCard[14:]
} else if user.IdCard != "" {
user.IdCard = "****************"
}
}
contacts := h.svc.GetUserContacts(uint(id))
response.OK(c, gin.H{"user": user, "contacts": contacts})
}
+16
View File
@@ -27,6 +27,7 @@ type Class struct {
GraduateYear int `gorm:"index:idx_class_unique,priority:5" json:"graduate_year"` // 毕业年份
AdminUserID uint `gorm:"index;not null" json:"admin_user_id"`
GraduationPhoto string `gorm:"size:512" json:"graduation_photo"` // 毕业照URL(每个班级唯一一张)
RowCount int `gorm:"default:1" json:"row_count"` // 毕业照排数
StudentNamesJSON string `gorm:"type:text" json:"-"` // 班级所有同学名字 JSON 数组
TeacherNamesJSON string `gorm:"type:text" json:"-"` // 老师名字 JSON 数组
Status ClassStatus `gorm:"size:16;default:pending;index" json:"status"`
@@ -114,3 +115,18 @@ type Invitation struct {
Status string `gorm:"size:16;default:active" json:"status"` // active / disabled
CreatedAt time.Time `json:"created_at"`
}
// ClassRoster 班级毕业照排位花名册(预录入人员,每个同学/老师单独一行存储,支持后续注册后绑定具体账户)
type ClassRoster struct {
ID uint `gorm:"primaryKey" json:"id"`
ClassID uint `gorm:"index:idx_class_roster;not null" json:"class_id"`
RowNo int `gorm:"index:idx_class_roster;not null" json:"row_no"` // 排号(从1开始,第1排为前排)
ColNo int `gorm:"not null" json:"col_no"` // 排内序号(从1开始,从左往右)
Name string `gorm:"size:64;not null;index" json:"name"` // 姓名
Role string `gorm:"size:16;default:student;index" json:"role"` // 身份:student(同学) / teacher(老师)
UserID uint `gorm:"index;default:0" json:"user_id"` // 绑定的系统用户ID(0表示未注册/未绑定账户)
IsClaimed bool `gorm:"default:false;index" json:"is_claimed"` // 是否已被具体账户绑定认领
ClaimedAt *time.Time `json:"claimed_at,omitempty"` // 绑定认领时间
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+157 -17
View File
@@ -1,17 +1,23 @@
package model
import "time"
import (
"encoding/json"
"sort"
"strings"
"time"
)
// RegionLevel 地区层级
type RegionLevel string
const (
LevelCountry RegionLevel = "country"
LevelProvince RegionLevel = "province" // 省/州/直辖市
LevelCounty RegionLevel = "county" // 县/区(最低级)
LevelProvince RegionLevel = "province" // 省/州/直辖市/自治区
LevelCity RegionLevel = "city" // 市/地区/自治州/盟
LevelCounty RegionLevel = "county" // 县/区/旗/县级市
)
// Region 地区表,自关联三级:国家 -> 省/州 -> 县/区
// Region 地区表,自关联多级:国家 -> 省/州 -> 市 -> 县/区
type Region struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null;index" json:"name"`
@@ -23,19 +29,53 @@ type Region struct {
CreatedAt time.Time `json:"created_at"`
}
// RegionTree 地区树节点
type RegionTree struct {
Region
Children []*RegionTree `json:"children,omitempty"`
}
// SchoolStatus 学校审核状态
type SchoolStatus string
const (
SchoolStatusPending SchoolStatus = "pending" // 待审核
SchoolStatusActive SchoolStatus = "active" // 正常
SchoolStatusRejected SchoolStatus = "rejected" // 已打回
)
// SchoolType 学校类型
type SchoolType string
const (
SchoolUniversity SchoolType = "university" // 大学(含学院层级)
SchoolJuniorHigh SchoolType = "junior_high" // 初中
SchoolSeniorHigh SchoolType = "senior_high" // 高中
SchoolPrimary SchoolType = "primary" // 小学
SchoolKindergarten SchoolType = "kindergarten" // 幼儿园
SchoolVocational SchoolType = "vocational" // 职业学校/中专(也可设学院)
SchoolUniversity SchoolType = "university" // 大学(含学院层级)
SchoolJuniorHigh SchoolType = "junior_high" // 初中
SchoolSeniorHigh SchoolType = "senior_high" // 高中
SchoolPrimary SchoolType = "primary" // 小学
SchoolKindergarten SchoolType = "kindergarten" // 幼儿园
SchoolVocational SchoolType = "vocational" // 职业学校/中专(也可设学院)
SchoolOther SchoolType = "other"
)
// SchoolTypeOrder 学校类型的展示与主类型优先级(数值越小优先级越高)
// 一所学校可同时涵盖多个学段(如完全中学 = 初中 + 高中),
// 主类型 school_type 取其中优先级最高的一个,用于兼容旧逻辑与统计。
var SchoolTypeOrder = map[SchoolType]int{
SchoolUniversity: 0,
SchoolVocational: 1,
SchoolSeniorHigh: 2,
SchoolJuniorHigh: 3,
SchoolPrimary: 4,
SchoolKindergarten: 5,
SchoolOther: 6,
}
// IsValid 是否为已定义的学校类型
func (s SchoolType) IsValid() bool {
_, ok := SchoolTypeOrder[s]
return ok
}
// HasCollege 该类型学校是否有学院层级
func (s SchoolType) HasCollege() bool {
return s == SchoolUniversity || s == SchoolVocational
@@ -43,14 +83,114 @@ func (s SchoolType) HasCollege() bool {
// School 学校
type School struct {
ID uint `gorm:"primaryKey" json:"id"`
RegionID uint `gorm:"index;not null" json:"region_id"` // 必须挂在 county 级地区下
Name string `gorm:"size:128;not null;index" json:"name"`
NameEn string `gorm:"size:128" json:"name_en"`
ID uint `gorm:"primaryKey" json:"id"`
RegionID uint `gorm:"index;not null" json:"region_id"` // 挂在 county 或 city 级地区下
Name string `gorm:"size:128;not null;index" json:"name"`
NameEn string `gorm:"size:128" json:"name_en"`
// SchoolType 主类型:SchoolTypes 中优先级最高的一个,保留用于兼容旧数据与统计
SchoolType SchoolType `gorm:"size:24;index;not null" json:"school_type"`
Address string `gorm:"size:255" json:"address"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// SchoolTypes 全部涵盖的学段,英文逗号分隔且无空格(如 "junior_high,senior_high"),
// 便于使用 MySQL FIND_IN_SET 检索
SchoolTypes string `gorm:"size:128;index" json:"school_types_raw"`
Address string `gorm:"size:255" json:"address"`
Status SchoolStatus `gorm:"size:16;default:active;index" json:"status"` // pending, active, rejected
RejectReason string `gorm:"size:255" json:"reject_reason"`
CreatedBy uint `gorm:"default:0;index" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 关联字段(非数据库持久化)
RegionPath string `gorm:"-" json:"region_path,omitempty"`
}
// TypeList 返回学校涵盖的全部学段;旧数据未写 school_types 时回退到主类型
func (s *School) TypeList() []SchoolType {
list := ParseSchoolTypes(s.SchoolTypes)
if len(list) == 0 && s.SchoolType != "" {
return []SchoolType{s.SchoolType}
}
return list
}
// HasType 学校是否涵盖指定学段
func (s *School) HasType(t SchoolType) bool {
for _, item := range s.TypeList() {
if item == t {
return true
}
}
return false
}
// HasCollegeType 学校是否存在支持学院层级的学段(大学/职业院校)
func (s *School) HasCollegeType() bool {
for _, item := range s.TypeList() {
if item.HasCollege() {
return true
}
}
return false
}
// RequiresCollege 建班时是否必须选择学院:仅当所有学段都支持学院层级
// (例如"大学 + 高中"的学校,走高中学段建班时无需选学院)
func (s *School) RequiresCollege() bool {
list := s.TypeList()
if len(list) == 0 {
return false
}
for _, item := range list {
if !item.HasCollege() {
return false
}
}
return true
}
// MarshalJSON 输出时附带 school_types 数组,前端可直接使用
func (s School) MarshalJSON() ([]byte, error) {
type schoolAlias School
list := (&s).TypeList()
strs := make([]string, 0, len(list))
for _, item := range list {
strs = append(strs, string(item))
}
return json.Marshal(struct {
schoolAlias
SchoolTypeList []string `json:"school_types"`
}{schoolAlias(s), strs})
}
// ParseSchoolTypes 解析逗号分隔的学校类型,去重并按优先级排序
func ParseSchoolTypes(raw string) []SchoolType {
return NormalizeSchoolTypes(strings.Split(raw, ","))
}
// NormalizeSchoolTypes 校验、去重并按优先级排序学校类型集合
func NormalizeSchoolTypes(items []string) []SchoolType {
seen := map[SchoolType]bool{}
res := make([]SchoolType, 0, len(items))
for _, item := range items {
t := SchoolType(strings.TrimSpace(item))
if t == "" || !t.IsValid() || seen[t] {
continue
}
seen[t] = true
res = append(res, t)
}
sort.SliceStable(res, func(i, j int) bool {
return SchoolTypeOrder[res[i]] < SchoolTypeOrder[res[j]]
})
return res
}
// JoinSchoolTypes 将类型集合拼为可供 FIND_IN_SET 使用的字符串
func JoinSchoolTypes(items []SchoolType) string {
strs := make([]string, 0, len(items))
for _, item := range items {
strs = append(strs, string(item))
}
return strings.Join(strs, ",")
}
// College 学院(仅大学/职业学校有)
+21 -14
View File
@@ -6,20 +6,27 @@ import (
// User 用户表
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:32;not null" json:"-"` // MD5 32字符
Salt string `gorm:"size:32;not null" json:"-"` // 密码盐
Email string `gorm:"size:128;index" json:"email"` // 用于找回密码
Nickname string `gorm:"size:64" json:"nickname"`
Avatar string `gorm:"size:512" json:"avatar"`
Bio string `gorm:"size:512" json:"bio"`
Role string `gorm:"size:16;default:user;index" json:"role"` // user / admin
Status string `gorm:"size:16;default:active;index" json:"status"` // active / banned
BanReason string `gorm:"size:255" json:"ban_reason"`
BannedAt *time.Time `json:"banned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:32;not null" json:"-"` // MD5 32字符
Salt string `gorm:"size:32;not null" json:"-"` // 密码盐
Email string `gorm:"size:128;index" json:"email"` // 用于找回密码
Phone string `gorm:"size:32" json:"phone"` // 联系电话
RealName string `gorm:"size:64" json:"real_name"` // 真实姓名
Gender string `gorm:"size:16" json:"gender"` // 性别:男 / 女
Nation string `gorm:"size:32" json:"nation"` // 民族
PoliticalStatus string `gorm:"size:32" json:"political_status"` // 政治身份/政治面貌
IdCard string `gorm:"size:32" json:"id_card"` // 身份证号
Address string `gorm:"size:255" json:"address"` // 家庭住址
Nickname string `gorm:"size:64" json:"nickname"`
Avatar string `gorm:"size:512" json:"avatar"`
Bio string `gorm:"size:512" json:"bio"`
Role string `gorm:"size:16;default:user;index" json:"role"` // user / admin
Status string `gorm:"size:16;default:active;index" json:"status"` // active / banned
BanReason string `gorm:"size:255" json:"ban_reason"`
BannedAt *time.Time `json:"banned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ContactType 联系方式类型
+21 -1
View File
@@ -133,6 +133,25 @@ func Register(r *gin.Engine, jm *jwt.Manager, deps *Deps) {
admin.DELETE("/classes/:id", deps.Admin.Delete)
admin.GET("/classes/:id/logs", deps.Admin.AuditLogs)
admin.POST("/cleanup", deps.Admin.TriggerCleanup)
// 基础数据 - 行政区划管理
admin.GET("/regions/tree", deps.Region.Tree)
admin.POST("/regions", deps.Region.Create)
admin.PUT("/regions/:id", deps.Region.Update)
admin.DELETE("/regions/:id", deps.Region.Delete)
admin.POST("/regions/init-china", deps.Region.InitChina)
// 基础数据 - 学校管理与审核
admin.GET("/schools/stats", deps.School.Stats)
admin.GET("/schools", deps.School.List)
admin.POST("/schools", deps.School.Create)
admin.PUT("/schools/:id", deps.School.Update)
admin.DELETE("/schools/:id", deps.School.Delete)
admin.POST("/schools/:id/approve", deps.School.Approve)
admin.POST("/schools/:id/reject", deps.School.Reject)
admin.POST("/schools/:id/colleges", deps.School.CreateCollege)
admin.PUT("/colleges/:id", deps.School.UpdateCollege)
admin.DELETE("/colleges/:id", deps.School.DeleteCollege)
// 系统配置管理
admin.GET("/config", deps.Config.GetAll)
admin.PUT("/config", deps.Config.Update)
@@ -141,7 +160,8 @@ func Register(r *gin.Engine, jm *jwt.Manager, deps *Deps) {
admin.POST("/menus", deps.Menu.Create)
admin.PUT("/menus/:id", deps.Menu.Update)
admin.DELETE("/menus/:id", deps.Menu.Delete)
// 账号封禁管理
// 用户管理与账号封禁
admin.GET("/users", deps.Admin.ListUsers)
admin.GET("/users/banned", deps.Admin.BannedUsers)
admin.POST("/users/:id/ban", deps.Admin.BanUser)
admin.POST("/users/:id/unban", deps.Admin.UnbanUser)
+35 -12
View File
@@ -226,20 +226,28 @@ func (s *AdminService) DetailedStats() (*DetailedStats, error) {
{Name: "被打回", Count: core.RejectedClasses},
}
// 学校类型分布
type schoolTypeRow struct {
SchoolType string `gorm:"column:school_type"`
Cnt int64 `gorm:"column:cnt"`
// 学校类型分布(一所学校可涵盖多个学段,按学段分别计数)
typeMap := map[model.SchoolType]string{
model.SchoolUniversity: "大学",
model.SchoolVocational: "职业学校",
model.SchoolSeniorHigh: "高中",
model.SchoolJuniorHigh: "初中",
model.SchoolPrimary: "小学",
model.SchoolKindergarten: "幼儿园",
model.SchoolOther: "其他",
}
var stRows []schoolTypeRow
s.db.Model(&model.School{}).Select("school_type, COUNT(*) as cnt").Group("school_type").Scan(&stRows)
typeMap := map[string]string{"university": "大学", "vocational": "职业学校", "senior_high": "高中", "junior_high": "初中", "primary": "小学", "kindergarten": "幼儿园", "other": "其他"}
for _, r := range stRows {
name := typeMap[r.SchoolType]
if name == "" {
name = r.SchoolType
orderedTypes := []model.SchoolType{
model.SchoolUniversity, model.SchoolVocational, model.SchoolSeniorHigh,
model.SchoolJuniorHigh, model.SchoolPrimary, model.SchoolKindergarten, model.SchoolOther,
}
for _, t := range orderedTypes {
var cnt int64
s.db.Model(&model.School{}).
Where("FIND_IN_SET(?, school_types) > 0 OR ((school_types IS NULL OR school_types = '') AND school_type = ?)", t, t).
Count(&cnt)
if cnt > 0 {
ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: typeMap[t], Count: cnt})
}
ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: name, Count: r.Cnt})
}
// 地区TOP10(按省级行政区统计班级数)
@@ -347,6 +355,21 @@ func (s *AdminService) UnbanUser(userID uint) error {
return s.db.Save(&user).Error
}
// ListUsers 获取用户列表(支持状态与关键词检索)
func (s *AdminService) ListUsers(status, keyword string) ([]model.User, error) {
var list []model.User
query := s.db.Model(&model.User{})
if status != "" && status != "all" {
query = query.Where("status = ?", status)
}
if keyword != "" {
k := "%" + keyword + "%"
query = query.Where("username LIKE ? OR real_name LIKE ? OR nickname LIKE ? OR email LIKE ? OR phone LIKE ?", k, k, k, k, k)
}
err := query.Order("id desc").Find(&list).Error
return list, err
}
// ListBannedUsers 获取封禁用户列表
func (s *AdminService) ListBannedUsers() ([]model.User, error) {
var list []model.User
+576
View File
@@ -0,0 +1,576 @@
package service
type ChinaCityData struct {
Name string
Code string
Counties []string
}
type ChinaProvinceData struct {
Name string
Code string
Cities []ChinaCityData
}
var chinaProvincesData = []ChinaProvinceData{
// 4 直辖市
{
Name: "北京市", Code: "110000",
Cities: []ChinaCityData{
{
Name: "北京市", Code: "110100",
Counties: []string{"东城区", "西城区", "朝阳区", "丰台区", "石景山区", "海淀区", "门头沟区", "房山区", "通州区", "顺义区", "昌平区", "大兴区", "怀柔区", "平谷区", "密云区", "延庆区"},
},
},
},
{
Name: "天津市", Code: "120000",
Cities: []ChinaCityData{
{
Name: "天津市", Code: "120100",
Counties: []string{"和平区", "河东区", "河西区", "南开区", "河北区", "红桥区", "东丽区", "西青区", "津南区", "北辰区", "武清区", "宝坻区", "滨海新区", "宁河区", "静海区", "蓟州区"},
},
},
},
{
Name: "上海市", Code: "310000",
Cities: []ChinaCityData{
{
Name: "上海市", Code: "310100",
Counties: []string{"黄浦区", "徐汇区", "长宁区", "静安区", "普陀区", "虹口区", "杨浦区", "闵行区", "宝山区", "嘉定区", "浦东新区", "金山区", "松江区", "青浦区", "奉贤区", "崇明区"},
},
},
},
{
Name: "重庆市", Code: "500000",
Cities: []ChinaCityData{
{
Name: "重庆市", Code: "500100",
Counties: []string{
"渝中区", "大渡口区", "江北区", "沙坪坝区", "九龙坡区", "南岸区", "北碚区", "渝北区", "巴南区",
"万州区", "涪陵区", "永川区", "合川区", "江津区", "璧山区", "铜梁区", "潼南区", "荣昌区",
"开州区", "梁平区", "武隆区", "城口县", "丰都县", "垫江县", "忠县", "云阳县", "奉节县", "巫山县", "巫溪县",
},
},
},
},
// 华北
{
Name: "河北省", Code: "130000",
Cities: []ChinaCityData{
{Name: "石家庄市", Code: "130100", Counties: []string{"长安区", "桥西区", "新华区", "井陉矿区", "裕华区", "藁城区", "鹿泉区", "栾城区", "正定县", "辛集市", "晋州市", "新乐市"}},
{Name: "唐山市", Code: "130200", Counties: []string{"路南区", "路北区", "古冶区", "开平区", "丰南区", "丰润区", "曹妃甸区", "滦州市", "迁安市", "遵化市"}},
{Name: "秦皇岛市", Code: "130300", Counties: []string{"海港区", "山海关区", "北戴河区", "抚宁区", "青龙县", "昌黎县", "卢龙县"}},
{Name: "邯郸市", Code: "130400", Counties: []string{"邯山区", "丛台区", "复兴区", "峰峰矿区", "肥乡区", "永年区", "武安市"}},
{Name: "邢台市", Code: "130500", Counties: []string{"襄都区", "信都区", "任泽区", "南和区", "南宫市", "沙河市"}},
{Name: "保定市", Code: "130600", Counties: []string{"竞秀区", "莲池区", "满城区", "清苑区", "徐水区", "涿州市", "定州市", "安国市", "高碑店市"}},
{Name: "张家口市", Code: "130700", Counties: []string{"桥东区", "桥西区", "宣化区", "下花园区", "万全区", "崇礼区"}},
{Name: "承德市", Code: "130800", Counties: []string{"双桥区", "双滦区", "鹰手营子矿区", "承德县", "兴隆县", "滦平县", "隆化县", "丰宁县", "宽城县", "围场县", "平泉市"}},
{Name: "沧州市", Code: "130900", Counties: []string{"新华区", "运河区", "沧县", "青县", "东光县", "海兴县", "盐山县", "肃宁县", "南皮县", "吴桥县", "献县", "孟村县", "泊头市", "任丘市", "黄骅市", "河间市"}},
{Name: "廊坊市", Code: "131000", Counties: []string{"安次区", "广阳区", "固安县", "永清县", "香河县", "大城县", "文安县", "大厂县", "霸州市", "三河市"}},
{Name: "衡水市", Code: "131100", Counties: []string{"桃城区", "冀州区", "枣强县", "武邑县", "武强县", "饶阳县", "安平县", "故城县", "景县", "阜城县", "深州市"}},
},
},
{
Name: "山西省", Code: "140000",
Cities: []ChinaCityData{
{Name: "太原市", Code: "140100", Counties: []string{"小店区", "迎泽区", "杏花岭区", "尖草坪区", "万柏林区", "晋源区", "清徐县", "阳曲县", "娄烦县", "古交市"}},
{Name: "大同市", Code: "140200", Counties: []string{"新荣区", "平城区", "云冈区", "云州区", "阳高县", "天镇县", "广灵县", "灵丘县", "浑源县", "左云县"}},
{Name: "阳泉市", Code: "140300", Counties: []string{"城区", "矿区", "郊区", "平定县", "盂县"}},
{Name: "长治市", Code: "140400", Counties: []string{"潞州区", "上党区", "屯留区", "潞城区", "襄垣县", "平顺县", "黎城县", "壶关县", "长子县", "武乡县", "沁县", "沁源县"}},
{Name: "晋城市", Code: "140500", Counties: []string{"城区", "沁水县", "阳城县", "陵川县", "泽州县", "高平市"}},
{Name: "朔州市", Code: "140600", Counties: []string{"朔城区", "平鲁区", "山阴县", "应县", "右玉县", "怀仁市"}},
{Name: "晋中市", Code: "140700", Counties: []string{"榆次区", "太谷区", "榆社县", "左权县", "和顺县", "昔阳县", "寿阳县", "祁县", "平遥县", "灵石县", "介休市"}},
{Name: "运城市", Code: "140800", Counties: []string{"盐湖区", "临猗县", "万荣县", "闻喜县", "稷山县", "新绛县", "绛县", "垣曲县", "夏县", "平陆县", "芮城县", "永济市", "河津市"}},
{Name: "忻州市", Code: "140900", Counties: []string{"忻府区", "定襄县", "五台县", "代县", "繁峙县", "宁武县", "静乐县", "神池县", "五寨县", "岢岚县", "河曲县", "保德县", "偏关县", "原平市"}},
{Name: "临汾市", Code: "141000", Counties: []string{"尧都区", "曲沃县", "翼城县", "襄汾县", "洪洞县", "古县", "安泽县", "浮山县", "吉县", "乡宁县", "大宁县", "隰县", "永和县", "蒲县", "汾西县", "侯马市", "霍州市"}},
{Name: "吕梁市", Code: "141100", Counties: []string{"离石区", "文水县", "交城县", "兴县", "临县", "柳林县", "石楼县", "岚县", "方山县", "中阳县", "交口县", "孝义市", "汾阳市"}},
},
},
{
Name: "内蒙古自治区", Code: "150000",
Cities: []ChinaCityData{
{Name: "呼和浩特市", Code: "150100", Counties: []string{"新城区", "回民区", "玉泉区", "赛罕区", "土默特左旗", "托克托县", "和林格尔县", "清水河县", "武川县"}},
{Name: "包头市", Code: "150200", Counties: []string{"东河区", "昆都仑区", "青山区", "石拐区", "白云鄂博矿区", "九原区", "土默特右旗", "固阳县", "达尔罕茂明安联合旗"}},
{Name: "乌海市", Code: "150300", Counties: []string{"海勃湾区", "海南区", "乌达区"}},
{Name: "赤峰市", Code: "150400", Counties: []string{"红山区", "元宝山区", "松山区", "阿鲁科尔沁旗", "巴林左旗", "巴林右旗", "林西县", "克什克腾旗", "翁牛特旗", "喀喇沁旗", "宁城县", "敖汉旗"}},
{Name: "通辽市", Code: "150500", Counties: []string{"科尔沁区", "科尔沁左翼中旗", "科尔沁左翼后旗", "开鲁县", "库伦旗", "奈曼旗", "扎鲁特旗", "霍林郭勒市"}},
{Name: "鄂尔多斯市", Code: "150600", Counties: []string{"东胜区", "康巴什区", "达拉特旗", "准格尔旗", "鄂托克前旗", "鄂托克旗", "杭锦旗", "乌审旗", "伊金霍洛旗"}},
{Name: "呼伦贝尔市", Code: "150700", Counties: []string{"海拉尔区", "扎赉诺尔区", "阿荣旗", "莫力达瓦达斡尔族自治旗", "鄂伦春自治旗", "鄂温克族自治旗", "陈巴尔虎旗", "新巴尔虎左旗", "新巴尔虎右旗", "满洲里市", "牙克石市", "扎兰屯市", "额尔古纳市", "根河市"}},
{Name: "巴彦淖尔市", Code: "150800", Counties: []string{"临河区", "五原县", "磴口县", "乌拉特前旗", "乌拉特中旗", "乌拉特后旗", "杭锦后旗"}},
{Name: "乌兰察布市", Code: "150900", Counties: []string{"集宁区", "卓资县", "化德县", "商都县", "兴和县", "凉城县", "察哈尔右翼前旗", "察哈尔右翼中旗", "察哈尔右翼后旗", "四子王旗", "丰镇市"}},
{Name: "兴安盟", Code: "152200", Counties: []string{"乌兰浩特市", "阿尔山市", "科尔沁右翼前旗", "科尔沁右翼中旗", "扎赉特旗", "突泉县"}},
{Name: "锡林郭勒盟", Code: "152500", Counties: []string{"二连浩特市", "锡林浩特市", "阿巴嘎旗", "苏尼特左旗", "苏尼特右旗", "东乌珠穆沁旗", "西乌珠穆沁旗", "太仆寺旗", "镶黄旗", "正镶白旗", "正蓝旗", "多伦县"}},
{Name: "阿拉善盟", Code: "152900", Counties: []string{"阿拉善左旗", "阿拉善右旗", "额济纳旗"}},
},
},
// 东北
{
Name: "辽宁省", Code: "210000",
Cities: []ChinaCityData{
{Name: "沈阳市", Code: "210100", Counties: []string{"和平区", "沈河区", "大东区", "皇姑区", "铁西区", "苏家屯区", "浑南区", "沈北新区", "于洪区", "辽中区", "康平县", "法库县", "新民市"}},
{Name: "大连市", Code: "210200", Counties: []string{"中山区", "西岗区", "沙河口区", "甘井子区", "旅顺口区", "金州区", "普兰店区", "长海县", "瓦房店市", "庄河市"}},
{Name: "鞍山市", Code: "210300", Counties: []string{"铁东区", "铁西区", "立山区", "千山区", "台安县", "岫岩县", "海城市"}},
{Name: "抚顺市", Code: "210400", Counties: []string{"新抚区", "东洲区", "望花区", "顺城区", "抚顺县", "新宾县", "清原县"}},
{Name: "本溪市", Code: "210500", Counties: []string{"平山区", "溪湖区", "明山区", "南芬区", "本溪县", "桓仁县"}},
{Name: "丹东市", Code: "210600", Counties: []string{"元宝区", "振兴区", "振安区", "宽甸县", "东港市", "凤城市"}},
{Name: "锦州市", Code: "210700", Counties: []string{"古塔区", "凌河区", "太和区", "黑山县", "义县", "凌海市", "北镇市"}},
{Name: "营口市", Code: "210800", Counties: []string{"站前区", "西市区", "鲅鱼圈区", "老边区", "盖州市", "大石桥市"}},
{Name: "阜新市", Code: "210900", Counties: []string{"海州区", "太平区", "新邱区", "细河区", "清河门区", "阜新县", "彰武县"}},
{Name: "辽阳市", Code: "211000", Counties: []string{"白塔区", "文圣区", "宏伟区", "弓长岭区", "太子河区", "辽阳县", "灯塔市"}},
{Name: "盘锦市", Code: "211100", Counties: []string{"双台子区", "兴隆台区", "大洼区", "盘山县"}},
{Name: "铁岭市", Code: "211200", Counties: []string{"银州区", "清河区", "铁岭县", "西丰县", "昌图县", "调兵山市", "开原市"}},
{Name: "朝阳市", Code: "211300", Counties: []string{"双塔区", "龙城区", "朝阳县", "建平县", "喀喇沁左翼县", "北票市", "凌源市"}},
{Name: "葫芦岛市", Code: "211400", Counties: []string{"连山区", "龙港区", "南票区", "绥中县", "建昌县", "兴城市"}},
},
},
{
Name: "吉林省", Code: "220000",
Cities: []ChinaCityData{
{Name: "长春市", Code: "220100", Counties: []string{"南关区", "宽城区", "朝阳区", "二道区", "绿园区", "双阳区", "九台区", "农安县", "榆树市", "德惠市", "公主岭市"}},
{Name: "吉林市", Code: "220200", Counties: []string{"昌邑区", "龙潭区", "船营区", "丰满区", "永吉县", "蛟河市", "桦甸市", "舒兰市", "磐石市"}},
{Name: "四平市", Code: "220300", Counties: []string{"铁西区", "铁东区", "梨树县", "伊通县", "双辽市"}},
{Name: "辽源市", Code: "220400", Counties: []string{"龙山区", "西安区", "东丰县", "东辽县"}},
{Name: "通化市", Code: "220500", Counties: []string{"东昌区", "二道江区", "通化县", "辉南县", "柳河县", "梅河口市", "集安市"}},
{Name: "白山市", Code: "220600", Counties: []string{"浑江区", "江源区", "抚松县", "靖宇县", "长白县", "临江市"}},
{Name: "松原市", Code: "220700", Counties: []string{"宁江区", "前郭尔罗斯县", "长岭县", "乾安县", "扶余市"}},
{Name: "白城市", Code: "220800", Counties: []string{"洮北区", "镇赉县", "通榆县", "洮南市", "大安市"}},
{Name: "延边朝鲜族自治州", Code: "222400", Counties: []string{"延吉市", "图们市", "敦化市", "珲春市", "龙井市", "和龙市", "汪清县", "安图县"}},
},
},
{
Name: "黑龙江省", Code: "230000",
Cities: []ChinaCityData{
{Name: "哈尔滨市", Code: "230100", Counties: []string{"道里区", "南岗区", "道外区", "平房区", "松北区", "香坊区", "呼兰区", "阿城区", "双城区", "依兰县", "方正县", "宾县", "巴彦县", "木兰县", "通河县", "延寿县", "尚志市", "五常市"}},
{Name: "齐齐哈尔市", Code: "230200", Counties: []string{"龙沙区", "建华区", "铁锋区", "昂昂溪区", "富拉尔基区", "碾子山区", "梅里斯达斡尔族区", "龙江县", "依安县", "泰来县", "甘南县", "富裕县", "克山县", "克东县", "拜泉县", "讷河市"}},
{Name: "鸡西市", Code: "230300", Counties: []string{"鸡冠区", "恒山区", "滴道区", "梨树区", "城子河区", "麻山区", "鸡东县", "虎林市", "密山市"}},
{Name: "鹤岗市", Code: "230400", Counties: []string{"向阳区", "工农区", "南山区", "兴安区", "东山区", "兴山区", "萝北县", "绥滨县"}},
{Name: "双鸭山市", Code: "230500", Counties: []string{"尖山区", "岭东区", "四方台区", "宝山区", "集贤县", "友谊县", "宝清县", "饶河县"}},
{Name: "大庆市", Code: "230600", Counties: []string{"萨尔图区", "龙凤区", "让胡路区", "红岗区", "大同区", "肇州县", "肇源县", "林甸县", "杜尔伯特县"}},
{Name: "伊春市", Code: "230700", Counties: []string{"伊美区", "乌翠区", "友好区", "金林区", "嘉荫县", "汤旺县", "丰林县", "大箐山县", "南岔县", "铁力市"}},
{Name: "佳木斯市", Code: "230800", Counties: []string{"向阳区", "前进区", "东风区", "郊区", "桦南县", "桦川县", "汤原县", "同江市", "富锦市", "抚远市"}},
{Name: "七台河市", Code: "230900", Counties: []string{"新兴区", "桃山区", "茄子河区", "勃利县"}},
{Name: "牡丹江市", Code: "231000", Counties: []string{"东安区", "阳明区", "爱民区", "西安区", "林口县", "绥芬河市", "海林市", "宁安市", "穆棱市", "东宁市"}},
{Name: "黑河市", Code: "231100", Counties: []string{"爱辉区", "逊克县", "孙吴县", "北安市", "五大连池市", "嫩江市"}},
{Name: "绥化市", Code: "231200", Counties: []string{"北林区", "望奎县", "兰西县", "青冈县", "庆安县", "明水县", "绥棱县", "安达市", "肇东市", "海伦市"}},
{Name: "大兴安岭地区", Code: "232700", Counties: []string{"漠河市", "呼玛县", "塔河县"}},
},
},
// 华东
{
Name: "江苏省", Code: "320000",
Cities: []ChinaCityData{
{Name: "南京市", Code: "320100", Counties: []string{"玄武区", "秦淮区", "建邺区", "鼓楼区", "浦口区", "栖霞区", "雨花台区", "江宁区", "六合区", "溧水区", "高淳区"}},
{Name: "无锡市", Code: "320200", Counties: []string{"锡山区", "惠山区", "滨湖区", "梁溪区", "新吴区", "江阴市", "宜兴市"}},
{Name: "徐州市", Code: "320300", Counties: []string{"鼓楼区", "云龙区", "贾汪区", "泉山区", "铜山区", "丰县", "沛县", "睢宁县", "新沂市", "邳州市"}},
{Name: "常州市", Code: "320400", Counties: []string{"天宁区", "钟楼区", "新北区", "武进区", "金坛区", "溧阳市"}},
{Name: "苏州市", Code: "320500", Counties: []string{"姑苏区", "虎丘区", "吴中区", "相城区", "吴江区", "常熟市", "张家港市", "昆山市", "太仓市"}},
{Name: "南通市", Code: "320600", Counties: []string{"崇川区", "通州区", "海门区", "如东县", "启东市", "如皋市", "海安市"}},
{Name: "连云港市", Code: "320700", Counties: []string{"连云区", "海州区", "赣榆区", "东海县", "灌云县", "灌南县"}},
{Name: "淮安市", Code: "320800", Counties: []string{"淮安区", "淮阴区", "清江浦区", "洪泽区", "涟水县", "盱眙县", "金湖县"}},
{Name: "盐城市", Code: "320900", Counties: []string{"亭湖区", "盐都区", "大丰区", "响水县", "滨海县", "阜宁县", "射阳县", "建湖县", "东台市"}},
{Name: "扬州市", Code: "321000", Counties: []string{"广陵区", "邗江区", "江都区", "宝应县", "仪征市", "高邮市"}},
{Name: "镇江市", Code: "321100", Counties: []string{"京口区", "润州区", "丹徒区", "丹阳市", "扬中市", "句容市"}},
{Name: "泰州市", Code: "321200", Counties: []string{"海陵区", "高港区", "姜堰区", "兴化市", "靖江市", "泰兴市"}},
{Name: "宿迁市", Code: "321300", Counties: []string{"宿城区", "宿豫区", "沭阳县", "泗阳县", "泗洪县"}},
},
},
{
Name: "浙江省", Code: "330000",
Cities: []ChinaCityData{
{Name: "杭州市", Code: "330100", Counties: []string{"上城区", "拱墅区", "西湖区", "滨江区", "萧山区", "余杭区", "富阳区", "临安区", "临平区", "钱塘区", "桐庐县", "淳安县", "建德市"}},
{Name: "宁波市", Code: "330200", Counties: []string{"海曙区", "江北区", "北仑区", "镇海区", "鄞州区", "奉化区", "象山县", "宁海县", "余姚市", "慈溪市"}},
{Name: "温州市", Code: "330300", Counties: []string{"鹿城区", "龙湾区", "瓯海区", "洞头区", "永嘉县", "平阳县", "苍南县", "文成县", "泰顺县", "瑞安市", "乐清市", "龙港市"}},
{Name: "嘉兴市", Code: "330400", Counties: []string{"南湖区", "秀洲区", "嘉善县", "海盐县", "海宁市", "平湖市", "桐乡市"}},
{Name: "湖州市", Code: "330500", Counties: []string{"吴兴区", "南浔区", "德清县", "长兴县", "安吉县"}},
{Name: "绍兴市", Code: "330600", Counties: []string{"越城区", "柯桥区", "上虞区", "新昌县", "诸暨市", "嵊州市"}},
{Name: "金华市", Code: "330700", Counties: []string{"婺城区", "金东区", "武义县", "浦江县", "磐安县", "兰溪市", "义乌市", "东阳市", "永康市"}},
{Name: "衢州市", Code: "330800", Counties: []string{"柯城区", "衢江区", "常山县", "开化县", "龙游县", "江山市"}},
{Name: "舟山市", Code: "330900", Counties: []string{"定海区", "普陀区", "岱山县", "嵊泗县"}},
{Name: "台州市", Code: "331000", Counties: []string{"椒江区", "黄岩区", "路桥区", "三门县", "天台县", "仙居县", "温岭市", "临海市", "玉环市"}},
{Name: "丽水市", Code: "331100", Counties: []string{"莲都区", "青田县", "缙云县", "遂昌县", "松阳县", "云和县", "庆元县", "景宁县", "龙泉市"}},
},
},
{
Name: "安徽省", Code: "340000",
Cities: []ChinaCityData{
{Name: "合肥市", Code: "340100", Counties: []string{"瑶海区", "庐阳区", "蜀山区", "包河区", "长丰县", "肥东县", "肥西县", "庐江县", "巢湖市"}},
{Name: "芜湖市", Code: "340200", Counties: []string{"镜湖区", "鸠江区", "弋江区", "湾沚区", "繁昌区", "南陵县", "无为市"}},
{Name: "蚌埠市", Code: "340300", Counties: []string{"龙子湖区", "蚌山区", "禹会区", "淮上区", "怀远县", "五河县", "固镇县"}},
{Name: "淮南市", Code: "340400", Counties: []string{"大通区", "田家庵区", "谢家集区", "八公山区", "潘集区", "凤台县", "寿县"}},
{Name: "马鞍山市", Code: "340500", Counties: []string{"花山区", "雨山区", "博望区", "当涂县", "含山县", "和县"}},
{Name: "淮北市", Code: "340600", Counties: []string{"杜集区", "相山区", "烈山区", "濉溪县"}},
{Name: "铜陵市", Code: "340700", Counties: []string{"铜官区", "义安区", "郊区", "枞阳县"}},
{Name: "安庆市", Code: "340800", Counties: []string{"迎江区", "大观区", "宜秀区", "怀宁县", "太湖县", "宿松县", "望江县", "岳西县", "潜山市", "桐城市"}},
{Name: "黄山市", Code: "340900", Counties: []string{"屯溪区", "黄山区", "徽州区", "歙县", "休宁县", "黟县", "祁门县"}},
{Name: "滁州市", Code: "341000", Counties: []string{"琅琊区", "南谯区", "来安县", "全椒县", "定远县", "凤阳县", "天长市", "明光市"}},
{Name: "阜阳市", Code: "341200", Counties: []string{"颍州区", "颍东区", "颍泉区", "临泉县", "太和县", "阜南县", "颍上县", "界首市"}},
{Name: "宿州市", Code: "341300", Counties: []string{"埇桥区", "砀山县", "萧县", "灵璧县", "泗县"}},
{Name: "六安市", Code: "341500", Counties: []string{"金安区", "裕安区", "叶集区", "霍邱县", "舒城县", "金寨县", "霍山县"}},
{Name: "亳州市", Code: "341600", Counties: []string{"谯城区", "涡阳县", "蒙城县", "利辛县"}},
{Name: "池州市", Code: "341700", Counties: []string{"贵池区", "东至县", "石台县", "青阳县"}},
{Name: "宣城市", Code: "341800", Counties: []string{"宣州区", "郎溪县", "广德市", "泾县", "绩溪县", "旌德县", "宁国市"}},
},
},
{
Name: "福建省", Code: "350000",
Cities: []ChinaCityData{
{Name: "福州市", Code: "350100", Counties: []string{"鼓楼区", "台江区", "仓山区", "马尾区", "晋安区", "长乐区", "闽侯县", "连江县", "罗源县", "闽清县", "永泰县", "平潭县", "福清市"}},
{Name: "厦门市", Code: "350200", Counties: []string{"思明区", "海沧区", "湖里区", "集美区", "同安区", "翔安区"}},
{Name: "莆田市", Code: "350300", Counties: []string{"城厢区", "涵江区", "荔城区", "秀屿区", "仙游县"}},
{Name: "三明市", Code: "350400", Counties: []string{"三元区", "沙县区", "明溪县", "清流县", "宁化县", "大田县", "尤溪县", "将乐县", "泰宁县", "建宁县", "永安市"}},
{Name: "泉州市", Code: "350500", Counties: []string{"鲤城区", "丰泽区", "洛江区", "泉港区", "惠安县", "安溪县", "永春县", "德化县", "石狮市", "晋江市", "南安市"}},
{Name: "漳州市", Code: "350600", Counties: []string{"芗城区", "龙文区", "龙海区", "长泰区", "云霄县", "漳浦县", "诏安县", "东山县", "南靖县", "平和县", "华安县"}},
{Name: "南平市", Code: "350700", Counties: []string{"延平区", "建阳区", "顺昌县", "浦城县", "光泽县", "松溪县", "政和县", "邵武市", "武夷山市", "建瓯市"}},
{Name: "龙岩市", Code: "350800", Counties: []string{"新罗区", "永定区", "长汀县", "上杭县", "武平县", "连城县", "漳平市"}},
{Name: "宁德市", Code: "350900", Counties: []string{"蕉城区", "霞浦县", "古田县", "屏南县", "寿宁县", "周宁县", "柘荣县", "福安市", "福鼎市"}},
},
},
{
Name: "江西省", Code: "360000",
Cities: []ChinaCityData{
{Name: "南昌市", Code: "360100", Counties: []string{"东湖区", "西湖区", "青云谱区", "青山湖区", "新建区", "红谷滩区", "南昌县", "安义县", "进贤县"}},
{Name: "景德镇市", Code: "360200", Counties: []string{"昌江区", "珠山区", "浮梁县", "乐平市"}},
{Name: "萍乡市", Code: "360300", Counties: []string{"安源区", "湘东区", "莲花县", "上栗县", "芦溪县"}},
{Name: "九江市", Code: "360400", Counties: []string{"濂溪区", "浔阳区", "柴桑区", "武宁县", "修水县", "永修县", "德安县", "都昌县", "湖口县", "彭泽县", "瑞昌市", "共青城市", "庐山市"}},
{Name: "新余市", Code: "360500", Counties: []string{"渝水区", "分宜县"}},
{Name: "鹰潭市", Code: "360600", Counties: []string{"月湖区", "余江区", "贵溪市"}},
{Name: "赣州市", Code: "360700", Counties: []string{"章贡区", "南康区", "赣县区", "信丰县", "大余县", "上犹县", "崇义县", "安远县", "定南县", "全南县", "宁都县", "于都县", "兴国县", "会昌县", "寻乌县", "石城县", "瑞金市", "龙南市"}},
{Name: "吉安市", Code: "360800", Counties: []string{"吉州区", "青原区", "吉安县", "吉水县", "峡江县", "新干县", "永丰县", "泰和县", "遂川县", "万安县", "安福县", "永新县", "井冈山市"}},
{Name: "宜春市", Code: "360900", Counties: []string{"袁州区", "奉新县", "万载县", "上高县", "宜丰县", "靖安县", "铜鼓县", "丰城市", "樟树市", "高安市"}},
{Name: "抚州市", Code: "361000", Counties: []string{"临川区", "东乡区", "南城县", "黎川县", "南丰县", "崇仁县", "乐安县", "宜黄县", "金溪县", "资溪县", "广昌县"}},
{Name: "上饶市", Code: "361100", Counties: []string{"信州区", "广丰区", "广信区", "玉山县", "铅山县", "横峰县", "弋阳县", "余干县", "鄱阳县", "万年县", "婺源县", "德兴市"}},
},
},
{
Name: "山东省", Code: "370000",
Cities: []ChinaCityData{
{Name: "济南市", Code: "370100", Counties: []string{"历下区", "市中区", "槐荫区", "天桥区", "历城区", "长清区", "章丘区", "济阳区", "莱芜区", "钢城区", "平阴县", "商河县"}},
{Name: "青岛市", Code: "370200", Counties: []string{"市南区", "市北区", "黄岛区", "崂山区", "李沧区", "城阳区", "即墨区", "胶州市", "平度市", "莱西市"}},
{Name: "淄博市", Code: "370300", Counties: []string{"淄川区", "张店区", "博山区", "临淄区", "周村区", "桓台县", "高青县", "沂源县"}},
{Name: "枣庄市", Code: "370400", Counties: []string{"市中区", "薛城区", "峄城区", "台儿庄区", "山亭区", "滕州市"}},
{Name: "东营市", Code: "370500", Counties: []string{"东营区", "河口区", "垦利区", "利津县", "广饶县"}},
{Name: "烟台市", Code: "370600", Counties: []string{"芝罘区", "福山区", "牟平区", "莱山区", "蓬莱区", "龙口市", "莱阳市", "莱州市", "招远市", "栖霞市", "海阳市"}},
{Name: "潍坊市", Code: "370700", Counties: []string{"潍城区", "寒亭区", "坊子区", "奎文区", "临朐县", "昌乐县", "青州市", "诸城市", "寿光市", "安丘市", "高密市", "昌邑市"}},
{Name: "济宁市", Code: "370800", Counties: []string{"任城区", "兖州区", "微山县", "鱼台县", "金乡县", "嘉祥县", "汶上县", "泗水县", "梁山县", "曲阜市", "邹城市"}},
{Name: "泰安市", Code: "370900", Counties: []string{"泰山区", "岱岳区", "宁阳县", "东平县", "新泰市", "肥城市"}},
{Name: "威海市", Code: "371000", Counties: []string{"环翠区", "文登区", "荣成市", "乳山市"}},
{Name: "日照市", Code: "371100", Counties: []string{"东港区", "岚山区", "五莲县", "莒县"}},
{Name: "临沂市", Code: "371300", Counties: []string{"兰山区", "罗庄区", "河东区", "沂南县", "郯城县", "沂水县", "兰陵县", "费县", "平邑县", "莒南县", "蒙阴县", "临沭县"}},
{Name: "德州市", Code: "371400", Counties: []string{"德城区", "陵城区", "宁津县", "庆云县", "临邑县", "齐河县", "平原县", "夏津县", "武城县", "乐陵市", "禹城市"}},
{Name: "聊城市", Code: "371500", Counties: []string{"东昌府区", "茌平区", "阳谷县", "莘县", "东阿县", "冠县", "高唐县", "临清市"}},
{Name: "滨州市", Code: "371600", Counties: []string{"滨城区", "沾化区", "惠民县", "阳信县", "无棣县", "博兴县", "邹平市"}},
{Name: "菏泽市", Code: "371700", Counties: []string{"牡丹区", "定陶区", "曹县", "单县", "成武县", "巨野县", "郓城县", "鄄城县", "东明县"}},
},
},
// 华中
{
Name: "河南省", Code: "410000",
Cities: []ChinaCityData{
{Name: "郑州市", Code: "410100", Counties: []string{"中原区", "二七区", "管城回族区", "金水区", "上街区", "惠济区", "中牟县", "巩义市", "荥阳市", "新密市", "新郑市", "登封市"}},
{Name: "开封市", Code: "410200", Counties: []string{"龙亭区", "顺河回族区", "鼓楼区", "禹王台区", "祥符区", "杞县", "通许县", "尉氏县", "兰考县"}},
{Name: "洛阳市", Code: "410300", Counties: []string{"老城区", "西工区", "瀍河回族区", "涧西区", "洛龙区", "孟津区", "偃师区", "新安县", "栾川县", "嵩县", "汝阳县", "宜阳县", "洛宁县", "伊川县"}},
{Name: "平顶山市", Code: "410400", Counties: []string{"新华区", "卫东区", "石龙区", "湛河区", "宝丰县", "叶县", "鲁山县", "郏县", "舞钢市", "汝州市"}},
{Name: "安阳市", Code: "410500", Counties: []string{"文峰区", "北关区", "殷都区", "龙安区", "安阳县", "汤阴县", "滑县", "内黄县", "林州市"}},
{Name: "鹤壁市", Code: "410600", Counties: []string{"鹤山区", "山城区", "淇滨区", "浚县", "淇县"}},
{Name: "新乡市", Code: "410700", Counties: []string{"红旗区", "卫滨区", "凤泉区", "牧野区", "新乡县", "获嘉县", "原阳县", "延津县", "封丘县", "卫辉市", "辉县市"}},
{Name: "焦作市", Code: "410800", Counties: []string{"解放区", "中站区", "马村区", "山阳区", "修武县", "博爱县", "武陟县", "温县", "沁阳市", "孟州市"}},
{Name: "濮阳市", Code: "410900", Counties: []string{"华龙区", "清丰县", "南乐县", "范县", "台前县", "濮阳县"}},
{Name: "许昌市", Code: "411000", Counties: []string{"魏都区", "建安区", "鄢陵县", "襄城县", "禹州市", "长葛市"}},
{Name: "漯河市", Code: "411100", Counties: []string{"源汇区", "郾城区", "召陵区", "舞阳县", "临颍县"}},
{Name: "三门峡市", Code: "411200", Counties: []string{"湖滨区", "陕州区", "渑池县", "卢氏县", "义马市", "灵宝市"}},
{Name: "南阳市", Code: "411300", Counties: []string{"宛城区", "卧龙区", "南召县", "方城县", "西峡县", "镇平县", "内乡县", "淅川县", "社旗县", "唐河县", "新野县", "桐柏县", "邓州市"}},
{Name: "商丘市", Code: "411400", Counties: []string{"梁园区", "睢阳区", "民权县", "睢县", "宁陵县", "柘城县", "虞城县", "夏邑县", "永城市"}},
{Name: "信阳市", Code: "411500", Counties: []string{"浉河区", "平桥区", "罗山县", "光山县", "新县", "商城县", "固始县", "潢川县", "淮滨县", "息县"}},
{Name: "周口市", Code: "411600", Counties: []string{"川汇区", "淮阳区", "扶沟县", "西华县", "商水县", "沈丘县", "郸城县", "太康县", "鹿邑县", "项城市"}},
{Name: "驻马店市", Code: "411700", Counties: []string{"驿城区", "西平县", "上蔡县", "平舆县", "正阳县", "确山县", "泌阳县", "汝南县", "遂平县", "新蔡县"}},
{Name: "济源市", Code: "419001", Counties: []string{"济源市"}},
},
},
{
Name: "湖北省", Code: "420000",
Cities: []ChinaCityData{
{Name: "武汉市", Code: "420100", Counties: []string{"江岸区", "江汉区", "硚口区", "汉阳区", "武昌区", "青山区", "洪山区", "东西湖区", "汉南区", "蔡甸区", "江夏区", "黄陂区", "新洲区"}},
{Name: "黄石市", Code: "420200", Counties: []string{"黄石港区", "西塞山区", "下陆区", "铁山区", "阳新县", "大冶市"}},
{Name: "十堰市", Code: "420300", Counties: []string{"茅箭区", "张湾区", "郧阳区", "郧西县", "竹山县", "竹溪县", "房县", "丹江口市"}},
{Name: "宜昌市", Code: "420500", Counties: []string{"西陵区", "伍家岗区", "点军区", "猇亭区", "夷陵区", "远安县", "兴山县", "秭归县", "长阳县", "五峰县", "宜都市", "当阳市", "枝江市"}},
{Name: "襄阳市", Code: "420600", Counties: []string{"襄城区", "樊城区", "襄州区", "南漳县", "谷城县", "保康县", "老河口市", "枣阳市", "宜城市"}},
{Name: "鄂州市", Code: "420700", Counties: []string{"梁子湖区", "华容区", "鄂城区"}},
{Name: "荆门市", Code: "420800", Counties: []string{"东宝区", "掇刀区", "京山市", "沙洋县", "钟祥市"}},
{Name: "孝感市", Code: "420900", Counties: []string{"孝南区", "孝昌县", "大悟县", "云梦县", "应城市", "安陆市", "汉川市"}},
{Name: "荆州市", Code: "421000", Counties: []string{"沙市区", "荆州区", "公安县", "江陵县", "石首市", "洪湖市", "松滋市", "监利市"}},
{Name: "黄冈市", Code: "421100", Counties: []string{"黄州区", "团风县", "红安县", "罗田县", "英山县", "浠水县", "蕲春县", "黄梅县", "麻城市", "武穴市"}},
{Name: "咸宁市", Code: "421200", Counties: []string{"咸安区", "嘉鱼县", "通城县", "崇阳县", "通山县", "赤壁市"}},
{Name: "随州市", Code: "421300", Counties: []string{"曾都区", "随县", "广水市"}},
{Name: "恩施土家族苗族自治州", Code: "422800", Counties: []string{"恩施市", "利川市", "建始县", "巴东县", "宣恩县", "咸丰县", "来凤县", "鹤峰县"}},
{Name: "省直辖县级", Code: "429000", Counties: []string{"仙桃市", "潜江市", "天门市", "神农架林区"}},
},
},
{
Name: "湖南省", Code: "430000",
Cities: []ChinaCityData{
{Name: "长沙市", Code: "430100", Counties: []string{"芙蓉区", "天心区", "岳麓区", "开福区", "雨花区", "望城区", "长沙县", "浏阳市", "宁乡市"}},
{Name: "株洲市", Code: "430200", Counties: []string{"荷塘区", "芦淞区", "石峰区", "天元区", "渌口区", "攸县", "茶陵县", "炎陵县", "醴陵市"}},
{Name: "湘潭市", Code: "430300", Counties: []string{"雨湖区", "岳塘区", "湘潭县", "湘乡市", "韶山市"}},
{Name: "衡阳市", Code: "430400", Counties: []string{"珠晖区", "雁峰区", "石鼓区", "蒸湘区", "南岳区", "衡阳县", "衡南县", "衡山县", "衡东县", "祁东县", "耒阳市", "常宁市"}},
{Name: "邵阳市", Code: "430500", Counties: []string{"双清区", "大祥区", "北塔区", "邵东市", "新邵县", "邵阳县", "隆回县", "洞口县", "绥宁县", "新宁县", "城步县", "武冈市"}},
{Name: "岳阳市", Code: "430600", Counties: []string{"岳阳楼区", "云溪区", "君山区", "岳阳县", "华容县", "湘阴县", "平江县", "汨罗市", "临湘市"}},
{Name: "常德市", Code: "430700", Counties: []string{"武陵区", "鼎城区", "安乡县", "汉寿县", "澧县", "临澧县", "桃源县", "石门县", "津市市"}},
{Name: "张家界市", Code: "430800", Counties: []string{"永定区", "武陵源区", "慈利县", "桑植县"}},
{Name: "益阳市", Code: "430900", Counties: []string{"资阳区", "赫山区", "南县", "桃江县", "安化县", "沅江市"}},
{Name: "郴州市", Code: "431000", Counties: []string{"北湖区", "苏仙区", "桂阳县", "宜章县", "永兴县", "嘉禾县", "临武县", "汝城县", "桂东县", "安仁县", "资兴市"}},
{Name: "永州市", Code: "431100", Counties: []string{"零陵区", "冷水滩区", "祁阳市", "东安县", "双牌县", "道县", "江永县", "宁远县", "蓝山县", "新田县", "江华县"}},
{Name: "怀化市", Code: "431200", Counties: []string{"鹤城区", "中方县", "沅陵县", "辰溪县", "溆浦县", "会同县", "麻阳县", "新晃县", "芷江县", "靖州县", "通道县", "洪江市"}},
{Name: "娄底市", Code: "431300", Counties: []string{"娄星区", "双峰县", "新化县", "冷水江市", "涟源市"}},
{Name: "湘西土家族苗族自治州", Code: "433100", Counties: []string{"吉首市", "泸溪县", "凤凰县", "花垣县", "保靖县", "古丈县", "永顺县", "龙山县"}},
},
},
// 华南
{
Name: "广东省", Code: "440000",
Cities: []ChinaCityData{
{Name: "广州市", Code: "440100", Counties: []string{"荔湾区", "越秀区", "海珠区", "天河区", "白云区", "黄埔区", "番禺区", "花都区", "南沙区", "从化区", "增城区"}},
{Name: "韶关市", Code: "440200", Counties: []string{"武江区", "浈江区", "曲江区", "始兴县", "仁化县", "翁源县", "乳源县", "新丰县", "乐昌市", "南雄市"}},
{Name: "深圳市", Code: "440300", Counties: []string{"罗湖区", "福田区", "南山区", "宝安区", "龙岗区", "盐田区", "龙华区", "坪山区", "光明区", "大鹏新区"}},
{Name: "珠海市", Code: "440400", Counties: []string{"香洲区", "斗门区", "金湾区"}},
{Name: "汕头市", Code: "440500", Counties: []string{"金平区", "龙湖区", "濠江区", "潮阳区", "潮南区", "澄海区", "南澳县"}},
{Name: "佛山市", Code: "440600", Counties: []string{"禅城区", "南海区", "顺德区", "三水区", "高明区"}},
{Name: "江门市", Code: "440700", Counties: []string{"蓬江区", "江海区", "新会区", "台山市", "开平市", "鹤山市", "恩平市"}},
{Name: "湛江市", Code: "440800", Counties: []string{"赤坎区", "霞山区", "坡头区", "麻章区", "遂溪县", "徐闻县", "廉江市", "雷州市", "吴川市"}},
{Name: "茂名市", Code: "440900", Counties: []string{"茂南区", "电白区", "高州市", "化州市", "信宜市"}},
{Name: "肇庆市", Code: "441200", Counties: []string{"端州区", "鼎湖区", "高要区", "广宁县", "怀集县", "封开县", "德庆县", "四会市"}},
{Name: "惠州市", Code: "441300", Counties: []string{"惠城区", "惠阳区", "博罗县", "惠东县", "龙门县"}},
{Name: "梅州市", Code: "441400", Counties: []string{"梅江区", "梅县区", "大埔县", "丰顺县", "五华县", "平远县", "蕉岭县", "兴宁市"}},
{Name: "汕尾市", Code: "441500", Counties: []string{"城区", "海丰县", "陆河县", "陆丰市"}},
{Name: "河源市", Code: "441600", Counties: []string{"源城区", "紫金县", "龙川县", "连平县", "和平县", "东源县"}},
{Name: "阳江市", Code: "441700", Counties: []string{"江城区", "阳东区", "阳西县", "阳春市"}},
{Name: "清远市", Code: "441800", Counties: []string{"清城区", "清新区", "佛冈县", "阳山县", "连山县", "连南县", "英德市", "连州市"}},
{Name: "东莞市", Code: "441900", Counties: []string{"莞城区", "南城区", "东城区", "万江区", "虎门镇", "长安镇", "松山湖"}},
{Name: "中山市", Code: "442000", Counties: []string{"石岐区", "东区", "中山港区", "西区", "南区", "五桂山区", "小榄镇", "古镇镇"}},
{Name: "潮州市", Code: "445100", Counties: []string{"湘桥区", "潮安区", "饶平县"}},
{Name: "揭阳市", Code: "445200", Counties: []string{"榕城区", "揭东区", "揭西县", "惠来县", "普宁市"}},
{Name: "云浮市", Code: "445300", Counties: []string{"云城区", "云安区", "新兴县", "郁南县", "罗定市"}},
},
},
{
Name: "广西壮族自治区", Code: "450000",
Cities: []ChinaCityData{
{Name: "南宁市", Code: "450100", Counties: []string{"兴宁区", "青秀区", "江南区", "西乡塘区", "良庆区", "邕宁区", "武鸣区", "隆安县", "马山县", "上林县", "宾阳县", "横州市"}},
{Name: "柳州市", Code: "450200", Counties: []string{"城中区", "鱼峰区", "柳南区", "柳北区", "柳江区", "柳城县", "鹿寨县", "融安县", "融水县", "三江县"}},
{Name: "桂林市", Code: "450300", Counties: []string{"秀峰区", "叠彩区", "象山区", "七星区", "雁山区", "临桂区", "阳朔县", "灵川县", "全州县", "兴安县", "永福县", "灌阳县", "龙胜县", "资源县", "平乐县", "荔浦市", "恭城县"}},
{Name: "梧州市", Code: "450400", Counties: []string{"万秀区", "长洲区", "龙圩区", "苍梧县", "藤县", "蒙山县", "岑溪市"}},
{Name: "北海市", Code: "450500", Counties: []string{"海城区", "银海区", "铁山港区", "合浦县"}},
{Name: "防城港市", Code: "450600", Counties: []string{"港口区", "防城区", "上思县", "东兴市"}},
{Name: "钦州市", Code: "450700", Counties: []string{"钦南区", "钦北区", "灵山县", "浦北县"}},
{Name: "贵港市", Code: "450800", Counties: []string{"港北区", "港南区", "覃塘区", "平南县", "桂平市"}},
{Name: "玉林市", Code: "450900", Counties: []string{"玉州区", "福绵区", "容县", "陆川县", "博白县", "兴业县", "北流市"}},
{Name: "百色市", Code: "451000", Counties: []string{"右江区", "田阳区", "田东县", "平果市", "德保县", "那坡县", "凌云县", "乐业县", "田林县", "西林县", "隆林县", "靖西市"}},
{Name: "贺州市", Code: "451100", Counties: []string{"八步区", "平桂区", "昭平县", "钟山县", "富川县"}},
{Name: "河池市", Code: "451200", Counties: []string{"金城江区", "宜州区", "南丹县", "天峨县", "凤山县", "东兰县", "罗城县", "环江县", "巴马县", "都安县", "大化县"}},
{Name: "来宾市", Code: "451300", Counties: []string{"兴宾区", "忻城县", "象州县", "武宣县", "金秀县", "合山市"}},
{Name: "崇左市", Code: "451400", Counties: []string{"江州区", "扶绥县", "宁明县", "龙州县", "大新县", "天等县", "凭祥市"}},
},
},
{
Name: "海南省", Code: "460000",
Cities: []ChinaCityData{
{Name: "海口市", Code: "460100", Counties: []string{"秀英区", "龙华区", "琼山区", "美兰区"}},
{Name: "三亚市", Code: "460200", Counties: []string{"海棠区", "吉阳区", "天涯区", "崖州区"}},
{Name: "三沙市", Code: "460300", Counties: []string{"西沙区", "南沙区"}},
{Name: "儋州市", Code: "460400", Counties: []string{"那大镇", "白马井镇", "洋浦经济开发区"}},
{Name: "省直辖县级", Code: "469000", Counties: []string{"五指山市", "琼海市", "文昌市", "万宁市", "东方市", "定安县", "屯昌县", "澄迈县", "临高县", "白沙县", "昌江县", "乐东县", "陵水县", "保亭县", "琼中县"}},
},
},
// 西南
{
Name: "四川省", Code: "510000",
Cities: []ChinaCityData{
{Name: "成都市", Code: "510100", Counties: []string{"锦江区", "青羊区", "金牛区", "武侯区", "成华区", "龙泉驿区", "青白江区", "新都区", "温江区", "双流区", "郫都区", "新津区", "金堂县", "大邑县", "蒲江县", "都江堰市", "彭州市", "邛崃市", "崇州市", "简阳市"}},
{Name: "自贡市", Code: "510300", Counties: []string{"自流井区", "贡井区", "大安区", "沿滩区", "荣县", "富顺县"}},
{Name: "攀枝花市", Code: "510400", Counties: []string{"东区", "西区", "仁和区", "米易县", "盐边县"}},
{Name: "泸州市", Code: "510500", Counties: []string{"江阳区", "纳溪区", "龙马潭区", "泸县", "合江县", "叙永县", "古蔺县"}},
{Name: "德阳市", Code: "510600", Counties: []string{"旌阳区", "罗江区", "中江县", "广汉市", "什邡市", "绵竹市"}},
{Name: "绵阳市", Code: "510700", Counties: []string{"涪城区", "游仙区", "安州区", "三台县", "盐亭县", "梓潼县", "北川县", "平武县", "江油市"}},
{Name: "广元市", Code: "510800", Counties: []string{"利州区", "昭化区", "朝天区", "旺苍县", "青川县", "剑阁县", "苍溪县"}},
{Name: "遂宁市", Code: "510900", Counties: []string{"船山区", "安居区", "蓬溪县", "大英县", "射洪市"}},
{Name: "内江市", Code: "511000", Counties: []string{"市中区", "东兴区", "威远县", "资中县", "隆昌市"}},
{Name: "乐山市", Code: "511100", Counties: []string{"市中区", "沙湾区", "五通桥区", "金口河区", "犍为县", "井研县", "夹江县", "沐川县", "峨边县", "马边县", "峨眉山市"}},
{Name: "南充市", Code: "511300", Counties: []string{"顺庆区", "高坪区", "嘉陵区", "南部县", "营山县", "蓬安县", "仪陇县", "西充县", "阆中市"}},
{Name: "眉山市", Code: "511400", Counties: []string{"东坡区", "彭山区", "仁寿县", "洪雅县", "丹棱县", "青神县"}},
{Name: "宜宾市", Code: "511500", Counties: []string{"翠屏区", "南溪区", "叙州区", "江安县", "长宁县", "高县", "珙县", "筠连县", "兴文县", "屏山县"}},
{Name: "广安市", Code: "511600", Counties: []string{"广安区", "前锋区", "岳池县", "武胜县", "邻水县", "华蓥市"}},
{Name: "达州市", Code: "511700", Counties: []string{"通川区", "达川区", "宣汉县", "开江县", "大竹县", "渠县", "万源市"}},
{Name: "雅安市", Code: "511800", Counties: []string{"雨城区", "名山区", "荥经县", "汉源县", "石棉县", "天全县", "芦山县", "宝兴县"}},
{Name: "巴中市", Code: "511900", Counties: []string{"巴州区", "恩阳区", "通江县", "南江县", "平昌县"}},
{Name: "资阳市", Code: "512000", Counties: []string{"雁江区", "安岳县", "乐至县"}},
{Name: "阿坝藏族羌族自治州", Code: "513200", Counties: []string{"马尔康市", "汶川县", "理县", "茂县", "松潘县", "九寨沟县", "金川县", "小金县", "黑水县", "壤塘县", "阿坝县", "若尔盖县", "红原县"}},
{Name: "甘孜藏族自治州", Code: "513300", Counties: []string{"康定市", "泸定县", "丹巴县", "九龙县", "雅江县", "道孚县", "炉霍县", "甘孜县", "新龙县", "德格县", "白玉县", "石渠县", "色达县", "理塘县", "巴塘县", "乡城县", "稻城县", "得荣县"}},
{Name: "凉山彝族自治州", Code: "513400", Counties: []string{"西昌市", "会理市", "木里县", "盐源县", "德昌县", "会东县", "宁南县", "普格县", "布拖县", "金阳县", "昭觉县", "喜德县", "冕宁县", "越西县", "甘洛县", "美姑县", "雷波县"}},
},
},
{
Name: "贵州省", Code: "520000",
Cities: []ChinaCityData{
{Name: "贵阳市", Code: "520100", Counties: []string{"南明区", "云岩区", "花溪区", "乌当区", "白云区", "观山湖区", "开阳县", "息烽县", "修文县", "清镇市"}},
{Name: "六盘水市", Code: "520200", Counties: []string{"钟山区", "六枝特区", "水城区", "盘州市"}},
{Name: "遵义市", Code: "520300", Counties: []string{"红花岗区", "汇川区", "播州区", "桐梓县", "绥阳县", "正安县", "道真县", "务川县", "凤冈县", "湄潭县", "余庆县", "习水县", "赤水市", "仁怀市"}},
{Name: "安顺市", Code: "520400", Counties: []string{"西秀区", "平坝区", "普定县", "镇宁县", "关岭县", "紫云县"}},
{Name: "毕节市", Code: "520500", Counties: []string{"七星关区", "大方县", "黔西市", "金沙县", "织金县", "纳雍县", "威宁县", "赫章县"}},
{Name: "铜仁市", Code: "520600", Counties: []string{"碧江区", "万山区", "江口县", "玉屏县", "石阡县", "思南县", "印江县", "德江县", "沿河县", "松桃县"}},
{Name: "黔西南布依族苗族自治州", Code: "522300", Counties: []string{"兴义市", "兴仁市", "普安县", "晴隆县", "贞丰县", "望谟县", "册亨县", "安龙县"}},
{Name: "黔东南苗族侗族自治州", Code: "522600", Counties: []string{"凯里市", "黄平县", "施秉县", "三穗县", "镇远县", "岑巩县", "天柱县", "锦屏县", "剑河县", "台江县", "黎平县", "榕江县", "从江县", "雷山县", "麻江县", "丹寨县"}},
{Name: "黔南布依族苗族自治州", Code: "522700", Counties: []string{"都匀市", "福泉市", "荔波县", "贵定县", "瓮安县", "独山县", "平塘县", "罗甸县", "长顺县", "龙里县", "惠水县", "三都县"}},
},
},
{
Name: "云南省", Code: "530000",
Cities: []ChinaCityData{
{Name: "昆明市", Code: "530100", Counties: []string{"五华区", "盘龙区", "官渡区", "西山区", "东川区", "呈贡区", "晋宁区", "富民县", "宜良县", "石林县", "嵩明县", "禄劝县", "寻甸县", "安宁市"}},
{Name: "曲靖市", Code: "530300", Counties: []string{"麒麟区", "沾益区", "马龙区", "陆良县", "师宗县", "罗平县", "富源县", "会泽县", "宣威市"}},
{Name: "玉溪市", Code: "530400", Counties: []string{"红塔区", "江川区", "澄江市", "通海县", "华宁县", "易门县", "峨山县", "新平县", "元江县"}},
{Name: "保山市", Code: "530500", Counties: []string{"隆阳区", "施甸县", "龙陵县", "昌宁县", "腾冲市"}},
{Name: "昭通市", Code: "530600", Counties: []string{"昭阳区", "鲁甸县", "巧家县", "盐津县", "大关县", "永善县", "绥江县", "镇雄县", "彝良县", "威信县", "水富市"}},
{Name: "丽江市", Code: "530700", Counties: []string{"古城区", "玉龙县", "永胜县", "华坪县", "宁蒗县"}},
{Name: "普洱市", Code: "530800", Counties: []string{"思茅区", "宁洱县", "墨江县", "景东县", "景谷县", "镇沅县", "江城县", "孟连县", "澜沧县", "西盟县"}},
{Name: "临沧市", Code: "530900", Counties: []string{"临翔区", "凤庆县", "云县", "永德县", "镇康县", "双江县", "耿马县", "沧源县"}},
{Name: "楚雄彝族自治州", Code: "532300", Counties: []string{"楚雄市", "双柏县", "牟定县", "南华县", "姚安县", "大姚县", "永仁县", "元谋县", "武定县", "禄丰市"}},
{Name: "红河哈尼族彝族自治州", Code: "532500", Counties: []string{"个旧市", "开远市", "蒙自市", "弥勒市", "屏边县", "建水县", "石屏县", "泸西县", "元阳县", "红河县", "金平县", "绿春县", "河口县"}},
{Name: "文山壮族苗族自治州", Code: "532600", Counties: []string{"文山市", "砚山县", "西畴县", "麻栗坡县", "马关县", "丘北县", "广南县", "富宁县"}},
{Name: "西双版纳傣族自治州", Code: "532800", Counties: []string{"景洪市", "勐海县", "勐腊县"}},
{Name: "大理白族自治州", Code: "532900", Counties: []string{"大理市", "漾濞县", "祥云县", "宾川县", "弥渡县", "南涧县", "巍山县", "永平县", "云龙县", "洱源县", "剑川县", "鹤庆县"}},
{Name: "德宏傣族景颇族自治州", Code: "533100", Counties: []string{"瑞丽市", "芒市", "梁河县", "盈江县", "陇川县"}},
{Name: "怒江傈僳族自治州", Code: "533300", Counties: []string{"泸水市", "福贡县", "贡山县", "兰坪县"}},
{Name: "迪庆藏族自治州", Code: "533400", Counties: []string{"香格里拉市", "德钦县", "维西县"}},
},
},
{
Name: "西藏自治区", Code: "540000",
Cities: []ChinaCityData{
{Name: "拉萨市", Code: "540100", Counties: []string{"城关区", "堆龙德庆区", "达孜区", "林周县", "当雄县", "尼木县", "曲水县", "墨竹工卡县"}},
{Name: "日喀则市", Code: "540200", Counties: []string{"桑珠孜区", "南木林县", "江孜县", "定日县", "萨迦县", "拉孜县", "昂仁县", "谢通门县", "白朗县", "仁布县", "康马县", "定结县", "仲巴县", "亚东县", "吉隆县", "聂拉木县", "萨嘎县", "岗巴县"}},
{Name: "昌都市", Code: "540300", Counties: []string{"卡若区", "江达县", "贡觉县", "类乌齐县", "丁青县", "察雅县", "八宿县", "左贡县", "芒康县", "洛隆县", "边坝县"}},
{Name: "林芝市", Code: "540400", Counties: []string{"巴宜区", "工布江达县", "米林市", "墨脱县", "波密县", "察隅县", "朗县"}},
{Name: "山南市", Code: "540500", Counties: []string{"乃东区", "扎囊县", "贡嘎县", "桑日县", "琼结县", "曲松县", "措美县", "洛扎县", "加查县", "隆子县", "错那市", "浪卡子县"}},
{Name: "那曲市", Code: "540600", Counties: []string{"色尼区", "嘉黎县", "比如县", "聂荣县", "安多县", "申扎县", "索县", "班戈县", "巴青县", "尼玛县", "双湖县"}},
{Name: "阿里地区", Code: "542500", Counties: []string{"普兰县", "札达县", "噶尔县", "日土县", "革吉县", "改则县", "措勤县"}},
},
},
// 西北
{
Name: "陕西省", Code: "610000",
Cities: []ChinaCityData{
{Name: "西安市", Code: "610100", Counties: []string{"新城区", "碑林区", "莲湖区", "雁塔区", "灞桥区", "未央区", "阎良区", "临潼区", "长安区", "高陵区", "鄠邑区", "蓝田县", "周至县"}},
{Name: "铜川市", Code: "610200", Counties: []string{"王益区", "印台区", "耀州区", "宜君县"}},
{Name: "宝鸡市", Code: "610300", Counties: []string{"渭滨区", "金台区", "陈仓区", "凤翔区", "岐山县", "扶风县", "眉县", "陇县", "千阳县", "麟游县", "凤县", "太白县"}},
{Name: "咸阳市", Code: "610400", Counties: []string{"秦都区", "杨陵区", "渭城区", "三原县", "泾阳县", "乾县", "礼泉县", "永寿县", "长武县", "旬邑县", "淳化县", "武功县", "兴平市", "彬州市"}},
{Name: "渭南市", Code: "610500", Counties: []string{"临渭区", "华州区", "潼关县", "大荔县", "合阳县", "澄城县", "蒲城县", "白水县", "富平县", "韩城市", "华阴市"}},
{Name: "延安市", Code: "610600", Counties: []string{"宝塔区", "安塞区", "延长县", "延川县", "子长市", "志丹县", "吴起县", "甘泉县", "富县", "洛川县", "宜川县", "黄龙县", "黄陵县"}},
{Name: "汉中市", Code: "610700", Counties: []string{"汉台区", "南郑区", "城固县", "洋县", "西乡县", "勉县", "宁强县", "略阳县", "镇巴县", "留坝县", "佛坪县"}},
{Name: "榆林市", Code: "610800", Counties: []string{"榆阳区", "横山区", "府谷县", "靖边县", "定边县", "绥德县", "米脂县", "佳县", "吴堡县", "清涧县", "子洲县", "神木市"}},
{Name: "安康市", Code: "610900", Counties: []string{"汉滨区", "汉阴县", "石泉县", "宁陕县", "紫阳县", "岚皋县", "平利县", "镇坪县", "白河县", "旬阳市"}},
{Name: "商洛市", Code: "611000", Counties: []string{"商州区", "洛南县", "丹凤县", "商南县", "山阳县", "镇安县", "柞水县"}},
},
},
{
Name: "甘肃省", Code: "620000",
Cities: []ChinaCityData{
{Name: "兰州市", Code: "620100", Counties: []string{"城关区", "七里河区", "西固区", "安宁区", "红古区", "永登县", "皋兰县", "榆中县"}},
{Name: "嘉峪关市", Code: "620200", Counties: []string{"雄关街道", "钢城街道", "文殊镇", "新城镇"}},
{Name: "金昌市", Code: "620300", Counties: []string{"金川区", "永昌县"}},
{Name: "白银市", Code: "620400", Counties: []string{"白银区", "平川区", "靖远县", "会宁县", "景泰县"}},
{Name: "天水市", Code: "620500", Counties: []string{"秦州区", "麦积区", "清水县", "秦安县", "甘谷县", "武山县", "张家川县"}},
{Name: "武威市", Code: "620600", Counties: []string{"凉州区", "民勤县", "古浪县", "天祝县"}},
{Name: "张掖市", Code: "620700", Counties: []string{"甘州区", "肃南县", "民乐县", "临泽县", "高台县", "山丹县"}},
{Name: "平凉市", Code: "620800", Counties: []string{"崆峒区", "泾川县", "灵台县", "崇信县", "庄浪县", "静宁县", "华亭市"}},
{Name: "酒泉市", Code: "620900", Counties: []string{"肃州区", "金塔县", "瓜州县", "肃北县", "阿克塞县", "玉门市", "敦煌市"}},
{Name: "庆阳市", Code: "621000", Counties: []string{"西峰区", "庆城县", "环县", "华池县", "合水县", "正宁县", "宁县", "镇原县"}},
{Name: "定西市", Code: "621100", Counties: []string{"安定区", "通渭县", "陇西县", "渭源县", "临洮县", "漳县", "岷县"}},
{Name: "陇南市", Code: "621200", Counties: []string{"武都区", "成县", "文县", "宕昌县", "康县", "西和县", "礼县", "徽县", "两当县"}},
{Name: "临夏回族自治州", Code: "622900", Counties: []string{"临夏市", "临夏县", "康乐县", "永靖县", "广河县", "和政县", "东乡族自治县", "积石山县"}},
{Name: "甘南藏族自治州", Code: "623000", Counties: []string{"合作市", "临潭县", "卓尼县", "舟曲县", "迭部县", "玛曲县", "碌曲县", "夏河县"}},
},
},
{
Name: "青海省", Code: "630000",
Cities: []ChinaCityData{
{Name: "西宁市", Code: "630100", Counties: []string{"城东区", "城中区", "城西区", "城北区", "湟中区", "大通县", "湟源县"}},
{Name: "海东市", Code: "630200", Counties: []string{"乐都区", "平安区", "民和县", "互助县", "化隆县", "循化县"}},
{Name: "海北藏族自治州", Code: "632200", Counties: []string{"门源县", "祁连县", "海晏县", "刚察县"}},
{Name: "黄南藏族自治州", Code: "632300", Counties: []string{"同仁市", "尖扎县", "泽库县", "河南蒙古族自治县"}},
{Name: "海南藏族自治州", Code: "632500", Counties: []string{"共和县", "同德县", "贵德县", "兴海县", "贵南县"}},
{Name: "果洛藏族自治州", Code: "632600", Counties: []string{"玛沁县", "班玛县", "甘德县", "达日县", "久治县", "玛多县"}},
{Name: "玉树藏族自治州", Code: "632700", Counties: []string{"玉树市", "杂多县", "称多县", "治多县", "囊谦县", "曲麻莱县"}},
{Name: "海西蒙古族藏族自治州", Code: "632800", Counties: []string{"格尔木市", "德令哈市", "茫崖市", "乌兰县", "都兰县", "天峻县"}},
},
},
{
Name: "宁夏回族自治区", Code: "640000",
Cities: []ChinaCityData{
{Name: "银川市", Code: "640100", Counties: []string{"兴庆区", "西夏区", "金凤区", "永宁县", "贺兰县", "灵武市"}},
{Name: "石嘴山市", Code: "640200", Counties: []string{"大武口区", "惠农区", "平罗县"}},
{Name: "吴忠市", Code: "640300", Counties: []string{"利通区", "红寺堡区", "盐池县", "同心县", "青铜峡市"}},
{Name: "固原市", Code: "640400", Counties: []string{"原州区", "西吉县", "隆德县", "泾源县", "彭阳县"}},
{Name: "中卫市", Code: "640500", Counties: []string{"沙坡头区", "中宁县", "海原县"}},
},
},
{
Name: "新疆维吾尔自治区", Code: "650000",
Cities: []ChinaCityData{
{Name: "乌鲁木齐市", Code: "650100", Counties: []string{"天山区", "沙依巴克区", "新市区", "水磨沟区", "头屯河区", "达坂城区", "米东区", "乌鲁木齐县"}},
{Name: "克拉玛依市", Code: "650200", Counties: []string{"独山子区", "克拉玛依区", "白碱滩区", "乌尔禾区"}},
{Name: "吐鲁番市", Code: "650400", Counties: []string{"高昌区", "鄯善县", "托克逊县"}},
{Name: "哈密市", Code: "650500", Counties: []string{"伊州区", "巴里坤县", "伊吾县"}},
{Name: "昌吉回族自治州", Code: "652300", Counties: []string{"昌吉市", "阜康市", "呼图壁县", "玛纳斯县", "奇台县", "吉木萨尔县", "木垒县"}},
{Name: "博尔塔拉蒙古自治州", Code: "652700", Counties: []string{"博乐市", "阿拉山口市", "精河县", "温泉县"}},
{Name: "巴音郭楞蒙古自治州", Code: "652800", Counties: []string{"库尔勒市", "轮台县", "尉犁县", "若羌县", "且末县", "焉耆县", "和静县", "和硕县", "博湖县"}},
{Name: "阿克苏地区", Code: "652900", Counties: []string{"阿克苏市", "温宿县", "库车市", "沙雅县", "新和县", "拜城县", "乌什县", "阿瓦提县", "柯坪县"}},
{Name: "克孜勒苏柯尔克孜自治州", Code: "653000", Counties: []string{"阿图什市", "阿克陶县", "阿合奇县", "乌恰县"}},
{Name: "喀什地区", Code: "653100", Counties: []string{"喀什市", "疏附县", "疏勒县", "英吉沙县", "泽普县", "莎车县", "叶城县", "麦盖提县", "岳普湖县", "伽师县", "巴楚县", "塔什库尔干县"}},
{Name: "和田地区", Code: "653200", Counties: []string{"和田市", "和田县", "墨玉县", "皮山县", "洛浦县", "策勒县", "于田县", "民丰县"}},
{Name: "伊犁哈萨克自治州", Code: "654000", Counties: []string{"伊宁市", "奎屯市", "霍尔果斯市", "伊宁县", "察布查尔县", "霍城县", "巩留县", "新源县", "昭苏县", "特克斯县", "尼勒克县"}},
{Name: "塔城地区", Code: "654200", Counties: []string{"塔城市", "乌苏市", "沙湾市", "额敏县", "托里县", "裕民县", "和布克赛尔县"}},
{Name: "阿勒泰地区", Code: "654300", Counties: []string{"阿勒泰市", "布尔津县", "富蕴县", "福海县", "哈巴河县", "青河县", "吉木乃县"}},
{Name: "自治区直辖县级", Code: "659000", Counties: []string{"石河子市", "阿拉尔市", "图木舒克市", "五家渠市", "北屯市", "铁门关市", "双河市", "可克达拉市", "昆玉市", "胡杨河市", "新星市"}},
},
},
// 港澳台
{
Name: "台湾省", Code: "710000",
Cities: []ChinaCityData{
{Name: "台北市", Code: "710100", Counties: []string{"中正区", "大同区", "中山区", "松山区", "大安区", "万华区", "信义区", "士林区", "北投区", "内湖区", "南港区", "文山区"}},
{Name: "新北市", Code: "710200", Counties: []string{"板桥区", "三重区", "中和区", "永和区", "新庄区", "新店区", "树林区", "莺歌区", "三峡区", "淡水区", "汐止区", "瑞芳区"}},
{Name: "台中市", Code: "710300", Counties: []string{"中区", "东区", "南区", "西区", "北区", "西屯区", "南屯区", "北屯区", "丰原区"}},
{Name: "台南市", Code: "710400", Counties: []string{"东区", "南区", "北区", "安南区", "安平区", "中西区", "永康区", "新营区"}},
{Name: "高雄市", Code: "710500", Counties: []string{"盐埕区", "鼓山区", "左营区", "楠梓区", "三民区", "新兴区", "前金区", "苓雅区", "前镇区", "旗津区", "小港区", "凤山区"}},
{Name: "基隆市", Code: "710600", Counties: []string{"仁爱区", "信义区", "中正区", "中山区", "安乐区", "暖暖区", "七堵区"}},
{Name: "新竹市", Code: "710700", Counties: []string{"东区", "北区", "香山区"}},
{Name: "嘉义市", Code: "710800", Counties: []string{"东区", "西区"}},
},
},
{
Name: "香港特别行政区", Code: "810000",
Cities: []ChinaCityData{
{
Name: "香港特别行政区", Code: "810100",
Counties: []string{"中西区", "湾仔区", "东区", "南区", "油尖旺区", "深水埗区", "九龙城区", "黄大仙区", "观塘区", "荃湾区", "屯门区", "元朗区", "北区", "大埔区", "西贡区", "沙田区", "葵青区", "离岛区"},
},
},
},
{
Name: "澳门特别行政区", Code: "820000",
Cities: []ChinaCityData{
{
Name: "澳门特别行政区", Code: "820100",
Counties: []string{"花地玛堂区", "花王堂区", "望德堂区", "大堂区", "风顺堂区", "嘉模堂区", "圣方济各堂区", "路氹城"},
},
},
},
}
+340 -47
View File
@@ -21,17 +21,27 @@ func NewClassService(db *gorm.DB, configSvc *ConfigService) *ClassService {
return &ClassService{db: db, configSvc: configSvc}
}
// RosterItemReq 毕业照人员录入项
type RosterItemReq struct {
RowNo int `json:"row_no"`
ColNo int `json:"col_no"`
Name string `json:"name"`
Role string `json:"role"` // "student" | "teacher"
}
// CreateClassReq 创建班级请求
type CreateClassReq struct {
SchoolID uint `json:"school_id" binding:"required"`
CollegeID uint `json:"college_id"`
Name string `json:"name" binding:"required"`
Grade string `json:"grade" binding:"required"` // 如"2018级"
ClassNo string `json:"class_no" binding:"required"`
GraduateYear int `json:"graduate_year" binding:"required"`
GraduationPhoto string `json:"graduation_photo"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
SchoolID uint `json:"school_id" binding:"required"`
CollegeID uint `json:"college_id"`
Name string `json:"name"`
Grade string `json:"grade" binding:"required"` // 如"2018级"
ClassNo string `json:"class_no" binding:"required"`
GraduateYear int `json:"graduate_year"`
GraduationPhoto string `json:"graduation_photo"`
RowCount int `json:"row_count"`
Roster []RosterItemReq `json:"roster"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
}
// cleanNames 清洗名字:去空格、去空、去重,保持顺序
@@ -54,10 +64,6 @@ func cleanNames(names []string) []string {
// Create 创建班级
func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, error) {
// 0. 敏感词检测
if found, word := sensitive.Check(req.Name); found {
return nil, fmt.Errorf("班级名称包含敏感词:%s", word)
}
// 1. 校验用户有学习履历
var eduCount int64
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
@@ -72,19 +78,60 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
}
// 3. 校验学院(如果传了)
var collegeName string
if req.CollegeID > 0 {
if !school.SchoolType.HasCollege() {
if !school.HasCollegeType() {
return nil, errors.New("该学校类型不支持学院层级")
}
var college model.College
if err := s.db.Where("id = ? AND school_id = ?", req.CollegeID, req.SchoolID).First(&college).Error; err != nil {
return nil, errors.New("学院不存在或不属于该学校")
}
} else if school.SchoolType.HasCollege() {
// 大学/职业学校必须选学院
collegeName = college.Name
} else if school.RequiresCollege() {
// 纯大学/职业学校必须选学院;同时涵盖中小学学段的学校可不选
return nil, errors.New("该学校需要选择所属学院")
}
// 规范化年级与班级,自动补齐班级名称与推导毕业年份
req.Grade = strings.TrimSpace(req.Grade)
req.ClassNo = strings.TrimSpace(req.ClassNo)
if req.Name == "" {
req.Name = school.Name + collegeName + req.Grade + req.ClassNo
}
// 0. 敏感词检测
if found, word := sensitive.Check(req.Name); found {
return nil, fmt.Errorf("班级名称包含敏感词:%s", word)
}
if req.GraduateYear <= 0 {
startYear := 0
var numStr string
for _, ch := range req.Grade {
if ch >= '0' && ch <= '9' {
numStr += string(ch)
if len(numStr) == 4 {
fmt.Sscanf(numStr, "%d", &startYear)
break
}
} else {
numStr = ""
}
}
duration := 3
if strings.Contains(school.SchoolTypes, "primary") || school.SchoolType == model.SchoolPrimary {
duration = 6
} else if strings.Contains(school.SchoolTypes, "university") || school.SchoolType == model.SchoolUniversity {
duration = 4
}
if startYear > 0 {
req.GraduateYear = startYear + duration
} else {
req.GraduateYear = time.Now().Year()
}
}
// 4. 唯一性校验
var existCount int64
q := s.db.Model(&model.Class{}).Where("school_id = ? AND grade = ? AND class_no = ? AND graduate_year = ? AND status != ?",
@@ -99,9 +146,91 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
return nil, errors.New("该班级已存在,请勿重复创建")
}
// 5. 清洗名单
students := cleanNames(req.StudentNames)
teachers := cleanNames(req.TeacherNames)
// 5. 清洗名单与构建排位花名册(预录入每个同学与老师,未注册账号预留)
var rosters []model.ClassRoster
var students []string
var teachers []string
now := time.Now()
var creator model.User
s.db.Select("id, username, nickname").First(&creator, userID)
if len(req.Roster) > 0 {
maxRow := 1
for _, item := range req.Roster {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
rowNo := item.RowNo
if rowNo <= 0 {
rowNo = 1
}
if rowNo > maxRow {
maxRow = rowNo
}
role := item.Role
if role == "" {
role = "student"
}
if role == "teacher" {
teachers = append(teachers, name)
} else {
students = append(students, name)
}
r := model.ClassRoster{
RowNo: rowNo,
ColNo: item.ColNo,
Name: name,
Role: role,
}
// 若当前录入的名字与创建者本人姓名/昵称一致,且尚未绑定任何一条,自动认领绑定
if creator.ID > 0 && (name == creator.Nickname || name == creator.Username) {
r.UserID = creator.ID
r.IsClaimed = true
r.ClaimedAt = &now
}
rosters = append(rosters, r)
}
if req.RowCount < maxRow {
req.RowCount = maxRow
}
students = cleanNames(students)
teachers = cleanNames(teachers)
} else {
// 兼容旧格式调用
students = cleanNames(req.StudentNames)
teachers = cleanNames(req.TeacherNames)
col := 1
for _, name := range students {
r := model.ClassRoster{
RowNo: 1,
ColNo: col,
Name: name,
Role: "student",
}
if creator.ID > 0 && (name == creator.Nickname || name == creator.Username) {
r.UserID = creator.ID
r.IsClaimed = true
r.ClaimedAt = &now
}
rosters = append(rosters, r)
col++
}
for _, name := range teachers {
rosters = append(rosters, model.ClassRoster{
RowNo: 1,
ColNo: col,
Name: name,
Role: "teacher",
})
col++
}
if req.RowCount <= 0 {
req.RowCount = 1
}
}
// 6. 构建班级
graceDays := 30
@@ -117,24 +246,35 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
GraduateYear: req.GraduateYear,
AdminUserID: userID,
GraduationPhoto: req.GraduationPhoto,
RowCount: req.RowCount,
AuditDeadline: time.Now().AddDate(0, 0, graceDays),
}
class.SetStudentNames(students)
class.SetTeacherNames(teachers)
// 7. 名字不足8个直接打回
if len(students) < model.MinStudentCount {
// 7. 总人数不足8个直接打回
totalPeople := len(students) + len(teachers)
if totalPeople < model.MinStudentCount {
class.Status = model.ClassRejected
class.RejectReason = fmt.Sprintf("班级同学名单仅 %d 人,需至少录入 %d 人(毕业照上通常有全部同学姓名,请补齐后重新提交)", len(students), model.MinStudentCount)
class.RejectReason = fmt.Sprintf("班级录入人员仅 %d 人,需至少录入 %d 人(毕业照上通常有全部人员姓名,请补齐后重新提交)", totalPeople, model.MinStudentCount)
} else {
class.Status = model.ClassPending
}
// 8. 事务:创建班级 + 创建者成为管理员成员 + 审核日志
// 8. 事务:创建班级 + 保存分开的花名册预录入记录 + 创建者成为管理员成员 + 审核日志
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(class).Error; err != nil {
return err
}
// 写入花名册:每个同学/老师作为独立记录存储(未绑定具体账号)
if len(rosters) > 0 {
for i := range rosters {
rosters[i].ClassID = class.ID
}
if err := tx.Create(&rosters).Error; err != nil {
return err
}
}
member := &model.ClassMember{
ClassID: class.ID,
UserID: userID,
@@ -145,11 +285,28 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
if err := tx.Create(member).Error; err != nil {
return err
}
// 检查创建者是否在花名册中,若是则自动认领绑定对应站位
var creator model.User
if tx.Select("id, nickname, username, real_name").First(&creator, userID).Error == nil {
for i := range rosters {
name := rosters[i].Name
if (creator.RealName != "" && name == creator.RealName) || (creator.Nickname != "" && name == creator.Nickname) || name == creator.Username {
tx.Model(&model.ClassRoster{}).Where("class_id = ? AND row_no = ? AND col_no = ?", class.ID, rosters[i].RowNo, rosters[i].ColNo).Updates(map[string]interface{}{
"user_id": userID,
"is_claimed": true,
"claimed_at": time.Now(),
})
break
}
}
}
log := &model.AuditLog{
ClassID: class.ID,
AuditorID: userID,
Action: model.AuditSubmit,
Reason: fmt.Sprintf("创建班级,录入同学 %d 人、老师 %d 人", len(students), len(teachers)),
Reason: fmt.Sprintf("创建班级,按 %d 排录入 %d 位人员(学生 %d 人,老师 %d 人)", class.RowCount, len(rosters), len(students), len(teachers)),
}
return tx.Create(log).Error
})
@@ -161,22 +318,24 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
// ClassDetail 班级详情
type ClassDetail struct {
Class model.Class `json:"class"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
School *model.School `json:"school,omitempty"`
College *model.College `json:"college,omitempty"`
Admin *model.User `json:"admin,omitempty"`
Members []ClassMemberInfo `json:"members"`
Photos []model.ClassPhoto `json:"photos"`
MyRole string `json:"my_role"` // 当前用户角色,未加入为空
IsMember bool `json:"is_member"`
CanViewSensitive bool `json:"can_view_sensitive"` // 是否可查看照片和联系方式(成员/管理员)
Class model.Class `json:"class"`
RowCount int `json:"row_count"`
Roster []model.ClassRoster `json:"roster"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
School *model.School `json:"school,omitempty"`
College *model.College `json:"college,omitempty"`
Admin *model.User `json:"admin,omitempty"`
Members []ClassMemberInfo `json:"members"`
Photos []model.ClassPhoto `json:"photos"`
MyRole string `json:"my_role"` // 当前用户角色,未加入为空
IsMember bool `json:"is_member"`
CanViewSensitive bool `json:"can_view_sensitive"` // 是否可查看照片和联系方式(成员/管理员)
}
type ClassMemberInfo struct {
Member model.ClassMember `json:"member"`
User model.User `json:"user"`
Member model.ClassMember `json:"member"`
User model.User `json:"user"`
Contacts []model.UserContact `json:"contacts,omitempty"`
}
@@ -192,13 +351,51 @@ func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool)
// 数据隔离:只有班级成员或超级管理员可查看完整信息(照片+联系方式)
canViewSensitive := isSuperAdmin || (currentUserID > 0 && s.isMember(classID, currentUserID))
var rosters []model.ClassRoster
s.db.Where("class_id = ?", classID).Order("row_no asc, col_no asc, id asc").Find(&rosters)
detail := &ClassDetail{
Class: class,
StudentNames: class.StudentNames(),
TeacherNames: class.TeacherNames(),
Class: class,
RowCount: class.RowCount,
Roster: rosters,
StudentNames: class.StudentNames(),
TeacherNames: class.TeacherNames(),
CanViewSensitive: canViewSensitive,
}
// 兼容老数据:如果花名册表中暂无记录,则按 StudentNames/TeacherNames 构造第1排返回
if len(rosters) == 0 && (len(detail.StudentNames) > 0 || len(detail.TeacherNames) > 0) {
col := 1
for _, name := range detail.StudentNames {
detail.Roster = append(detail.Roster, model.ClassRoster{
ClassID: classID,
RowNo: 1,
ColNo: col,
Name: name,
Role: "student",
})
col++
}
for _, name := range detail.TeacherNames {
detail.Roster = append(detail.Roster, model.ClassRoster{
ClassID: classID,
RowNo: 1,
ColNo: col,
Name: name,
Role: "teacher",
})
col++
}
detail.RowCount = 1
}
if detail.RowCount <= 0 && len(detail.Roster) > 0 {
for _, r := range detail.Roster {
if r.RowNo > detail.RowCount {
detail.RowCount = r.RowNo
}
}
}
// 学校
var school model.School
if s.db.First(&school, class.SchoolID).Error == nil {
@@ -223,12 +420,26 @@ func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool)
for _, m := range members {
var u model.User
s.db.First(&u, m.UserID)
info := ClassMemberInfo{Member: m, User: u}
// 数据隔离:同班成员或超级管理员可查看联系方式
if canViewSensitive {
info.Contacts = s.getUserContacts(m.UserID)
// 敏感信息脱敏:非本人且非平台管理员时脱敏身份证号
if !isSuperAdmin && m.UserID != currentUserID {
if len(u.IdCard) == 18 {
u.IdCard = u.IdCard[:6] + "********" + u.IdCard[14:]
} else if u.IdCard != "" {
u.IdCard = "****************"
}
}
// 数据隔离:同班成员或超级管理员可查看联系方式;非成员清空敏感联系信息
if canViewSensitive {
info := ClassMemberInfo{Member: m, User: u}
info.Contacts = s.getUserContacts(m.UserID)
detail.Members = append(detail.Members, info)
} else {
u.Phone = ""
u.Email = ""
u.Address = ""
info := ClassMemberInfo{Member: m, User: u}
detail.Members = append(detail.Members, info)
}
detail.Members = append(detail.Members, info)
}
// 照片墙:数据隔离,只有成员或超级管理员可查看
@@ -259,7 +470,33 @@ func (s *ClassService) isMember(classID, userID uint) bool {
func (s *ClassService) getUserContacts(userID uint) []model.UserContact {
var list []model.UserContact
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&list)
var u model.User
if s.db.Select("id, phone, email, address").First(&u, userID).Error == nil {
hasPhone := false
hasEmail := false
hasAddress := false
for _, c := range list {
if c.Type == model.ContactPhone {
hasPhone = true
}
if c.Type == model.ContactEmail {
hasEmail = true
}
if c.Type == model.ContactAddress {
hasAddress = true
}
}
if !hasPhone && u.Phone != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactPhone, Value: u.Phone, IsPrimary: true})
}
if !hasEmail && u.Email != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactEmail, Value: u.Email, IsPrimary: true})
}
if !hasAddress && u.Address != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactAddress, Value: u.Address, IsPrimary: true})
}
}
return list
}
@@ -312,6 +549,13 @@ type JoinBySearchReq struct {
// JoinBySearch 通过搜索+8人验证加入班级
func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
// 0. 校验用户有学习履历
var eduCount int64
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
if eduCount == 0 {
return errors.New("请先完善个人学习履历,有学习履历的用户才能加入班级")
}
var class model.Class
if err := s.db.First(&class, req.ClassID).Error; err != nil {
return errors.New("班级不存在")
@@ -346,7 +590,29 @@ func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
JoinMethod: model.JoinBySearch,
JoinedAt: time.Now(),
}
return s.db.Create(member).Error
if err := s.db.Create(member).Error; err != nil {
return err
}
// 联查班级花名册:若当前用户姓名与花名册中未绑定人员匹配,自动绑定该坑位
var user model.User
if s.db.Select("id, nickname, username, real_name").First(&user, userID).Error == nil {
var targetRoster model.ClassRoster
q := s.db.Where("class_id = ? AND is_claimed = ? AND user_id = 0", class.ID, false)
if user.RealName != "" {
q = q.Where("name = ? OR name = ? OR name = ?", user.RealName, user.Nickname, user.Username)
} else {
q = q.Where("name = ? OR name = ?", user.Nickname, user.Username)
}
if err := q.First(&targetRoster).Error; err == nil {
s.db.Model(&targetRoster).Updates(map[string]interface{}{
"user_id": userID,
"is_claimed": true,
"claimed_at": time.Now(),
})
}
}
return nil
}
// ---- 邀请码 ----
@@ -400,6 +666,13 @@ type JoinByInvitationReq struct {
// JoinByInvitation 通过邀请码加入
func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (*model.Class, error) {
// 0. 校验用户有学习履历
var eduCount int64
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
if eduCount == 0 {
return nil, errors.New("请先完善个人学习履历,有学习履历的用户才能加入班级")
}
code := strings.ToUpper(strings.TrimSpace(req.Code))
var inv model.Invitation
if err := s.db.Where("code = ?", code).First(&inv).Error; err != nil {
@@ -436,6 +709,26 @@ func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (
if err := tx.Create(member).Error; err != nil {
return err
}
// 联查花名册自动绑定坑位
var u model.User
if tx.Select("id, nickname, username, real_name").First(&u, userID).Error == nil {
var targetRoster model.ClassRoster
q := tx.Where("class_id = ? AND is_claimed = ? AND user_id = 0", class.ID, false)
if u.RealName != "" {
q = q.Where("name = ? OR name = ? OR name = ?", u.RealName, u.Nickname, u.Username)
} else {
q = q.Where("name = ? OR name = ?", u.Nickname, u.Username)
}
if err := q.First(&targetRoster).Error; err == nil {
tx.Model(&targetRoster).Updates(map[string]interface{}{
"user_id": userID,
"is_claimed": true,
"claimed_at": time.Now(),
})
}
}
return tx.Model(&model.Invitation{}).Where("id = ?", inv.ID).
Update("used_count", gorm.Expr("used_count + 1")).Error
})
+13 -9
View File
@@ -52,6 +52,18 @@ var defaultConfigs = []model.SystemConfig{
{Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2},
}
// WarmUpCache 从数据库全量加载配置到内存缓存(单次快速查询)
func (s *ConfigService) WarmUpCache() {
var list []model.SystemConfig
if err := s.db.Find(&list).Error; err == nil {
s.mu.Lock()
for _, item := range list {
s.cache[item.Key] = item.Value
}
s.mu.Unlock()
}
}
// InitDefaults 初始化默认配置(不存在则创建,已存在但为空且默认有初始值则补齐)
func (s *ConfigService) InitDefaults() error {
for _, c := range defaultConfigs {
@@ -66,15 +78,7 @@ func (s *ConfigService) InitDefaults() error {
}
}
// 预热内存缓存
var list []model.SystemConfig
if err := s.db.Find(&list).Error; err == nil {
s.mu.Lock()
for _, item := range list {
s.cache[item.Key] = item.Value
}
s.mu.Unlock()
}
s.WarmUpCache()
return nil
}
+27 -9
View File
@@ -31,10 +31,11 @@ var defaultMenus = []model.Menu{
{ID: 18, ParentID: 8, Name: "Dashboard", Path: "/admin/dashboard", Component: "admin/dashboard/index", Title: "数据统计", Icon: "fas fa-chart-line", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 9, ParentID: 8, Name: "AdminAudit", Path: "/admin/audit", Component: "admin/audit/list/index", Title: "审核管理", Icon: "fas fa-clipboard-check", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 10, ParentID: 8, Name: "AdminAuditDetail", Path: "/admin/audit/detail/:id", Component: "admin/audit/detail/index", Title: "审核详情", Icon: "", Sort: 3, Visible: false, RequireAuth: true, RequireAdmin: true},
{ID: 11, ParentID: 8, Name: "AdminConfig", Path: "/admin/config", Component: "admin/config/index", Title: "系统配置", Icon: "fas fa-sliders-h", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 12, ParentID: 8, Name: "AdminMenu", Path: "/admin/menu", Component: "admin/menu/index", Title: "菜单管理", Icon: "fas fa-bars", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 19, ParentID: 8, Name: "UserManage", Path: "/admin/users", Component: "admin/users/index", Title: "用户管理", Icon: "fas fa-user-shield", Sort: 6, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 20, ParentID: 8, Name: "SensitiveWord", Path: "/admin/sensitive", Component: "admin/sensitive/index", Title: "敏感词管理", Icon: "fas fa-shield-alt", Sort: 7, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 21, ParentID: 8, Name: "AdminBaseData", Path: "/admin/basedata", Component: "admin/basedata/index", Title: "基础数据", Icon: "fas fa-database", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 11, ParentID: 8, Name: "AdminConfig", Path: "/admin/config", Component: "admin/config/index", Title: "系统配置", Icon: "fas fa-sliders-h", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 12, ParentID: 8, Name: "AdminMenu", Path: "/admin/menu", Component: "admin/menu/index", Title: "菜单管理", Icon: "fas fa-bars", Sort: 6, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 19, ParentID: 8, Name: "UserManage", Path: "/admin/users", Component: "admin/users/index", Title: "用户管理", Icon: "fas fa-user-shield", Sort: 7, Visible: true, RequireAuth: true, RequireAdmin: true},
{ID: 20, ParentID: 8, Name: "SensitiveWord", Path: "/admin/sensitive", Component: "admin/sensitive/index", Title: "敏感词管理", Icon: "fas fa-shield-alt", Sort: 8, Visible: true, RequireAuth: true, RequireAdmin: true},
// 基本设置(管理员)
{ID: 13, ParentID: 0, Name: "NormalSettings", Path: "/normalsettings", Component: "", Title: "基本设置", Icon: "fas fa-wrench", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/normalsettings/usersettings"},
{ID: 14, ParentID: 13, Name: "UserSettings", Path: "/normalsettings/usersettings", Component: "normalsettings/usersettings/list/index", Title: "用户设置", Icon: "fas fa-user-cog", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
@@ -47,12 +48,15 @@ var defaultMenus = []model.Menu{
// InitDefaults 初始化默认菜单(不存在则创建)
func (s *MenuService) InitDefaults() error {
for _, m := range defaultMenus {
var count int64
s.db.Model(&model.Menu{}).Where("id = ? OR name = ?", m.ID, m.Name).Count(&count)
if count == 0 {
if err := s.db.Create(&m).Error; err != nil {
var existing model.Menu
err := s.db.Where("id = ? OR name = ?", m.ID, m.Name).First(&existing).Error
if err != nil {
if err := s.db.Select("*").Create(&m).Error; err != nil {
return err
}
} else if !m.Visible && existing.Visible {
// 默认隐藏菜单(如详情页带 :id 参数、登录页)在库中若是显示状态,自动校正为隐藏
s.db.Model(&existing).Update("visible", false)
}
}
return nil
@@ -74,10 +78,19 @@ func (s *MenuService) GetMenuTree(isAdmin bool) ([]*model.MenuTree, error) {
// GetAll 获取所有菜单(管理用,含隐藏)
func (s *MenuService) GetAll() ([]model.Menu, error) {
var list []model.Menu
err := s.db.Order("parent_id, sort").Find(&list).Error
err := s.db.Order("sort, id").Find(&list).Error
return list, err
}
// GetAllTree 获取所有菜单树结构(管理用,含隐藏)
func (s *MenuService) GetAllTree() ([]*model.MenuTree, error) {
var all []model.Menu
if err := s.db.Order("sort, id").Find(&all).Error; err != nil {
return nil, err
}
return buildTree(all, 0), nil
}
// GetByID 获取单个菜单
func (s *MenuService) GetByID(id uint) (*model.Menu, error) {
var m model.Menu
@@ -97,6 +110,11 @@ func (s *MenuService) Update(m *model.Menu) error {
return s.db.Save(m).Error
}
// UpdateFields 更新菜单指定字段
func (s *MenuService) UpdateFields(id uint, updates map[string]interface{}) error {
return s.db.Model(&model.Menu{}).Where("id = ?", id).Updates(updates).Error
}
// Delete 删除菜单(同时删除子菜单)
func (s *MenuService) Delete(id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
+220 -11
View File
@@ -1,6 +1,8 @@
package service
import (
"errors"
"fmt"
"photowall/internal/model"
"gorm.io/gorm"
@@ -14,21 +16,34 @@ func NewRegionService(db *gorm.DB) *RegionService {
return &RegionService{db: db}
}
// ListByParent 根据父级ID查子地区;parentID=0 查国家
func (s *RegionService) ListByParent(parentID uint) ([]model.Region, error) {
// GetByID 根据 ID 查询地区
func (s *RegionService) GetByID(id uint) (*model.Region, error) {
var r model.Region
if err := s.db.First(&r, id).Error; err != nil {
return nil, errors.New("地区不存在")
}
return &r, nil
}
// ListByParent 根据父级ID查子地区;parentID=0 查国家;可选按层级筛选
func (s *RegionService) ListByParent(parentID uint, level ...model.RegionLevel) ([]model.Region, error) {
query := s.db.Where("parent_id = ?", parentID)
if len(level) > 0 && level[0] != "" {
query = query.Where("level = ?", level[0])
}
var list []model.Region
err := s.db.Where("parent_id = ?", parentID).Order("sort_order, name").Find(&list).Error
err := query.Order("sort_order, id").Find(&list).Error
return list, err
}
// ListCountries 查所有国家
func (s *RegionService) ListCountries() ([]model.Region, error) {
var list []model.Region
err := s.db.Where("level = ?", model.LevelCountry).Order("sort_order, name").Find(&list).Error
err := s.db.Where("level = ?", model.LevelCountry).Order("sort_order, id").Find(&list).Error
return list, err
}
// GetPath 获取某地区的完整路径(国家->省->县)
// GetPath 获取某地区的完整路径(国家->省->市->县)
func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) {
var r model.Region
if err := s.db.First(&r, regionID).Error; err != nil {
@@ -41,28 +56,222 @@ func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) {
if err := s.db.First(&parent, cur.ParentID).Error; err != nil {
break
}
// 插到前面
path = append([]model.Region{parent}, path...)
cur = parent
}
return path, nil
}
// Search 按名称搜索地区(用于快速定位)
// Search 按名称或代码搜索地区
func (s *RegionService) Search(keyword string, limit int) ([]model.Region, error) {
if limit <= 0 {
limit = 20
limit = 50
}
var list []model.Region
err := s.db.Where("name LIKE ? OR name_en LIKE ?", "%"+keyword+"%", "%"+keyword+"%").
Order("level, sort_order, name").Limit(limit).Find(&list).Error
err := s.db.Where("name LIKE ? OR name_en LIKE ? OR code LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%").
Order("level, sort_order, id").Limit(limit).Find(&list).Error
return list, err
}
// BatchCreate 平台管理员批量录入地区(导入完整行政区划数据用)
// Create 创建地区
func (s *RegionService) Create(r *model.Region) error {
if r.Name == "" {
return errors.New("地区名称不能为空")
}
if r.Level == "" {
return errors.New("请选择地区层级")
}
if r.ParentID > 0 {
var parent model.Region
if err := s.db.First(&parent, r.ParentID).Error; err != nil {
return errors.New("所选上级地区不存在")
}
}
return s.db.Create(r).Error
}
// Update 更新地区信息
func (s *RegionService) Update(id uint, updates map[string]interface{}) error {
res := s.db.Model(&model.Region{}).Where("id = ?", id).Updates(updates)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("地区不存在")
}
return nil
}
// Delete 删除地区(包含子项/学校引用防误删检查)
func (s *RegionService) Delete(id uint) error {
// 检查是否有子地区
var childCount int64
s.db.Model(&model.Region{}).Where("parent_id = ?", id).Count(&childCount)
if childCount > 0 {
return errors.New("该地区下仍有下级地区,请先处理下级地区")
}
// 检查是否有学校关联
var schoolCount int64
s.db.Model(&model.School{}).Where("region_id = ?", id).Count(&schoolCount)
if schoolCount > 0 {
return errors.New("该地区下已有关联学校,无法直接删除")
}
return s.db.Delete(&model.Region{}, id).Error
}
// GetTree 获取地区层级树(parentID=0 表示从顶级国家开始)
func (s *RegionService) GetTree(parentID uint) ([]*model.RegionTree, error) {
var all []model.Region
if err := s.db.Order("sort_order, id").Find(&all).Error; err != nil {
return nil, err
}
return buildRegionTree(all, parentID), nil
}
func buildRegionTree(list []model.Region, parentID uint) []*model.RegionTree {
var tree []*model.RegionTree
for i := range list {
if list[i].ParentID == parentID {
node := &model.RegionTree{
Region: list[i],
Children: buildRegionTree(list, list[i].ID),
}
tree = append(tree, node)
}
}
return tree
}
// BatchCreate 批量录入地区
func (s *RegionService) BatchCreate(regions []model.Region) error {
if len(regions) == 0 {
return nil
}
return s.db.CreateInBatches(regions, 100).Error
}
// InitChinaRegions 生成/初始化整个中国的地域选项(34个省/直辖市/自治区、地级市、区县)
func (s *RegionService) InitChinaRegions() (createdCount int, totalCount int, err error) {
// 1. 预加载所有已存在的地区,极大提升比对性能(避免数千次单条查询导致超时)
var allExisting []model.Region
if err := s.db.Select("id, name, level, parent_id, code").Find(&allExisting).Error; err != nil {
return 0, 0, err
}
regMap := make(map[string]uint, len(allExisting)+3500)
for _, r := range allExisting {
key := fmt.Sprintf("%d:%s:%s", r.ParentID, r.Level, r.Name)
regMap[key] = r.ID
}
// 2. 查找或创建国家:中国
chinaKey := fmt.Sprintf("0:%s:中国", model.LevelCountry)
chinaID, exists := regMap[chinaKey]
if !exists {
china := model.Region{
Name: "中国",
NameEn: "China",
Level: model.LevelCountry,
ParentID: 0,
Code: "CN",
SortOrder: 1,
}
if err := s.db.Create(&china).Error; err != nil {
return 0, 0, err
}
chinaID = china.ID
regMap[chinaKey] = chinaID
createdCount++
}
totalCount++
// 2.1 清理历史残留错误数据:挂在中国省级下的直接 county 记录(如无学校关联)
var wrongProvCounties []model.Region
s.db.Raw(`
SELECT c.id FROM yz_pw_regions c
JOIN yz_pw_regions p ON c.parent_id = p.id
WHERE c.level = 'county' AND p.level = 'province' AND p.parent_id = ?
`, chinaID).Scan(&wrongProvCounties)
if len(wrongProvCounties) > 0 {
var toDel []uint
for _, wc := range wrongProvCounties {
var scCount int64
s.db.Model(&model.School{}).Where("region_id = ?", wc.ID).Count(&scCount)
if scCount == 0 {
toDel = append(toDel, wc.ID)
}
}
if len(toDel) > 0 {
s.db.Where("id IN ?", toDel).Delete(&model.Region{})
}
}
// 3. 遍历全国省市县数据并批量写入
for pIdx, pData := range chinaProvincesData {
provKey := fmt.Sprintf("%d:%s:%s", chinaID, model.LevelProvince, pData.Name)
provID, provExists := regMap[provKey]
if !provExists {
prov := model.Region{
Name: pData.Name,
Level: model.LevelProvince,
ParentID: chinaID,
Code: pData.Code,
SortOrder: pIdx + 1,
}
if err := s.db.Create(&prov).Error; err != nil {
return createdCount, totalCount, err
}
provID = prov.ID
regMap[provKey] = provID
createdCount++
}
totalCount++
for cIdx, cData := range pData.Cities {
cityKey := fmt.Sprintf("%d:%s:%s", provID, model.LevelCity, cData.Name)
cityID, cityExists := regMap[cityKey]
if !cityExists {
city := model.Region{
Name: cData.Name,
Level: model.LevelCity,
ParentID: provID,
Code: cData.Code,
SortOrder: cIdx + 1,
}
if err := s.db.Create(&city).Error; err != nil {
return createdCount, totalCount, err
}
cityID = city.ID
regMap[cityKey] = cityID
createdCount++
}
totalCount++
var missingCounties []model.Region
for kIdx, countyName := range cData.Counties {
totalCount++
countyKey := fmt.Sprintf("%d:%s:%s", cityID, model.LevelCounty, countyName)
if _, ctyExists := regMap[countyKey]; !ctyExists {
missingCounties = append(missingCounties, model.Region{
Name: countyName,
Level: model.LevelCounty,
ParentID: cityID,
SortOrder: kIdx + 1,
})
regMap[countyKey] = 1
}
}
if len(missingCounties) > 0 {
if err := s.db.CreateInBatches(missingCounties, 100).Error; err != nil {
return createdCount, totalCount, err
}
createdCount += len(missingCounties)
}
}
}
return createdCount, totalCount, nil
}
+152 -9
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"photowall/internal/model"
"strings"
"gorm.io/gorm"
)
@@ -19,6 +20,7 @@ type SchoolQuery struct {
RegionID uint `form:"region_id"`
SchoolType model.SchoolType `form:"school_type"`
Keyword string `form:"keyword"`
Status string `form:"status"` // pending / active / rejected / all
Page int `form:"page"`
PageSize int `form:"page_size"`
}
@@ -32,6 +34,13 @@ type PageResp struct {
type SchoolListResp = PageResp // 别名兼容
type SchoolStatsResp struct {
Total int64 `json:"total"`
Pending int64 `json:"pending"`
Active int64 `json:"active"`
Rejected int64 `json:"rejected"`
}
func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
if q.Page <= 0 {
q.Page = 1
@@ -41,10 +50,19 @@ func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
}
db := s.db.Model(&model.School{})
if q.RegionID > 0 {
db = db.Where("region_id = ?", q.RegionID)
// 支持查该地区自身及所有下级地区下的学校
regionIDs := s.getSelfAndDescendantRegionIDs(q.RegionID)
db = db.Where("region_id IN ?", regionIDs)
}
if q.SchoolType != "" {
db = db.Where("school_type = ?", q.SchoolType)
// school_types 为逗号分隔的多学段字段;旧数据可能为空,回退比较主类型
db = db.Where("FIND_IN_SET(?, school_types) > 0 OR ((school_types IS NULL OR school_types = '') AND school_type = ?)", q.SchoolType, q.SchoolType)
}
if q.Status != "" && q.Status != "all" {
db = db.Where("status = ?", q.Status)
} else if q.Status == "" {
// 公开默认只查 active
db = db.Where("status = ? OR status = ''", model.SchoolStatusActive)
}
if q.Keyword != "" {
db = db.Where("name LIKE ? OR name_en LIKE ?", "%"+q.Keyword+"%", "%"+q.Keyword+"%")
@@ -56,37 +74,161 @@ func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
if err != nil {
return nil, err
}
// 填充地区路径
for i := range list {
list[i].RegionPath = s.getRegionPathString(list[i].RegionID)
}
return &SchoolListResp{List: list, Total: total, Page: q.Page}, nil
}
// Create 创建学校(必须挂在 county 级地区下)
// getSelfAndDescendantRegionIDs 获取自身及所有下级地区ID
func (s *SchoolService) getSelfAndDescendantRegionIDs(rootID uint) []uint {
res := []uint{rootID}
var children []uint
s.db.Model(&model.Region{}).Where("parent_id = ?", rootID).Pluck("id", &children)
for _, cID := range children {
res = append(res, s.getSelfAndDescendantRegionIDs(cID)...)
}
return res
}
func (s *SchoolService) getRegionPathString(regionID uint) string {
if regionID == 0 {
return ""
}
var names []string
curID := regionID
for curID > 0 {
var r model.Region
if err := s.db.First(&r, curID).Error; err != nil {
break
}
names = append([]string{r.Name}, names...)
curID = r.ParentID
}
return strings.Join(names, " / ")
}
// Stats 统计学校审核状态数据
func (s *SchoolService) Stats() (*SchoolStatsResp, error) {
var total, pending, active, rejected int64
s.db.Model(&model.School{}).Count(&total)
s.db.Model(&model.School{}).Where("status = ?", model.SchoolStatusPending).Count(&pending)
s.db.Model(&model.School{}).Where("status = ? OR status = ''", model.SchoolStatusActive).Count(&active)
s.db.Model(&model.School{}).Where("status = ?", model.SchoolStatusRejected).Count(&rejected)
return &SchoolStatsResp{
Total: total,
Pending: pending,
Active: active,
Rejected: rejected,
}, nil
}
// Create 创建学校(挂在 county 或 city 级地区下)
func (s *SchoolService) Create(school *model.School) error {
// 校验 region 必须是 county 级
var region model.Region
if err := s.db.First(&region, school.RegionID).Error; err != nil {
return errors.New("所属地区不存在")
}
if region.Level != model.LevelCounty {
return errors.New("学校必须挂载在区/县级地区下")
if region.Level != model.LevelCounty && region.Level != model.LevelCity {
return errors.New("学校必须挂载在市级或区/县级地区下")
}
if school.Name == "" {
return errors.New("学校名称不能为空")
}
if school.SchoolType == "" {
// 规整多学段:优先取 school_types,其次回退到单一主类型
types := model.ParseSchoolTypes(school.SchoolTypes)
if len(types) == 0 && school.SchoolType != "" {
types = model.NormalizeSchoolTypes([]string{string(school.SchoolType)})
}
if len(types) == 0 {
return errors.New("请选择学校类型")
}
school.SchoolTypes = model.JoinSchoolTypes(types)
school.SchoolType = types[0]
if school.Status == "" {
school.Status = model.SchoolStatusActive
}
return s.db.Create(school).Error
}
// Approve 审核通过学校
func (s *SchoolService) Approve(id uint) error {
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": model.SchoolStatusActive,
"reject_reason": "",
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("学校不存在")
}
return nil
}
// Reject 打回学校并填写原因
func (s *SchoolService) Reject(id uint, reason string) error {
if reason == "" {
return errors.New("请输入打回原因")
}
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": model.SchoolStatusRejected,
"reject_reason": reason,
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("学校不存在")
}
return nil
}
func (s *SchoolService) GetByID(id uint) (*model.School, error) {
var school model.School
if err := s.db.First(&school, id).Error; err != nil {
return nil, errors.New("学校不存在")
}
school.RegionPath = s.getRegionPathString(school.RegionID)
return &school, nil
}
func (s *SchoolService) Update(id uint, updates map[string]interface{}) error {
if regVal, ok := updates["region_id"]; ok {
var regID uint
switch v := regVal.(type) {
case float64:
regID = uint(v)
case int:
regID = uint(v)
case int64:
regID = uint(v)
case uint:
regID = v
}
if regID > 0 {
var region model.Region
if err := s.db.First(&region, regID).Error; err != nil {
return errors.New("所属地区不存在")
}
if region.Level != model.LevelCounty && region.Level != model.LevelCity {
return errors.New("学校必须挂载在市级或区/县级地区下")
}
}
}
// 多学段:school_types 变更时同步刷新主类型 school_type
if raw, ok := updates["school_types"]; ok {
typesStr, _ := raw.(string)
types := model.ParseSchoolTypes(typesStr)
if len(types) == 0 {
return errors.New("请选择学校类型")
}
updates["school_types"] = model.JoinSchoolTypes(types)
updates["school_type"] = types[0]
}
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(updates)
if res.Error != nil {
return res.Error
@@ -104,6 +246,8 @@ func (s *SchoolService) Delete(id uint) error {
if count > 0 {
return errors.New("该学校下已有班级,无法删除")
}
// 同时删除所属学院
s.db.Where("school_id = ?", id).Delete(&model.College{})
return s.db.Delete(&model.School{}, id).Error
}
@@ -119,12 +263,11 @@ func (s *SchoolService) CreateCollege(schoolID uint, name string) (*model.Colleg
if name == "" {
return nil, errors.New("学院名称不能为空")
}
// 校验学校是否支持学院
var school model.School
if err := s.db.First(&school, schoolID).Error; err != nil {
return nil, errors.New("学校不存在")
}
if !school.SchoolType.HasCollege() {
if !school.HasCollegeType() {
return nil, errors.New("该学校类型不支持学院层级")
}
c := &model.College{SchoolID: schoolID, Name: name}
+239 -12
View File
@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"photowall/internal/model"
"regexp"
"strings"
"gorm.io/gorm"
)
@@ -30,32 +32,176 @@ func (s *UserService) GetProfile(userID uint) (*ProfileResp, error) {
}
var contacts []model.UserContact
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&contacts)
// 融合基本资料与联系方式:若 contacts 中缺少基本资料中的 phone/email/address,补齐展示
hasPhone := false
hasEmail := false
hasAddress := false
for _, c := range contacts {
if c.Type == model.ContactPhone {
hasPhone = true
}
if c.Type == model.ContactEmail {
hasEmail = true
}
if c.Type == model.ContactAddress {
hasAddress = true
}
}
if !hasPhone && user.Phone != "" {
contacts = append(contacts, model.UserContact{
UserID: userID,
Type: model.ContactPhone,
Value: user.Phone,
IsPrimary: true,
})
}
if !hasEmail && user.Email != "" {
contacts = append(contacts, model.UserContact{
UserID: userID,
Type: model.ContactEmail,
Value: user.Email,
IsPrimary: true,
})
}
if !hasAddress && user.Address != "" {
contacts = append(contacts, model.UserContact{
UserID: userID,
Type: model.ContactAddress,
Value: user.Address,
IsPrimary: true,
})
}
var edu []model.EducationHistory
s.db.Where("user_id = ?", userID).Order("start_year desc, id desc").Find(&edu)
return &ProfileResp{User: user, Contacts: contacts, Education: edu}, nil
}
// IsValidIDCard 校验中国大陆18位二代居民身份证合法性
func IsValidIDCard(idCard string) bool {
idCard = strings.ToUpper(strings.TrimSpace(idCard))
if len(idCard) != 18 {
return false
}
// 正则粗筛:前17位数字,第18位数字或字母X
matched, _ := regexp.MatchString(`^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9X]$`, idCard)
if !matched {
return false
}
// ISO 7064:1983.MOD 11-2 校验码加权
weights := []int{7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}
checkCodes := []byte{'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'}
sum := 0
for i := 0; i < 17; i++ {
sum += int(idCard[i]-'0') * weights[i]
}
expectedCheck := checkCodes[sum%11]
return idCard[17] == expectedCheck
}
type UpdateProfileReq struct {
Nickname string `json:"nickname"`
Bio string `json:"bio"`
Avatar string `json:"avatar"`
RealName string `json:"real_name"`
Gender string `json:"gender"`
Nation string `json:"nation"`
PoliticalStatus string `json:"political_status"`
IdCard string `json:"id_card"`
Address string `json:"address"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
Email string `json:"email"`
Bio string `json:"bio"`
Avatar string `json:"avatar"`
}
func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileReq) error {
updates := map[string]interface{}{}
updates["real_name"] = strings.TrimSpace(req.RealName)
updates["gender"] = strings.TrimSpace(req.Gender)
updates["nation"] = strings.TrimSpace(req.Nation)
updates["political_status"] = strings.TrimSpace(req.PoliticalStatus)
idCard := strings.ToUpper(strings.TrimSpace(req.IdCard))
if idCard != "" && !strings.Contains(idCard, "*") {
if !IsValidIDCard(idCard) {
return errors.New("身份证号码格式不正确,请输入18位有效居民身份证号")
}
updates["id_card"] = idCard
} else if idCard == "" {
updates["id_card"] = ""
}
address := strings.TrimSpace(req.Address)
updates["address"] = address
phone := strings.TrimSpace(req.Phone)
updates["phone"] = phone
if req.Nickname != "" {
updates["nickname"] = req.Nickname
updates["nickname"] = strings.TrimSpace(req.Nickname)
}
if req.Bio != "" {
updates["bio"] = req.Bio
email := strings.TrimSpace(req.Email)
if email != "" {
updates["email"] = email
}
if req.Avatar != "" {
updates["avatar"] = req.Avatar
updates["bio"] = req.Bio
updates["avatar"] = strings.TrimSpace(req.Avatar)
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
return err
}
if len(updates) == 0 {
return nil
// 融合基本资料与联系方式:同步手机号、邮箱、住址到联系方式表
if phone != "" {
var primaryPhone model.UserContact
if err := s.db.Where("user_id = ? AND type = ? AND is_primary = ?", userID, model.ContactPhone, true).First(&primaryPhone).Error; err == nil {
s.db.Model(&primaryPhone).Update("value", phone)
} else {
var anyPhone model.UserContact
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactPhone).First(&anyPhone).Error; err == nil {
s.db.Model(&anyPhone).Updates(map[string]interface{}{"value": phone, "is_primary": true})
} else {
s.db.Create(&model.UserContact{
UserID: userID,
Type: model.ContactPhone,
Value: phone,
IsPrimary: true,
})
}
}
}
return s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
if email != "" {
var primaryEmail model.UserContact
if err := s.db.Where("user_id = ? AND type = ? AND is_primary = ?", userID, model.ContactEmail, true).First(&primaryEmail).Error; err == nil {
s.db.Model(&primaryEmail).Update("value", email)
} else {
var anyEmail model.UserContact
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactEmail).First(&anyEmail).Error; err == nil {
s.db.Model(&anyEmail).Updates(map[string]interface{}{"value": email, "is_primary": true})
} else {
s.db.Create(&model.UserContact{
UserID: userID,
Type: model.ContactEmail,
Value: email,
IsPrimary: true,
})
}
}
}
if address != "" {
var anyAddr model.UserContact
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactAddress).First(&anyAddr).Error; err == nil {
s.db.Model(&anyAddr).Updates(map[string]interface{}{"value": address, "is_primary": true})
} else {
s.db.Create(&model.UserContact{
UserID: userID,
Type: model.ContactAddress,
Value: address,
IsPrimary: true,
})
}
}
return nil
}
// AddContact 添加联系方式,校验各类型数量上限
@@ -85,6 +231,19 @@ func (s *UserService) AddContact(userID uint, contactType model.ContactType, val
if err := s.db.Create(c).Error; err != nil {
return nil, err
}
// 融合基本资料与联系方式:若设为主要联系方式或为首个联系方式,同步回写到 User 表中
if isPrimary || count == 0 {
switch contactType {
case model.ContactPhone:
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", value)
case model.ContactEmail:
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", value)
case model.ContactAddress:
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", value)
}
}
return c, nil
}
@@ -99,11 +258,24 @@ func (s *UserService) UpdateContact(userID, contactID uint, value string, isPrim
if isPrimary != nil && *isPrimary {
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, c.Type).Update("is_primary", false)
c.IsPrimary = true
// 同步回写至 User 表
switch c.Type {
case model.ContactPhone:
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", c.Value)
case model.ContactEmail:
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", c.Value)
case model.ContactAddress:
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", c.Value)
}
}
return s.db.Save(&c).Error
}
func (s *UserService) DeleteContact(userID, contactID uint) error {
var c model.UserContact
if err := s.db.Where("id = ? AND user_id = ?", contactID, userID).First(&c).Error; err != nil {
return errors.New("联系方式不存在")
}
res := s.db.Where("id = ? AND user_id = ?", contactID, userID).Delete(&model.UserContact{})
if res.Error != nil {
return res.Error
@@ -111,6 +283,35 @@ func (s *UserService) DeleteContact(userID, contactID uint) error {
if res.RowsAffected == 0 {
return errors.New("联系方式不存在")
}
// 若删除的是主联系方式,同步 User 表中对应信息
switch c.Type {
case model.ContactPhone:
var nextPhone model.UserContact
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactPhone).Order("is_primary desc, id").First(&nextPhone).Error; err == nil {
s.db.Model(&nextPhone).Update("is_primary", true)
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", nextPhone.Value)
} else {
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", "")
}
case model.ContactEmail:
var nextEmail model.UserContact
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactEmail).Order("is_primary desc, id").First(&nextEmail).Error; err == nil {
s.db.Model(&nextEmail).Update("is_primary", true)
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", nextEmail.Value)
} else {
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", "")
}
case model.ContactAddress:
var nextAddr model.UserContact
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactAddress).Order("is_primary desc, id").First(&nextAddr).Error; err == nil {
s.db.Model(&nextAddr).Update("is_primary", true)
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", nextAddr.Value)
} else {
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", "")
}
}
return nil
}
@@ -175,6 +376,32 @@ func (s *UserService) GetUserByID(userID uint) (*model.User, error) {
// GetUserContacts 公开用户联系方式(同班同学可查看)
func (s *UserService) GetUserContacts(userID uint) []model.UserContact {
var list []model.UserContact
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&list)
var u model.User
if s.db.Select("id, phone, email, address").First(&u, userID).Error == nil {
hasPhone := false
hasEmail := false
hasAddress := false
for _, c := range list {
if c.Type == model.ContactPhone {
hasPhone = true
}
if c.Type == model.ContactEmail {
hasEmail = true
}
if c.Type == model.ContactAddress {
hasAddress = true
}
}
if !hasPhone && u.Phone != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactPhone, Value: u.Phone, IsPrimary: true})
}
if !hasEmail && u.Email != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactEmail, Value: u.Email, IsPrimary: true})
}
if !hasAddress && u.Address != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactAddress, Value: u.Address, IsPrimary: true})
}
}
return list
}