From 269fbd08ff575612b0d571dbeae690d89bd35e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Tue, 2 Jun 2026 23:44:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=A1=A5=E5=8F=B7=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/api_getcard.go | 132 ++-- controllers/backend_article.go | 954 +++++++++++++++++++++++++++ controllers/backend_domain.go | 600 +++++++++++++++++ controllers/domain_common.go | 21 + controllers/platform_account_pool.go | 185 ++++-- controllers/platform_domain.go | 15 - controllers/pool_probe.go | 63 ++ docs/DEPLOYMENT_CHECKLIST.md | 392 ----------- docs/IMPLEMENTATION_COMPLETE.md | 404 ------------ docs/QUICK_START.md | 122 ---- docs/README_STORAGE.md | 210 ------ docs/storage-config-guide.md | 253 ------- docs/修复请求体为空问题.md | 200 ------ docs/快速修复-请求体为空.md | 122 ---- docs/接口文件.md | 26 - docs/立即执行-重启服务.md | 218 ------ models/cms_article.go | 159 +++++ models/init.go | 2 + models/system_tenant_domain.go | 2 +- pkg/tokenprobe/probe.go | 93 ++- routers/backend/backend.go | 69 +- 21 files changed, 2130 insertions(+), 2112 deletions(-) create mode 100644 controllers/backend_article.go create mode 100644 controllers/backend_domain.go create mode 100644 controllers/domain_common.go create mode 100644 controllers/pool_probe.go delete mode 100644 docs/DEPLOYMENT_CHECKLIST.md delete mode 100644 docs/IMPLEMENTATION_COMPLETE.md delete mode 100644 docs/QUICK_START.md delete mode 100644 docs/README_STORAGE.md delete mode 100644 docs/storage-config-guide.md delete mode 100644 docs/修复请求体为空问题.md delete mode 100644 docs/快速修复-请求体为空.md delete mode 100644 docs/接口文件.md delete mode 100644 docs/立即执行-重启服务.md create mode 100644 models/cms_article.go diff --git a/controllers/api_getcard.go b/controllers/api_getcard.go index 886f307..06a6cae 100644 --- a/controllers/api_getcard.go +++ b/controllers/api_getcard.go @@ -91,7 +91,7 @@ func (c *ApiGetCardController) GetCard() { } func (c *ApiGetCardController) extractCursor(platform, dataType string, now time.Time) { - for { + c.extractWithProbe("cursor", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) { var row models.PlatformAccountPoolCursor qs := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). Filter("is_extracted", 0). @@ -100,6 +100,55 @@ func (c *ApiGetCardController) extractCursor(platform, dataType string, now time qs = qs.Filter("data_type", dataType) } if err := qs.OrderBy("id").One(&row); err != nil { + return 0, nil, nil, "", "", nil, err + } + return row.ID, &row.Account, &row.Password, row.Token, row.DataType, row.IsUsed, nil + }) +} + +func (c *ApiGetCardController) extractWindsurf(platform, dataType string, now time.Time) { + c.extractWithProbe("windsurf", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) { + var row models.PlatformAccountPoolWindsurf + qs := models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)). + Filter("is_extracted", 0). + Filter("delete_time__isnull", true) + if dataType != "" { + qs = qs.Filter("data_type", dataType) + } + if err := qs.OrderBy("id").One(&row); err != nil { + return 0, nil, nil, "", "", nil, err + } + return row.ID, &row.Account, &row.Password, row.Token, row.DataType, nil, nil + }) +} + +func (c *ApiGetCardController) extractKrio(platform, dataType string, now time.Time) { + c.extractWithProbe("krio", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) { + var row models.PlatformAccountPoolKiro + qs := models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)). + Filter("is_extracted", 0). + Filter("delete_time__isnull", true) + if dataType != "" { + qs = qs.Filter("data_type", dataType) + } + if err := qs.OrderBy("id").One(&row); err != nil { + return 0, nil, nil, "", "", nil, err + } + return row.ID, &row.Account, &row.Password, row.Token, row.DataType, nil, nil + }) +} + +type poolRowFetcher func() (id uint64, account, password *string, token, rowDataType string, isUsed *int8, err error) + +// extractWithProbe 按 id 顺序提取并探测 Token 可用性;不可用则标记已提取并继续下一条。 +func (c *ApiGetCardController) extractWithProbe( + module, platform, dataType string, + now time.Time, + fetch poolRowFetcher, +) { + for { + id, account, password, token, rowDataType, isUsed, err := fetch() + if err != nil { if err == orm.ErrNoRows { c.cardErr(404, 404, "暂无可用卡密") } else { @@ -108,85 +157,40 @@ func (c *ApiGetCardController) extractCursor(platform, dataType string, now time return } - _, err := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). - Filter("id", row.ID). + tableName := poolTableName(module) + if tableName == "" { + c.cardErr(500, 500, "无效模块") + return + } + _, err = models.Orm.QueryTable(tableName). + Filter("id", id). Update(map[string]interface{}{ "is_extracted": 1, "extracted_time": now, "extracted_platform": platform, + "update_time": now, }) if err != nil { c.cardErr(500, 500, "提取失败") return } - // Cursor 号池需要先判断可用状态:is_used=1 才发送给前端; - // is_used=0(已用完/不可用)或 NULL(未探测)则继续提取下一条。 - if row.IsUsed != nil && *row.IsUsed == 1 { - c.cardOK(buildCardResult(&row.Account, &row.Password, row.Token, row.DataType)) - return + // 已有探测结论:可用则直接返回,不可用则继续下一条。 + if known, available := poolIsUsedAvailable(isUsed); known { + if available { + c.cardOK(buildCardResult(account, password, token, rowDataType)) + return + } + continue } - } -} -func (c *ApiGetCardController) extractWindsurf(platform, dataType string, now time.Time) { - var row models.PlatformAccountPoolWindsurf - qs := models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)). - Filter("is_extracted", 0). - Filter("delete_time__isnull", true) - if dataType != "" { - qs = qs.Filter("data_type", dataType) - } - if err := qs.OrderBy("id").One(&row); err != nil { - if err == orm.ErrNoRows { - c.cardErr(404, 404, "暂无可用卡密") - } else { - c.cardErr(500, 500, "查询失败") + if !poolProbeToken(module, rowDataType, token, id) { + continue } - return - } - _, err := models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)). - Filter("id", row.ID). - Update(map[string]interface{}{ - "is_extracted": 1, - "extracted_time": now, - "extracted_platform": platform, - }) - if err != nil { - c.cardErr(500, 500, "提取失败") - return - } - c.cardOK(buildCardResult(&row.Account, &row.Password, row.Token, row.DataType)) -} -func (c *ApiGetCardController) extractKrio(platform, dataType string, now time.Time) { - var row models.PlatformAccountPoolKiro - qs := models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)). - Filter("is_extracted", 0). - Filter("delete_time__isnull", true) - if dataType != "" { - qs = qs.Filter("data_type", dataType) - } - if err := qs.OrderBy("id").One(&row); err != nil { - if err == orm.ErrNoRows { - c.cardErr(404, 404, "暂无可用卡密") - } else { - c.cardErr(500, 500, "查询失败") - } + c.cardOK(buildCardResult(account, password, token, rowDataType)) return } - _, err := models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)). - Filter("id", row.ID). - Update(map[string]interface{}{ - "is_extracted": 1, - "extracted_time": now, - "extracted_platform": platform, - }) - if err != nil { - c.cardErr(500, 500, "提取失败") - return - } - c.cardOK(buildCardResult(&row.Account, &row.Password, row.Token, row.DataType)) } // buildCardResult 根据账号类型返回格式化字符串 diff --git a/controllers/backend_article.go b/controllers/backend_article.go new file mode 100644 index 0000000..7c3b7a6 --- /dev/null +++ b/controllers/backend_article.go @@ -0,0 +1,954 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendArticleController CMS 文章管理 +type BackendArticleController struct { + beego.Controller +} + +// BackendArticleCategoryController CMS 文章分类管理 +type BackendArticleCategoryController struct { + beego.Controller +} + +func (c *BackendArticleController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendArticleCategoryController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func cmsBackendClaims(c *beego.Controller) (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func cmsEffectiveTid(c *beego.Controller, claims *jwtutil.Claims) uint64 { + _ = c.ParseForm(1 << 20) + if tid, err := c.GetUint64("tid"); err == nil && tid > 0 { + return tid + } + if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-Id")); h != "" { + if v, e := strconv.ParseUint(h, 10, 64); e == nil { + return v + } + } + if claims != nil && claims.TenantId > 0 { + return uint64(claims.TenantId) + } + return 0 +} + +func (c *BackendArticleController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendArticleCategoryController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func cmsEnsureTables(c *beego.Controller) bool { + if err := models.EnsureCmsArticleTables(); err != nil { + c.Ctx.Output.SetStatus(500) + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "初始化文章表失败: " + err.Error()} + _ = c.ServeJSON() + return false + } + return true +} + +func cmsParseUintArg(v interface{}) uint64 { + switch x := v.(type) { + case float64: + if x > 0 { + return uint64(x) + } + case string: + if n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 64); err == nil { + return n + } + } + return 0 +} + +func cmsArticleToListItem(row models.CmsArticle, cateName string) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "title": row.Title, + "author": row.Author, + "cate": cateName, + "cate_id": row.CateID, + "status": row.Status, + "views": row.Views, + "likes": row.Likes, + "top": row.Top, + "recommend": row.Recommend, + "publish_date": models.CmsFormatTime(row.PublishTime), + "update_time": models.CmsFormatTime(row.UpdateTime), + } +} + +func cmsArticleToDetail(row models.CmsArticle, cateName string) map[string]interface{} { + pub := models.CmsFormatTime(row.PublishTime) + return map[string]interface{}{ + "id": row.ID, + "title": row.Title, + "author": row.Author, + "cate": cateName, + "cate_id": row.CateID, + "content": row.Content, + "desc": row.Desc, + "image": row.Image, + "is_trans": row.IsTrans, + "transurl": row.TransURL, + "status": row.Status, + "views": row.Views, + "view_count": row.Views, + "likes": row.Likes, + "top": row.Top, + "recommend": row.Recommend, + "publish_time": pub, + "publish_date": pub, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + "update_time": models.CmsFormatTime(row.UpdateTime), + } +} + +func cmsCategoryToMap(row models.CmsArticleCategory) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "name": row.Name, + "label": row.Name, + "cid": row.Cid, + "parentId": row.Cid, + "image": row.Image, + "desc": row.Desc, + "remark": row.Desc, + "sort": row.Sort, + "status": row.Status, + } +} + +// List GET /backend/articlesList +func (c *BackendArticleController) List() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 200 { + pageSize = 200 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + cateFilter := strings.TrimSpace(c.GetString("cate")) + + qs := models.Orm.QueryTable(new(models.CmsArticle)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("title__icontains", keyword) + } + if cateFilter != "" { + if cid, err := strconv.ParseUint(cateFilter, 10, 64); err == nil && cid > 0 { + qs = qs.Filter("cate_id", cid) + } + } + + total, _ := qs.Count() + var rows []models.CmsArticle + offset := (page - 1) * pageSize + _, err = qs.OrderBy("-top", "-id").Limit(pageSize, offset).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取文章列表失败") + return + } + + cateIDs := make([]uint64, 0, len(rows)) + for _, r := range rows { + if r.CateID > 0 { + cateIDs = append(cateIDs, r.CateID) + } + } + cateNames := models.CmsCategoryNameMap(tid, cateIDs) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsArticleToListItem(r, cateNames[r.CateID])) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total}, + } + _ = c.ServeJSON() +} + +// ListAll GET /backend/allarticles +func (c *BackendArticleController) ListAll() { + c.List() +} + +// Detail GET /backend/articles/:id +func (c *BackendArticleController) Detail() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + var row models.CmsArticle + err = models.Orm.QueryTable(new(models.CmsArticle)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&row) + if err == orm.ErrNoRows { + c.cmsJSONErr(404, 404, "文章不存在") + return + } + if err != nil { + c.cmsJSONErr(500, 500, "查询失败") + return + } + + cateName := "" + if row.CateID > 0 { + names := models.CmsCategoryNameMap(tid, []uint64{row.CateID}) + cateName = names[row.CateID] + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": cmsArticleToDetail(row, cateName), + } + _ = c.ServeJSON() +} + +type cmsArticlePayload struct { + Title string `json:"title"` + Author string `json:"author"` + Cate interface{} `json:"cate"` + Content string `json:"content"` + Desc string `json:"desc"` + Image string `json:"image"` + IsTrans int8 `json:"is_trans"` + TransURL *string `json:"transurl"` + Status int8 `json:"status"` + IgnoreSimilarity int `json:"ignore_similarity"` +} + +// Create POST /backend/createarticle +func (c *BackendArticleController) Create() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsArticlePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "标题不能为空") + return + } + + if p.IgnoreSimilarity != 1 { + similar, serr := models.CmsSimilarArticles(tid, title, 5) + if serr == nil && len(similar) > 0 { + c.Ctx.Output.SetStatus(409) + c.Data["json"] = map[string]interface{}{ + "code": 409, + "msg": "检测到相似标题", + "data": map[string]interface{}{"similar_articles": similar}, + } + _ = c.ServeJSON() + return + } + } + + now := time.Now() + cateID := cmsParseUintArg(p.Cate) + row := models.CmsArticle{ + Tid: tid, + Title: title, + Author: strings.TrimSpace(p.Author), + CateID: cateID, + Content: p.Content, + Desc: strings.TrimSpace(p.Desc), + Image: strings.TrimSpace(p.Image), + IsTrans: p.IsTrans, + TransURL: p.TransURL, + Status: p.Status, + CreateTime: now, + UpdateTime: &now, + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "创建失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "创建成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update POST /backend/editarticle/:id +func (c *BackendArticleController) Update() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsArticlePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + now := time.Now() + fields := map[string]interface{}{ + "title": strings.TrimSpace(p.Title), + "author": strings.TrimSpace(p.Author), + "cate_id": cmsParseUintArg(p.Cate), + "content": p.Content, + "desc": strings.TrimSpace(p.Desc), + "image": strings.TrimSpace(p.Image), + "is_trans": p.IsTrans, + "transurl": p.TransURL, + "status": p.Status, + "update_time": now, + } + n, err := models.Orm.QueryTable(new(models.CmsArticle)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(fields) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "文章不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/deletearticle/:id +func (c *BackendArticleController) Delete() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsArticle)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "文章不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +func (c *BackendArticleController) setArticleFlag(field string, value int8) { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + fields := map[string]interface{}{field: value, "update_time": now} + n, err := models.Orm.QueryTable(new(models.CmsArticle)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(fields) + if err != nil { + c.cmsJSONErr(500, 500, "操作失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "文章不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +func (c *BackendArticleController) Publish() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + var uid uint64 + raw, _ := io.ReadAll(c.Ctx.Request.Body) + if len(raw) > 0 { + var body struct { + UID uint64 `json:"uid"` + } + _ = json.Unmarshal(raw, &body) + uid = body.UID + } + if uid == 0 && claims != nil { + uid = uint64(claims.UserID) + } + + now := time.Now() + fields := map[string]interface{}{ + "status": int8(2), + "publish_time": now, + "publisher_id": uid, + "update_time": now, + } + n, err := models.Orm.QueryTable(new(models.CmsArticle)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(fields) + if err != nil { + c.cmsJSONErr(500, 500, "发布失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "文章不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "发布成功"} + _ = c.ServeJSON() +} + +func (c *BackendArticleController) Unpublish() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsArticle)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"status": int8(3), "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "下架失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "文章不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "下架成功"} + _ = c.ServeJSON() +} + +func (c *BackendArticleController) Recommend() { c.setArticleFlag("recommend", 1) } +func (c *BackendArticleController) Unrecommend() { c.setArticleFlag("recommend", 0) } +func (c *BackendArticleController) Top() { c.setArticleFlag("top", 1) } +func (c *BackendArticleController) Untop() { c.setArticleFlag("top", 0) } + +// List GET /backend/categories +func (c *BackendArticleCategoryController) List() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 0) + if pageSize == 0 { + pageSize, _ = c.GetInt("limit", 1000) + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 1000 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + qs := models.Orm.QueryTable(new(models.CmsArticleCategory)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + + total, _ := qs.Count() + var rows []models.CmsArticleCategory + offset := (page - 1) * pageSize + _, err = qs.OrderBy("sort", "id").Limit(pageSize, offset).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取分类失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsCategoryToMap(r)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total, "records": list}, + } + _ = c.ServeJSON() +} + +// ListAll GET /backend/allcategories +func (c *BackendArticleCategoryController) ListAll() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + qs := models.Orm.QueryTable(new(models.CmsArticleCategory)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + + var rows []models.CmsArticleCategory + _, err = qs.OrderBy("sort", "id").All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取分类失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsCategoryToMap(r)) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": list} + _ = c.ServeJSON() +} + +// Detail GET /backend/categories/:id +func (c *BackendArticleCategoryController) Detail() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + var row models.CmsArticleCategory + err = models.Orm.QueryTable(new(models.CmsArticleCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&row) + if err == orm.ErrNoRows { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + if err != nil { + c.cmsJSONErr(500, 500, "查询失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": cmsCategoryToMap(row)} + _ = c.ServeJSON() +} + +type cmsCategoryPayload struct { + Name string `json:"name"` + Image string `json:"image"` + Desc string `json:"desc"` + Sort int `json:"sort"` + Status int8 `json:"status"` + Cid uint64 `json:"cid"` +} + +// Create POST /backend/createCategory +func (c *BackendArticleCategoryController) Create() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsCategoryPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + name := strings.TrimSpace(p.Name) + if name == "" { + c.cmsJSONErr(400, 400, "分类名称不能为空") + return + } + + now := time.Now() + row := models.CmsArticleCategory{ + Tid: tid, + Cid: p.Cid, + Name: name, + Image: strings.TrimSpace(p.Image), + Desc: strings.TrimSpace(p.Desc), + Sort: p.Sort, + Status: p.Status, + CreateTime: now, + UpdateTime: &now, + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "创建失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "创建成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update POST /backend/editCategory/:id +func (c *BackendArticleCategoryController) Update() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsCategoryPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsArticleCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{ + "name": strings.TrimSpace(p.Name), + "image": strings.TrimSpace(p.Image), + "desc": strings.TrimSpace(p.Desc), + "sort": p.Sort, + "status": p.Status, + "cid": p.Cid, + "update_time": now, + }) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/categories/:id +func (c *BackendArticleCategoryController) Delete() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + childCnt, _ := models.Orm.QueryTable(new(models.CmsArticleCategory)). + Filter("tid", tid). + Filter("cid", id). + Filter("delete_time__isnull", true). + Count() + if childCnt > 0 { + c.cmsJSONErr(400, 400, "请先删除子分类") + return + } + + articleCnt, _ := models.Orm.QueryTable(new(models.CmsArticle)). + Filter("tid", tid). + Filter("cate_id", id). + Filter("delete_time__isnull", true). + Count() + if articleCnt > 0 { + c.cmsJSONErr(400, 400, "该分类下还有文章,无法删除") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsArticleCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// UpdateStatus PATCH /backend/categories/:id/status +func (c *BackendArticleCategoryController) UpdateStatus() { + if !cmsEnsureTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p struct { + Status int8 `json:"status"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsArticleCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"status": p.Status, "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} diff --git a/controllers/backend_domain.go b/controllers/backend_domain.go new file mode 100644 index 0000000..c3e701b --- /dev/null +++ b/controllers/backend_domain.go @@ -0,0 +1,600 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendDomainPoolController 主域名池管理 +type BackendDomainPoolController struct { + beego.Controller +} + +// BackendTenantDomainController 租户域名管理 +type BackendTenantDomainController struct { + beego.Controller +} + +func requireBackend(c *beego.Controller) (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +// ===== 主域名池 ===== + +// Index GET /backend/domain/pool/index?page=&pageSize=&main_domain=&status= +func (c *BackendDomainPoolController) Index() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 200 { + pageSize = 200 + } + + mainDomain := strings.TrimSpace(c.GetString("main_domain")) + statusStr := strings.TrimSpace(c.GetString("status")) + + qs := models.Orm.QueryTable(new(models.SystemDomainPool)).Filter("delete_time__isnull", true) + if mainDomain != "" { + qs = qs.Filter("main_domain__icontains", mainDomain) + } + if statusStr != "" { + if st, err := strconv.Atoi(statusStr); err == nil { + qs = qs.Filter("status", st) + } + } + + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取主域名池失败: "+err.Error()) + return + } + var rows []models.SystemDomainPool + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取主域名池失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + item := map[string]interface{}{ + "id": rows[i].ID, + "main_domain": rows[i].MainDomain, + "status": rows[i].Status, + "create_time": rows[i].CreateTime.Format("2006-01-02 15:04:05"), + "update_time": "", + } + if rows[i].UpdateTime != nil { + item["update_time"] = rows[i].UpdateTime.Format("2006-01-02 15:04:05") + } + list = append(list, item) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// GetEnabledDomains GET /backend/domain/pool/getEnabledDomains +func (c *BackendDomainPoolController) GetEnabledDomains() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + var rows []models.SystemDomainPool + _, err := models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("status", 1). + Filter("delete_time__isnull", true). + OrderBy("-id"). + All(&rows) + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取主域名失败: "+err.Error()) + return + } + out := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + out = append(out, map[string]interface{}{ + "id": rows[i].ID, + "main_domain": rows[i].MainDomain, + "status": rows[i].Status, + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + +// Create POST /backend/domain/pool/create +func (c *BackendDomainPoolController) Create() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var p domainPoolPayload + if err := json.Unmarshal(raw, &p); err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + md := strings.TrimSpace(p.MainDomain) + if md == "" { + jsonErr(&c.Controller, 400, 400, "主域名不能为空") + return + } + if p.Status != 0 && p.Status != 1 { + p.Status = 1 + } + // 简单去重 + cnt, _ := models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("main_domain", md). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + jsonErr(&c.Controller, 400, 400, "主域名已存在") + return + } + row := &models.SystemDomainPool{MainDomain: md, Status: p.Status} + if _, err := models.Orm.Insert(row); err != nil { + jsonErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "创建成功"} + _ = c.ServeJSON() +} + +// Update POST /backend/domain/pool/update +func (c *BackendDomainPoolController) Update() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var p domainPoolPayload + if err := json.Unmarshal(raw, &p); err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + if p.ID == 0 { + jsonErr(&c.Controller, 400, 400, "id 不能为空") + return + } + md := strings.TrimSpace(p.MainDomain) + if md == "" { + jsonErr(&c.Controller, 400, 400, "主域名不能为空") + return + } + if p.Status != 0 && p.Status != 1 { + p.Status = 1 + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("id", p.ID). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"main_domain": md, "status": p.Status, "update_time": now}) + if err != nil { + jsonErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) + return + } + if n == 0 { + jsonErr(&c.Controller, 404, 404, "记录不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/domain/pool/delete/:id +func (c *BackendDomainPoolController) Delete() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + jsonErr(&c.Controller, 400, 400, "无效ID") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error()) + return + } + if n == 0 { + jsonErr(&c.Controller, 404, 404, "记录不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// ToggleStatus POST /backend/domain/pool/toggleStatus body:{id} +func (c *BackendDomainPoolController) ToggleStatus() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var p struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var row models.SystemDomainPool + if err := models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("id", p.ID). + Filter("delete_time__isnull", true). + One(&row); err != nil { + jsonErr(&c.Controller, 404, 404, "记录不存在") + return + } + newStatus := int8(1) + if row.Status == 1 { + newStatus = 0 + } + now := time.Now() + _, err = models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("id", p.ID). + Update(map[string]interface{}{"status": newStatus, "update_time": now}) + if err != nil { + jsonErr(&c.Controller, 500, 500, "切换失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// ===== 租户域名 ===== + +// Index GET /backend/domain/tenant/index?page=&pageSize=&tid=&status=&sub_domain= +func (c *BackendTenantDomainController) Index() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 200 { + pageSize = 200 + } + + tid, _ := c.GetUint64("tid") + statusStr := strings.TrimSpace(c.GetString("status")) + subDomain := strings.TrimSpace(c.GetString("sub_domain")) + + qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("delete_time__isnull", true) + if tid > 0 { + qs = qs.Filter("tid", tid) + } + if statusStr != "" { + if st, err := strconv.Atoi(statusStr); err == nil { + qs = qs.Filter("status", st) + } + } + if subDomain != "" { + qs = qs.Filter("sub_domain__icontains", subDomain) + } + + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error()) + return + } + var rows []models.SystemTenantDomain + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error()) + return + } + list := make([]models.SystemTenantDomain, 0, len(rows)) + list = append(list, rows...) + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total}, + } + _ = c.ServeJSON() +} + +// MyDomains GET /backend/domain/tenant/myDomains?tid=1 +func (c *BackendTenantDomainController) MyDomains() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + tid, _ := c.GetUint64("tid") + if tid == 0 { + jsonErr(&c.Controller, 400, 400, "租户ID不能为空") + return + } + var rows []models.SystemTenantDomain + _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + OrderBy("-id"). + All(&rows) + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": rows} + _ = c.ServeJSON() +} + +// Apply POST /backend/domain/tenant/apply body:{tid,sub_domain,main_domain} +func (c *BackendTenantDomainController) Apply() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var p struct { + Tid uint64 `json:"tid"` + SubDomain string `json:"sub_domain"` + MainDomain string `json:"main_domain"` + } + if err := json.Unmarshal(raw, &p); err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + if p.Tid == 0 { + jsonErr(&c.Controller, 400, 400, "租户ID不能为空") + return + } + sub := strings.TrimSpace(p.SubDomain) + main := strings.TrimSpace(p.MainDomain) + if sub == "" { + jsonErr(&c.Controller, 400, 400, "二级域名前缀不能为空") + return + } + if main == "" { + jsonErr(&c.Controller, 400, 400, "请选择主域名") + return + } + if !subDomainRe.MatchString(sub) { + jsonErr(&c.Controller, 400, 400, "二级域名前缀格式不正确") + return + } + + // 该租户是否已有域名 + cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)). + Filter("tid", p.Tid). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + jsonErr(&c.Controller, 400, 400, "该租户已有域名,请删除后再次申请") + return + } + + // 主域名存在且启用 + var pool models.SystemDomainPool + if err := models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("main_domain", main). + Filter("status", 1). + Filter("delete_time__isnull", true). + One(&pool); err != nil { + jsonErr(&c.Controller, 400, 400, "主域名不存在或已禁用") + return + } + + // 二级域名是否已被使用(同主域名下) + used, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)). + Filter("sub_domain", sub). + Filter("main_domain", main). + Filter("delete_time__isnull", true). + Count() + if used > 0 { + jsonErr(&c.Controller, 400, 400, "该二级域名已被使用") + return + } + + full := sub + "." + main + now := time.Now() + tid := p.Tid + row := &models.SystemTenantDomain{ + Tid: &tid, + SubDomain: &sub, + MainDomain: &main, + FullDomain: &full, + Status: 0, + CreateTime: now, + UpdateTime: &now, + } + id, err := models.Orm.Insert(row) + if err != nil { + jsonErr(&c.Controller, 500, 500, "申请失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审核", "data": map[string]interface{}{"id": uint64(id)}} + _ = c.ServeJSON() +} + +// Audit POST /backend/domain/tenant/audit body:{id,action} action=approve/reject +func (c *BackendTenantDomainController) Audit() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var p struct { + ID uint64 `json:"id"` + Action string `json:"action"` + } + if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var row models.SystemTenantDomain + if err := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).One(&row); err != nil { + jsonErr(&c.Controller, 404, 404, "域名不存在") + return + } + if row.Status != 0 { + jsonErr(&c.Controller, 400, 400, "该域名已审核过了") + return + } + newStatus := 2 + msg := "已拒绝" + if strings.ToLower(strings.TrimSpace(p.Action)) == "approve" { + newStatus = 1 + msg = "审核通过" + } + now := time.Now() + _, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{ + "status": newStatus, + "update_time": now, + }) + if err != nil { + jsonErr(&c.Controller, 500, 500, "审核失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg} + _ = c.ServeJSON() +} + +// ToggleStatus POST /backend/domain/tenant/toggleStatus body:{id} +func (c *BackendTenantDomainController) ToggleStatus() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var p struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + var row models.SystemTenantDomain + if err := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).One(&row); err != nil { + jsonErr(&c.Controller, 404, 404, "域名不存在") + return + } + if row.Status == 0 { + jsonErr(&c.Controller, 400, 400, "审核中不可操作") + return + } + newStatus := 2 + if row.Status == 2 { + newStatus = 1 + } + now := time.Now() + _, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{ + "status": newStatus, + "update_time": now, + }) + if err != nil { + jsonErr(&c.Controller, 500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/domain/tenant/delete/:id +func (c *BackendTenantDomainController) Delete() { + if _, err := requireBackend(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + jsonErr(&c.Controller, 400, 400, "参数错误") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemTenantDomain)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error()) + return + } + if n == 0 { + jsonErr(&c.Controller, 404, 404, "域名不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// 用于复杂筛选时可扩展:当前保留 orm.Condition import,避免被 gofmt 删除 +var _ = orm.NewCondition diff --git a/controllers/domain_common.go b/controllers/domain_common.go new file mode 100644 index 0000000..defeac1 --- /dev/null +++ b/controllers/domain_common.go @@ -0,0 +1,21 @@ +package controllers + +import ( + "regexp" + + beego "github.com/beego/beego/v2/server/web" +) + +func jsonErr(c *beego.Controller, httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +type domainPoolPayload struct { + ID uint64 `json:"id"` + MainDomain string `json:"main_domain"` + Status int8 `json:"status"` +} + +var subDomainRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`) diff --git a/controllers/platform_account_pool.go b/controllers/platform_account_pool.go index 8c8563e..0f0d068 100644 --- a/controllers/platform_account_pool.go +++ b/controllers/platform_account_pool.go @@ -619,69 +619,150 @@ func replenishPoolRow(c *beego.Controller, module string) { platform := payload.Platform remark := strings.TrimSpace(payload.Remark) + replenishWithProbe(c, module, payload.Type, platform, remark, now) +} + +type poolReplenishCandidate struct { + id uint64 + dataType string + token string + isUsed *int8 + row interface{} +} + +type poolReplenishFetcher func() (*poolReplenishCandidate, error) + +// replenishWithProbe 按 id 顺序补号并探测;不可用则标记 is_extracted=2 后继续下一条。 +func replenishWithProbe(c *beego.Controller, module, dataType, platform, remark string, now time.Time) { + var fetch poolReplenishFetcher switch module { case "cursor": - var row models.PlatformAccountPoolCursor - if err := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). - Filter("is_extracted", 0).Filter("data_type", payload.Type). - OrderBy("id").One(&row); err != nil { - poolJSONErr(c, 404, 404, "暂无可用账号") - return + fetch = func() (*poolReplenishCandidate, error) { + var row models.PlatformAccountPoolCursor + err := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). + Filter("is_extracted", 0). + Filter("data_type", dataType). + Filter("delete_time__isnull", true). + OrderBy("id"). + One(&row) + if err != nil { + return nil, err + } + return &poolReplenishCandidate{ + id: row.ID, dataType: row.DataType, token: row.Token, isUsed: row.IsUsed, row: row, + }, nil } - if _, err = models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)).Filter("id", row.ID).Update(map[string]interface{}{ - "is_extracted": int8(2), "extracted_time": now, "extracted_platform": platform, "remark": remark, - }); err != nil { - poolJSONErr(c, 500, 500, "补号失败: "+err.Error()) - return - } - row.IsExtracted = 2 - row.ExtractedTime = &now - row.ExtractedPlatform = &platform - row.Remark = remark - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "补号成功", "data": row} case "windsurf": - var row models.PlatformAccountPoolWindsurf - if err := models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)). - Filter("is_extracted", 0).Filter("data_type", payload.Type). - OrderBy("id").One(&row); err != nil { - poolJSONErr(c, 404, 404, "暂无可用账号") - return + fetch = func() (*poolReplenishCandidate, error) { + var row models.PlatformAccountPoolWindsurf + err := models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)). + Filter("is_extracted", 0). + Filter("data_type", dataType). + Filter("delete_time__isnull", true). + OrderBy("id"). + One(&row) + if err != nil { + return nil, err + } + return &poolReplenishCandidate{ + id: row.ID, dataType: row.DataType, token: row.Token, row: row, + }, nil } - if _, err = models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)).Filter("id", row.ID).Update(map[string]interface{}{ - "is_extracted": int8(2), "extracted_time": now, "extracted_platform": platform, "remark": remark, - }); err != nil { - poolJSONErr(c, 500, 500, "补号失败: "+err.Error()) - return - } - row.IsExtracted = 2 - row.ExtractedTime = &now - row.ExtractedPlatform = &platform - row.Remark = remark - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "补号成功", "data": row} case "krio": - var row models.PlatformAccountPoolKiro - if err := models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)). - Filter("is_extracted", 0).Filter("data_type", payload.Type). - OrderBy("id").One(&row); err != nil { - poolJSONErr(c, 404, 404, "暂无可用账号") - return + fetch = func() (*poolReplenishCandidate, error) { + var row models.PlatformAccountPoolKiro + err := models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)). + Filter("is_extracted", 0). + Filter("data_type", dataType). + Filter("delete_time__isnull", true). + OrderBy("id"). + One(&row) + if err != nil { + return nil, err + } + return &poolReplenishCandidate{ + id: row.ID, dataType: row.DataType, token: row.Token, row: row, + }, nil } - if _, err = models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)).Filter("id", row.ID).Update(map[string]interface{}{ - "is_extracted": int8(2), "extracted_time": now, "extracted_platform": platform, "remark": remark, - }); err != nil { - poolJSONErr(c, 500, 500, "补号失败: "+err.Error()) - return - } - row.IsExtracted = 2 - row.ExtractedTime = &now - row.ExtractedPlatform = &platform - row.Remark = remark - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "补号成功", "data": row} default: poolJSONErr(c, 400, 400, "无效模块") return } - _ = c.ServeJSON() + + tableName := poolTableName(module) + if tableName == "" { + poolJSONErr(c, 400, 400, "无效模块") + return + } + + for { + candidate, err := fetch() + if err != nil { + if err == orm.ErrNoRows { + poolJSONErr(c, 404, 404, "暂无可用账号") + } else { + poolJSONErr(c, 500, 500, "查询失败") + } + return + } + + updateFields := map[string]interface{}{ + "is_extracted": int8(2), + "extracted_time": now, + "extracted_platform": platform, + "remark": remark, + "update_time": now, + } + if _, err = models.Orm.QueryTable(tableName). + Filter("id", candidate.id). + Update(updateFields); err != nil { + poolJSONErr(c, 500, 500, "补号失败: "+err.Error()) + return + } + + if known, available := poolIsUsedAvailable(candidate.isUsed); known { + if !available { + continue + } + } else if !poolProbeToken(module, candidate.dataType, candidate.token, candidate.id) { + continue + } + + data := replenishApplyResponse(candidate.row, platform, remark, now) + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "补号成功", "data": data} + _ = c.ServeJSON() + return + } +} + +func replenishApplyResponse(row interface{}, platform, remark string, now time.Time) interface{} { + pf := platform + switch r := row.(type) { + case models.PlatformAccountPoolCursor: + r.IsExtracted = 2 + r.ExtractedTime = &now + r.ExtractedPlatform = &pf + r.Remark = remark + if r.IsUsed == nil || *r.IsUsed != 1 { + used := int8(1) + r.IsUsed = &used + } + return r + case models.PlatformAccountPoolWindsurf: + r.IsExtracted = 2 + r.ExtractedTime = &now + r.ExtractedPlatform = &pf + r.Remark = remark + return r + case models.PlatformAccountPoolKiro: + r.IsExtracted = 2 + r.ExtractedTime = &now + r.ExtractedPlatform = &pf + r.Remark = remark + return r + default: + return row + } } func updatePoolRemark(c *beego.Controller, module string) { diff --git a/controllers/platform_domain.go b/controllers/platform_domain.go index a5bcf72..13a826d 100644 --- a/controllers/platform_domain.go +++ b/controllers/platform_domain.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "regexp" "strconv" "strings" "time" @@ -45,12 +44,6 @@ func requirePlatform(c *beego.Controller) (*jwtutil.Claims, error) { return claims, nil } -func jsonErr(c *beego.Controller, httpStatus, bizCode int, msg string) { - c.Ctx.Output.SetStatus(httpStatus) - c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} - _ = c.ServeJSON() -} - // ===== 主域名池 ===== // Index GET /platform/domain/pool/index?page=&pageSize=&main_domain=&status= @@ -150,12 +143,6 @@ func (c *PlatformDomainPoolController) GetEnabledDomains() { _ = c.ServeJSON() } -type domainPoolPayload struct { - ID uint64 `json:"id"` - MainDomain string `json:"main_domain"` - Status int8 `json:"status"` -} - // Create POST /platform/domain/pool/create func (c *PlatformDomainPoolController) Create() { if _, err := requirePlatform(&c.Controller); err != nil { @@ -397,8 +384,6 @@ func (c *PlatformTenantDomainController) MyDomains() { _ = c.ServeJSON() } -var subDomainRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`) - // Apply POST /platform/domain/tenant/apply body:{tid,sub_domain,main_domain} func (c *PlatformTenantDomainController) Apply() { if _, err := requirePlatform(&c.Controller); err != nil { diff --git a/controllers/pool_probe.go b/controllers/pool_probe.go new file mode 100644 index 0000000..be8ef84 --- /dev/null +++ b/controllers/pool_probe.go @@ -0,0 +1,63 @@ +package controllers + +import ( + "strings" + "time" + + "server/models" + "server/pkg/tokenprobe" +) + +func poolTableName(module string) string { + switch module { + case "cursor": + return new(models.PlatformAccountPoolCursor).TableName() + case "windsurf": + return new(models.PlatformAccountPoolWindsurf).TableName() + case "krio": + return new(models.PlatformAccountPoolKiro).TableName() + default: + return "" + } +} + +// poolNeedsTokenProbe account 类型无 Token,无需探测;tk / account_tk 需探测。 +func poolNeedsTokenProbe(dataType, token string) bool { + if strings.TrimSpace(token) == "" { + return false + } + return dataType != "account" +} + +func poolSaveCursorIsUsed(id uint64, isUsed int8) { + _, _ = models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). + Filter("id", id). + Update(map[string]interface{}{ + "is_used": isUsed, + "update_time": time.Now(), + }) +} + +// poolProbeToken 探测 Token;cursor 模块会回写 is_used。 +func poolProbeToken(module, dataType, token string, rowID uint64) bool { + if !poolNeedsTokenProbe(dataType, token) { + return true + } + r := tokenprobe.ProbeOfficial(module, token) + if module == "cursor" && rowID > 0 { + var isUsed int8 + if r.OK { + isUsed = 1 + } + poolSaveCursorIsUsed(rowID, isUsed) + } + return r.OK +} + +// poolIsUsedAvailable 已有探测结论时:1=可用,0=不可用,nil=未探测。 +func poolIsUsedAvailable(isUsed *int8) (known bool, available bool) { + if isUsed == nil { + return false, false + } + return true, *isUsed == 1 +} diff --git a/docs/DEPLOYMENT_CHECKLIST.md b/docs/DEPLOYMENT_CHECKLIST.md deleted file mode 100644 index 74853bc..0000000 --- a/docs/DEPLOYMENT_CHECKLIST.md +++ /dev/null @@ -1,392 +0,0 @@ -# 存储功能部署检查清单 - -## 部署前准备 - -### 1. 环境检查 - -- [ ] Go 1.17+ 已安装 -- [ ] MySQL 5.7+ 已安装并运行 -- [ ] Node.js 14+ 已安装(前端) -- [ ] 网络连接正常 - -### 2. 依赖安装 - -```bash -# 后端依赖 -cd go -go mod download -go mod tidy - -# 前端依赖(如需要) -cd platform -npm install -``` - -### 3. 数据库迁移 - -```bash -# 备份数据库 -mysqldump -u root -p your_database > backup_$(date +%Y%m%d).sql - -# 执行迁移 -mysql -u root -p your_database < go/migrations/add_storage_config_table.sql - -# 验证表创建 -mysql -u root -p your_database -e "SHOW TABLES LIKE 'yz_system_storage_config';" -mysql -u root -p your_database -e "DESC yz_system_storage_config;" -``` - -## 部署步骤 - -### 1. 后端部署 - -```bash -cd go - -# 编译 -go build -o server main.go - -# 或使用bee工具 -bee run -``` - -### 2. 前端部署 - -```bash -cd platform - -# 开发环境 -npm run dev - -# 生产环境 -npm run build -``` - -### 3. 配置验证 - -访问:http://localhost:8080/platform/storageConfig - -预期响应: -```json -{ - "code": 200, - "msg": "success", - "data": { - "storage_type": "local", - ... - } -} -``` - -## 功能测试 - -### 1. 存储配置测试 - -#### 测试本地存储 - -1. 登录平台管理后台 -2. 进入:系统设置 → 平台设置 → 存储配置 -3. 选择"本地存储" -4. 点击"保存设置" -5. 验证保存成功 - -#### 测试七牛云存储 - -1. 准备七牛云账号和配置信息 -2. 选择"七牛云存储" -3. 填写配置: - - AccessKey: `your_access_key` - - SecretKey: `your_secret_key` - - Bucket: `your_bucket` - - CDN域名: `https://cdn.example.com` - - 存储区域: `z0` -4. 点击"保存设置" -5. 验证保存成功 - -### 2. 文件上传测试 - -#### 本地存储上传 - -1. 配置为本地存储 -2. 上传测试文件 -3. 检查文件是否保存到 `uploads/` 目录 -4. 验证文件URL格式:`/uploads/2024/01/01/xxx.jpg` -5. 访问文件URL,确认可以访问 - -#### 七牛云上传 - -1. 配置为七牛云存储 -2. 上传测试文件 -3. 检查数据库记录 -4. 验证文件URL格式:`https://cdn.example.com/2024/01/01/xxx.jpg` -5. 访问文件URL,确认可以访问 - -### 3. 文件迁移测试 - -1. 准备一些本地存储的文件 -2. 配置七牛云存储 -3. 调用迁移API: - ```bash - curl -X POST http://localhost:8080/platform/storage/migrateToQiniu - ``` -4. 检查迁移进度和结果 -5. 验证文件URL已更新 -6. 访问新URL,确认文件可访问 - -## 性能测试 - -### 1. 上传性能 - -```bash -# 测试单文件上传 -time curl -F "file=@test.jpg" http://localhost:8080/platform/uploadfile - -# 测试批量上传 -for i in {1..10}; do - curl -F "file=@test$i.jpg" http://localhost:8080/platform/uploadfile & -done -wait -``` - -### 2. 迁移性能 - -- 准备100个测试文件 -- 执行迁移 -- 记录总耗时 -- 计算平均速度 - -## 监控检查 - -### 1. 日志检查 - -```bash -# 查看服务日志 -tail -f logs/server.log - -# 查看错误日志 -grep ERROR logs/server.log - -# 查看上传日志 -grep "文件上传" logs/server.log -``` - -### 2. 数据库检查 - -```sql --- 检查存储配置 -SELECT * FROM yz_system_storage_config; - --- 检查文件记录 -SELECT COUNT(*) FROM yz_system_files; - --- 检查最近上传的文件 -SELECT * FROM yz_system_files ORDER BY create_time DESC LIMIT 10; -``` - -### 3. 存储空间检查 - -```bash -# 本地存储空间 -du -sh uploads/ - -# 七牛云存储空间(在七牛云控制台查看) -``` - -## 安全检查 - -### 1. 配置安全 - -- [ ] SecretKey 不在日志中输出 -- [ ] 配置文件权限正确(600) -- [ ] 数据库连接使用强密码 -- [ ] API接口有认证保护 - -### 2. 文件安全 - -- [ ] 文件大小限制生效(200MB) -- [ ] 文件类型验证正常 -- [ ] 恶意文件上传被拦截 -- [ ] 文件访问权限正确 - -### 3. 网络安全 - -- [ ] HTTPS配置正确 -- [ ] CDN域名已备案 -- [ ] 防火墙规则正确 -- [ ] 跨域配置正确 - -## 回滚计划 - -### 如果部署失败 - -1. 停止服务 - ```bash - pkill -f server - ``` - -2. 恢复数据库 - ```bash - mysql -u root -p your_database < backup_YYYYMMDD.sql - ``` - -3. 恢复代码 - ```bash - git checkout previous_version - ``` - -4. 重启服务 - ```bash - cd go - bee run - ``` - -## 常见问题 - -### 问题1: 依赖安装失败 - -**解决方法:** -```bash -# 清理缓存 -go clean -modcache - -# 使用代理 -export GOPROXY=https://goproxy.cn,direct - -# 重新安装 -go mod download -``` - -### 问题2: 数据库迁移失败 - -**解决方法:** -```bash -# 检查表是否已存在 -mysql -u root -p your_database -e "SHOW TABLES LIKE 'yz_system_storage_config';" - -# 如果存在,先删除 -mysql -u root -p your_database -e "DROP TABLE IF EXISTS yz_system_storage_config;" - -# 重新执行迁移 -mysql -u root -p your_database < go/migrations/add_storage_config_table.sql -``` - -### 问题3: 七牛云上传失败 - -**检查项:** -- AccessKey 和 SecretKey 是否正确 -- Bucket 是否存在 -- 存储区域是否匹配 -- 网络连接是否正常 - -**测试连接:** -```bash -curl -I https://your-cdn-domain.com -``` - -### 问题4: 文件访问404 - -**本地存储:** -```bash -# 检查文件是否存在 -ls -la uploads/2024/01/01/ - -# 检查Nginx配置 -nginx -t - -# 检查文件权限 -chmod 644 uploads/2024/01/01/* -``` - -**七牛云:** -- 检查CDN域名是否正确 -- 检查文件是否上传成功 -- 检查空间访问权限 - -## 部署完成确认 - -### 功能确认 - -- [ ] 存储配置页面正常显示 -- [ ] 本地存储配置保存成功 -- [ ] 七牛云配置保存成功 -- [ ] 本地存储上传正常 -- [ ] 七牛云上传正常 -- [ ] 文件访问正常 -- [ ] 文件迁移功能正常 -- [ ] 错误处理正常 -- [ ] 日志记录正常 - -### 性能确认 - -- [ ] 上传速度正常(< 5秒/10MB) -- [ ] 访问速度正常(< 1秒) -- [ ] 迁移速度正常(> 10文件/秒) -- [ ] 内存使用正常(< 500MB) -- [ ] CPU使用正常(< 50%) - -### 安全确认 - -- [ ] 认证保护生效 -- [ ] 文件大小限制生效 -- [ ] 文件类型验证生效 -- [ ] 敏感信息不泄露 -- [ ] 日志不包含密钥 - -## 上线通知 - -### 通知内容 - -``` -【系统升级通知】 - -尊敬的用户: - -系统已完成存储功能升级,新增以下功能: - -1. 支持七牛云存储 -2. 支持存储配置管理 -3. 支持文件迁移功能 - -升级后的优势: -- 更快的访问速度(CDN加速) -- 更高的可靠性(云端备份) -- 更低的成本(按需付费) - -如有问题,请联系技术支持。 - -感谢您的支持! -``` - -## 后续优化 - -### 短期优化(1周内) - -- [ ] 添加上传进度显示 -- [ ] 添加批量上传功能 -- [ ] 优化错误提示 -- [ ] 添加使用统计 - -### 中期优化(1个月内) - -- [ ] 添加图片压缩 -- [ ] 添加缩略图生成 -- [ ] 添加水印功能 -- [ ] 添加访问统计 - -### 长期优化(3个月内) - -- [ ] 支持更多存储服务 -- [ ] 添加文件管理界面 -- [ ] 添加自动备份 -- [ ] 添加CDN配置 - ---- - -**部署完成后,请在此签名确认:** - -- 部署人员:__________ -- 部署时间:__________ -- 测试人员:__________ -- 测试时间:__________ -- 审核人员:__________ -- 审核时间:__________ diff --git a/docs/IMPLEMENTATION_COMPLETE.md b/docs/IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index b501b5a..0000000 --- a/docs/IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,404 +0,0 @@ -# 🎉 存储配置功能 - 完整实现报告 - -## 项目概述 - -本项目已完整实现文件存储的配置、上传和迁移功能,支持本地存储和七牛云存储的无缝切换。 - -## ✅ 完成的工作清单 - -### 1. 数据库层 (100%) - -- ✅ 创建 `yz_system_storage_config` 表 -- ✅ 编写数据库迁移SQL -- ✅ 添加默认配置数据 - -**文件:** -- `go/migrations/add_storage_config_table.sql` - -### 2. 后端核心服务 (100%) - -#### 存储服务抽象层 -- ✅ 定义 `StorageService` 接口 -- ✅ 实现 `LocalStorage` 本地存储 -- ✅ 实现 `QiniuStorage` 七牛云存储 -- ✅ 实现 `GetStorageService()` 自动选择 - -**文件:** -- `go/services/storage_service.go` (新增, 300+ 行) - -**功能:** -- 统一的上传接口 -- 自动MD5计算 -- 支持所有七牛云区域 -- 完整的错误处理 - -#### 文件迁移服务 -- ✅ 实现并发迁移逻辑 -- ✅ 实现进度跟踪 -- ✅ 实现错误收集 -- ✅ 实现数据库更新 - -**文件:** -- `go/services/storage_migration.go` (新增, 200+ 行) - -**功能:** -- 5个并发迁移 -- 实时进度显示 -- 错误详细记录 -- 自动回滚机制 - -### 3. 后端控制器 (100%) - -#### 存储配置控制器 -- ✅ 获取存储配置 API -- ✅ 保存存储配置 API -- ✅ 参数验证 -- ✅ 错误处理 - -**文件:** -- `go/controllers/storage_config.go` (新增, 150+ 行) - -#### 迁移控制器 -- ✅ 迁移到七牛云 API -- ✅ 查询迁移进度 API - -**文件:** -- `go/controllers/storage_migration.go` (新增, 60+ 行) - -#### 文件上传控制器改造 -- ✅ 集成存储服务 -- ✅ 自动选择存储方式 -- ✅ MD5去重检查 -- ✅ 失败自动回滚 - -**文件:** -- `go/controllers/platform_file.go` (修改, 重构上传逻辑) - -### 4. 后端模型和路由 (100%) - -- ✅ 创建 `StorageConfig` 模型 -- ✅ 注册模型到ORM -- ✅ 添加存储配置路由 -- ✅ 添加迁移路由 - -**文件:** -- `go/models/storage_config.go` (新增) -- `go/models/init.go` (修改) -- `go/routers/platform/platform.go` (修改) - -### 5. 依赖管理 (100%) - -- ✅ 添加七牛云SDK依赖 -- ✅ 更新 go.mod -- ✅ 创建依赖安装脚本 - -**文件:** -- `go/go.mod` (修改) -- `go/scripts/install_dependencies.sh` (新增) -- `go/scripts/install_dependencies.bat` (新增) - -### 6. 前端实现 (100%) - -#### API接口 -- ✅ 获取存储配置接口 -- ✅ 保存存储配置接口 - -**文件:** -- `platform/src/api/sitesettings.js` (修改) - -#### 配置组件 -- ✅ 存储类型切换 -- ✅ 七牛云配置表单 -- ✅ 表单验证 -- ✅ 本地草稿保存 -- ✅ 友好的提示信息 - -**文件:** -- `platform/src/views/system/platformsettings/components/storageSettings.vue` (新增, 250+ 行) - -#### 主页面 -- ✅ 添加存储配置标签页 -- ✅ 集成配置组件 - -**文件:** -- `platform/src/views/system/platformsettings/index.vue` (修改) - -### 7. 文档和脚本 (100%) - -- ✅ 详细使用指南 -- ✅ 实现总结文档 -- ✅ 部署检查清单 -- ✅ 测试脚本 -- ✅ README文档 - -**文件:** -- `docs/storage-config-guide.md` (新增) -- `README_STORAGE.md` (新增) -- `DEPLOYMENT_CHECKLIST.md` (新增) -- `go/scripts/test_storage.sh` (新增) -- `IMPLEMENTATION_COMPLETE.md` (本文件) - -## 📊 代码统计 - -### 新增文件 - -| 类型 | 文件数 | 代码行数 | -|------|--------|---------| -| Go后端 | 4 | ~800行 | -| Vue前端 | 1 | ~250行 | -| SQL | 1 | ~20行 | -| 脚本 | 3 | ~150行 | -| 文档 | 5 | ~2000行 | -| **总计** | **14** | **~3220行** | - -### 修改文件 - -| 文件 | 修改内容 | -|------|---------| -| `go/controllers/platform_file.go` | 重构上传逻辑 | -| `go/models/init.go` | 注册新模型 | -| `go/routers/platform/platform.go` | 添加路由 | -| `go/go.mod` | 添加依赖 | -| `platform/src/api/sitesettings.js` | 添加API | -| `platform/src/views/system/platformsettings/index.vue` | 添加标签页 | - -## 🎯 核心功能 - -### 1. 存储服务抽象 - -```go -type StorageService interface { - Upload(file, header) (*UploadResult, error) - GetPublicURL(key string) string - Delete(key string) error -} -``` - -### 2. 自动选择存储 - -```go -storageService, _ := services.GetStorageService() -// 根据配置自动返回 LocalStorage 或 QiniuStorage -``` - -### 3. 统一上传接口 - -```go -result, err := storageService.Upload(file, header) -// 返回统一的 UploadResult,包含URL、Key、Size、MD5 -``` - -### 4. 文件迁移 - -```go -progress, err := services.MigrateLocalToQiniu(tenantID) -// 并发迁移,实时进度,错误收集 -``` - -## 🔧 技术栈 - -### 后端 -- Go 1.17+ -- Beego v2.1.0 -- 七牛云SDK v7.18.2 -- MySQL 5.7+ - -### 前端 -- Vue 3 -- Element Plus -- Axios - -## 📦 部署步骤 - -### 1. 安装依赖 -```bash -cd go -go mod download -go mod tidy -``` - -### 2. 数据库迁移 -```bash -mysql -u root -p your_database < go/migrations/add_storage_config_table.sql -``` - -### 3. 启动服务 -```bash -cd go -bee run -``` - -### 4. 配置存储 -访问:平台管理后台 → 系统设置 → 平台设置 → 存储配置 - -## 🧪 测试覆盖 - -### 单元测试 -- [ ] 存储服务接口测试 -- [ ] 本地存储上传测试 -- [ ] 七牛云上传测试 -- [ ] 迁移服务测试 - -### 集成测试 -- [x] API接口测试 -- [x] 文件上传测试 -- [x] 配置保存测试 -- [x] 前端界面测试 - -### 性能测试 -- [ ] 上传性能测试 -- [ ] 并发上传测试 -- [ ] 迁移性能测试 - -## 📈 性能指标 - -### 上传性能 -- 本地存储:~50MB/s -- 七牛云:~10MB/s(受网络影响) - -### 迁移性能 -- 并发数:5 -- 速度:~10文件/秒 -- 内存占用:< 100MB - -## 🔒 安全特性 - -- ✅ 参数验证 -- ✅ 文件大小限制(200MB) -- ✅ 文件类型验证 -- ✅ MD5去重 -- ✅ 错误处理 -- ✅ 失败回滚 -- ⚠️ 密钥加密(待实现) - -## 🚀 扩展性 - -### 支持的存储类型 -- ✅ 本地存储 -- ✅ 七牛云存储 -- ⏳ 阿里云OSS(待实现) -- ⏳ 腾讯云COS(待实现) -- ⏳ AWS S3(待实现) - -### 可扩展功能 -- ⏳ 图片压缩 -- ⏳ 缩略图生成 -- ⏳ 水印添加 -- ⏳ 视频转码 -- ⏳ 断点续传 -- ⏳ 分片上传 - -## 📝 使用示例 - -### 配置本地存储 -```javascript -{ - storage_type: "local" -} -``` - -### 配置七牛云 -```javascript -{ - storage_type: "qiniu", - qiniu_access_key: "your_key", - qiniu_secret_key: "your_secret", - qiniu_bucket: "your_bucket", - qiniu_domain: "https://cdn.example.com", - qiniu_region: "z0" -} -``` - -### 上传文件 -```go -// 自动选择存储 -storageService, _ := services.GetStorageService() -result, _ := storageService.Upload(file, header) -fmt.Println(result.URL) // 完整访问URL -``` - -### 迁移文件 -```go -progress, _ := services.MigrateLocalToQiniu(tenantID) -fmt.Printf("成功: %d, 失败: %d\n", progress.Success, progress.Failed) -``` - -## 🐛 已知问题 - -1. ⚠️ 密钥明文存储(建议加密) -2. ⚠️ 迁移进度查询未实现(需要Redis或全局变量) -3. ⚠️ 从七牛云迁移到本地未实现 - -## 📅 后续计划 - -### 短期(1周) -- [ ] 添加密钥加密 -- [ ] 实现迁移进度查询 -- [ ] 添加单元测试 - -### 中期(1个月) -- [ ] 支持阿里云OSS -- [ ] 支持腾讯云COS -- [ ] 添加图片处理功能 - -### 长期(3个月) -- [ ] 支持AWS S3 -- [ ] 添加文件管理界面 -- [ ] 添加访问统计 -- [ ] 添加自动备份 - -## 🎓 学习资源 - -- 七牛云文档:https://developer.qiniu.com/ -- Go SDK文档:https://github.com/qiniu/go-sdk -- Beego文档:https://beego.vip/ -- Vue3文档:https://vuejs.org/ - -## 👥 贡献者 - -- 开发:AI Assistant -- 测试:待定 -- 文档:AI Assistant - -## 📄 许可证 - -本项目遵循项目原有许可证。 - ---- - -## ✨ 总结 - -本次实现完成了: - -1. ✅ **完整的存储服务抽象层**,支持多种存储方式 -2. ✅ **自动化的文件上传**,根据配置自动选择存储 -3. ✅ **强大的文件迁移功能**,支持并发迁移和进度跟踪 -4. ✅ **友好的配置界面**,简单易用的前端配置 -5. ✅ **完善的文档**,包括使用指南、部署清单、测试脚本 - -**代码质量:** -- 清晰的架构设计 -- 完整的错误处理 -- 详细的代码注释 -- 统一的代码风格 - -**可维护性:** -- 模块化设计 -- 接口抽象 -- 易于扩展 -- 文档完善 - -**生产就绪:** -- 完整的功能实现 -- 详细的部署文档 -- 测试脚本 -- 故障排查指南 - ---- - -**🎉 项目已完成,可以投入生产使用!** - -如有问题,请参考: -- 使用指南:`docs/storage-config-guide.md` -- 部署清单:`DEPLOYMENT_CHECKLIST.md` -- 快速开始:`README_STORAGE.md` diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md deleted file mode 100644 index 04b8b9c..0000000 --- a/docs/QUICK_START.md +++ /dev/null @@ -1,122 +0,0 @@ -# 🚀 存储配置功能 - 5分钟快速开始 - -## 第一步:安装依赖(1分钟) - -```bash -cd go -go mod download -go mod tidy -``` - -## 第二步:数据库迁移(1分钟) - -```bash -mysql -u root -p your_database < go/migrations/add_storage_config_table.sql -``` - -验证: -```bash -mysql -u root -p your_database -e "DESC yz_system_storage_config;" -``` - -## 第三步:启动服务(1分钟) - -```bash -cd go -bee run -# 或 -go run main.go -``` - -## 第四步:配置存储(2分钟) - -### 方式1:使用本地存储(无需配置) - -1. 访问:http://localhost:8080/#/system/platformsettings -2. 点击"存储配置"标签 -3. 选择"本地存储" -4. 点击"保存设置" - -✅ 完成!文件将保存到 `uploads/` 目录 - -### 方式2:使用七牛云存储 - -1. 访问:http://localhost:8080/#/system/platformsettings -2. 点击"存储配置"标签 -3. 选择"七牛云存储" -4. 填写配置: - ``` - AccessKey: 你的AccessKey - SecretKey: 你的SecretKey - Bucket: 你的Bucket名称 - CDN域名: https://你的CDN域名 - 存储区域: z0(华东) - ``` -5. 点击"保存设置" - -✅ 完成!文件将上传到七牛云 - -## 测试上传 - -### 使用Postman测试 - -``` -POST http://localhost:8080/platform/uploadfile -Headers: - Authorization: Bearer your_token -Body: - form-data - file: 选择文件 -``` - -### 使用curl测试 - -```bash -curl -X POST \ - -H "Authorization: Bearer your_token" \ - -F "file=@test.jpg" \ - http://localhost:8080/platform/uploadfile -``` - -## 常见问题 - -### Q1: 依赖安装失败? - -```bash -export GOPROXY=https://goproxy.cn,direct -go mod download -``` - -### Q2: 数据库连接失败? - -检查 `go/conf/app.conf` 中的数据库配置: -```ini -mysqluser = root -mysqlpass = your_password -mysqlurls = 127.0.0.1:3306 -mysqldb = your_database -``` - -### Q3: 七牛云上传失败? - -1. 检查密钥是否正确 -2. 检查Bucket是否存在 -3. 检查存储区域是否匹配 -4. 测试网络连接:`curl -I https://你的CDN域名` - -## 下一步 - -- 📖 阅读完整文档:`README_STORAGE.md` -- 🔧 查看部署清单:`DEPLOYMENT_CHECKLIST.md` -- 📚 查看使用指南:`docs/storage-config-guide.md` -- ✅ 查看实现报告:`IMPLEMENTATION_COMPLETE.md` - -## 获取帮助 - -- 查看日志:`tail -f logs/server.log` -- 查看错误:`grep ERROR logs/server.log` -- 七牛云文档:https://developer.qiniu.com/ - ---- - -**🎉 恭喜!你已经完成了存储配置功能的快速开始!** diff --git a/docs/README_STORAGE.md b/docs/README_STORAGE.md deleted file mode 100644 index 27487ad..0000000 --- a/docs/README_STORAGE.md +++ /dev/null @@ -1,210 +0,0 @@ -# 存储配置功能 - 完整实现 - -## ✅ 已完成的所有工作 - -本项目已完整实现文件存储的配置、上传和迁移功能,支持本地存储和七牛云存储。 - -## 快速开始 - -### 1. 安装依赖 - -```bash -cd go -go mod download -go mod tidy -``` - -或使用脚本: -- Linux/Mac: `./scripts/install_dependencies.sh` -- Windows: `scripts\install_dependencies.bat` - -### 2. 执行数据库迁移 - -```bash -mysql -u root -p your_database < migrations/add_storage_config_table.sql -``` - -### 3. 重启服务 - -```bash -bee run -``` - -### 4. 配置存储 - -访问:平台管理后台 → 系统设置 → 平台设置 → 存储配置 - -## 核心功能 - -### ✅ 1. 存储服务抽象层 - -**文件**: `services/storage_service.go` - -- 统一的存储接口 `StorageService` -- 本地存储实现 `LocalStorage` -- 七牛云存储实现 `QiniuStorage` -- 自动选择存储服务 `GetStorageService()` -- 支持所有七牛云存储区域 - -### ✅ 2. 文件上传改造 - -**文件**: `controllers/platform_file.go` - -- 自动根据配置选择存储方式 -- MD5去重检查 -- 失败自动回滚 -- 完整的错误处理 - -### ✅ 3. 文件迁移功能 - -**文件**: `services/storage_migration.go` - -- 从本地迁移到七牛云 -- 并发迁移(5个并发) -- 实时进度跟踪 -- 错误收集和报告 - -### ✅ 4. 存储配置管理 - -**后端**: -- `models/storage_config.go` - 数据模型 -- `controllers/storage_config.go` - API控制器 - -**前端**: -- `platform/src/views/system/platformsettings/components/storageSettings.vue` - 配置界面 - -### ✅ 5. API接口 - -**存储配置**: -- `GET /platform/storageConfig` - 获取配置 -- `POST /platform/saveStorageConfig` - 保存配置 - -**文件上传**: -- `POST /platform/uploadfile` - 上传文件(自动选择存储) - -**文件迁移**: -- `POST /platform/storage/migrateToQiniu` - 迁移到七牛云 -- `GET /platform/storage/migrationProgress` - 查询进度 - -## 技术实现 - -### 存储服务架构 - -``` -┌─────────────────────────────────────┐ -│ File Upload Controller │ -│ (platform_file.go) │ -└──────────────┬──────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ Storage Service Interface │ -│ (storage_service.go) │ -└──────────┬──────────────────────────┘ - │ - ┌──────┴──────┐ - │ │ - ▼ ▼ -┌─────────┐ ┌──────────┐ -│ Local │ │ Qiniu │ -│ Storage │ │ Storage │ -└─────────┘ └──────────┘ -``` - -## 七牛云配置 - -### 存储区域 - -| 区域名称 | 代码 | -|---------|------| -| 华东-浙江 | z0 | -| 华北-河北 | z1 | -| 华南-广东 | z2 | -| 北美-洛杉矶 | na0 | -| 亚太-新加坡 | as0 | -| 华东-浙江2 | cn-east-2 | - -### 配置步骤 - -1. 注册七牛云账号 -2. 创建存储空间(Bucket) -3. 获取 AccessKey 和 SecretKey -4. 配置 CDN 域名 -5. 在系统中填写配置 - -## 文件清单 - -### 后端核心文件 - -``` -go/ -├── models/ -│ ├── storage_config.go # 存储配置模型 -│ └── init.go # 模型注册(已修改) -├── controllers/ -│ ├── storage_config.go # 存储配置控制器 -│ ├── storage_migration.go # 迁移控制器 -│ └── platform_file.go # 文件上传(已改造) -├── services/ -│ ├── storage_service.go # 存储服务(核心) -│ └── storage_migration.go # 迁移服务 -├── routers/ -│ └── platform/platform.go # 路由注册(已修改) -├── migrations/ -│ └── add_storage_config_table.sql # 数据库迁移 -├── scripts/ -│ ├── install_dependencies.sh # 依赖安装(Linux/Mac) -│ └── install_dependencies.bat # 依赖安装(Windows) -└── go.mod # 依赖管理(已添加七牛云SDK) -``` - -## 使用示例 - -### 配置本地存储 - -```javascript -{ - storage_type: "local" -} -``` - -### 配置七牛云存储 - -```javascript -{ - storage_type: "qiniu", - qiniu_access_key: "your_access_key", - qiniu_secret_key: "your_secret_key", - qiniu_bucket: "your_bucket", - qiniu_domain: "https://cdn.example.com", - qiniu_region: "z0" -} -``` - -### 上传文件 - -```go -// 后端自动选择存储 -storageService, _ := services.GetStorageService() -result, _ := storageService.Upload(file, header) -// result.URL 是完整的访问URL -``` - -### 迁移文件 - -```go -// 迁移到七牛云 -progress, err := services.MigrateLocalToQiniu(tenantID) -fmt.Printf("成功: %d, 失败: %d\n", progress.Success, progress.Failed) -``` - -## 更多文档 - -- 详细使用指南:`docs/storage-config-guide.md` -- 部署检查清单:`docs/DEPLOYMENT_CHECKLIST.md` -- 快速开始:`docs/QUICK_START.md` -- 实现报告:`docs/IMPLEMENTATION_COMPLETE.md` - ---- - -**所有功能已完整实现并测试通过!** 🎉 diff --git a/docs/storage-config-guide.md b/docs/storage-config-guide.md deleted file mode 100644 index 8f5f306..0000000 --- a/docs/storage-config-guide.md +++ /dev/null @@ -1,253 +0,0 @@ -# 存储配置功能说明 - -## 功能概述 - -系统支持两种文件存储方式: -1. **本地存储**:文件存储在服务器本地磁盘 -2. **七牛云存储**:文件存储在七牛云对象存储服务 - -## 数据库变更 - -### 新增表 - -**表名**: `yz_system_storage_config` - -**字段说明**: -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | bigint(20) | 主键ID | -| storage_type | varchar(20) | 存储类型: local/qiniu | -| qiniu_access_key | varchar(255) | 七牛云AccessKey | -| qiniu_secret_key | varchar(255) | 七牛云SecretKey | -| qiniu_bucket | varchar(128) | 七牛云Bucket名称 | -| qiniu_domain | varchar(255) | 七牛云CDN域名 | -| qiniu_region | varchar(50) | 七牛云存储区域 | -| create_time | datetime | 创建时间 | -| update_time | datetime | 更新时间 | - -### 执行迁移 - -```bash -# 在MySQL中执行迁移脚本 -mysql -u your_user -p your_database < go/migrations/add_storage_config_table.sql -``` - -## 后端实现 - -### 新增文件 - -1. **模型文件**: `go/models/storage_config.go` - - 定义 `StorageConfig` 模型 - - 提供 `GetStorageConfig()` 方法获取配置 - -2. **控制器文件**: `go/controllers/storage_config.go` - - `GetStorageConfig`: 获取存储配置 - - `SaveStorageConfig`: 保存存储配置 - -3. **路由注册**: `go/routers/platform/platform.go` - ```go - beego.Router("/platform/storageConfig", &controllers.StorageConfigController{}, "get:GetStorageConfig") - beego.Router("/platform/saveStorageConfig", &controllers.StorageConfigController{}, "post:SaveStorageConfig") - ``` - -### API接口 - -#### 获取存储配置 -``` -GET /platform/storageConfig -``` - -**响应示例**: -```json -{ - "code": 200, - "msg": "success", - "data": { - "storage_type": "qiniu", - "qiniu_access_key": "your_access_key", - "qiniu_secret_key": "your_secret_key", - "qiniu_bucket": "your_bucket", - "qiniu_domain": "https://cdn.example.com", - "qiniu_region": "z0" - } -} -``` - -#### 保存存储配置 -``` -POST /platform/saveStorageConfig -``` - -**请求体**: -```json -{ - "storage_type": "qiniu", - "qiniu_access_key": "your_access_key", - "qiniu_secret_key": "your_secret_key", - "qiniu_bucket": "your_bucket", - "qiniu_domain": "https://cdn.example.com", - "qiniu_region": "z0" -} -``` - -## 前端实现 - -### 新增文件 - -1. **API文件**: `platform/src/api/sitesettings.js` - - 新增 `getStorageConfig()` 方法 - - 新增 `saveStorageConfig()` 方法 - -2. **组件文件**: `platform/src/views/system/platformsettings/components/storageSettings.vue` - - 存储配置表单组件 - - 支持本地存储和七牛云存储切换 - - 表单验证和数据持久化 - -3. **页面更新**: `platform/src/views/system/platformsettings/index.vue` - - 新增"存储配置"标签页 - -### 使用说明 - -1. 登录平台管理后台 -2. 进入"系统设置" -> "平台设置" -3. 切换到"存储配置"标签页 -4. 选择存储类型: - - **本地存储**:无需额外配置 - - **七牛云存储**:需要填写以下信息 - -### 七牛云配置步骤 - -1. **注册七牛云账号** - - 访问 https://www.qiniu.com/ - - 注册并完成实名认证 - -2. **创建存储空间** - - 登录七牛云控制台 - - 进入"对象存储" -> "空间管理" - - 点击"新建空间" - - 填写空间名称(Bucket) - - 选择存储区域 - - 设置访问控制(建议选择"公开") - -3. **获取密钥** - - 进入"个人中心" -> "密钥管理" - - 查看或创建 AccessKey 和 SecretKey - -4. **配置CDN域名** - - 在存储空间详情页,进入"域名管理" - - 添加自定义域名或使用测试域名 - - 完成域名备案和CNAME解析 - - 获取CDN加速域名 - -5. **填写配置信息** - - AccessKey: 从密钥管理获取 - - SecretKey: 从密钥管理获取 - - Bucket: 存储空间名称 - - CDN域名: 完整的域名地址(如 https://cdn.example.com) - - 存储区域: 选择创建空间时的区域 - -### 存储区域对照表 - -| 区域名称 | 区域代码 | -|---------|---------| -| 华东-浙江 | z0 | -| 华北-河北 | z1 | -| 华南-广东 | z2 | -| 北美-洛杉矶 | na0 | -| 亚太-新加坡 | as0 | -| 华东-浙江2 | cn-east-2 | - -## 后续开发建议 - -### 文件上传服务改造 - -需要修改文件上传相关的代码,根据 `storage_type` 选择不同的存储方式: - -```go -// 示例代码 -func UploadFile(file *multipart.FileHeader) (string, error) { - cfg, _ := models.GetStorageConfig() - - switch cfg.StorageType { - case "qiniu": - return uploadToQiniu(file, cfg) - case "local": - return uploadToLocal(file) - default: - return uploadToLocal(file) - } -} -``` - -### 七牛云SDK集成 - -需要安装七牛云Go SDK: - -```bash -go get github.com/qiniu/go-sdk/v7 -``` - -示例上传代码: - -```go -import ( - "github.com/qiniu/go-sdk/v7/auth/qbox" - "github.com/qiniu/go-sdk/v7/storage" -) - -func uploadToQiniu(file *multipart.FileHeader, cfg *models.StorageConfig) (string, error) { - mac := qbox.NewMac(cfg.QiniuAccessKey, cfg.QiniuSecretKey) - putPolicy := storage.PutPolicy{ - Scope: cfg.QiniuBucket, - } - upToken := putPolicy.UploadToken(mac) - - // 配置上传参数 - cfg := storage.Config{ - Zone: &storage.ZoneHuadong, // 根据 cfg.QiniuRegion 选择 - UseHTTPS: true, - UseCdnDomains: false, - } - - formUploader := storage.NewFormUploader(&cfg) - ret := storage.PutRet{} - - // 执行上传 - err := formUploader.PutFile(context.Background(), &ret, upToken, key, localFile, nil) - if err != nil { - return "", err - } - - // 返回完整URL - return cfg.QiniuDomain + "/" + ret.Key, nil -} -``` - -## 注意事项 - -1. **安全性** - - SecretKey 在数据库中明文存储,建议后续加密处理 - - 生产环境建议使用环境变量或密钥管理服务 - -2. **成本** - - 七牛云存储和流量会产生费用 - - 建议设置合理的存储策略和CDN缓存规则 - -3. **迁移** - - 切换存储方式时,已有文件不会自动迁移 - - 需要手动迁移或保持双存储支持 - -4. **备份** - - 重要文件建议定期备份 - - 七牛云支持跨区域备份功能 - -## 测试清单 - -- [ ] 数据库表创建成功 -- [ ] 后端API接口正常 -- [ ] 前端页面显示正常 -- [ ] 本地存储配置保存成功 -- [ ] 七牛云配置保存成功 -- [ ] 表单验证正常工作 -- [ ] 配置切换功能正常 -- [ ] 数据持久化正常 diff --git a/docs/修复请求体为空问题.md b/docs/修复请求体为空问题.md deleted file mode 100644 index fdbb85b..0000000 --- a/docs/修复请求体为空问题.md +++ /dev/null @@ -1,200 +0,0 @@ -# 修复请求体为空问题 - -## 问题描述 - -七牛云上传成功后,保存文件记录到数据库时失败: - -``` -POST https://api.yunzer.cn/platform/qiniu/save 400 -{"code": 400, "msg": "参数解析失败: 请求体为空"} -``` - -## 问题原因 - -Beego 框架默认不会复制请求体到 `c.Ctx.Input.RequestBody`,需要显式启用 `CopyRequestBody` 配置。 - -## 解决方案 - -### 1. 修改 go/conf/app.conf - -添加配置: - -```properties -# 启用请求体复制(允许多次读取请求体) -copyrequestbody = true -``` - -### 2. 修改 go/main.go - -在代码中显式启用: - -```go -func main() { - // 初始化数据库 - models.Init(version.Version) - - // 启用请求体复制(允许多次读取请求体) - beego.BConfig.CopyRequestBody = true // ← 新增 - - // 设置最大请求体大小(10MB,足够登录请求使用) - beego.BConfig.MaxMemory = 10 << 20 // 10MB - - // 静态资源:映射 /uploads 到本地 uploads 目录,供前端访问上传文件 - beego.SetStaticPath("/uploads", "uploads") - - beego.Run() -} -``` - -### 3. 添加调试日志 - -在 `go/controllers/qiniu_upload.go` 的 `SaveFileRecord` 方法中添加日志: - -```go -// 调试:打印请求体 -body := c.Ctx.Input.RequestBody -fmt.Println("SaveFileRecord 请求体长度:", len(body)) -fmt.Println("SaveFileRecord 请求体内容:", string(body)) -``` - -## 重启服务 - -```bash -# 重启 Go 服务 -systemctl restart go-api - -# 查看服务状态 -systemctl status go-api - -# 查看日志 -tail -f /www/wwwroot/api.yunzer.cn/go.log -``` - -## 测试步骤 - -1. 重启后端服务 -2. 登录前端系统 -3. 进入软件升级页面 -4. 上传一个文件 -5. 观察后端日志 - -### 预期日志输出 - -``` -SaveFileRecord 请求体长度: 123 -SaveFileRecord 请求体内容: {"key":"2026/04/09/xxx.exe","hash":"xxx","size":60742452,"name":"xxx.exe","mimeType":"application/x-msdownload","cate":0} -``` - -### 预期响应 - -```json -{ - "code": 200, - "data": { - "url": "http://7colud.yunzer.cn/2026/04/09/xxx.exe", - "id": 123, - "name": "xxx.exe", - "key": "2026/04/09/xxx.exe" - } -} -``` - -## 相关配置说明 - -### CopyRequestBody 的作用 - -Beego 框架中,请求体默认只能读取一次。如果需要在多个地方读取请求体(例如中间件和控制器),需要启用 `CopyRequestBody`。 - -启用后,Beego 会在接收到请求时将请求体复制到 `c.Ctx.Input.RequestBody`,允许多次读取。 - -### 配置方式 - -有两种方式启用: - -1. **配置文件方式** (`go/conf/app.conf`): - ```properties - copyrequestbody = true - ``` - -2. **代码方式** (`go/main.go`): - ```go - beego.BConfig.CopyRequestBody = true - ``` - -建议两种方式都配置,确保生效。 - -## 注意事项 - -### 1. 内存占用 - -启用 `CopyRequestBody` 会增加内存占用,因为每个请求的请求体都会被复制到内存中。 - -对于大文件上传,建议: -- 使用七牛云直传(不经过服务器) -- 只在需要的接口启用请求体复制 - -### 2. 与登录接口的兼容性 - -之前修复登录问题时,我们已经将登录接口改为使用 `c.Ctx.Input.RequestBody`,启用 `CopyRequestBody` 后,登录接口也能正常工作。 - -### 3. MaxMemory 配置 - -`MaxMemory` 配置控制请求体的最大大小: - -```go -beego.BConfig.MaxMemory = 10 << 20 // 10MB -``` - -对于七牛云直传,文件不经过服务器,所以这个限制不影响大文件上传。 - -## 验证修复 - -### 1. 检查配置是否生效 - -重启服务后,查看日志中是否有请求体内容输出。 - -### 2. 测试上传功能 - -上传一个文件,检查: -- 七牛云上传是否成功 -- 数据库记录是否保存成功 -- 文件 URL 是否正确 - -### 3. 检查数据库 - -```sql -SELECT id, name, src, size, type, cate, md5, create_time -FROM system_file -ORDER BY id DESC -LIMIT 5; -``` - -应该能看到新上传的文件记录。 - -## 回滚方案 - -如果修复后出现其他问题,可以临时禁用: - -```go -// go/main.go -beego.BConfig.CopyRequestBody = false -``` - -或在 `go/conf/app.conf` 中: - -```properties -copyrequestbody = false -``` - -然后重启服务。 - -## 相关文件 - -- `go/main.go` - 主程序入口 -- `go/conf/app.conf` - 配置文件 -- `go/controllers/qiniu_upload.go` - 七牛云上传控制器 -- `go/controllers/platform_auth.go` - 登录控制器(也使用 RequestBody) - -## 更新日期 - -2026-04-09 diff --git a/docs/快速修复-请求体为空.md b/docs/快速修复-请求体为空.md deleted file mode 100644 index 853bdc0..0000000 --- a/docs/快速修复-请求体为空.md +++ /dev/null @@ -1,122 +0,0 @@ -# 快速修复:请求体为空问题 - -## 问题 -``` -POST /platform/qiniu/save 400 -{"code": 400, "msg": "参数解析失败: 请求体为空"} -``` - -## 快速修复步骤 - -### 1. 重启后端服务(已修改配置) - -```bash -systemctl restart go-api -``` - -### 2. 查看服务状态 - -```bash -systemctl status go-api -``` - -预期输出: -``` -● go-api.service - Go API Server - Loaded: loaded - Active: active (running) -``` - -### 3. 查看日志 - -```bash -tail -f /www/wwwroot/api.yunzer.cn/go.log -``` - -### 4. 测试上传 - -1. 登录系统 -2. 进入软件升级页面 -3. 上传一个文件 - -### 5. 观察日志输出 - -应该看到: -``` -SaveFileRecord 请求体长度: xxx -SaveFileRecord 请求体内容: {"key":"...","hash":"...","size":...} -``` - -## 已修改的文件 - -✅ `go/main.go` - 添加 `beego.BConfig.CopyRequestBody = true` -✅ `go/conf/app.conf` - 添加 `copyrequestbody = true` -✅ `go/controllers/qiniu_upload.go` - 添加调试日志 - -## 如果还是失败 - -### 检查 1: 服务是否重启成功 - -```bash -systemctl status go-api -``` - -如果失败,查看错误: -```bash -journalctl -u go-api -n 50 -``` - -### 检查 2: 配置是否生效 - -查看日志中是否有请求体内容输出。如果没有,说明配置未生效。 - -### 检查 3: 前端请求是否正确 - -打开浏览器开发者工具,查看 Network 标签: -- 请求方法:POST -- Content-Type: application/json -- 请求体:应该有 JSON 数据 - -## 完整上传流程 - -``` -1. 前端上传文件到七牛云 ✓ - ↓ -2. 七牛云返回文件信息 ✓ - { - "key": "2026/04/09/xxx.exe", - "hash": "xxx", - "size": 60742452 - } - ↓ -3. 前端调用 /platform/qiniu/save ← 这里失败了 - POST /platform/qiniu/save - Body: { - "key": "...", - "hash": "...", - "size": ..., - "name": "...", - "mimeType": "...", - "cate": 0 - } - ↓ -4. 后端保存到数据库 ← 修复后应该成功 - ↓ -5. 返回文件 URL -``` - -## 修复原理 - -Beego 框架默认不复制请求体,需要启用 `CopyRequestBody`: - -```go -// 修复前 -c.Ctx.Input.RequestBody // 空的 - -// 修复后(启用 CopyRequestBody) -c.Ctx.Input.RequestBody // 包含请求体数据 -``` - -## 更新时间 - -2026-04-09 diff --git a/docs/接口文件.md b/docs/接口文件.md deleted file mode 100644 index 9160e5a..0000000 --- a/docs/接口文件.md +++ /dev/null @@ -1,26 +0,0 @@ -## 接口文件 - -> 约定:每新增一个对外接口,都需要在本文件登记(端/方法/路径/描述/鉴权/入出参简述)。 - -### platform(平台端) - -| 方法 | 路径 | 描述 | -|---|---|---| -| `POST` | `/platform/login` | 平台登录 | -| `POST` | `/platform/sendLoginCode` | 发送登录验证码 | -| `POST` | `/platform/loginBySms` | 手机号验证码登录 | -| `POST` | `/platform/logout` | 平台退出登录 | -| `GET` | `/platform/login/getGeetest3Infos` | 获取极验3.0配置 | -| `GET` | `/platform/login/getGeetest4Infos` | 获取极验4.0配置 | -| `GET` | `/platform/login/getOpenVerify` | 判断是否开启登录验证 | -| `POST` | `/platform/resetPassword` | 忘记密码重置 | -| `POST` | `/platform/sendResetCode` | 发送找回密码验证码 | - -#### `/platform/login` 详情 - -- 入参(JSON body):`{ "username": string, "password": string }` -- 出参(JSON):`{ "success": boolean, "token": string }` -- 说明:当前使用占位登录逻辑,仅校验非空并返回平台用户 JWT,后续接真实用户/租户表 - -> 其余 `/platform/*` 登录相关接口(发送验证码、极验、重置密码等)目前仅返回 `501 Not Implemented`,后续按实际需求逐步补全。 - diff --git a/docs/立即执行-重启服务.md b/docs/立即执行-重启服务.md deleted file mode 100644 index 0018a3b..0000000 --- a/docs/立即执行-重启服务.md +++ /dev/null @@ -1,218 +0,0 @@ -# 立即执行:重启服务 - -## 修复内容 - -✅ 已修复日志错误(`beego.Info` → `fmt.Println`) -✅ 已启用 `CopyRequestBody` 配置 -✅ 已添加调试输出 - -## 立即执行 - -### 1. 重启服务 - -```bash -systemctl restart go-api -``` - -### 2. 检查服务状态 - -```bash -systemctl status go-api -``` - -**预期输出**: -``` -● go-api.service - Go API Server - Active: active (running) -``` - -如果显示 `failed`,查看错误: -```bash -journalctl -u go-api -n 50 -``` - -### 3. 查看实时日志 - -```bash -tail -f /www/wwwroot/api.yunzer.cn/go.log -``` - -或者查看标准输出(调试日志会输出到这里): -```bash -journalctl -u go-api -f -``` - -### 4. 测试上传 - -1. 打开浏览器,登录系统 -2. 进入:平台管理 → 软件升级 -3. 点击"新增"或"编辑" -4. 上传一个文件(建议先用小文件测试) - -### 5. 观察日志 - -在终端中应该看到: - -``` -SaveFileRecord 请求体长度: 150 -SaveFileRecord 请求体内容: {"key":"2026/04/09/1775732976777726699.exe","hash":"loozoz7qv9flWXsS5UldWdPX9-T_","size":60742452,"name":"xxx.exe","mimeType":"application/x-msdownload","cate":0} -``` - -### 6. 验证结果 - -**前端应该显示**: -- 上传进度条 -- 上传成功提示 -- 文件 URL: `http://7colud.yunzer.cn/2026/04/09/xxxxx.exe` - -**数据库验证**: -```bash -mysql -u go-platform -p -h 212.64.112.158 -P 3388 go-platform -``` - -```sql -SELECT id, name, src, size, type, create_time -FROM system_file -ORDER BY id DESC -LIMIT 5; -``` - -## 完整上传流程 - -``` -用户选择文件 - ↓ -前端获取存储配置 - storageType: 'qiniu' - ↓ -前端获取上传凭证 - token, region: 'z2' - ↓ -前端直接上传到七牛云 - POST https://upload-z2.qiniup.com - ✓ 成功返回: {key, hash, size} - ↓ -前端保存文件记录 - POST /platform/qiniu/save - Body: {key, hash, size, name, mimeType, cate} - ↓ -后端接收请求 - ✓ CopyRequestBody 已启用 - ✓ 请求体不为空 - ↓ -后端保存到数据库 - INSERT INTO system_file - ↓ -返回文件信息 - {url, id, name, key} - ↓ -前端显示上传成功 -``` - -## 如果还是失败 - -### 问题 1: 服务启动失败 - -**检查**: -```bash -journalctl -u go-api -n 50 -``` - -**常见原因**: -- 端口被占用 -- 数据库连接失败 -- 配置文件错误 - -### 问题 2: 请求体仍然为空 - -**检查**: -1. 确认服务已重启 -2. 查看日志中是否有 "请求体长度: 0" -3. 检查前端请求的 Content-Type 是否为 `application/json` - -**解决**: -```bash -# 确保配置生效 -grep -i "copyrequestbody" /www/wwwroot/api.yunzer.cn/conf/app.conf - -# 应该看到 -copyrequestbody = true -``` - -### 问题 3: 七牛云上传失败 - -**检查**: -- 浏览器控制台是否有 CORS 错误 -- 七牛云 bucket 是否存在 -- 区域配置是否正确(z2) - -**解决**: -参见 `platform/docs/七牛云上传测试步骤.md` - -## 调试技巧 - -### 1. 查看完整请求 - -浏览器开发者工具 → Network 标签 → 找到 `/platform/qiniu/save` 请求: -- Headers: 查看 Content-Type -- Payload: 查看请求体内容 -- Response: 查看响应内容 - -### 2. 查看后端日志 - -```bash -# 实时日志 -tail -f /www/wwwroot/api.yunzer.cn/go.log - -# 或者查看 systemd 日志(包含 fmt.Println 输出) -journalctl -u go-api -f -``` - -### 3. 测试 API - -使用 curl 测试: -```bash -# 获取 token(先登录) -TOKEN="your_token_here" - -# 测试保存接口 -curl -X POST https://api.yunzer.cn/platform/qiniu/save \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "key": "test/test.txt", - "hash": "test123", - "size": 1024, - "name": "test.txt", - "mimeType": "text/plain", - "cate": 0 - }' -``` - -## 成功标志 - -✓ 服务启动成功 -✓ 日志中看到请求体内容 -✓ 前端显示上传成功 -✓ 数据库有新记录 -✓ 文件 URL 可以访问 - -## 下一步 - -上传成功后,可以: -1. 移除调试日志(`fmt.Println`) -2. 测试大文件上传 -3. 测试批量上传 -4. 验证文件去重功能 - -## 联系支持 - -如果问题仍然存在,请提供: -1. 服务状态输出 -2. 完整的错误日志 -3. 浏览器控制台截图 -4. 请求和响应的详细信息 - -## 更新时间 - -2026-04-09 diff --git a/models/cms_article.go b/models/cms_article.go new file mode 100644 index 0000000..8ff270f --- /dev/null +++ b/models/cms_article.go @@ -0,0 +1,159 @@ +package models + +import ( + "sync" + "time" + + "github.com/beego/beego/v2/client/orm" +) + +// CmsArticleCategory CMS 文章分类 yz_cms_article_category +type CmsArticleCategory struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Cid uint64 `orm:"column(cid);default(0)" json:"cid"` + Name string `orm:"column(name);size(100)" json:"name"` + Image string `orm:"column(image);size(500);default()" json:"image"` + Desc string `orm:"column(desc);size(500);default()" json:"desc"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsArticleCategory) TableName() string { + return "yz_cms_article_category" +} + +// CmsArticle CMS 文章 yz_cms_article +type CmsArticle struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Title string `orm:"column(title);size(255)" json:"title"` + Author string `orm:"column(author);size(100);default()" json:"author"` + CateID uint64 `orm:"column(cate_id);default(0)" json:"cate_id"` + Content string `orm:"column(content);type(mediumtext);null" json:"content"` + Desc string `orm:"column(desc);size(500);default()" json:"desc"` + Image string `orm:"column(image);size(500);default()" json:"image"` + IsTrans int8 `orm:"column(is_trans);default(0)" json:"is_trans"` + TransURL *string `orm:"column(transurl);size(500);null" json:"transurl"` + Status int8 `orm:"column(status);default(0)" json:"status"` + Top int8 `orm:"column(top);default(0)" json:"top"` + Recommend int8 `orm:"column(recommend);default(0)" json:"recommend"` + Views int `orm:"column(views);default(0)" json:"views"` + Likes int `orm:"column(likes);default(0)" json:"likes"` + PublisherID *uint64 `orm:"column(publisher_id);null" json:"publisher_id"` + PublishTime *time.Time `orm:"column(publish_time);type(datetime);null" json:"publish_time"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsArticle) TableName() string { + return "yz_cms_article" +} + +var cmsArticleTablesOnce sync.Once + +// EnsureCmsArticleTables 首次使用时自动建表(若不存在)。 +func EnsureCmsArticleTables() error { + var err error + cmsArticleTablesOnce.Do(func() { + _, err = Orm.Raw(` +CREATE TABLE IF NOT EXISTS yz_cms_article_category ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + tid bigint unsigned NOT NULL DEFAULT 0, + cid bigint unsigned NOT NULL DEFAULT 0, + name varchar(100) NOT NULL DEFAULT '', + image varchar(500) NOT NULL DEFAULT '', + ` + "`desc`" + ` varchar(500) NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 0, + status tinyint NOT NULL DEFAULT 1, + create_time datetime NOT NULL, + update_time datetime DEFAULT NULL, + delete_time datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_tid_cid (tid, cid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`).Exec() + if err != nil { + return + } + _, err = Orm.Raw(` +CREATE TABLE IF NOT EXISTS yz_cms_article ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + tid bigint unsigned NOT NULL DEFAULT 0, + title varchar(255) NOT NULL DEFAULT '', + author varchar(100) NOT NULL DEFAULT '', + cate_id bigint unsigned NOT NULL DEFAULT 0, + content mediumtext, + ` + "`desc`" + ` varchar(500) NOT NULL DEFAULT '', + image varchar(500) NOT NULL DEFAULT '', + is_trans tinyint NOT NULL DEFAULT 0, + transurl varchar(500) DEFAULT NULL, + status tinyint NOT NULL DEFAULT 0, + top tinyint NOT NULL DEFAULT 0, + recommend tinyint NOT NULL DEFAULT 0, + views int NOT NULL DEFAULT 0, + likes int NOT NULL DEFAULT 0, + publisher_id bigint unsigned DEFAULT NULL, + publish_time datetime DEFAULT NULL, + create_time datetime NOT NULL, + update_time datetime DEFAULT NULL, + delete_time datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_tid_status (tid, status), + KEY idx_cate_id (cate_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`).Exec() + }) + return err +} + +func CmsCategoryNameMap(tid uint64, ids []uint64) map[uint64]string { + out := make(map[uint64]string) + if len(ids) == 0 { + return out + } + var rows []CmsArticleCategory + _, _ = Orm.QueryTable(new(CmsArticleCategory)). + Filter("tid", tid). + Filter("id__in", ids). + Filter("delete_time__isnull", true). + All(&rows, "ID", "Name") + for _, r := range rows { + out[r.ID] = r.Name + } + return out +} + +func CmsFormatTime(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func CmsSimilarArticles(tid uint64, title string, limit int) ([]orm.Params, error) { + if limit <= 0 { + limit = 5 + } + var rows []CmsArticle + _, err := Orm.QueryTable(new(CmsArticle)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Filter("title__icontains", title). + Limit(limit). + All(&rows, "ID", "Title") + if err != nil { + return nil, err + } + out := make([]orm.Params, 0, len(rows)) + for _, r := range rows { + out = append(out, orm.Params{ + "id": r.ID, + "title": r.Title, + "similarity": 80, + }) + } + return out, nil +} diff --git a/models/init.go b/models/init.go index 45bb3a8..3718ad1 100644 --- a/models/init.go +++ b/models/init.go @@ -59,6 +59,8 @@ func Init(_ string) { new(PlatformAccountPoolKiro), new(PlatformAccountPoolWindsurf), new(PlatformAccountPoolCursor), + new(CmsArticleCategory), + new(CmsArticle), ) // 创建全局 Ormer diff --git a/models/system_tenant_domain.go b/models/system_tenant_domain.go index 8d5541b..fe44f64 100644 --- a/models/system_tenant_domain.go +++ b/models/system_tenant_domain.go @@ -16,5 +16,5 @@ type SystemTenantDomain struct { } func (m *SystemTenantDomain) TableName() string { - return "yz_tenant_domain" + return "yz_system_tenant_domain" } diff --git a/pkg/tokenprobe/probe.go b/pkg/tokenprobe/probe.go index 6771c87..d911d5e 100644 --- a/pkg/tokenprobe/probe.go +++ b/pkg/tokenprobe/probe.go @@ -1,9 +1,8 @@ -// Package tokenprobe 使用号池内 Token 调用各厂商接口做可用性探测。 -// Cursor 走 api2.cursor.sh 的 Connect + protobuf 二进制流(非 JSON 文本接口)。 package tokenprobe import ( "bytes" + "crypto/tls" "encoding/base64" "encoding/json" "fmt" @@ -14,9 +13,13 @@ import ( "time" ) -var httpClient = &http.Client{Timeout: 25 * time.Second} +var httpClient = &http.Client{ + Timeout: 12 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, +} -// Result 探测结果(Cursor 会填充 ProbeMessage / Endpoint / BytesRead / RawPreview 等) type Result struct { OK bool `json:"ok"` Detail string `json:"detail"` @@ -26,12 +29,10 @@ type Result struct { BytesRead int `json:"bytesRead,omitempty"` RawPreview string `json:"rawPreview,omitempty"` RequestBodyPrefixHex string `json:"requestBodyPrefixHex,omitempty"` - // StreamProtocol / StreamNote 仅 Cursor Agent 探测填充,说明二进制流与结论边界 - StreamProtocol string `json:"streamProtocol,omitempty"` - StreamNote string `json:"streamNote,omitempty"` + StreamProtocol string `json:"streamProtocol,omitempty"` + StreamNote string `json:"streamNote,omitempty"` } -// ProbeOfficial 按号池模块探测 Token(cursor / windsurf / krio) func ProbeOfficial(module, rawToken string) Result { tok := normalizeBearerToken(strings.TrimSpace(rawToken)) if tok == "" { @@ -58,18 +59,78 @@ func normalizeBearerToken(s string) string { } func probeCursor(token string) Result { - return probeCursorHiAgent(token) + url := "https://api2.cursor.sh/auth/full_stripe_profile" + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return Result{OK: false, Detail: "构造请求失败: " + err.Error()} + } + + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + req.Header.Set("X-Cursor-Client-Version", "3.0.16") + req.Header.Set("X-New-Onboarding-Completed", "false") + req.Header.Set("X-Ghost-Mode", "true") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Cursor/3.0.16 Chrome/142.0.7444.265 Electron/39.8.1 Safari/537.36") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "vscode-file://vscode-app") + req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + req.Header.Set("Accept-Language", "zh-CN") + req.Header.Set("Priority", "u=1, i") + + resp, err := httpClient.Do(req) + if err != nil { + return Result{OK: false, Detail: "请求官方账单接口超时/网络失败: " + err.Error(), HTTPStatus: 500} + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16384)) + jsonStr := string(body) + + res := Result{ + HTTPStatus: resp.StatusCode, + Endpoint: url, + BytesRead: len(body), + RawPreview: jsonStr, + StreamProtocol: "HTTP/2 JSON REST", + StreamNote: "2026型画像探测", + ProbeMessage: "GET full_stripe_profile", + } + + if resp.StatusCode != http.StatusOK { + res.OK = false + res.Detail = fmt.Sprintf("Token已失效或被官方拉黑(HTTP %d)", resp.StatusCode) + return res + } + + if strings.Contains(jsonStr, `"noModelsRemaining":true`) || + strings.Contains(jsonStr, `"is_usage_limited":true`) || + strings.Contains(jsonStr, `"hard_limit_reached"`) || + strings.Contains(jsonStr, `"blocked"`) || + (strings.Contains(jsonStr, `"membershipType":"free"`) && strings.Contains(jsonStr, `"trialEligible":false`)) { + res.OK = false + res.Detail = "Token存活,但属于无额度Free空壳号(上号必弹付费墙)" + return res + } + + if len(jsonStr) < 10 { + res.OK = false + res.Detail = "官方接口返回异常空数据" + return res + } + + res.OK = true + res.Detail = "检测成功,高速算力/Agent额度健康" + return res } func probeWindsurf(apiKey string) Result { payload := map[string]interface{}{ "metadata": map[string]string{ - "apiKey": apiKey, - "ideName": "windsurf", - "ideVersion": "0.0.0", - "extensionName": "windsurf", - "extensionVersion": "0.0.0", - "locale": "zh", + "apiKey": apiKey, + "ideName": "windsurf", + "ideVersion": "0.0.0", + "extensionName": "windsurf", + "extensionVersion": "0.0.0", + "locale": "zh", }, } raw, err := json.Marshal(payload) @@ -122,7 +183,7 @@ func probeKiro(accessToken string) Result { if arn == "" { return Result{ OK: false, - Detail: "无法从 Token 中解析 profileArn,Kiro 暂无法自动探测(需完整登录 JWT)", + Detail: "无法从 Token 中解析 profileArn,Kiro 暂无法自动探测", } } diff --git a/routers/backend/backend.go b/routers/backend/backend.go index 28d5cf3..1cc327a 100644 --- a/routers/backend/backend.go +++ b/routers/backend/backend.go @@ -14,31 +14,20 @@ func Register() { // RegisterAuthRoutes 注册 backend 认证相关路由。 func RegisterAuthRoutes() { - // backend 登录相关(统一走 /backend/*) + // 登录、注册与找回密码相关 beego.Router("/backend/login", &controllers.BackendAuthController{}, "post:LoginBackend") beego.Router("/backend/sendLoginCode", &controllers.BackendAuthController{}, "post:SendLoginCode") beego.Router("/backend/loginBySms", &controllers.BackendAuthController{}, "post:LoginBySms") beego.Router("/backend/logout", &controllers.BackendAuthController{}, "post:Logout") - - // 极验与登录验证配置 - beego.Router("/backend/login/getGeetest3Infos", &controllers.BackendAuthController{}, "get:GetGeetest3Infos") - beego.Router("/backend/login/getGeetest4Infos", &controllers.BackendAuthController{}, "get:GetGeetest4Infos") - beego.Router("/backend/login/getOpenVerify", &controllers.BackendAuthController{}, "get:GetOpenVerify") - - // 登录相关接口 - beego.Router("/backend/login/getGeetest3Infos", &controllers.BackendAuthController{}, "get:GetGeetest3Infos") - beego.Router("/backend/login/getGeetest4Infos", &controllers.BackendAuthController{}, "get:GetGeetest4Infos") - beego.Router("/backend/login/getOpenVerify", &controllers.BackendAuthController{}, "get:GetOpenVerify") - - // 注册与找回密码 beego.Router("/backend/register", &controllers.BackendAuthController{}, "post:Register") beego.Router("/backend/sendRegisterCode", &controllers.BackendAuthController{}, "post:SendRegisterCode") beego.Router("/backend/resetPassword", &controllers.BackendAuthController{}, "post:ResetPassword") beego.Router("/backend/sendResetCode", &controllers.BackendAuthController{}, "post:SendResetCode") - // 租户站点设置 - beego.Router("/backend/normalInfos", &controllers.BackendSiteSettingsController{}, "get:GetNormalInfos") - beego.Router("/backend/saveNormalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveNormalInfos") + // 极验与登录验证配置 + beego.Router("/backend/login/getGeetest3Infos", &controllers.BackendAuthController{}, "get:GetGeetest3Infos") + beego.Router("/backend/login/getGeetest4Infos", &controllers.BackendAuthController{}, "get:GetGeetest4Infos") + beego.Router("/backend/login/getOpenVerify", &controllers.BackendAuthController{}, "get:GetOpenVerify") // 菜单接口 beego.Router("/backend/menu/:id", &controllers.BackendMenuController{}, "get:GetBackendMenu") @@ -50,6 +39,18 @@ func RegisterAuthRoutes() { beego.Router("/backend/operationLogs/:id", &controllers.BackendOperationLogController{}, "get:Detail;delete:Delete") beego.Router("/backend/operationLogs/batchDelete", &controllers.BackendOperationLogController{}, "post:BatchDelete") + // 租户站点设置 + beego.Router("/backend/normalInfos", &controllers.BackendSiteSettingsController{}, "get:GetNormalInfos") + beego.Router("/backend/saveNormalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveNormalInfos") + beego.Router("/backend/legalInfos", &controllers.BackendSiteSettingsController{}, "get:GetLegalInfos") + beego.Router("/backend/saveLegalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveLegalInfos") + beego.Router("/backend/companyInfos", &controllers.BackendSiteSettingsController{}, "get:GetCompanyInfos") + beego.Router("/backend/saveCompanyInfos", &controllers.BackendSiteSettingsController{}, "post:SaveCompanyInfos") + beego.Router("/backend/companySeo", &controllers.BackendSiteSettingsController{}, "get:GetCompanySeo") + beego.Router("/backend/saveCompanySeo", &controllers.BackendSiteSettingsController{}, "post:SaveCompanySeo") + beego.Router("/backend/loginVerifyInfos", &controllers.BackendLoginVerifyController{}, "get:GetLoginVerifyInfos") + beego.Router("/backend/saveloginVerifyInfos", &controllers.BackendLoginVerifyController{}, "post:SaveLoginVerifyInfos") + // 文件管理(yz_system_files / yz_system_files_category) beego.Router("/backend/usercate", &controllers.BackendFileController{}, "get:GetUserCate") beego.Router("/backend/allfiles", &controllers.BackendFileController{}, "get:GetAllFiles") @@ -101,6 +102,40 @@ func RegisterAuthRoutes() { beego.Router("/backend/erp/editPosition/:id", &controllers.BackendErpController{}, "post:EditPosition") beego.Router("/backend/erp/deletePosition/:id", &controllers.BackendErpController{}, "delete:DeletePosition") - // 文章管理相关接口 + // 文章管理 + beego.Router("/backend/articlesList", &controllers.BackendArticleController{}, "get:List") + beego.Router("/backend/allarticles", &controllers.BackendArticleController{}, "get:ListAll") + beego.Router("/backend/articles/:id", &controllers.BackendArticleController{}, "get:Detail") + beego.Router("/backend/createarticle", &controllers.BackendArticleController{}, "post:Create") + beego.Router("/backend/editarticle/:id", &controllers.BackendArticleController{}, "post:Update") + beego.Router("/backend/deletearticle/:id", &controllers.BackendArticleController{}, "delete:Delete") + beego.Router("/backend/publisharticle/:id", &controllers.BackendArticleController{}, "post:Publish") + beego.Router("/backend/unPublisharticle/:id", &controllers.BackendArticleController{}, "post:Unpublish") + beego.Router("/backend/articleRecommend/:id", &controllers.BackendArticleController{}, "post:Recommend") + beego.Router("/backend/unArticleRecommend/:id", &controllers.BackendArticleController{}, "post:Unrecommend") + beego.Router("/backend/articleTop/:id", &controllers.BackendArticleController{}, "post:Top") + beego.Router("/backend/unArticleTop/:id", &controllers.BackendArticleController{}, "post:Untop") + + beego.Router("/backend/categories", &controllers.BackendArticleCategoryController{}, "get:List") + beego.Router("/backend/allcategories", &controllers.BackendArticleCategoryController{}, "get:ListAll") + beego.Router("/backend/categories/:id", &controllers.BackendArticleCategoryController{}, "get:Detail;delete:Delete") + beego.Router("/backend/createCategory", &controllers.BackendArticleCategoryController{}, "post:Create") + beego.Router("/backend/editCategory/:id", &controllers.BackendArticleCategoryController{}, "post:Update") + beego.Router("/backend/categories/:id/status", &controllers.BackendArticleCategoryController{}, "patch:UpdateStatus") + + // 域名管理(主域名池 / 租户域名) + beego.Router("/backend/domain/pool/index", &controllers.BackendDomainPoolController{}, "get:Index") + beego.Router("/backend/domain/pool/getEnabledDomains", &controllers.BackendDomainPoolController{}, "get:GetEnabledDomains") + beego.Router("/backend/domain/pool/create", &controllers.BackendDomainPoolController{}, "post:Create") + beego.Router("/backend/domain/pool/update", &controllers.BackendDomainPoolController{}, "post:Update") + beego.Router("/backend/domain/pool/delete/:id", &controllers.BackendDomainPoolController{}, "delete:Delete") + beego.Router("/backend/domain/pool/toggleStatus", &controllers.BackendDomainPoolController{}, "post:ToggleStatus") + + beego.Router("/backend/domain/tenant/index", &controllers.BackendTenantDomainController{}, "get:Index") + beego.Router("/backend/domain/tenant/myDomains", &controllers.BackendTenantDomainController{}, "get:MyDomains") + beego.Router("/backend/domain/tenant/apply", &controllers.BackendTenantDomainController{}, "post:Apply") + beego.Router("/backend/domain/tenant/audit", &controllers.BackendTenantDomainController{}, "post:Audit") + beego.Router("/backend/domain/tenant/toggleStatus", &controllers.BackendTenantDomainController{}, "post:ToggleStatus") + beego.Router("/backend/domain/tenant/delete/:id", &controllers.BackendTenantDomainController{}, "delete:Delete") }