Files
xk-of-api/internal/service/admin.go
2026-07-18 05:58:41 +08:00

387 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"sort"
"sync"
"xk-of-api/internal/model"
)
// AdminService 负责后台登录、菜单、权限和官网 CMS 管理等后台业务逻辑。
type AdminService struct{}
// Admin 暴露后台服务单例,控制器只调用服务层,不直接处理业务细节。
var Admin = AdminService{}
var adminStore = struct {
sync.RWMutex
settings model.SiteSettings
pages []model.SitePage
sections []model.SiteSection
media []model.SiteMedia
jobs []model.RecruitJob
}{
settings: defaultSiteSettings(),
pages: defaultSitePages(),
sections: defaultSiteSections(),
media: defaultSiteMedia(),
jobs: defaultRecruitJobs(),
}
// Login 返回后台登录占位结果,后续接入 nl_admin 密码校验和 token 签发。
func (s AdminService) Login(account string) map[string]interface{} {
if account == "" {
account = "admin"
}
return map[string]interface{}{"token": "dev-token", "user": map[string]interface{}{"name": account, "role": "super_admin"}}
}
// Menus 返回支持单列和双列布局的后台多级菜单树。
func (s AdminService) Menus() []model.Menu {
return []model.Menu{
{Id: 1, ParentId: 0, Title: "控制台", Path: "/dashboard", Component: "Dashboard", Icon: "dashboard", Type: "menu", Permission: "dashboard:view", MenuArea: 1},
{Id: 2, ParentId: 0, Title: "官网运营", Path: "/site", Component: "Layout", Icon: "global", Type: "catalog", Permission: "site:view", MenuArea: 1, Children: []model.Menu{
{Id: 21, ParentId: 2, Title: "页面模块", Path: "/site/sections", Component: "SiteSections", Icon: "blocks", Type: "menu", Permission: "site:section:view", MenuArea: 2},
{Id: 22, ParentId: 2, Title: "咨询线索", Path: "/site/leads", Component: "SiteLeads", Icon: "message", Type: "menu", Permission: "site:lead:view", MenuArea: 2},
{Id: 23, ParentId: 2, Title: "图片管理", Path: "/site/media", Component: "SiteMedia", Icon: "image", Type: "menu", Permission: "site:media:view", MenuArea: 2},
{Id: 24, ParentId: 2, Title: "招聘管理", Path: "/site/jobs", Component: "RecruitJobs", Icon: "briefcase", Type: "menu", Permission: "site:job:view", MenuArea: 2},
{Id: 25, ParentId: 2, Title: "站点设置", Path: "/site/settings", Component: "SiteSettings", Icon: "setting", Type: "menu", Permission: "site:settings:view", MenuArea: 2},
}},
{Id: 3, ParentId: 0, Title: "商业化", Path: "/commerce", Component: "Layout", Icon: "wallet", Type: "catalog", Permission: "commerce:view", MenuArea: 1, Children: []model.Menu{
{Id: 31, ParentId: 3, Title: "套餐管理", Path: "/commerce/plans", Component: "PlanList", Icon: "package", Type: "menu", Permission: "plan:view", MenuArea: 2},
{Id: 32, ParentId: 3, Title: "订单管理", Path: "/commerce/orders", Component: "OrderList", Icon: "orders", Type: "menu", Permission: "order:view", MenuArea: 2},
}},
{Id: 4, ParentId: 0, Title: "系统管理", Path: "/system", Component: "Layout", Icon: "setting", Type: "catalog", Permission: "system:view", MenuArea: 1, Children: []model.Menu{
{Id: 41, ParentId: 4, Title: "管理员", Path: "/system/admins", Component: "AdminList", Icon: "user", Type: "menu", Permission: "admin:view", MenuArea: 2},
{Id: 42, ParentId: 4, Title: "角色权限", Path: "/system/roles", Component: "RoleList", Icon: "safety", Type: "menu", Permission: "role:view", MenuArea: 2},
{Id: 43, ParentId: 4, Title: "菜单管理", Path: "/system/menus", Component: "MenuList", Icon: "menu", Type: "menu", Permission: "menu:view", MenuArea: 2},
}},
}
}
// MenuLayout 返回全局默认布局和用户最终布局。
func (s AdminService) MenuLayout() model.MenuLayout {
return model.MenuLayout{GlobalLayout: "double", UserLayout: "", FinalLayout: "double"}
}
// UpdateMenuLayout 保存管理员个人菜单布局偏好。
func (s AdminService) UpdateMenuLayout(layout string) model.MenuLayout {
if layout != "single" && layout != "double" {
layout = "double"
}
return model.MenuLayout{GlobalLayout: "double", UserLayout: layout, FinalLayout: layout}
}
// Roles 返回后台角色占位数据。
func (s AdminService) Roles() []map[string]interface{} {
return []map[string]interface{}{{"id": 1, "name": "超级管理员", "code": "super_admin"}, {"id": 2, "name": "运营人员", "code": "operator"}}
}
// SaveRoleMenus 保存角色菜单授权占位结果。
func (s AdminService) SaveRoleMenus(roleId int, menuIds []int) map[string]interface{} {
return map[string]interface{}{"role_id": roleId, "menu_ids": menuIds, "saved": true}
}
// SitePages 返回官网页面配置,支持每个页面独立维护 SEO 和 GSO/GEO 内容。
func (s AdminService) SitePages() []model.SitePage {
adminStore.RLock()
defer adminStore.RUnlock()
return copyPages(adminStore.pages)
}
// SaveSitePage 保存页面 SEO/GSO 配置;一期先写入内存,后续替换为 nl_site_page 持久化。
func (s AdminService) SaveSitePage(page model.SitePage) model.SitePage {
adminStore.Lock()
defer adminStore.Unlock()
if page.Id == 0 {
page.Id = nextPageId(adminStore.pages)
}
if page.GsoContent == nil {
page.GsoContent = map[string]interface{}{}
}
adminStore.pages = upsertPage(adminStore.pages, page)
return page
}
// SiteSections 返回官网页面模块配置,可按页面编码过滤。
func (s AdminService) SiteSections(pageCode string) []model.SiteSection {
adminStore.RLock()
defer adminStore.RUnlock()
sections := make([]model.SiteSection, 0)
for _, section := range adminStore.sections {
if pageCode == "" || section.PageCode == pageCode {
sections = append(sections, section)
}
}
sortSections(sections)
return sections
}
// SaveSiteSection 保存官网页面模块;内容字段保持 JSON 结构,便于后台控制标题、按钮、列表和图片。
func (s AdminService) SaveSiteSection(section model.SiteSection) model.SiteSection {
adminStore.Lock()
defer adminStore.Unlock()
if section.Id == 0 {
section.Id = nextSectionId(adminStore.sections)
}
if section.Content == nil {
section.Content = map[string]interface{}{}
}
adminStore.sections = upsertSection(adminStore.sections, section)
return section
}
// SiteMedia 返回官网素材,覆盖 LOGO、favicon、页面图片、分享图和招聘图。
func (s AdminService) SiteMedia(mediaType string) []model.SiteMedia {
adminStore.RLock()
defer adminStore.RUnlock()
items := make([]model.SiteMedia, 0)
for _, item := range adminStore.media {
if mediaType == "" || item.MediaType == mediaType {
items = append(items, item)
}
}
sortMedia(items)
return items
}
// SaveSiteMedia 保存官网素材 URL真实上传和对象存储可在后续扩展。
func (s AdminService) SaveSiteMedia(media model.SiteMedia) model.SiteMedia {
adminStore.Lock()
defer adminStore.Unlock()
if media.Id == 0 {
media.Id = nextMediaId(adminStore.media)
}
adminStore.media = upsertMedia(adminStore.media, media)
return media
}
// RecruitJobs 返回后台招聘职位列表。
func (s AdminService) RecruitJobs() []model.RecruitJob {
adminStore.RLock()
defer adminStore.RUnlock()
jobs := append([]model.RecruitJob(nil), adminStore.jobs...)
sortJobs(jobs)
return jobs
}
// SaveRecruitJob 保存招聘职位;一期先写入内存,后续接入 nl_recruit_job。
func (s AdminService) SaveRecruitJob(job model.RecruitJob) model.RecruitJob {
adminStore.Lock()
defer adminStore.Unlock()
if job.Id == 0 {
job.Id = nextJobId(adminStore.jobs)
}
if job.Requirements == nil {
job.Requirements = []string{}
}
if job.PublishedDate == "" {
job.PublishedDate = "2026-07-05"
}
adminStore.jobs = upsertJob(adminStore.jobs, job)
return job
}
// SiteSettings 返回站点全局配置,页面未配置时使用这里的默认值。
func (s AdminService) SiteSettings() model.SiteSettings {
adminStore.RLock()
defer adminStore.RUnlock()
return copySettings(adminStore.settings)
}
// SaveSiteSettings 保存站点全局配置包含标题、LOGO、ico、SEO、GSO/GEO 和高德地图配置。
func (s AdminService) SaveSiteSettings(settings model.SiteSettings) model.SiteSettings {
adminStore.Lock()
defer adminStore.Unlock()
if settings.DefaultSeo == nil {
settings.DefaultSeo = map[string]interface{}{}
}
if settings.DefaultGso == nil {
settings.DefaultGso = map[string]interface{}{}
}
adminStore.settings = copySettings(settings)
return copySettings(adminStore.settings)
}
func defaultSiteSettings() model.SiteSettings {
return model.SiteSettings{
SiteTitle: "萧康云医官网",
SiteLogo: "/src/assets/images/logo.png",
SiteLogoDark: "/src/assets/images/logo-white.png",
SiteFavicon: "/favicon.ico",
IcpText: "浙ICP备0000000号",
Copyright: "Copyright © 萧康云医",
ContactPhone: "0571-00000000",
ContactEmail: "contact@xiaokang.example",
ContactAddress: "杭州市萧山区鸿盛路与高新六路交叉口东200米",
DefaultSeo: map[string]interface{}{
"title": "萧康云医官网",
"keywords": "诊所SaaS,云医,诊所管理",
"description": "萧康云医为诊所提供获客、预约、接诊、复诊和经营分析能力。",
},
DefaultGso: map[string]interface{}{
"brand": "萧康云医",
"summary": "面向诊所赋能的 SaaS 平台",
"services": []string{"患者端", "医生端", "老板端", "推广员端", "PC后台"},
},
AmapKey: "",
AmapSecurityCode: "",
}
}
func defaultSitePages() []model.SitePage {
return []model.SitePage{
{Id: 1, Code: "home", Title: "首页", SeoTitle: "萧康云医官网", SeoKeywords: "诊所SaaS,诊所管理,云医", SeoDescription: "萧康云医为诊所提供获客、预约、接诊、复诊和经营分析能力。", GsoContent: map[string]interface{}{"summary": "面向诊所赋能的 SaaS 平台", "entities": []string{"萧康云医", "诊所SaaS"}}, Status: 1, Sort: 1},
{Id: 2, Code: "features", Title: "产品能力", SeoTitle: "萧康云医产品能力", SeoKeywords: "患者端,医生端,推广员端,PC后台", SeoDescription: "了解萧康云医患者端、医生端、老板端、推广员端和后台能力。", GsoContent: map[string]interface{}{"summary": "多端协同产品能力"}, Status: 1, Sort: 2},
{Id: 3, Code: "pricing", Title: "价格方案", SeoTitle: "萧康云医价格", SeoKeywords: "SaaS定价,诊所套餐", SeoDescription: "查看萧康云医基础版、增长版、连锁版套餐。", GsoContent: map[string]interface{}{"summary": "诊所 SaaS 套餐价格"}, Status: 1, Sort: 3},
{Id: 4, Code: "careers", Title: "加入我们", SeoTitle: "加入萧康云医", SeoKeywords: "招聘,医疗SaaS招聘", SeoDescription: "加入萧康云医团队,共建诊所赋能平台。", GsoContent: map[string]interface{}{"summary": "萧康云医招聘职位"}, Status: 1, Sort: 4},
}
}
func defaultSiteSections() []model.SiteSection {
return []model.SiteSection{
{Id: 1, PageCode: "features", SectionKey: "hero", Title: "能力地图", Subtitle: "多端协同与经营后台", Content: map[string]interface{}{"layout": "capability_map", "image": "/src/assets/images/features0001.jpeg"}, Status: 1, Sort: 1},
{Id: 2, PageCode: "features", SectionKey: "workflow", Title: "流程切片", Subtitle: "获客到复诊", Content: map[string]interface{}{"steps": []string{"获客", "预约", "接诊", "复诊", "复盘"}}, Status: 1, Sort: 2},
{Id: 3, PageCode: "pricing", SectionKey: "plans", Title: "套餐矩阵", Subtitle: "月付/年付与权益对比", Content: map[string]interface{}{"billing": []string{"month", "year"}}, Status: 1, Sort: 1},
}
}
func defaultSiteMedia() []model.SiteMedia {
return []model.SiteMedia{
{Id: 1, MediaType: "logo", GroupCode: "brand", Title: "站点LOGO", Url: "/src/assets/images/logo.png", Alt: "萧康云医LOGO", Status: 1, Sort: 1},
{Id: 2, MediaType: "favicon", GroupCode: "brand", Title: "favicon", Url: "/favicon.ico", Alt: "萧康云医图标", Status: 1, Sort: 2},
{Id: 3, MediaType: "page_image", GroupCode: "features", Title: "产品能力首图", Url: "/src/assets/images/features0001.jpeg", Alt: "萧康云医产品能力", Status: 1, Sort: 3},
{Id: 4, MediaType: "share_image", GroupCode: "seo", Title: "默认分享图", Url: "/src/assets/images/home0001.jpeg", Alt: "萧康云医官网分享图", Status: 1, Sort: 4},
}
}
func defaultRecruitJobs() []model.RecruitJob {
return []model.RecruitJob{
{Id: 1, Title: "高级前端开发工程师", Department: "技术部", JobType: "全职", City: "杭州", Salary: "25K-45K", Experience: "5年以上", Description: "负责萧康云医官网、后台和小程序端前端架构与体验优化。", Requirements: []string{"熟悉 Vue 3 与工程化", "有中后台或 SaaS 项目经验", "关注性能、可访问性和组件化"}, Manager: "张经理", ManagerTitle: "技术负责人", Email: "tech@xiaokang.example", Phone: "0571-00000000", Featured: true, Status: 1, Sort: 1, PublishedDate: "2026-07-05"},
{Id: 2, Title: "诊所运营顾问", Department: "运营部", JobType: "全职", City: "杭州", Salary: "12K-22K", Experience: "3年以上", Description: "面向诊所客户完成系统演示、上线辅导和经营复盘。", Requirements: []string{"熟悉医疗或本地生活服务行业", "具备客户培训和项目推进能力", "表达清晰,能沉淀方法论"}, Manager: "王经理", ManagerTitle: "运营负责人", Email: "ops@xiaokang.example", Phone: "0571-00000001", Featured: false, Status: 1, Sort: 2, PublishedDate: "2026-07-05"},
}
}
func copyMap(source map[string]interface{}) map[string]interface{} {
if source == nil {
return map[string]interface{}{}
}
result := make(map[string]interface{}, len(source))
for key, value := range source {
result[key] = value
}
return result
}
func copySettings(settings model.SiteSettings) model.SiteSettings {
settings.DefaultSeo = copyMap(settings.DefaultSeo)
settings.DefaultGso = copyMap(settings.DefaultGso)
return settings
}
func copyPages(pages []model.SitePage) []model.SitePage {
items := append([]model.SitePage(nil), pages...)
sort.SliceStable(items, func(i, j int) bool {
return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id
})
return items
}
func upsertPage(items []model.SitePage, item model.SitePage) []model.SitePage {
for index := range items {
if items[index].Id == item.Id || item.Code != "" && items[index].Code == item.Code {
items[index] = item
return items
}
}
return append(items, item)
}
func upsertSection(items []model.SiteSection, item model.SiteSection) []model.SiteSection {
for index := range items {
if items[index].Id == item.Id || item.SectionKey != "" && items[index].PageCode == item.PageCode && items[index].SectionKey == item.SectionKey {
items[index] = item
return items
}
}
return append(items, item)
}
func upsertMedia(items []model.SiteMedia, item model.SiteMedia) []model.SiteMedia {
for index := range items {
if items[index].Id == item.Id {
items[index] = item
return items
}
}
return append(items, item)
}
func upsertJob(items []model.RecruitJob, item model.RecruitJob) []model.RecruitJob {
for index := range items {
if items[index].Id == item.Id {
items[index] = item
return items
}
}
return append(items, item)
}
func sortSections(items []model.SiteSection) {
sort.SliceStable(items, func(i, j int) bool {
return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id
})
}
func sortMedia(items []model.SiteMedia) {
sort.SliceStable(items, func(i, j int) bool {
return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id
})
}
func sortJobs(items []model.RecruitJob) {
sort.SliceStable(items, func(i, j int) bool {
return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id
})
}
func nextPageId(items []model.SitePage) int {
maxId := 0
for _, item := range items {
if item.Id > maxId {
maxId = item.Id
}
}
return maxId + 1
}
func nextSectionId(items []model.SiteSection) int {
maxId := 0
for _, item := range items {
if item.Id > maxId {
maxId = item.Id
}
}
return maxId + 1
}
func nextMediaId(items []model.SiteMedia) int {
maxId := 0
for _, item := range items {
if item.Id > maxId {
maxId = item.Id
}
}
return maxId + 1
}
func nextJobId(items []model.RecruitJob) int {
maxId := 0
for _, item := range items {
if item.Id > maxId {
maxId = item.Id
}
}
return maxId + 1
}