diff --git a/go/controllers/api_getcard.go b/go/controllers/api_getcard.go index 886f307..06a6cae 100644 --- a/go/controllers/api_getcard.go +++ b/go/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/go/controllers/backend_article.go b/go/controllers/backend_article.go new file mode 100644 index 0000000..832a024 --- /dev/null +++ b/go/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/go/controllers/backend_domain.go b/go/controllers/backend_domain.go new file mode 100644 index 0000000..c3e701b --- /dev/null +++ b/go/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/go/controllers/domain_common.go b/go/controllers/domain_common.go new file mode 100644 index 0000000..defeac1 --- /dev/null +++ b/go/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/go/controllers/platform_account_pool.go b/go/controllers/platform_account_pool.go index 8c8563e..f23d077 100644 --- a/go/controllers/platform_account_pool.go +++ b/go/controllers/platform_account_pool.go @@ -621,24 +621,70 @@ func replenishPoolRow(c *beego.Controller, module string) { 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 + checkedCount := 0 + unavailableCount := 0 + + for { + 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 { + msg := "暂无可用账号" + if checkedCount > 0 { + msg = fmt.Sprintf("已检测%d个账号,其中%d个不可用,暂无可用账号", checkedCount, unavailableCount) + } + poolJSONErr(c, 404, 404, msg) + return + } + + checkedCount++ + isAvailable := poolProbeToken("cursor", row.DataType, row.Token, row.ID) + if !isAvailable { + unavailableCount++ + if _, err := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)).Filter("id", row.ID).Update(map[string]interface{}{ + // 补号流程检测出来不可用/已用完的号,仍然归类为“补号”记录。 + // 不要写成已提取/已用完状态;只有接口提取后再标记不可用的号才归到已提取侧。 + "is_extracted": int8(2), + "is_used": int8(0), + "extracted_time": now, + "extracted_platform": platform, + "remark": remark, + "update_time": now, + }); err != nil { + poolJSONErr(c, 500, 500, "补号检测失败: "+err.Error()) + return + } + continue + } + + if _, err := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)).Filter("id", row.ID).Update(map[string]interface{}{ + "is_extracted": int8(2), + "is_used": int8(1), + "extracted_time": now, + "extracted_platform": platform, + "remark": remark, + "update_time": now, + }); err != nil { + poolJSONErr(c, 500, 500, "补号失败: "+err.Error()) + return + } + row.IsExtracted = 2 + isUsed := int8(1) + row.IsUsed = &isUsed + row.ExtractedTime = &now + row.ExtractedPlatform = &platform + row.Remark = remark + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "补号成功", + "data": row, + "probe": map[string]interface{}{ + "checkedCount": checkedCount, + "unavailableCount": unavailableCount, + }, + } + break } - 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)). @@ -973,12 +1019,12 @@ func probePoolToken(c *beego.Controller, module string) { if r.StreamNote != "" { data["streamNote"] = r.StreamNote } + // Cursor 探测状态只按底层探针结论 r.OK 保存。 + // 注意:客户端版本过旧只是 warning,Token 仍可用时 r.OK=true,不能因此写成已用完。 if module == "cursor" && payload.ID > 0 && r.HTTPStatus == http.StatusOK { - var isUsed int8 + isUsed := int8(0) if r.OK { isUsed = 1 - } else { - isUsed = 0 } if _, uerr := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)).Filter("id", payload.ID).Update(orm.Params{ "is_used": isUsed, @@ -995,6 +1041,62 @@ func probePoolToken(c *beego.Controller, module string) { _ = c.ServeJSON() } +func poolTableName(module string) string { + switch module { + case "cursor": + return (&models.PlatformAccountPoolCursor{}).TableName() + case "windsurf": + return (&models.PlatformAccountPoolWindsurf{}).TableName() + case "krio": + return (&models.PlatformAccountPoolKiro{}).TableName() + default: + return "" + } +} + +func poolIsUsedAvailable(isUsed *int8) (known bool, available bool) { + if isUsed == nil { + return false, false + } + switch *isUsed { + case 1: + return true, true + case 0: + return true, false + default: + return false, false + } +} + +func poolProbeToken(module, rowDataType, token string, id uint64) bool { + token = strings.TrimSpace(token) + if rowDataType == "account" || token == "" { + return true + } + + r := tokenprobe.ProbeOfficial(module, token) + + // Cursor 自动探测只按底层探针结论 r.OK 判定。 + // 客户端版本过旧是 warning,不代表 Token 已用完;只有 tokenprobe 明确判定额度用尽/不可用时 r.OK 才为 false。 + available := r.OK + + // 更新数据库中的 is_used 字段 + if module == "cursor" && id > 0 { + isUsed := int8(0) + if available { + isUsed = 1 + } + _, _ = models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). + Filter("id", id). + Update(orm.Params{ + "is_used": isUsed, + "update_time": time.Now(), + }) + } + + return available +} + func (c *PlatformAccountPoolCursorController) List() { listPoolRows(&c.Controller, "cursor") } func (c *PlatformAccountPoolCursorController) Add() { addPoolRow(&c.Controller, "cursor") } func (c *PlatformAccountPoolCursorController) BatchAdd() { batchAddPoolRows(&c.Controller, "cursor") } diff --git a/go/controllers/platform_domain.go b/go/controllers/platform_domain.go index a5bcf72..13a826d 100644 --- a/go/controllers/platform_domain.go +++ b/go/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/go/go.zip b/go/go.zip new file mode 100644 index 0000000..f4b2a7e Binary files /dev/null and b/go/go.zip differ diff --git a/go/models/cms_article.go b/go/models/cms_article.go new file mode 100644 index 0000000..f67903f --- /dev/null +++ b/go/models/cms_article.go @@ -0,0 +1,192 @@ +package models + +import ( + "fmt" + "strings" + "time" + + "github.com/beego/beego/v2/client/orm" +) + +// CmsArticle CMS文章表: yz_cms_article +type CmsArticle struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" 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(text);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(1)" json:"status"` // 1草稿 2已发布 3已下架 + Views int64 `orm:"column(views);default(0)" json:"views"` + Likes int64 `orm:"column(likes);default(0)" json:"likes"` + Top int8 `orm:"column(top);default(0)" json:"top"` + Recommend int8 `orm:"column(recommend);default(0)" json:"recommend"` + PublishTime *time.Time `orm:"column(publish_time);type(datetime);null" json:"publish_time"` + PublisherID uint64 `orm:"column(publisher_id);default(0)" json:"publisher_id"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;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" +} + +// CmsArticleCategory CMS文章分类表: yz_cms_article_category +type CmsArticleCategory struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + Cid uint64 `orm:"column(cid);default(0)" json:"cid"` // 父级分类ID + 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);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;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" +} + +// EnsureCmsArticleTables 确保 CMS 文章相关表存在。 +func EnsureCmsArticleTables() error { + sqls := []string{ + `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 DEFAULT CURRENT_TIMESTAMP, + ` + "`update_time`" + ` datetime NULL DEFAULT NULL, + ` + "`delete_time`" + ` datetime NULL DEFAULT NULL, + PRIMARY KEY (` + "`id`" + `), + KEY ` + "`idx_tid`" + ` (` + "`tid`" + `), + KEY ` + "`idx_tid_cid`" + ` (` + "`tid`" + `,` + "`cid`" + `), + KEY ` + "`idx_delete_time`" + ` (` + "`delete_time`" + `) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `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`" + ` longtext NULL, + ` + "`desc`" + ` varchar(500) NOT NULL DEFAULT '', + ` + "`image`" + ` varchar(500) NOT NULL DEFAULT '', + ` + "`is_trans`" + ` tinyint NOT NULL DEFAULT 0, + ` + "`transurl`" + ` varchar(500) NULL DEFAULT NULL, + ` + "`status`" + ` tinyint NOT NULL DEFAULT 1, + ` + "`views`" + ` bigint NOT NULL DEFAULT 0, + ` + "`likes`" + ` bigint NOT NULL DEFAULT 0, + ` + "`top`" + ` tinyint NOT NULL DEFAULT 0, + ` + "`recommend`" + ` tinyint NOT NULL DEFAULT 0, + ` + "`publish_time`" + ` datetime NULL DEFAULT NULL, + ` + "`publisher_id`" + ` bigint unsigned NOT NULL DEFAULT 0, + ` + "`create_time`" + ` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + ` + "`update_time`" + ` datetime NULL DEFAULT NULL, + ` + "`delete_time`" + ` datetime NULL DEFAULT NULL, + PRIMARY KEY (` + "`id`" + `), + KEY ` + "`idx_tid`" + ` (` + "`tid`" + `), + KEY ` + "`idx_tid_cate`" + ` (` + "`tid`" + `,` + "`cate_id`" + `), + KEY ` + "`idx_tid_status`" + ` (` + "`tid`" + `,` + "`status`" + `), + KEY ` + "`idx_delete_time`" + ` (` + "`delete_time`" + `) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + } + + for _, sqlStr := range sqls { + if _, err := Orm.Raw(sqlStr).Exec(); err != nil { + return err + } + } + return nil +} + +// CmsFormatTime 格式化 CMS 可空时间。 +func CmsFormatTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +// CmsCategoryNameMap 批量获取分类ID到分类名称的映射。 +func CmsCategoryNameMap(tid uint64, cateIDs []uint64) map[uint64]string { + result := make(map[uint64]string) + if tid == 0 || len(cateIDs) == 0 { + return result + } + + seen := make(map[uint64]bool) + ids := make([]uint64, 0, len(cateIDs)) + for _, id := range cateIDs { + if id == 0 || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + if len(ids) == 0 { + return result + } + + var rows []CmsArticleCategory + _, err := Orm.QueryTable(new(CmsArticleCategory)). + Filter("tid", tid). + Filter("id__in", ids). + Filter("delete_time__isnull", true). + All(&rows, "id", "name") + if err != nil && err != orm.ErrNoRows { + return result + } + for _, row := range rows { + result[row.ID] = row.Name + } + return result +} + +// CmsSimilarArticles 根据标题查找相似文章,用于创建文章时提示重复内容。 +func CmsSimilarArticles(tid uint64, title string, limit int) ([]map[string]interface{}, error) { + title = strings.TrimSpace(title) + if tid == 0 || title == "" { + return []map[string]interface{}{}, nil + } + if limit <= 0 { + limit = 5 + } + + var rows []CmsArticle + _, err := Orm.QueryTable(new(CmsArticle)). + Filter("tid", tid). + Filter("title__icontains", title). + Filter("delete_time__isnull", true). + OrderBy("-id"). + Limit(limit). + All(&rows, "id", "title", "cate_id", "status", "create_time") + if err != nil && err != orm.ErrNoRows { + return nil, err + } + + out := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + out = append(out, map[string]interface{}{ + "id": row.ID, + "title": row.Title, + "cate_id": row.CateID, + "status": row.Status, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + "similarity": fmt.Sprintf("标题包含“%s”", title), + }) + } + return out, nil +} diff --git a/go/models/init.go b/go/models/init.go index 45bb3a8..bcc357f 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -56,6 +56,8 @@ func Init(_ string) { new(ComplaintCategory), new(PlatformComplaint), new(SystemSoftwareUpgrade), + new(CmsArticle), + new(CmsArticleCategory), new(PlatformAccountPoolKiro), new(PlatformAccountPoolWindsurf), new(PlatformAccountPoolCursor), diff --git a/go/output.log b/go/output.log new file mode 100644 index 0000000..b475a1f Binary files /dev/null and b/go/output.log differ diff --git a/go/pkg/tokenprobe/cursor_hi.go b/go/pkg/tokenprobe/cursor_hi.go index 2a9ccf6..d2ad382 100644 --- a/go/pkg/tokenprobe/cursor_hi.go +++ b/go/pkg/tokenprobe/cursor_hi.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "os" + "regexp" "runtime" "strings" "time" @@ -22,7 +23,7 @@ import ( const ( cursorBackendURL = "https://api2.cursor.sh" cursorAgentPath = "/aiserver.v1.ChatService/StreamUnifiedChatWithTools" - cursorClientVersion = "2.6.22" + cursorClientVersion = "3.6.31" cursorHiMaxRead = 512 * 1024 // probeHiText 发往官方 Agent 的探测内容(与前端展示 probeMessage 一致) probeHiText = "hi" @@ -276,55 +277,43 @@ var cursorQuotaTipSig = []byte("Get Cursor Pro for more Agent usage, unlimited T const cursorLimitTipPrefix = "Get Cursor Pro for more Agent usage, unlimited Tab" -// classifyCursorRawStream 在官方流式二进制/文本中匹配用量与升级提示(ASCII 区不区分大小写 + UTF-8 短语) -func classifyCursorRawStream(raw []byte) (blocked bool, reason string) { +// classifyCursorRawStream 在官方流式二进制/文本中匹配用量与升级提示 +// 返回 (isQuotaExhausted, message) +// isQuotaExhausted: true 表示额度用完/Token不可用,false 表示 Token 可用(可能有警告信息) +func classifyCursorRawStream(raw []byte) (isQuotaExhausted bool, message string) { if len(raw) == 0 { return false, "" } + + // 额度用尽只按明确完整提示判定,避免“可用但带推广/提示文案”的 Token 被误标为已用完。 + // 用户自定义的二进制特征仍保留给部署方精确配置。 for _, sig := range cursorQuotaExhaustedSigsFromEnv() { if bytes.Contains(raw, sig) { - return true, fmt.Sprintf("流中匹配:CURSOR_QUOTA_EXHAUSTED_SIG_HEX 配置的二进制特征(%d 字节)", len(sig)) + return true, "该TOKEN已用完(额度已耗尽)" } } if bytes.Contains(raw, cursorQuotaTipSig) { - return true, "流中匹配:" + string(cursorQuotaTipSig) - } - // 社区脚本:仅到「…Agent usage」的 ASCII 前缀(流里可能只有前半段) - if bytes.Contains(raw, cursorQuotaExhaustedSigCommunity) { - return true, "流中匹配:Get Cursor Pro for more Agent usage…(社区 QuotaExhaustedSignature 前缀)" - } - if bytes.Contains(raw, []byte(cursorLimitTipPrefix)) { - return true, "流中匹配:" + cursorLimitTipPrefix + "…" - } - low := append([]byte(nil), raw...) - asciiLowerInPlace(low) - if bytes.Contains(low, []byte("you've hit your usage limit")) || - bytes.Contains(low, []byte("youve hit your usage limit")) || - bytes.Contains(low, []byte("hit your usage limit")) { - return true, "流中匹配:hit your usage limit / you've hit your usage limit" - } - if bytes.Contains(low, []byte("get cursor pro for more agent usage")) { - return true, "流中匹配:get cursor pro for more agent usage" - } - if bytes.Contains(low, []byte("upgrade to pro")) { - return true, "流中匹配:upgrade to pro" - } - if bytes.Contains(low, []byte("get cursor pro")) && bytes.Contains(low, []byte("agent")) { - return true, "流中匹配:get cursor pro + agent" - } - if bytes.Contains(low, []byte("usage limit")) { - return true, "流中匹配:usage limit" - } - if bytes.Contains(low, []byte("unlimited tab")) && bytes.Contains(low, []byte("cursor pro")) { - return true, "流中匹配:unlimited tab + cursor pro" + return true, "该TOKEN已用完(Get Cursor Pro for more Agent usage, unlimited Tab, and more.)" } flat := strings.ToLower(strings.ToValidUTF8(string(raw), "\uFFFD")) - flat = strings.ReplaceAll(flat, "\u2019", "'") // 右单引号 + flat = strings.ReplaceAll(flat, "\u2019", "'") flat = strings.ReplaceAll(flat, "`", "'") - if strings.Contains(flat, "you've hit your usage limit") { - return true, "流中匹配:you've hit your usage limit(UTF-8)" + + if strings.Contains(flat, "suspicious activity") || + strings.Contains(flat, "unauthenticated") || + strings.Contains(flat, "unauthorized request") || + strings.Contains(flat, "unauthorizedrequest") || + strings.Contains(flat, "error_unauthorized") { + return true, "该TOKEN不可用(账号触发可疑活动风控/未认证,需要重新登录)" } + + // 版本过旧警告 - 这不是额度问题,Token 仍然可用 + // 返回 false,表示 Token 可用 + if strings.Contains(flat, "very old version") || strings.Contains(flat, "update to the latest version") { + return false, "Token可用,但客户端版本过旧,建议更新到最新版本" + } + return false, "" } @@ -400,41 +389,29 @@ func decodeConnectFramedBody(raw []byte) ([]byte, string, bool) { return nil, "", false } - note := fmt.Sprintf("响应体已按 Connect 分帧解析(%d 帧", frameCount) - if compressedFrames > 0 { - note += fmt.Sprintf(",其中 %d 帧已做 gzip 解压", compressedFrames) - } - note += ")后分析" - return out.Bytes(), note, true + return out.Bytes(), "", true } func decodeCursorResponseBody(raw []byte, contentEncoding string) ([]byte, string) { - if decoded, note, ok := decodeConnectFramedBody(raw); ok { - return decoded, note + if decoded, _, ok := decodeConnectFramedBody(raw); ok { + return decoded, "" } enc := strings.ToLower(strings.TrimSpace(contentEncoding)) if strings.Contains(enc, "gzip") || looksLikeGzip(raw) { decoded, err := gunzipBytes(raw) if err != nil { - if strings.Contains(enc, "gzip") { - return raw, "响应头声明 gzip,但解压失败,已回退为原始字节预览" - } - return raw, "检测到 gzip 魔数,但解压失败,已回退为原始字节预览" + return raw, "" } - if strings.Contains(enc, "gzip") { - return decoded, "响应体已按 gzip 解压后分析" - } - return decoded, "响应体虽未显式声明 Content-Encoding,但按 gzip 魔数解压后分析" + return decoded, "" } - if enc != "" { - return raw, "响应头 Content-Encoding=" + enc + ",当前未额外解码,按原始字节分析" - } - return raw, "响应体未压缩或未声明压缩,且未识别为 Connect 分帧,按原始字节分析" + return raw, "" } -// cursorStreamProtocol 与官方客户端一致:Connect-RPC + protobuf 体,HTTP/2 流式 -const cursorStreamProtocol = "Connect-Protocol-Version:1 + application/connect+proto,HTTP/2 二进制流(gRPC 兼容形态,非 JSON REST)" +// cursorStreamProtocol 与官方客户端一致:Connect-RPC + protobuf 体,HTTP/2 流式。 +// 当前探测接口使用新版 Agent:/aiserver.v1.ChatService/StreamUnifiedChatWithTools。 +// 若 Cursor 后续强制更高客户端版本,可通过环境变量 CURSOR_CLIENT_VERSION 覆盖默认 X-Cursor-Client-Version。 +const cursorStreamProtocol = "Connect-Protocol-Version:1 + application/connect+proto,HTTP/2 二进制流(gRPC/ConnectRPC 兼容形态,非 JSON REST)" // cursorStreamNote 说明 rawPreview / ok 的含义边界(与「仅通 200」结论一致) const cursorStreamNote = `【协议】本 URL 为 Cursor 官方 Agent 流式接口,请求体为 protobuf(requestBodyPrefixHex 可见非表单/JSON)。` + @@ -484,6 +461,189 @@ func cursorProbeResult(ok bool, detail string, httpStatus int, reqBody, raw, pre } } +// cursorReadableServerOutput 从 Cursor 的 protobuf 二进制流里提取适合展示的可读文本。 +// 注意:这里不完整解析 proto,只做展示层清洗,避免把字段号、长度前缀、UUID、think 过程等内容直接展示给用户。 +func cursorReadableServerOutput(decoded []byte, maxBytes int) string { + if len(decoded) == 0 { + return "" + } + + s := strings.ToValidUTF8(string(decoded), "") + s = strings.ReplaceAll(s, "\uFFFD", "") + finalMarkerRe := regexp.MustCompile(`(?is)<\s*[||]\s*final\s*[||]\s*>`) + s = finalMarkerRe.ReplaceAllString(s, "") + + var b strings.Builder + lastSpace := false + for _, r := range s { + switch { + case r == '\r' || r == '\n' || r == '\t' || r == ' ': + if !lastSpace { + b.WriteByte('\n') + } + lastSpace = true + case r >= 32: + b.WriteRune(r) + lastSpace = false + default: + // protobuf 字段号、长度前缀等控制字符经常刚好位于单词/JSON 字段之间。 + // 这里用分隔符替代直接丢弃,避免 Your + request 被粘成 Yourrequest。 + if !lastSpace { + b.WriteByte('\n') + } + lastSpace = true + } + } + + cleaned := strings.TrimSpace(b.String()) + if cleaned == "" { + return "" + } + + uuidRe := regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`) + cleaned = uuidRe.ReplaceAllString(cleaned, "") + + var parts []string + const oldVersionText = "This is a very old version of Cursor. Please update to the latest version at [cursor.com/downloads](https://cursor.com/downloads)" + if strings.Contains(cleaned, oldVersionText) { + parts = append(parts, oldVersionText) + } + + // 优先展示最终回复:先按 切分;没有 final 标记时,按被二进制流切碎的 标记切分。 + finalText := "" + if idx := strings.LastIndex(cleaned, ""); idx >= 0 { + finalText = cleaned[idx+len(""):] + } else { + thinkCloseRe := regexp.MustCompile(`(?is)`) + matches := thinkCloseRe.FindAllStringIndex(cleaned, -1) + if len(matches) > 0 { + finalText = cleaned[matches[len(matches)-1][1]:] + } + } + if strings.TrimSpace(finalText) == "" { + finalText = cleaned + } + + finalText = cursorJoinFragmentedText(finalText) + if errorMessage := cursorExtractReadableCursorError(finalText); errorMessage != "" { + finalText = errorMessage + } + finalText = strings.TrimSuffix(finalText, "{}") + finalText = strings.TrimSpace(finalText) + finalText = strings.Trim(finalText, `'"#%{} `) + + // 清理流尾残留的二进制标记,例如:a%߯B{} + tailJunkRe := regexp.MustCompile(`(?is)\s+[a-z]?%[^\s]{0,12}B\{\}\s*$`) + finalText = tailJunkRe.ReplaceAllString(finalText, "") + finalText = strings.TrimSpace(finalText) + + if finalText != "" && !strings.Contains(strings.Join(parts, "\n"), finalText) { + parts = append(parts, finalText) + } + + if len(parts) > 0 { + cleaned = strings.Join(parts, "\n\n") + } else { + cleaned = finalText + } + + if maxBytes > 0 && len(cleaned) > maxBytes { + cleaned = cleaned[:maxBytes] + for len(cleaned) > 0 && !utf8.ValidString(cleaned) { + cleaned = cleaned[:len(cleaned)-1] + } + cleaned += "…(已截断)" + } + + return strings.TrimSpace(cleaned) +} + +func cursorExtractReadableCursorError(text string) string { + if text == "" { + return "" + } + + unescaped := strings.ReplaceAll(text, `\n`, "\n") + unescaped = strings.ReplaceAll(unescaped, `\"`, `"`) + unescaped = strings.ReplaceAll(unescaped, `\/`, `/`) + + if !(strings.Contains(strings.ToLower(unescaped), "error") || + strings.Contains(strings.ToLower(unescaped), "unauthenticated") || + strings.Contains(strings.ToLower(unescaped), "unauthorized") || + strings.Contains(strings.ToLower(unescaped), "suspicious activity")) { + return "" + } + + messageRe := regexp.MustCompile(`(?is)"(?:message|detail)"\s*:\s*"([^"]+)"`) + matches := messageRe.FindAllStringSubmatch(unescaped, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + msg := strings.TrimSpace(match[1]) + if msg == "" { + continue + } + msg = strings.ReplaceAll(msg, `\n`, "\n") + msg = strings.ReplaceAll(msg, `\"`, `"`) + msg = cursorJoinFragmentedText(msg) + + lowerMsg := strings.ToLower(msg) + if strings.Contains(lowerMsg, "suspicious activity") || + strings.Contains(lowerMsg, "blocked") || + strings.Contains(lowerMsg, "unauthorized") || + strings.Contains(lowerMsg, "unauthenticated") { + return msg + } + } + + if strings.Contains(strings.ToLower(unescaped), "suspicious activity") { + return "Your request has been blocked as our system has detected suspicious activity from your account. For troubleshooting, please visit the Cursor Docs at https://cursor.com/docs/troubleshooting/common-issues#suspicious-activity-message." + } + + return "" +} + +func cursorJoinFragmentedText(text string) string { + lines := strings.Split(text, "\n") + parts := make([]string, 0, len(lines)) + + for _, line := range lines { + part := strings.TrimSpace(line) + if part == "" { + continue + } + parts = append(parts, part) + } + + out := strings.Join(parts, " ") + + // 标点前不保留空格。 + punctRe := regexp.MustCompile(`\s+([.,!?;:)\]}"',。!?;:)】》])`) + out = punctRe.ReplaceAllString(out, "$1") + + // 只修复很明确的“单词内部被切开”场景,避免把 How can / I help / with your 误拼成 Howcan / Ihelp / withyour。 + singlePrefixRe := regexp.MustCompile(`\b([b-hj-zB-HJ-Z])\s+([a-z]{2,})\b`) + out = singlePrefixRe.ReplaceAllString(out, "$1$2") + + commonSuffixRe := regexp.MustCompile(`\b([A-Za-z]{3,})\s+(ing|ed|er|ers|ly|s)\b`) + out = commonSuffixRe.ReplaceAllString(out, "$1$2") + + spaceRe := regexp.MustCompile(`\s+`) + out = spaceRe.ReplaceAllString(out, " ") + return strings.TrimSpace(out) +} + +// cursorServerOutputDetail 将服务器响应内容放入 detail,便于前端只展示 detail 时也能看到服务端输出。 +func cursorServerOutputDetail(prefix string, decoded []byte) string { + serverOutput := cursorReadableServerOutput(decoded, 8000) + if serverOutput == "" { + return prefix + } + return prefix + ",服务器可读输出:\n" + serverOutput +} + +// probeCursorHiAgent 探测 Cursor Token 可用性 func probeCursorHiAgent(authToken string) Result { if strings.Contains(authToken, "::") { if i := strings.LastIndex(authToken, "::"); i >= 0 { @@ -504,7 +664,7 @@ func probeCursorHiAgent(authToken string) Result { fullURL := cursorBackendURL + cursorAgentPath req, err := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(body)) if err != nil { - r := cursorProbeResult(false, err.Error(), 0, body, nil, nil) + r := cursorProbeResult(false, "请求失败: "+err.Error(), 0, body, nil, nil) return r } req.Header.Set("Authorization", "Bearer "+authToken) @@ -532,29 +692,36 @@ func probeCursorHiAgent(authToken string) Result { resp, err := cursorProbeHTTPClient.Do(req) if err != nil { - return cursorProbeResult(false, "请求 Cursor Agent 失败: "+err.Error(), 0, body, nil, nil) + return cursorProbeResult(false, "请求失败: "+err.Error(), 0, body, nil, nil) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(io.LimitReader(resp.Body, cursorHiMaxRead)) - decoded, decodeNote := decodeCursorResponseBody(raw, resp.Header.Get("Content-Encoding")) - blocked, reason := classifyCursorRawStream(decoded) - if blocked { - return cursorProbeResult(false, reason+";"+decodeNote, resp.StatusCode, body, raw, decoded) + decoded, _ := decodeCursorResponseBody(raw, resp.Header.Get("Content-Encoding")) + isQuotaExhausted, msg := classifyCursorRawStream(decoded) + if isQuotaExhausted { + return cursorProbeResult(false, msg, resp.StatusCode, body, raw, decoded) } - detail := fmt.Sprintf("HTTP %d(非 200);%s;说明与协议边界见 streamNote", resp.StatusCode, decodeNote) - return cursorProbeResult(false, detail, resp.StatusCode, body, raw, decoded) + // 非 200 状态码且不是额度问题 + return cursorProbeResult(false, fmt.Sprintf("HTTP %d - Token不可用", resp.StatusCode), resp.StatusCode, body, raw, decoded) } var buf bytes.Buffer _, _ = io.Copy(&buf, io.LimitReader(resp.Body, cursorHiMaxRead)) raw := buf.Bytes() - decoded, decodeNote := decodeCursorResponseBody(raw, resp.Header.Get("Content-Encoding")) - blocked, reason := classifyCursorRawStream(decoded) - if blocked { - return cursorProbeResult(false, reason+";"+decodeNote, resp.StatusCode, body, raw, decoded) + decoded, _ := decodeCursorResponseBody(raw, resp.Header.Get("Content-Encoding")) + isQuotaExhausted, msg := classifyCursorRawStream(decoded) + + if isQuotaExhausted { + // Token 不可用(额度用完等) + return cursorProbeResult(false, msg, resp.StatusCode, body, raw, decoded) } - detail := "HTTP 200;未命中内置英文关键词;" + decodeNote + ";二进制流含义与 ok 边界见 streamNote" - return cursorProbeResult(true, detail, resp.StatusCode, body, raw, decoded) + + // Token 可用时也把服务器实际输出放到 Detail,避免前端只展示 Detail 时看不到 RawPreview。 + if msg != "" { + return cursorProbeResult(true, cursorServerOutputDetail(msg, decoded), resp.StatusCode, body, raw, decoded) + } + + return cursorProbeResult(true, cursorServerOutputDetail("Token可用", decoded), resp.StatusCode, body, raw, decoded) } diff --git a/go/pkg/tokenprobe/probe.go b/go/pkg/tokenprobe/probe.go index 6771c87..4daa625 100644 --- a/go/pkg/tokenprobe/probe.go +++ b/go/pkg/tokenprobe/probe.go @@ -26,9 +26,8 @@ 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) @@ -39,7 +38,8 @@ func ProbeOfficial(module, rawToken string) Result { } switch module { case "cursor": - return probeCursor(tok) + // 直接使用 cursor_hi.go 中已有的完整探测函数 + return probeCursorHiAgent(tok) case "windsurf": return probeWindsurf(tok) case "krio": @@ -57,19 +57,21 @@ func normalizeBearerToken(s string) string { return s } +// probeCursor Cursor Token 探测(直接使用 cursor_hi.go 的实现) func probeCursor(token string) Result { return probeCursorHiAgent(token) } +// probeWindsurf WindSurf 探测 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) @@ -117,6 +119,7 @@ func probeWindsurf(apiKey string) Result { } } +// probeKiro Kiro 探测 func probeKiro(accessToken string) Result { arn := findProfileArnInJWT(accessToken) if arn == "" { @@ -160,6 +163,7 @@ func probeKiro(accessToken string) Result { } } +// decodeJWTPayloadMap 解析 JWT payload func decodeJWTPayloadMap(raw string) (map[string]interface{}, error) { tok := normalizeBearerToken(strings.TrimSpace(raw)) parts := strings.Split(tok, ".") @@ -177,6 +181,7 @@ func decodeJWTPayloadMap(raw string) (map[string]interface{}, error) { return m, nil } +// findProfileArnInJWT 从 JWT 中查找 profileArn func findProfileArnInJWT(raw string) string { m, err := decodeJWTPayloadMap(raw) if err != nil { @@ -185,6 +190,7 @@ func findProfileArnInJWT(raw string) string { return findProfileArnValue(m) } +// findProfileArnValue 递归查找 profileArn func findProfileArnValue(v interface{}) string { switch x := v.(type) { case map[string]interface{}: diff --git a/go/test.exe b/go/test.exe new file mode 100644 index 0000000..167123e Binary files /dev/null and b/go/test.exe differ diff --git a/platform/src/views/accountpool/components/patch.vue b/platform/src/views/accountpool/components/patch.vue index a6bdcd1..5870e0c 100644 --- a/platform/src/views/accountpool/components/patch.vue +++ b/platform/src/views/accountpool/components/patch.vue @@ -1,3 +1,4 @@ +