diff --git a/package.json b/package.json index 7f062b8..3a9093f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kevisual/code-center-module", - "version": "0.0.23", + "version": "0.0.24", "description": "", "main": "dist/system.mjs", "module": "dist/system.mjs", @@ -27,17 +27,17 @@ }, "dependencies": { "@kevisual/auth": "1.0.5", - "@kevisual/context": "^0.0.3", - "@kevisual/router": "^0.0.22", + "@kevisual/router": "^0.0.23", "@kevisual/use-config": "^1.0.19", "ioredis": "^5.6.1", "nanoid": "^5.1.5", - "pg": "^8.16.1", + "pg": "^8.16.2", "sequelize": "^6.37.7", "socket.io": "^4.8.1", "zod": "^3.25.67" }, "devDependencies": { + "@kevisual/context": "^0.0.3", "@kevisual/types": "^0.0.10", "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^28.0.6", @@ -50,14 +50,14 @@ "@types/formidable": "^3.4.5", "@types/jsonwebtoken": "^9.0.10", "@types/lodash-es": "^4.17.12", - "@types/node": "^24.0.3", + "@types/node": "^24.0.4", "@types/react": "^19.1.8", "@types/uuid": "^10.0.0", - "concurrently": "^9.1.2", + "concurrently": "^9.2.0", "cross-env": "^7.0.3", "nodemon": "^3.1.10", "rimraf": "^6.0.1", - "rollup": "^4.44.0", + "rollup": "^4.44.1", "rollup-plugin-copy": "^3.5.0", "rollup-plugin-dts": "^6.2.1", "rollup-plugin-esbuild": "^6.2.1", @@ -82,6 +82,10 @@ "import": "./dist/oauth.mjs", "types": "./dist/oauth.d.ts" }, + "./mark-model": { + "import": "./dist/mark-model.mjs", + "types": "./dist/mark-model.d.ts" + }, "./src/*": { "import": "./src/*" } diff --git a/rollup.config.mjs b/rollup.config.mjs index 8c87eaa..a7d9049 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -186,5 +186,46 @@ const oauthConfig = [ ], }, ] - -export default [config, dtsConfig, ...systemConfig, ...modelConfig, ...oauthConfig]; +const markModelConfig = [ + { + input: './src/mark/mark-model.ts', + output: { + dir: './dist', + entryFileNames: 'mark-model.js', + format: 'esm', + }, + external: [ + ...external, // 引入外部依赖 + ], + plugins: [ + replace(replaceConfig), + alias({ + entries: [ + { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 + ], + }), + resolve({ + preferBuiltins: true, // 强制优先使用内置模块 + }), + commonjs(), + esbuild({ + target: 'node22', // 目标为 Node.js 14 + minify: false, // 启用代码压缩 + tsconfig: 'tsconfig.json', + }), + json(), + ], + }, + { + input: './src/mark/mark-model.ts', + output: { + dir: './dist', + entryFileNames: 'mark-model.d.ts', + format: 'esm', + }, + plugins: [ + dts(), + ], + }, +] +export default [config, dtsConfig, ...systemConfig, ...modelConfig, ...oauthConfig, ...markModelConfig]; diff --git a/src/app.ts b/src/app.ts index 2d68f39..78d56a7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import { App } from '@kevisual/router'; -import { useContextKey, useContext } from '@kevisual/use-config/context'; +import { useContextKey } from '@kevisual/context'; -const init = () => { +const init = (): App => { return new App({ serverOptions: { cors: { @@ -10,4 +10,4 @@ const init = () => { }, }); }; -export const app = useContextKey('app', init); +export const app: App = useContextKey('app', init); diff --git a/src/mark/mark-model.ts b/src/mark/mark-model.ts new file mode 100644 index 0000000..73578fc --- /dev/null +++ b/src/mark/mark-model.ts @@ -0,0 +1,321 @@ +import { useContextKey } from '@kevisual/context'; +import { nanoid, customAlphabet } from 'nanoid'; +import { DataTypes, Model, ModelAttributes } from 'sequelize'; +import type { Sequelize } from 'sequelize'; +export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); +export type Mark = Partial>; +export type MarkData = { + md?: string; // markdown + mdList?: string[]; // markdown list + type?: string; // 类型 markdown | json | html | image | video | audio | code | link | file + data?: any; + key?: string; // 文件的名称, 唯一 + push?: boolean; // 是否推送到elasticsearch + pushTime?: Date; // 推送时间 + summary?: string; // 摘要 + nodes?: MarkDataNode[]; // 节点 + [key: string]: any; +}; +export type MarkFile = { + id: string; + name: string; + url: string; + size: number; + type: 'self' | 'data' | 'generate'; // generate为生成文件 + query: string; // 'data.nodes[id].content'; + hash: string; + fileKey: string; // 文件的名称, 唯一 +}; +export type MarkDataNode = { + id?: string; + [key: string]: any; +}; +export type MarkConfig = { + [key: string]: any; +}; +export type MarkAuth = { + [key: string]: any; +}; +/** + * 隐秘内容 + * auth + * config + * + */ +export class MarkModel extends Model { + declare id: string; + declare title: string; // 标题,可以ai生成 + declare description: string; // 描述,可以ai生成 + declare cover: string; // 封面,可以ai生成 + declare thumbnail: string; // 缩略图 + declare key: string; // 文件路径 + declare markType: string; // markdown | json | html | image | video | audio | code | link | file + declare link: string; // 访问链接 + declare tags: string[]; // 标签 + declare summary: string; // 摘要, description的简化版 + declare data: MarkData; // 数据 + + declare uid: string; // 操作用户的id + declare puid: string; // 父级用户的id, 真实用户 + declare config: MarkConfig; // mark属于一定不会暴露的内容。 + + declare fileList: MarkFile[]; // 文件管理 + declare uname: string; // 用户的名称, 或者着别名 + + declare createdAt: Date; + declare updatedAt: Date; + declare version: number; + /** + * 加锁更新data中的node的节点,通过node的id + * @param param0 + */ + static async updateJsonNode(id: string, node: MarkDataNode, opts?: { operate?: 'update' | 'delete'; Model?: any; sequelize?: Sequelize }) { + const sequelize = opts?.sequelize || (await useContextKey('sequelize')); + const transaction = await sequelize.transaction(); // 开启事务 + const operate = opts.operate || 'update'; + const isUpdate = operate === 'update'; + const Model = opts.Model || MarkModel; + try { + // 1. 获取当前的 JSONB 字段值(加锁) + const mark = await Model.findByPk(id, { + transaction, + lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改 + }); + if (!mark) { + throw new Error('Mark not found'); + } + // 2. 修改特定的数组元素 + const data = mark.data as MarkData; + const items = data.nodes; + if (!node.id) { + node.id = random(12); + } + + // 找到要更新的元素 + const itemIndex = items.findIndex((item) => item.id === node.id); + if (itemIndex === -1) { + isUpdate && items.push(node); + } else { + if (isUpdate) { + items[itemIndex] = node; + } else { + items.splice(itemIndex, 1); + } + } + const version = Number(mark.version) + 1; + // 4. 更新 JSONB 字段 + const result = await mark.update( + { + data: { + ...data, + nodes: items, + }, + version, + }, + { transaction }, + ); + + await transaction.commit(); + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async updateJsonNodes(id: string, nodes: { node: MarkDataNode; operate?: 'update' | 'delete' }[], opts?: { Model?: any; sequelize?: Sequelize }) { + const sequelize = opts?.sequelize || (await useContextKey('sequelize')); + const transaction = await sequelize.transaction(); // 开启事务 + const Model = opts?.Model || MarkModel; + try { + const mark = await Model.findByPk(id, { + transaction, + lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改 + }); + if (!mark) { + throw new Error('Mark not found'); + } + const data = mark.data as MarkData; + const _nodes = data.nodes || []; + // 过滤不在nodes中的节点 + const blankNodes = nodes.filter((node) => !_nodes.find((n) => n.id === node.node.id)).map((node) => node.node); + // 更新或删除节点 + const newNodes = _nodes + .map((node) => { + const nodeOperate = nodes.find((n) => n.node.id === node.id); + if (nodeOperate) { + if (nodeOperate.operate === 'delete') { + return null; + } + return nodeOperate.node; + } + return node; + }) + .filter((node) => node !== null); + const version = Number(mark.version) + 1; + const result = await mark.update( + { + data: { + ...data, + nodes: [...blankNodes, ...newNodes], + }, + version, + }, + { transaction }, + ); + await transaction.commit(); + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async updateData(id: string, data: MarkData, opts: { Model?: any; sequelize?: Sequelize }) { + const sequelize = opts.sequelize || (await useContextKey('sequelize')); + const transaction = await sequelize.transaction(); // 开启事务 + const Model = opts.Model || MarkModel; + const mark = await Model.findByPk(id, { + transaction, + lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改 + }); + if (!mark) { + throw new Error('Mark not found'); + } + const version = Number(mark.version) + 1; + const result = await mark.update( + { + ...mark.data, + ...data, + data: { + ...mark.data, + ...data, + }, + version, + }, + { transaction }, + ); + await transaction.commit(); + return result; + } + static async createNew(data: any, opts: { Model?: any; sequelize?: Sequelize }) { + const sequelize = opts.sequelize || (await useContextKey('sequelize')); + const transaction = await sequelize.transaction(); // 开启事务 + const Model = opts.Model || MarkModel; + const result = await Model.create({ ...data, version: 1 }, { transaction }); + await transaction.commit(); + return result; + } +} +export type MarkInitOpts = { + tableName: string; + sequelize?: Sequelize; + callInit?: (attribute: ModelAttributes) => ModelAttributes; + Model?: T extends typeof MarkModel ? T : typeof MarkModel; +}; +export type Opts = { + sync?: boolean; + alter?: boolean; + logging?: boolean | ((...args: any) => any); + force?: boolean; +}; +export const MarkMInit = async (opts: MarkInitOpts, sync?: Opts) => { + const sequelize = await useContextKey('sequelize'); + opts.sequelize = opts.sequelize || sequelize; + const { callInit, Model, ...optsRest } = opts; + const modelAttribute = { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + comment: 'id', + }, + title: { + type: DataTypes.TEXT, + defaultValue: '', + }, + key: { + type: DataTypes.TEXT, // 对应的minio的文件路径 + defaultValue: '', + }, + markType: { + type: DataTypes.TEXT, + defaultValue: 'md', // markdown | json | html | image | video | audio | code | link | file + comment: '类型', + }, + description: { + type: DataTypes.TEXT, + defaultValue: '', + }, + cover: { + type: DataTypes.TEXT, + defaultValue: '', + comment: '封面', + }, + thumbnail: { + type: DataTypes.TEXT, + defaultValue: '', + comment: '缩略图', + }, + link: { + type: DataTypes.TEXT, + defaultValue: '', + comment: '链接', + }, + tags: { + type: DataTypes.JSONB, + defaultValue: [], + }, + summary: { + type: DataTypes.TEXT, + defaultValue: '', + comment: '摘要', + }, + config: { + type: DataTypes.JSONB, + defaultValue: {}, + }, + data: { + type: DataTypes.JSONB, + defaultValue: {}, + }, + fileList: { + type: DataTypes.JSONB, + defaultValue: [], + }, + uname: { + type: DataTypes.STRING, + defaultValue: '', + comment: '用户的名称, 更新后的用户的名称', + }, + version: { + type: DataTypes.INTEGER, // 更新刷新版本,多人协作 + defaultValue: 1, + }, + uid: { + type: DataTypes.UUID, + allowNull: true, + }, + puid: { + type: DataTypes.UUID, + allowNull: true, + }, + }; + const InitModel = Model || MarkModel; + InitModel.init(callInit ? callInit(modelAttribute) : modelAttribute, { + sequelize, + paranoid: true, + ...optsRest, + }); + if (sync && sync.sync) { + const { sync: _, ...rest } = sync; + MarkModel.sync({ alter: true, logging: false, ...rest }).catch((e) => { + console.error('MarkModel sync', e); + }); + } +}; + +export const markModelInit = MarkMInit; + +export const syncMarkModel = async (sync?: Opts, tableName = 'micro_mark') => { + const sequelize = await useContextKey('sequelize'); + await MarkMInit({ sequelize, tableName }, sync); +}; diff --git a/src/models/org.ts b/src/models/org.ts index c440f5e..08bd414 100644 --- a/src/models/org.ts +++ b/src/models/org.ts @@ -1,5 +1,5 @@ import { DataTypes, Model, Op, Sequelize } from 'sequelize'; -import { useContextKey } from '@kevisual/use-config/context'; +import { useContextKey } from '@kevisual/context'; import { SyncOpts, User } from './user.ts'; type AddUserOpts = { diff --git a/src/models/user-secret.ts b/src/models/user-secret.ts index 8a8903f..b095bfe 100644 --- a/src/models/user-secret.ts +++ b/src/models/user-secret.ts @@ -1,6 +1,6 @@ import { DataTypes, Model, Sequelize } from 'sequelize'; -import { useContextKey } from '@kevisual/use-config/context'; +import { useContextKey } from '@kevisual/context'; import { Redis } from 'ioredis'; import { SyncOpts, User } from './user.ts'; import { oauth } from '../oauth/auth.ts'; diff --git a/src/models/user.ts b/src/models/user.ts index 4a091cb..c37fea2 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -3,7 +3,7 @@ import { nanoid, customAlphabet } from 'nanoid'; import { CustomError } from '@kevisual/router'; import { Org } from './org.ts'; -import { useContextKey } from '@kevisual/use-config/context'; +import { useContextKey } from '@kevisual/context'; import { Redis } from 'ioredis'; import { oauth } from '../oauth/auth.ts'; import { cryptPwd } from '../oauth/salt.ts'; diff --git a/src/modules/init.ts b/src/modules/init.ts index 88dbee2..ff92867 100644 --- a/src/modules/init.ts +++ b/src/modules/init.ts @@ -1,4 +1,3 @@ -import { useContextKey, useContext } from '@kevisual/use-config/context'; import { sequelize } from './sequelize.ts'; export { sequelize }; diff --git a/src/modules/redis.ts b/src/modules/redis.ts index 424c356..48c091d 100644 --- a/src/modules/redis.ts +++ b/src/modules/redis.ts @@ -1,6 +1,6 @@ import { Redis } from 'ioredis'; import { useConfig } from '@kevisual/use-config/env'; -import { useContextKey } from '@kevisual/use-config/context'; +import { useContextKey } from '@kevisual/context'; const configEnv = useConfig(); const redisConfig = { diff --git a/src/modules/sequelize.ts b/src/modules/sequelize.ts index 60da5be..f9d2958 100644 --- a/src/modules/sequelize.ts +++ b/src/modules/sequelize.ts @@ -1,6 +1,6 @@ import { useConfig } from '@kevisual/use-config/env'; import { Sequelize } from 'sequelize'; -import { useContextKey } from '@kevisual/use-config/context'; +import { useContextKey } from '@kevisual/context'; const configEnv = useConfig();