diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go index 08e3337..9545fdd 100644 --- a/go/cmd/server/main.go +++ b/go/cmd/server/main.go @@ -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) diff --git a/go/internal/config/config.go b/go/internal/config/config.go index d00450e..b229d99 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -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 +} diff --git a/go/internal/database/database.go b/go/internal/database/database.go index 36f3fa1..569180d 100644 --- a/go/internal/database/database.go +++ b/go/internal/database/database.go @@ -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{}, diff --git a/go/internal/database/seed.go b/go/internal/database/seed.go index e22f02c..761beed 100644 --- a/go/internal/database/seed.go +++ b/go/internal/database/seed.go @@ -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"}) diff --git a/go/internal/handler/admin_handler.go b/go/internal/handler/admin_handler.go index fbf8cae..ce648b7 100644 --- a/go/internal/handler/admin_handler.go +++ b/go/internal/handler/admin_handler.go @@ -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() diff --git a/go/internal/handler/class_handler.go b/go/internal/handler/class_handler.go index 5ca9ab6..af840a4 100644 --- a/go/internal/handler/class_handler.go +++ b/go/internal/handler/class_handler.go @@ -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) diff --git a/go/internal/handler/menu_handler.go b/go/internal/handler/menu_handler.go index f40a1e7..6dcdedc 100644 --- a/go/internal/handler/menu_handler.go +++ b/go/internal/handler/menu_handler.go @@ -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": "删除成功"}) diff --git a/go/internal/handler/region_handler.go b/go/internal/handler/region_handler.go index 3814857..b5a9cb9 100644 --- a/go/internal/handler/region_handler.go +++ b/go/internal/handler/region_handler.go @@ -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, + }) +} diff --git a/go/internal/handler/school_handler.go b/go/internal/handler/school_handler.go index 5a9d0fb..4fd268c 100644 --- a/go/internal/handler/school_handler.go +++ b/go/internal/handler/school_handler.go @@ -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) diff --git a/go/internal/handler/user_handler.go b/go/internal/handler/user_handler.go index 2ed77db..1d0d46a 100644 --- a/go/internal/handler/user_handler.go +++ b/go/internal/handler/user_handler.go @@ -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}) } diff --git a/go/internal/model/class.go b/go/internal/model/class.go index a5105a0..c58cbf3 100644 --- a/go/internal/model/class.go +++ b/go/internal/model/class.go @@ -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"` +} diff --git a/go/internal/model/region.go b/go/internal/model/region.go index 018c72a..8d906ac 100644 --- a/go/internal/model/region.go +++ b/go/internal/model/region.go @@ -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 学院(仅大学/职业学校有) diff --git a/go/internal/model/user.go b/go/internal/model/user.go index e7ed9a4..e22687e 100644 --- a/go/internal/model/user.go +++ b/go/internal/model/user.go @@ -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 联系方式类型 diff --git a/go/internal/router/router.go b/go/internal/router/router.go index 43541b4..8276ecb 100644 --- a/go/internal/router/router.go +++ b/go/internal/router/router.go @@ -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) diff --git a/go/internal/service/admin_service.go b/go/internal/service/admin_service.go index 473e60f..af54aa4 100644 --- a/go/internal/service/admin_service.go +++ b/go/internal/service/admin_service.go @@ -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 diff --git a/go/internal/service/china_data.go b/go/internal/service/china_data.go new file mode 100644 index 0000000..8926083 --- /dev/null +++ b/go/internal/service/china_data.go @@ -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{"花地玛堂区", "花王堂区", "望德堂区", "大堂区", "风顺堂区", "嘉模堂区", "圣方济各堂区", "路氹城"}, + }, + }, + }, +} diff --git a/go/internal/service/class_service.go b/go/internal/service/class_service.go index 8c9b3e8..f50a15d 100644 --- a/go/internal/service/class_service.go +++ b/go/internal/service/class_service.go @@ -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 }) diff --git a/go/internal/service/config_service.go b/go/internal/service/config_service.go index 0b00933..2a54d73 100644 --- a/go/internal/service/config_service.go +++ b/go/internal/service/config_service.go @@ -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 } diff --git a/go/internal/service/menu_service.go b/go/internal/service/menu_service.go index 467ab7d..50367d7 100644 --- a/go/internal/service/menu_service.go +++ b/go/internal/service/menu_service.go @@ -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 { diff --git a/go/internal/service/region_service.go b/go/internal/service/region_service.go index 05bed35..959b92d 100644 --- a/go/internal/service/region_service.go +++ b/go/internal/service/region_service.go @@ -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 +} diff --git a/go/internal/service/school_service.go b/go/internal/service/school_service.go index 49c2391..138954e 100644 --- a/go/internal/service/school_service.go +++ b/go/internal/service/school_service.go @@ -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(®ion, 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(®ion, 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} diff --git a/go/internal/service/user_service.go b/go/internal/service/user_service.go index 1d456a2..8b092d9 100644 --- a/go/internal/service/user_service.go +++ b/go/internal/service/user_service.go @@ -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 } diff --git a/vue/src/App.vue b/vue/src/App.vue index 09b4b33..8573cad 100644 --- a/vue/src/App.vue +++ b/vue/src/App.vue @@ -9,14 +9,14 @@