Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion app/controller/skills.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions app/controller/skillsRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions app/model/skills_item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '源仓库文件更新时间',
Expand Down Expand Up @@ -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'] },
],
}
Expand Down
95 changes: 79 additions & 16 deletions app/service/skills.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -122,6 +128,7 @@ class SkillsService extends Service {
await this.ensureSkillsItemVersionColumn();
await this.ensureSkillsItemPackageColumns();
await this.ensureSkillsItemContributorColumn();
await this.ensureSkillsItemDownloadsColumn();
this.storageReady = true;
})();

Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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 ||
Expand Down Expand Up @@ -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();
Expand All @@ -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();
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
};
Expand Down
29 changes: 24 additions & 5 deletions app/service/skillsRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -360,6 +378,7 @@ class SkillsRegistryService extends Service {

const version = skill.version || 'latest';
return {
slug: skill.slug,
fileName: `${slug}-${version}.zip`,
content: zip.toBuffer(),
};
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions app/utils/skill-stats.js
Original file line number Diff line number Diff line change
@@ -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<object>} items
* @param {{ parentKey: string, fields: string[] }} opts
* @returns {Record<string, Map<string, number>>}
*/
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,
};
17 changes: 13 additions & 4 deletions app/web/components/skills/SkillCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -64,8 +70,11 @@ export const SkillCard: React.FC<SkillCardProps> = ({
技能包
</Tag>
)}
<span className="stars-badge">
<StarOutlined /> {skill.stars || 0}
<span className="stars-badge" title="Stars">
<StarOutlined /> {skill.stars}
</span>
<span className="downloads-badge" title="下载量">
<DownloadOutlined /> {skill.downloads}
</span>
{onEdit && (
<button
Expand Down Expand Up @@ -126,7 +135,7 @@ export const SkillCard: React.FC<SkillCardProps> = ({
<span className="meta-label">更新</span>
<span className="meta-value">
{skill.updatedAt
? new Date(skill.updatedAt).toLocaleDateString('zh-CN')
? moment(skill.updatedAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</span>
</span>
Expand Down
3 changes: 2 additions & 1 deletion app/web/components/skills/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@
height: 20px;
line-height: 18px;
}
.stars-badge {
.stars-badge,
.downloads-badge {
display: inline-flex;
align-items: center;
gap: 4px;
Expand Down
Loading
Loading