diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..b4fdbe29 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,8 @@ +# Copilot Code Review + +When performing a code review, respond in Simplified Chinese (简体中文). + +- Write all review comments, summaries, and suggestions in 简体中文. +- Keep code identifiers, file paths, command names, API routes, and package names in their original form (do not translate them). +- Prefer concrete, actionable feedback over generic praise. +- Call out regressions, security issues, and missing tests when relevant. diff --git a/app/controller/skills.js b/app/controller/skills.js index b040ef31..065b1ece 100644 --- a/app/controller/skills.js +++ b/app/controller/skills.js @@ -33,7 +33,13 @@ class SkillsController extends Controller { async downloadSkillArchive() { const { ctx } = this; const { slug } = ctx.query; - const { fileName, content } = await ctx.service.skills.getSkillArchive(slug); + const { + slug: resolvedSlug, + fileName, + content, + } = await ctx.service.skills.getSkillArchive(slug); + // Count only on actual zip download (not install-meta sha256 rebuild). + await ctx.service.skills.incrementDownloads(resolvedSlug); ctx.set('Content-Type', 'application/zip'); ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); ctx.body = content; diff --git a/app/controller/skillsRegistry.js b/app/controller/skillsRegistry.js index 63454f53..169bb4b7 100644 --- a/app/controller/skillsRegistry.js +++ b/app/controller/skillsRegistry.js @@ -87,6 +87,8 @@ class SkillsRegistryController extends Controller { ctx.body = { error: '技能不存在' }; return; } + // Count only on successful zip download (same seam as web controller). + await ctx.service.skills.incrementDownloads(result.slug); ctx.set('Content-Type', 'application/zip'); ctx.set( 'Content-Disposition', diff --git a/app/model/skills_item.js b/app/model/skills_item.js index b9b3827a..a9893c0a 100644 --- a/app/model/skills_item.js +++ b/app/model/skills_item.js @@ -51,6 +51,12 @@ module.exports = (app) => { allowNull: false, defaultValue: 0, }, + downloads: { + type: INTEGER, + allowNull: false, + defaultValue: 0, + comment: 'zip 成功下发次数(Web 下载 + CLI install)', + }, updated_at_remote: { type: DATE, comment: '源仓库文件更新时间', @@ -119,6 +125,8 @@ module.exports = (app) => { { fields: ['source_id'] }, { fields: ['category'] }, { fields: ['stars'] }, + // downloads index is created in ensureSkillsItemDownloadsColumn + // after the column exists (sync cannot add index for a missing column). { fields: ['updated_at_remote'] }, ], } diff --git a/app/service/skills.js b/app/service/skills.js index 2b84ded1..5c4913b7 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -14,6 +14,12 @@ const { extractSkillMdDescription, resolveMarketCardDescription, } = require('../utils/skill-utils'); +const { + coerceCount, + sumCounts, + aggregateCountsByParent, + isDuplicateIndexError, +} = require('../utils/skill-stats'); const GitHubStarsClient = require('../utils/github-stars'); const CommandRunner = require('../utils/command-runner'); @@ -122,6 +128,7 @@ class SkillsService extends Service { await this.ensureSkillsItemVersionColumn(); await this.ensureSkillsItemPackageColumns(); await this.ensureSkillsItemContributorColumn(); + await this.ensureSkillsItemDownloadsColumn(); this.storageReady = true; })(); @@ -177,6 +184,53 @@ class SkillsService extends Service { }); } + async ensureSkillsItemDownloadsColumn() { + const queryInterface = this.app.model.getQueryInterface(); + const table = await queryInterface.describeTable('skills_items'); + if (!table.downloads) { + await queryInterface.addColumn('skills_items', 'downloads', { + type: this.app.Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + comment: 'zip 成功下发次数(Web 下载 + CLI install)', + }); + } + + // Index is not on the model: sync would ADD INDEX before the column exists + // on upgraded DBs. Create once the column is present; ignore duplicate index. + try { + await queryInterface.addIndex('skills_items', ['downloads'], { + name: 'idx_skills_downloads', + }); + } catch (error) { + if (!isDuplicateIndexError(error)) throw error; + } + } + + /** + * Count one successful zip delivery for a skill slug. + * Failures are logged only — download response must not depend on this. + */ + async incrementDownloads(slug) { + const value = String(slug || '').trim(); + if (!value) return; + + try { + await this.ensureStorageReady(); + const { SkillsItem } = this.app.model; + await SkillsItem.increment('downloads', { + by: 1, + where: { slug: value, is_delete: 0 }, + // Do not bump updated_at — downloads must not reorder "recent" sort. + silent: true, + }); + } catch (error) { + this.ctx.logger.warn( + `[skills] increment downloads failed for ${value}: ${error.message}` + ); + } + } + parseJsonArray(value) { if (!value) return []; if (Array.isArray(value)) return value; @@ -201,6 +255,7 @@ class SkillsService extends Service { tags: skill.tags, allowedTools: skill.allowedTools, stars: skill.stars, + downloads: skill.downloads, updatedAt: skill.updatedAt, sourceRepo: skill.sourceRepo, sourcePath: skill.sourcePath, @@ -223,7 +278,8 @@ class SkillsService extends Service { version: row.version || '', tags: this.parseJsonArray(row.tags), allowedTools: this.parseJsonArray(row.allowed_tools), - stars: Number(row.stars) || 0, + stars: coerceCount(row.stars), + downloads: coerceCount(row.downloads), updatedAt: ( row.updated_at || row.updated_at_remote || @@ -283,22 +339,24 @@ class SkillsService extends Service { const safePageSize = Math.max(parseInt(pageSize, 10) || 20, 1); const { skills, categories } = this.skillCache; - // Aggregate child stars by parent slug for package star totals - const childStarsByParent = new Map(); - for (const item of skills) { - if (item.parentSlug) { - const current = childStarsByParent.get(item.parentSlug) || 0; - childStarsByParent.set(item.parentSlug, current + (Number(item.stars) || 0)); - } - } + const { stars: childStarsByParent, downloads: childDownloadsByParent } = + aggregateCountsByParent(skills, { + parentKey: 'parentSlug', + fields: ['stars', 'downloads'], + }); let list = [...skills] .filter((item) => !item.parentSlug) .map((item) => { - if (item.isPackage === 1 && childStarsByParent.has(item.slug)) { - return { ...item, stars: childStarsByParent.get(item.slug) }; + if (item.isPackage !== 1) return item; + const next = { ...item }; + if (childStarsByParent.has(item.slug)) { + next.stars = childStarsByParent.get(item.slug); } - return item; + if (childDownloadsByParent.has(item.slug)) { + next.downloads = childDownloadsByParent.get(item.slug); + } + return next; }); if (keyword) { const value = String(keyword).toLowerCase(); @@ -320,6 +378,10 @@ class SkillsService extends Service { if (sortBy === 'recent') { return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); } + if (sortBy === 'downloads') { + if (b.downloads !== a.downloads) return b.downloads - a.downloads; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + } if (b.stars !== a.stars) return b.stars - a.stars; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); @@ -397,10 +459,8 @@ class SkillsService extends Service { ], }); detail.children = children.map((row) => this.toPublicSkill(this.toSkillDto(row))); - detail.stars = detail.children.reduce( - (sum, child) => sum + (Number(child.stars) || 0), - 0 - ); + detail.stars = sumCounts(detail.children, (child) => child.stars); + detail.downloads = sumCounts(detail.children, (child) => child.downloads); } return detail; @@ -491,7 +551,10 @@ class SkillsService extends Service { }); } + // Pure zip build — do not count here. getInstallMeta also builds zip for sha256; + // counting belongs only on the real download HTTP handler. return { + slug: skill.slug, fileName: `${rootFolder}.zip`, content: zip.toBuffer(), }; diff --git a/app/service/skillsRegistry.js b/app/service/skillsRegistry.js index 98dbf92a..47f68eb6 100644 --- a/app/service/skillsRegistry.js +++ b/app/service/skillsRegistry.js @@ -4,6 +4,7 @@ const fs = require('fs'); const ignore = require('ignore'); const path = require('path'); const skillUtils = require('../utils/skill-utils'); +const { coerceCount, sumCounts } = require('../utils/skill-stats'); const skillFingerprint = require('../../contracts/skill-fingerprint'); const { SKILL_CATEGORY_OPTIONS, @@ -67,7 +68,7 @@ class SkillsRegistryService extends Service { newest: { key: 'newest', field: 'updated_at', type: 'date' }, createdAt: { key: 'newest', field: 'updated_at', type: 'date' }, updated: { key: 'newest', field: 'updated_at', type: 'date' }, - downloads: { key: 'stars', field: 'stars', type: 'number' }, + downloads: { key: 'downloads', field: 'downloads', type: 'number' }, stars: { key: 'stars', field: 'stars', type: 'number' }, }; const sortConfig = sortMap[sort] || sortMap.newest; @@ -107,7 +108,10 @@ class SkillsRegistryService extends Service { return { items: items.map((skill) => { const tags = this.parseJsonArray(skill.tags); - const stats = { stars: skill.stars || 0, downloads: 0 }; + const stats = { + stars: coerceCount(skill.stars), + downloads: coerceCount(skill.downloads), + }; const item = { slug: skill.slug, displayName: skill.name, @@ -161,7 +165,10 @@ class SkillsRegistryService extends Service { const version = skill.version || ''; const tags = this.parseJsonArray(skill.tags); - const stats = { stars: skill.stars || 0, downloads: 0 }; + const stats = { + stars: coerceCount(skill.stars), + downloads: coerceCount(skill.downloads), + }; const createdAt = skill.created_at ? new Date(skill.created_at).getTime() : 0; const updatedAt = skill.updated_at ? new Date(skill.updated_at).getTime() : 0; let fingerprint = null; @@ -210,12 +217,23 @@ class SkillsRegistryService extends Service { summary: child.description || null, version: child.version || null, tags: this.parseJsonArray(child.tags), - stats: { stars: child.stars || 0, downloads: 0 }, + stats: { + stars: coerceCount(child.stars), + downloads: coerceCount(child.downloads), + }, createdAt: child.created_at ? new Date(child.created_at).getTime() : 0, updatedAt: child.updated_at ? new Date(child.updated_at).getTime() : 0, isPackage: false, parentSlug: child.parent_slug, })); + detail.skill.stats.downloads = sumCounts( + detail.skill.children, + (child) => child.stats.downloads + ); + detail.skill.stats.stars = sumCounts( + detail.skill.children, + (child) => child.stats.stars + ); } return detail; @@ -360,6 +378,7 @@ class SkillsRegistryService extends Service { const version = skill.version || 'latest'; return { + slug: skill.slug, fileName: `${slug}-${version}.zip`, content: zip.toBuffer(), }; @@ -759,7 +778,7 @@ class SkillsRegistryService extends Service { encodeListCursor(skill, sortConfig) { const rawValue = skill[sortConfig.field]; const value = - sortConfig.type === 'date' ? new Date(rawValue).getTime() : Number(rawValue) || 0; + sortConfig.type === 'date' ? new Date(rawValue).getTime() : coerceCount(rawValue); return Buffer.from( JSON.stringify({ sort: sortConfig.key, diff --git a/app/utils/skill-stats.js b/app/utils/skill-stats.js new file mode 100644 index 00000000..37adbb14 --- /dev/null +++ b/app/utils/skill-stats.js @@ -0,0 +1,48 @@ +function coerceCount(value) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.floor(n); +} + +function sumCounts(items, getCount) { + let sum = 0; + for (const item of items || []) { + sum += coerceCount(getCount(item)); + } + return sum; +} + +/** + * @param {Array} items + * @param {{ parentKey: string, fields: string[] }} opts + * @returns {Record>} + */ +function aggregateCountsByParent(items, { parentKey, fields }) { + const maps = {}; + for (const field of fields) { + maps[field] = new Map(); + } + for (const item of items || []) { + const parent = item[parentKey]; + if (parent == null || parent === '') continue; + for (const field of fields) { + const current = maps[field].get(parent) || 0; + maps[field].set(parent, current + coerceCount(item[field])); + } + } + return maps; +} + +function isDuplicateIndexError(error) { + const code = error?.original?.code || error?.parent?.code || error?.code; + if (code === 'ER_DUP_KEYNAME') return true; + const errno = error?.original?.errno ?? error?.parent?.errno ?? error?.errno; + return errno === 1061; +} + +module.exports = { + coerceCount, + sumCounts, + aggregateCountsByParent, + isDuplicateIndexError, +}; diff --git a/app/web/components/skills/SkillCard.tsx b/app/web/components/skills/SkillCard.tsx index 9614c2fb..ed625f05 100644 --- a/app/web/components/skills/SkillCard.tsx +++ b/app/web/components/skills/SkillCard.tsx @@ -1,6 +1,12 @@ import React from 'react'; -import { FileTextOutlined, FolderOutlined, StarOutlined } from '@ant-design/icons'; +import { + DownloadOutlined, + FileTextOutlined, + FolderOutlined, + StarOutlined, +} from '@ant-design/icons'; import { Card, Checkbox, Tag } from 'antd'; +import moment from 'moment'; import type { SkillItem } from '@/pages/skills/types'; import './style.scss'; @@ -64,8 +70,11 @@ export const SkillCard: React.FC = ({ 技能包 )} - - {skill.stars || 0} + + {skill.stars} + + + {skill.downloads} {onEdit && (