2026-09-04-article-sort-order.md 22 KB

文章推荐排序(精选顺序可定义)实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 让运营在管理端可定义精选文章在首页/知识中心的展示顺序,提供上移/下移操作,无需前端额外开发。

架构:articles 表新增 sort_order 列(整数,越小越靠前);getFeatured()getPublicList() 查询改用 sort_order ASC;管理端新增 /api/admin/articles/move 接口支持相邻交换 + 全局重编号;管理端列表对精选文章显示上移/下移按钮和序号徽章。

技术栈: Spring Boot 2.7.18 + MyBatis-Plus、Vue 2 + Element UI、MySQL


文件清单

文件 操作 说明
cfc-backend/.../entity/Article.java 修改 sortOrder 字段
cfc-backend/.../service/ArticleService.java 修改 改排序 + 改 toggleFeatured + 新增 moveSortOrder / getMaxSortOrder
cfc-backend/.../controller/admin/AdminArticleController.java 修改 新增 POST /move 接口
cfc-backend/.../config/DatabaseInitializer.java 修改 迁移293 加 sort_order 列
cfc-backend/src/main/resources/schema.sql 修改 articles CREATE TABLE 加 sort_order 列
cfc-web/src/api/article.js 修改 新增 adminArticleMove
cfc-web/src/views/admin/ArticleManage.vue 修改 精选行显示上移/下移 + 序号徽章
docs/superpowers/api/API_REFERENCE.md 修改 新增 /move 接口文档

任务 1:数据库迁移 — 新增 sort_order 列

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  • 修改:cfc-backend/src/main/resources/schema.sql

  • [ ] 步骤 1:在 DatabaseInitializer.java 末尾(迁移292 之后)追加迁移293

打开 DatabaseInitializer.java,在:

        // 迁移292: health_status 补 member_id 并改为成员级归档

块结束后(约第 10330 行,} 后)追加:

        // 迁移293: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
        ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
        log.info("已添加 articles.sort_order 列(迁移293)");
  • 步骤 2:更新 schema.sql 的 articles 建表语句

打开 cfc-backend/src/main/resources/schema.sql,找到 CREATE TABLE IF NOT EXISTS articles ((约第 1395 行),在 is_featured 列之后、published_at 之前插入:

    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',

当前 schema.sql 原文(需要修改的位置):

    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
    published_at DATETIME DEFAULT NULL COMMENT '发布时间',

改为:

    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
    published_at DATETIME DEFAULT NULL COMMENT '发布时间',
  • [ ] 步骤 3:编译验证

    cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
    

预期:无 ERROR 行,退出码 0。

  • [ ] 步骤 4:Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
    git add cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(articles): 迁移293 为 articles 表添加 sort_order 列"
    

任务 2:Article 实体加 sortOrder 字段

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

  • [ ] 步骤 1:在 Article.java 的 isFeatured 字段后加 sortOrder

找到:

    private Integer isFeatured;
    private Date publishedAt;

改为:

    private Integer isFeatured;
    /** 推荐排序值(越小越靠前,仅精选文章参与排序) */
    private Integer sortOrder;
    private Date publishedAt;
  • [ ] 步骤 2:编译验证

    cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
    

预期:无 ERROR 行,退出码 0。

  • [ ] 步骤 3:Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
    git commit -m "feat(articles): Article 实体加 sortOrder 字段"
    

任务 3:ArticleService — 改排序逻辑 + toggleFeatured 追加队尾 + 新增 moveSortOrder

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

步骤 1:改 getFeatured() 排序

找到 ArticleService.java 第 125 行 getFeatured 方法,将:

        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .eq(Article::getIsFeatured, 1)
                .orderByDesc(Article::getPublishedAt)
                .last("LIMIT " + Math.max(size, 50));

改为:

        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .eq(Article::getIsFeatured, 1)
                .orderByAsc(Article::getSortOrder)
                .orderByDesc(Article::getPublishedAt)
                .last("LIMIT " + Math.max(size, 50));

步骤 2:改 getPublicList() 排序

找到 ArticleService.java 第 86 行 getPublicList 方法,将:

        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .orderByDesc(Article::getIsFeatured)
                .orderByDesc(Article::getPublishedAt);

改为:

        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .orderByDesc(Article::getIsFeatured)
                .orderByAsc(Article::getSortOrder)
                .orderByDesc(Article::getPublishedAt);

步骤 3:改 toggleFeatured() 设为精选时追加队尾

找到第 541-548 行 toggleFeatured 方法,将:

    @Transactional
    public void toggleFeatured(Long id, int isFeatured) {
        Article article = articleMapper.selectById(id);
        if (article == null) return;
        article.setIsFeatured(isFeatured);
        article.setUpdatedAt(new Date());
        articleMapper.updateById(article);
    }

改为:

    @Transactional
    public void toggleFeatured(Long id, int isFeatured) {
        Article article = articleMapper.selectById(id);
        if (article == null) return;
        article.setIsFeatured(isFeatured);
        article.setUpdatedAt(new Date());
        if (isFeatured == 1) {
            // 设为精选时追加到队尾,不抢占既有顺序
            Integer maxSort = getMaxSortOrder();
            article.setSortOrder((maxSort == null ? 0 : maxSort) + 1);
        }
        articleMapper.updateById(article);
    }

步骤 4:新增 getMaxSortOrder() 和 moveSortOrder() 两个方法

toggleFeatured() 方法之后(约第 548 行后)插入以下两个方法:

    /**
     * 查询已发布且精选的文章中最大的 sort_order;若没有精选文章返回 null。
     */
    private Integer getMaxSortOrder() {
        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .eq(Article::getIsFeatured, 1)
                .select(Article::getSortOrder)
                .orderByDesc(Article::getSortOrder)
                .last("LIMIT 1");
        Article top = articleMapper.selectOne(wrapper);
        return top != null ? top.getSortOrder() : null;
    }

    /**
     * 上移/下移精选文章的推荐顺序。
     * direction: 'up' 或 'down'。
     * 边界(已在最前/最后)抛 RuntimeException,调用方会返回 400。
     */
    @Transactional
    public String moveSortOrder(Long id, String direction) {
        Article target = articleMapper.selectById(id);
        if (target == null) {
            throw new RuntimeException("文章不存在");
        }
        if (!"published".equals(target.getStatus()) || !Integer.valueOf(1).equals(target.getIsFeatured())) {
            throw new RuntimeException("仅已发布且精选的文章可调整顺序");
        }
        if (!"up".equals(direction) && !"down".equals(direction)) {
            throw new RuntimeException("direction 只能是 up 或 down");
        }

        // 按展示序(sort_order ASC, published_at ASC)取全部精选文章列表
        // published_at ASC 用于并列时稳定排序(sort_order 已唯一后不影响结果)
        LambdaQueryWrapper<Article> listWrapper = new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .eq(Article::getIsFeatured, 1)
                .orderByAsc(Article::getSortOrder)
                .orderByAsc(Article::getPublishedAt);
        List<Article> featured = articleMapper.selectList(listWrapper);

        int idx = -1;
        for (int i = 0; i < featured.size(); i++) {
            if (featured.get(i).getId().equals(id)) {
                idx = i;
                break;
            }
        }
        if (idx == -1) {
            throw new RuntimeException("文章不存在");
        }
        if ("up".equals(direction) && idx <= 0) {
            throw new RuntimeException("已在最前面");
        }
        if ("down".equals(direction) && idx >= featured.size() - 1) {
            throw new RuntimeException("已在最后面");
        }

        int swapIdx = "up".equals(direction) ? idx - 1 : idx + 1;
        Long swapId = featured.get(swapIdx).getId();

        // 交换 sort_order(先交换,避免覆盖)
        Article a = featured.get(idx);
        Article b = featured.get(swapIdx);
        Integer tmpSort = a.getSortOrder();
        a.setSortOrder(b.getSortOrder());
        b.setSortOrder(tmpSort);
        a.setUpdatedAt(new Date());
        b.setUpdatedAt(new Date());
        articleMapper.updateById(a);
        articleMapper.updateById(b);

        // 全局重编号 1..N,消除并列与空洞
        LambdaQueryWrapper<Article> renumWrapper = new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .eq(Article::getIsFeatured, 1)
                .orderByAsc(Article::getSortOrder)
                .orderByAsc(Article::getPublishedAt);
        List<Article> sorted = articleMapper.selectList(renumWrapper);
        Date now = new Date();
        for (int i = 0; i < sorted.size(); i++) {
            sorted.get(i).setSortOrder(i + 1);
            sorted.get(i).setUpdatedAt(now);
            articleMapper.updateById(sorted.get(i));
        }

        return "已调整顺序";
    }
  • [ ] 步骤 5:编译验证

    cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
    

预期:无 ERROR 行,退出码 0。

  • [ ] 步骤 6:Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
    git commit -m "feat(articles): getFeatured/getPublicList 改按 sort_order 排序,toggleFeatured 追加队尾,新增 moveSortOrder"
    

任务 4:AdminArticleController 新增 POST /move 接口

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

  • [ ] 步骤 1:在 toggleFeatured 方法后插入 move 接口

找到 AdminArticleController.java 第 265-271 行 toggleFeatured 方法之后,插入:

    @PostMapping("/move")
    public Result<String> move(@RequestBody Map<String, Object> body,
                                @RequestAttribute("role") String role) {
        if (!"admin".equals(role)) {
            return Result.error("无权限");
        }
        Long id = ParamUtils.getLong(body.get("id"));
        String direction = (String) body.get("direction");
        if (id == null) {
            return Result.error("缺少文章ID");
        }
        if (direction == null || (!"up".equals(direction) && !"down".equals(direction))) {
            return Result.error("direction 只能是 up 或 down");
        }
        try {
            articleService.moveSortOrder(id, direction);
            return Result.success("已调整顺序");
        } catch (RuntimeException e) {
            return Result.error(e.getMessage());
        }
    }
  • [ ] 步骤 2:编译验证

    cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
    

预期:无 ERROR 行,退出码 0。

  • [ ] 步骤 3:Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
    git commit -m "feat(articles): AdminArticleController 新增 POST /move 接口"
    

任务 5:cfc-web — api/article.js 加 adminArticleMove

文件:

  • 修改:cfc-web/src/api/article.js

  • [ ] 步骤 1:在 adminArticleToggleFeatured 后新增 adminArticleMove

找到 cfc-web/src/api/article.jsadminArticleToggleFeatured 函数(约第 25-27 行),在其后插入:

export function adminArticleMove(data) {
  return request({ url: '/api/admin/articles/move', method: 'post', data })
}

完整 api/article.js 关键片段(修改后):

export function adminArticleToggleFeatured(data) {
  return request({ url: '/api/admin/articles/toggle-featured', method: 'post', data })
}

export function adminArticleMove(data) {
  return request({ url: '/api/admin/articles/move', method: 'post', data })
}
  • [ ] 步骤 2:Commit

    cd /sc-data/cfc
    git add cfc-web/src/api/article.js
    git commit -m "feat(web): article API 新增 adminArticleMove"
    

任务 6:cfc-web — ArticleManage.vue 加上移/下移按钮与序号徽章

文件:

  • 修改:cfc-web/src/views/admin/ArticleManage.vue

步骤 1:在 data() 中引入 adminArticleMove

<script> 的 import 块末尾(第 309-310 行之后)追加:

import {
  adminArticleList,
  adminArticlePublish,
  adminArticleToggleFeatured,
  adminArticleDelete,
  adminArticleCategoriesList,
  adminArticleAudit,
  adminArticleDetail,
  adminArticleCreate,
  adminArticleSubmitReview,
  adminArticleWithdraw,
  adminArticleReDraft,
  adminArticleReadingRecords,
  adminArticleMove
} from '@/api/article.js'

步骤 2:为发布中 + 精选行新增 handleMove

在 ArticleManage.vue 的 methods 对象内,紧接 handleToggleFeatured 方法(约第 465-475 行)之后插入:

    async handleMove(row, direction) {
      try {
        await this.$confirm(
          direction === 'up' ? '确认将《' + row.title + '》上移一位?' : '确认将《' + row.title + '》下移一位?',
          '提示'
        )
        await adminArticleMove({ id: row.id, direction })
        this.$message.success('已调整顺序')
        this.loadList()
      } catch (e) {
        if (e !== 'cancel') {
          this.$message.error(e.message || '调整顺序失败')
        }
      }
    },

步骤 3:在 handleActionCmd 的 toggleFeatured 分支旁增加 move 分支(可选,通过下拉菜单)

handleActionCmd 方法的 switch 块中,case 'toggleFeatured' 之后追加:

        case 'moveUp': this.handleMove(row, 'up'); break;
        case 'moveDown': this.handleMove(row, 'down'); break;

步骤 4:为发布中行的下拉菜单新增上移/下移项

找到 ArticleManage.vue 第 182-191 行("发布中"模板)的 <el-dropdown-menu> 内,在"精选/取消精选"菜单项之后追加两个项:

                  <el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
                  <el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>

完整发布中下拉菜单最终代码:

            <!-- 发布中:编辑 + 下架 + 精选 -->
            <template v-else-if="row.status === 'published'">
              <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
              <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                <el-button size="mini">
                  更多<i class="el-icon-arrow-down el-icon--right"></i>
                </el-button>
                <el-dropdown-menu slot="dropdown">
                  <el-dropdown-item command="withdraw" icon="el-icon-download">下架</el-dropdown-item>
                  <el-dropdown-item command="toggleFeatured" icon="el-icon-star-on">精选/取消精选</el-dropdown-item>
                  <el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
                  <el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>
                  <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
                </el-dropdown-menu>
              </el-dropdown>
            </template>

步骤 5:在标题列展示精选序号徽章

找到 ArticleManage.vue 第 61-66 行(标题列):

        <el-table-column label="标题" min-width="220">
          <template slot-scope="{ row }">
            <span>{{ row.title }}</span>
            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选</el-tag>
          </template>
        </el-table-column>

改为(序号徽章用橙色,仅排序值 > 0 时展示):

        <el-table-column label="标题" min-width="220">
          <template slot-scope="{ row }">
            <span>{{ row.title }}</span>
            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选{{ row.sortOrder && row.sortOrder > 0 ? ' ' + toChineseNum(row.sortOrder) : '' }}</el-tag>
          </template>
        </el-table-column>

步骤 6:添加 toChineseNum 辅助方法

methods 对象末尾(handleActionCmd 之前)插入:

    toChineseNum(n) {
      var digits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
      if (n <= 10) return digits[n]
      var tens = Math.floor(n / 10)
      var ones = n % 10
      if (tens === 1) return '十' + (ones === 0 ? '' : digits[ones])
      return digits[tens] + '十' + (ones === 0 ? '' : digits[ones])
    },

步骤 7:保存并提交

cd /sc-data/cfc
git add cfc-web/src/views/admin/ArticleManage.vue
git commit -m "feat(web): ArticleManage 精选行增加上移/下移 + 序号徽章"

任务 7:更新 API_REFERENCE.md

文件:

  • 修改:docs/superpowers/api/API_REFERENCE.md

  • [ ] 步骤 1:在 /api/admin/articles/* 表格后追加一行

找到第 592 行附近:

| `/api/admin/articles/*` | 文章管理 |

改为:

| `/api/admin/articles/*` | 文章管理 |
| `POST /api/admin/articles/move` | 调整精选文章的推荐顺序(`{ id, direction: 'up'|'down' }`,已边界时返回 400) |
  • 步骤 2:更新 PROJECT-OVERVIEW.md(文档增删需同步)

打开 docs/superpowers/PROJECT-OVERVIEW.md,在"文档索引"或相应章节追加一行:

- `specs/2026-09-04-article-sort-order-design.md` — 文章推荐排序(精选顺序可定义)设计规格

(如 PROJECT-OVERVIEW.md 无专门规格章节,追加到文档索引表格末尾即可)

  • [ ] 步骤 3:Commit

    cd /sc-data/cfc
    git add docs/superpowers/api/API_REFERENCE.md
    git add docs/superpowers/PROJECT-OVERVIEW.md
    git commit -m "docs: 更新 API_REFERENCE 与 PROJECT-OVERVIEW(article sort order)"
    

任务 8:端到端验证

  • [ ] 步骤 1:后端编译

    cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | grep -E "ERROR|BUILD"
    

    预期:无 ERROR 行,或仅 [WARNING],BUILD SUCCESS。

  • [ ] 步骤 2:小程序前端编译(无实际改动,语法检查)

    cd /sc-data/cfc/cfc-frontend && node -e "
    const fs = require('fs');
    const src = fs.readFileSync('pages/index-home/index.vue', 'utf8');
    const m = src.match(/<script>([\s\S]*?)<\/script>/);
    if (!m) { console.log('no script'); process.exit(0); }
    try { new Function(m[1]); console.log('index-home script OK'); } catch(e) { console.error('ERROR:', e.message); process.exit(1); }
    "
    
  • [ ] 步骤 3:提交 git status 检查

    cd /sc-data/cfc && git status --short
    

    预期:仅 cfc-web/.last_build_commitcfc-web/package-lock.json 有未提交变更(属 cfc-web 构建产物,与本次无关)。


规格自检

规格要求 对应任务
迁移 293 加 sort_order 列 任务 1 ✓
Article 实体加 sortOrder 任务 2 ✓
getFeatured() 排序改 sort_order ASC 任务 3 步骤 1 ✓
getPublicList() 排序改 sort_order ASC 任务 3 步骤 2 ✓
toggleFeatured 设为精选追加队尾 任务 3 步骤 3 ✓
新增 moveSortOrder 交换+重编号 任务 3 步骤 4 ✓
新增 POST /move 接口 任务 4 ✓
api/article.js 新增 adminArticleMove 任务 5 ✓
ArticleManage.vue 上移/下移菜单 任务 6 步骤 4 ✓
标题列序号徽章 任务 6 步骤 5 ✓
API_REFERENCE.md 同步 任务 7 ✓
PROJECT-OVERVIEW.md 同步 任务 7 步骤 2 ✓
边界:非精选文章拒绝 moveSortOrder 中判断 ✓
边界:已在最前/最后提示 throw RuntimeException ✓
边界:非法 direction 拒绝 moveSortOrder 中判断 ✓

全部规格要求已覆盖,无遗漏。