Refactor config management to use Drizzle ORM

- Replaced Sequelize with Drizzle ORM in config-related routes and models.
- Updated database queries to use Drizzle's syntax for selecting, inserting, updating, and deleting configurations.
- Removed the ConfigModel class and replaced it with direct database interactions.
- Introduced nanoid for generating unique IDs for new configurations.
- Added new routes for managing marks, including CRUD operations and versioning.
- Implemented transaction handling for critical operations in the MarkModel.
- Enhanced error handling and validation in routes.
This commit is contained in:
2026-02-05 16:31:09 +08:00
parent 5200cf4c38
commit 266b7b33de
13 changed files with 924 additions and 191 deletions

View File

@@ -1,8 +1,8 @@
import { app } from '@/app.ts'; import { eq, and, inArray } from 'drizzle-orm';
import { ConfigModel } from './models/model.ts'; import { app, db, schema } from '@/app.ts';
import { oss } from '@/app.ts'; import { oss } from '@/app.ts';
import { ConfigOssService } from '@kevisual/oss/services'; import { ConfigOssService } from '@kevisual/oss/services';
import { Op } from 'sequelize'; import { nanoid } from 'nanoid';
app app
.route({ .route({
@@ -20,14 +20,12 @@ app
}, },
}); });
const { list, keys, keyEtagMap } = await configOss.getList(); const { list, keys, keyEtagMap } = await configOss.getList();
const configList = await ConfigModel.findAll({ const configList = await db.select()
where: { .from(schema.kvConfig)
key: { .where(and(
[Op.in]: keys, inArray(schema.kvConfig.key, keys),
}, eq(schema.kvConfig.uid, tokenUser.id)
uid: tokenUser.id, ));
},
});
const needUpdateList = list.filter((item) => { const needUpdateList = list.filter((item) => {
const key = item.key; const key = item.key;
const hash = keyEtagMap.get(key); const hash = keyEtagMap.get(key);
@@ -43,30 +41,33 @@ app
const keyETag = keyEtagMap.get(key); const keyETag = keyEtagMap.get(key);
const configData = keyDataMap.get(key); const configData = keyDataMap.get(key);
if (keyETag && configData) { if (keyETag && configData) {
const [config, created] = await ConfigModel.findOrCreate({ const existing = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tokenUser.id)))
uid: tokenUser.id, .limit(1);
},
defaults: { let config;
if (existing.length === 0) {
const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
key, key,
title: key, title: key,
description: `${key}:${keyETag} 同步而来`, description: `${key}:${keyETag} 同步而来`,
uid: tokenUser.id, uid: tokenUser.id,
hash: keyETag, hash: keyETag,
data: configData, data: configData,
}, }).returning();
}); config = inserted[0];
if (!created) { } else {
await config.update( const updated = await db.update(schema.kvConfig)
{ .set({
hash: keyETag, hash: keyETag,
data: json, data: json,
}, updatedAt: new Date().toISOString(),
{ })
fields: ['hash', 'data'], .where(eq(schema.kvConfig.id, existing[0].id))
}, .returning();
); config = updated[0];
} }
updateList.push(config); updateList.push(config);
} }

View File

@@ -1,7 +1,8 @@
import { app } from '@/app.ts'; import { eq, and } from 'drizzle-orm';
import { ConfigModel } from './models/model.ts'; import { app, db, schema } from '@/app.ts';
import { User } from '@/models/user.ts'; import { User } from '@/models/user.ts';
import { defaultKeys } from './models/default-keys.ts'; import { defaultKeys } from './models/default-keys.ts';
import { nanoid } from 'nanoid';
app app
.route({ .route({
@@ -27,19 +28,28 @@ app
} }
const defaultConfig = defaultKeys.find((item) => item.key === configKey); const defaultConfig = defaultKeys.find((item) => item.key === configKey);
const [config, created] = await ConfigModel.findOrCreate({ const existing = await db.select()
where: { .from(schema.kvConfig)
key: configKey, .where(and(
uid: tokenUser.id, eq(schema.kvConfig.key, configKey),
}, eq(schema.kvConfig.uid, tokenUser.id)
defaults: { ))
.limit(1);
let config;
if (existing.length === 0) {
const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
title: defaultConfig?.key, title: defaultConfig?.key,
description: defaultConfig?.description || '', description: defaultConfig?.description || '',
key: configKey, key: configKey,
uid: tokenUser.id, uid: tokenUser.id,
data: defaultConfig?.data, data: defaultConfig?.data,
}, }).returning();
}); config = inserted[0];
} else {
config = existing[0];
}
ctx.body = config; ctx.body = config;
}) })

View File

@@ -1,8 +1,9 @@
import { app } from '@/app.ts'; import { eq, desc, and, inArray } from 'drizzle-orm';
import { ConfigModel } from './models/model.ts'; import { app, db, schema } from '@/app.ts';
import { ShareConfigService } from './services/share.ts'; import { ShareConfigService } from './services/share.ts';
import { oss } from '@/app.ts'; import { oss } from '@/app.ts';
import { ConfigOssService } from '@kevisual/oss/services'; import { ConfigOssService } from '@kevisual/oss/services';
import { nanoid } from 'nanoid';
app app
.route({ .route({
@@ -13,12 +14,10 @@ app
}) })
.define(async (ctx) => { .define(async (ctx) => {
const { id } = ctx.state.tokenUser; const { id } = ctx.state.tokenUser;
const config = await ConfigModel.findAll({ const config = await db.select()
where: { .from(schema.kvConfig)
uid: id, .where(eq(schema.kvConfig.uid, id))
}, .orderBy(desc(schema.kvConfig.updatedAt));
order: [['updatedAt', 'DESC']],
});
ctx.body = { ctx.body = {
list: config, list: config,
}; };
@@ -36,9 +35,10 @@ app
const tokernUser = ctx.state.tokenUser; const tokernUser = ctx.state.tokenUser;
const tuid = tokernUser.id; const tuid = tokernUser.id;
const { id, data, ...rest } = ctx.query?.data || {}; const { id, data, ...rest } = ctx.query?.data || {};
let config: ConfigModel; let config: any;
if (id) { if (id) {
config = await ConfigModel.findByPk(id); const configs = await db.select().from(schema.kvConfig).where(eq(schema.kvConfig.id, id)).limit(1);
config = configs[0];
let keyIsChange = false; let keyIsChange = false;
if (rest?.key) { if (rest?.key) {
keyIsChange = rest.key !== config?.key; keyIsChange = rest.key !== config?.key;
@@ -48,47 +48,57 @@ app
} }
if (keyIsChange) { if (keyIsChange) {
const key = rest.key; const key = rest.key;
const keyConfig = await ConfigModel.findOne({ const keyConfigs = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
uid: tuid, .limit(1);
}, const keyConfig = keyConfigs[0];
});
if (keyConfig && keyConfig.id !== id) { if (keyConfig && keyConfig.id !== id) {
ctx.throw(403, 'key is already exists'); ctx.throw(403, 'key is already exists');
} }
} }
await config.update({ const updated = await db.update(schema.kvConfig)
data: data, .set({
...rest, data: data,
}); ...rest,
if (config.data?.permission?.share === 'public') { updatedAt: new Date().toISOString(),
})
.where(eq(schema.kvConfig.id, id))
.returning();
config = updated[0];
if ((config.data as any)?.permission?.share === 'public') {
await ShareConfigService.expireShareConfig(config.key, tokernUser.username); await ShareConfigService.expireShareConfig(config.key, tokernUser.username);
} }
ctx.body = config; ctx.body = config;
} else if (rest?.key) { } else if (rest?.key) {
// id 不存在key存在则属于更新key不能重复 // id 不存在key存在则属于更新key不能重复
const key = rest.key; const key = rest.key;
config = await ConfigModel.findOne({ const configs = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
uid: tuid, .limit(1);
}, config = configs[0];
});
if (config) { if (config) {
await config.update({ const updated = await db.update(schema.kvConfig)
data: data, .set({
...rest, data: data,
}); ...rest,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.kvConfig.id, config.id))
.returning();
config = updated[0];
ctx.body = config; ctx.body = config;
} else { } else {
// 根据key创建一个配置 // 根据key创建一个配置
config = await ConfigModel.create({ const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
key, key,
...rest, ...rest,
data: data, data: data,
uid: tuid, uid: tuid,
}); }).returning();
config = inserted[0];
ctx.body = config; ctx.body = config;
} }
} }
@@ -103,22 +113,25 @@ app
const data = config.data; const data = config.data;
const hash = ossConfig.hash(data); const hash = ossConfig.hash(data);
if (config.hash !== hash) { if (config.hash !== hash) {
config.hash = hash; await db.update(schema.kvConfig)
await config.save({ .set({
fields: ['hash'], hash: hash,
}); updatedAt: new Date().toISOString(),
})
.where(eq(schema.kvConfig.id, config.id));
await ossConfig.putJsonObject(key, data); await ossConfig.putJsonObject(key, data);
} }
} }
if (config) return; if (config) return;
// id和key不存在。创建一个新的配置, 而且没有id的 // id和key不存在。创建一个新的配置, 而且没有id的
const newConfig = await ConfigModel.create({ const newConfig = await db.insert(schema.kvConfig).values({
id: nanoid(),
...rest, ...rest,
data: data, data: data,
uid: tuid, uid: tuid,
}); }).returning();
ctx.body = newConfig; ctx.body = newConfig[0];
}) })
.addTo(app); .addTo(app);
@@ -136,17 +149,17 @@ app
if (!id && !key) { if (!id && !key) {
ctx.throw(400, 'id or key is required'); ctx.throw(400, 'id or key is required');
} }
let config: ConfigModel; let config: any;
if (id) { if (id) {
config = await ConfigModel.findByPk(id); const configs = await db.select().from(schema.kvConfig).where(eq(schema.kvConfig.id, id)).limit(1);
config = configs[0];
} }
if (!config && key) { if (!config && key) {
config = await ConfigModel.findOne({ const configs = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
uid: tuid, .limit(1);
}, config = configs[0];
});
} }
if (!config) { if (!config) {
ctx.throw(404, 'config not found'); ctx.throw(404, 'config not found');
@@ -171,12 +184,9 @@ app
const tuid = tokernUser.id; const tuid = tokernUser.id;
const { id, key } = ctx.query?.data || {}; const { id, key } = ctx.query?.data || {};
if (id || key) { if (id || key) {
const search: any = id ? { id } : { key }; const search: any = id ? eq(schema.kvConfig.id, id) : eq(schema.kvConfig.key, key);
const config = await ConfigModel.findOne({ const configs = await db.select().from(schema.kvConfig).where(search).limit(1);
where: { const config = configs[0];
...search
},
});
if (config && config.uid === tuid) { if (config && config.uid === tuid) {
const key = config.key; const key = config.key;
const ossConfig = ConfigOssService.fromBase({ const ossConfig = ConfigOssService.fromBase({
@@ -190,7 +200,7 @@ app
await ossConfig.deleteObject(key); await ossConfig.deleteObject(key);
} catch (e) { } } catch (e) { }
} }
await config.destroy(); await db.delete(schema.kvConfig).where(eq(schema.kvConfig.id, config.id));
} else { } else {
ctx.throw(403, 'no permission'); ctx.throw(403, 'no permission');
} }

View File

@@ -1,7 +1,8 @@
import { useContextKey } from '@kevisual/context'; import { useContextKey } from '@kevisual/context';
import { sequelize } from '../../../modules/sequelize.ts';
import { DataTypes, Model } from 'sequelize';
import { Permission } from '@kevisual/permission'; import { Permission } from '@kevisual/permission';
import { eq, and } from 'drizzle-orm';
import { db, schema } from '../../../app.ts';
import { nanoid } from 'nanoid';
export interface ConfigData { export interface ConfigData {
key?: string; key?: string;
@@ -9,23 +10,24 @@ export interface ConfigData {
permission?: Permission; permission?: Permission;
} }
export type Config = Partial<InstanceType<typeof ConfigModel>>; export type Config = {
id: string;
title: string | null;
description: string | null;
tags: unknown;
key: string | null;
data: unknown;
uid: string | null;
hash: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
/** /**
* 用户配置 * 用户配置
*/ */
export class ConfigModel extends Model { export class ConfigModel {
declare id: string;
declare title: string;
declare description: string;
declare tags: string[];
/**
* @important 配置key 默认可以为空,如何设置了,必须要唯一。
*/
declare key: string;
declare data: ConfigData; // files
declare uid: string;
declare hash: string;
/** /**
* 获取用户配置 * 获取用户配置
* @param key 配置key * @param key 配置key
@@ -35,37 +37,60 @@ export class ConfigModel extends Model {
* @returns 配置 * @returns 配置
*/ */
static async getConfig(key: string, opts: { uid: string; defaultData?: any }) { static async getConfig(key: string, opts: { uid: string; defaultData?: any }) {
const [config, isNew] = await ConfigModel.findOrCreate({ const existing = await db.select()
where: { key, uid: opts.uid }, .from(schema.kvConfig)
defaults: { .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, opts.uid)))
key, .limit(1);
title: key,
uid: opts.uid, if (existing.length > 0) {
data: opts?.defaultData || {}, return {
}, config: existing[0],
}); isNew: false,
};
}
const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
key,
title: key,
uid: opts.uid,
data: opts?.defaultData || {},
}).returning();
return { return {
config: config, config: inserted[0],
isNew, isNew: true,
}; };
} }
static async setConfig(key: string, opts: { uid: string; data: any }) { static async setConfig(key: string, opts: { uid: string; data: any }) {
let config = await ConfigModel.findOne({ const existing = await db.select()
where: { key, uid: opts.uid }, .from(schema.kvConfig)
}); .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, opts.uid)))
if (config) { .limit(1);
config.data = { ...config.data, ...opts.data };
await config.save(); if (existing.length > 0) {
const config = existing[0];
const updated = await db.update(schema.kvConfig)
.set({
data: { ...(config.data as any || {}), ...opts.data },
updatedAt: new Date().toISOString(),
})
.where(eq(schema.kvConfig.id, config.id))
.returning();
return updated[0];
} else { } else {
config = await ConfigModel.create({ const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
title: key, title: key,
key, key,
uid: opts.uid, uid: opts.uid,
data: opts.data, data: opts.data,
}); }).returning();
return inserted[0];
} }
return config;
} }
/** /**
* 获取上传配置 * 获取上传配置
* @param key 配置key * @param key 配置key
@@ -82,7 +107,7 @@ export class ConfigModel extends Model {
uid: opts.uid, uid: opts.uid,
defaultData: defaultConfig, defaultData: defaultConfig,
}); });
const data = config.config.data; const data = config.config.data as any;
const prefix = `/${data.key}/${data.version}`; const prefix = `/${data.key}/${data.version}`;
return { return {
config: config.config, config: config.config,
@@ -90,6 +115,7 @@ export class ConfigModel extends Model {
prefix, prefix,
}; };
} }
static async setUploadConfig(opts: { uid: string; data: { key?: string; version?: string } }) { static async setUploadConfig(opts: { uid: string; data: { key?: string; version?: string } }) {
const config = await ConfigModel.setConfig('upload.json', { const config = await ConfigModel.setConfig('upload.json', {
uid: opts.uid, uid: opts.uid,
@@ -98,52 +124,5 @@ export class ConfigModel extends Model {
return config; return config;
} }
} }
ConfigModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
comment: 'id',
},
title: {
type: DataTypes.TEXT,
defaultValue: '',
},
key: {
type: DataTypes.TEXT,
defaultValue: '',
},
description: {
type: DataTypes.TEXT,
defaultValue: '',
},
tags: {
type: DataTypes.JSONB,
defaultValue: [],
},
hash: {
type: DataTypes.TEXT,
defaultValue: '',
},
data: {
type: DataTypes.JSONB,
defaultValue: {},
},
uid: {
type: DataTypes.UUID,
allowNull: true,
},
},
{
sequelize,
tableName: 'kv_config',
paranoid: true,
},
);
// ConfigModel.sync({ alter: true, logging: false }).catch((e) => {
// console.error('ConfigModel sync', e);
// });
useContextKey('ConfigModel', () => ConfigModel); useContextKey('ConfigModel', () => ConfigModel);

View File

@@ -1,10 +1,10 @@
import { ConfigModel, Config } from '../models/model.ts'; import { Config } from '../models/model.ts';
import { CustomError } from '@kevisual/router'; import { CustomError } from '@kevisual/router';
import { redis } from '@/app.ts'; import { redis, db, schema } from '@/app.ts';
import { User } from '@/models/user.ts'; import { eq, and } from 'drizzle-orm';
import { UserPermission, UserPermissionOptions } from '@kevisual/permission'; import { UserPermission, UserPermissionOptions } from '@kevisual/permission';
export class ShareConfigService extends ConfigModel { export class ShareConfigService {
/** /**
* 获取分享的配置 * 获取分享的配置
* @param key 配置的key * @param key 配置的key
@@ -22,26 +22,30 @@ export class ShareConfigService extends ConfigModel {
} }
const owner = username; const owner = username;
if (shareCacheConfig) { if (shareCacheConfig) {
const permission = new UserPermission({ permission: shareCacheConfig?.data?.permission, owner }); const permission = new UserPermission({ permission: (shareCacheConfig?.data as any)?.permission, owner });
const result = permission.checkPermissionSuccess(options); const result = permission.checkPermissionSuccess(options);
if (!result.success) { if (!result.success) {
throw new CustomError(403, 'no permission'); throw new CustomError(403, 'no permission');
} }
return shareCacheConfig; return shareCacheConfig;
} }
const user = await User.findOne({ const users = await db.select()
where: { username }, .from(schema.cfUser)
}); .where(eq(schema.cfUser.username, username))
.limit(1);
const user = users[0];
if (!user) { if (!user) {
throw new CustomError(404, 'user not found'); throw new CustomError(404, 'user not found');
} }
const config = await ConfigModel.findOne({ const configs = await db.select()
where: { key, uid: user.id }, .from(schema.kvConfig)
}); .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, user.id)))
.limit(1);
const config = configs[0];
if (!config) { if (!config) {
throw new CustomError(404, 'config not found'); throw new CustomError(404, 'config not found');
} }
const permission = new UserPermission({ permission: config?.data?.permission, owner }); const permission = new UserPermission({ permission: (config?.data as any)?.permission, owner });
const result = permission.checkPermissionSuccess(options); const result = permission.checkPermissionSuccess(options);
if (!result.success) { if (!result.success) {
throw new CustomError(403, 'no permission'); throw new CustomError(403, 'no permission');

View File

@@ -13,8 +13,9 @@ app
const config = await ConfigModel.getUploadConfig({ const config = await ConfigModel.getUploadConfig({
uid: tokenUser.id, uid: tokenUser.id,
}); });
const key = config?.config?.data?.key || ''; const data: any = config?.config?.data || {};
const version = config?.config?.data?.version || ''; const key = data.key || '';
const version = data.version || '';
const username = tokenUser.username; const username = tokenUser.username;
const prefix = `${key}/${version}/`; const prefix = `${key}/${version}/`;
ctx.body = { ctx.body = {
@@ -35,7 +36,7 @@ app
}) })
.define(async (ctx) => { .define(async (ctx) => {
const { id } = ctx.state.tokenUser; const { id } = ctx.state.tokenUser;
const data = ctx.query.data || {}; const data = ctx.query?.data || {};
const { key, version } = data; const { key, version } = data;
if (!key && !version) { if (!key && !version) {
ctx.throw(400, 'key or version is required'); ctx.throw(400, 'key or version is required');

View File

@@ -10,6 +10,8 @@ import './config/index.ts';
// import './file-listener/index.ts'; // import './file-listener/index.ts';
import './mark/index.ts';
import './light-code/index.ts'; import './light-code/index.ts';
import './ai/index.ts'; import './ai/index.ts';

View File

@@ -1,6 +1,6 @@
import { eq, desc, and, like, or } from 'drizzle-orm'; import { eq, desc, and, like, or } from 'drizzle-orm';
import { CustomError } from '@kevisual/router';
import { app, db, schema } from '../../app.ts'; import { app, db, schema } from '../../app.ts';
import { CustomError } from '@kevisual/router';
import { filter } from '@kevisual/js-filter' import { filter } from '@kevisual/js-filter'
import { z } from 'zod'; import { z } from 'zod';
app app

1
src/routes/mark/index.ts Normal file
View File

@@ -0,0 +1 @@
import './list.ts';

308
src/routes/mark/list.ts Normal file
View File

@@ -0,0 +1,308 @@
import { eq, desc, and, like, or, count, sql } from 'drizzle-orm';
import { app, db, schema } from '../../app.ts';
import { MarkServices } from './services/mark.ts';
import dayjs from 'dayjs';
import { nanoid } from 'nanoid';
app
.route({
path: 'mark',
key: 'list',
description: 'mark list.',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
ctx.body = await MarkServices.getList({
uid: tokenUser.id,
query: ctx.query,
queryType: 'simple',
});
})
.addTo(app);
app
.route({
path: 'mark',
key: 'getVersion',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query;
if (id) {
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
ctx.body = {
version: Number(markModel.version),
updatedAt: markModel.updatedAt,
createdAt: markModel.createdAt,
id: markModel.id,
};
} else {
ctx.throw(400, 'id is required');
// const [markModel, created] = await MarkModel.findOrCreate({
// where: {
// uid: tokenUser.id,
// puid: tokenUser.uid,
// title: dayjs().format('YYYY-MM-DD'),
// },
// defaults: {
// title: dayjs().format('YYYY-MM-DD'),
// uid: tokenUser.id,
// markType: 'wallnote',
// tags: ['daily'],
// },
// });
// ctx.body = {
// version: Number(markModel.version),
// updatedAt: markModel.updatedAt,
// createdAt: markModel.createdAt,
// id: markModel.id,
// created: created,
// };
}
})
.addTo(app);
app
.route({
path: 'mark',
key: 'get',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query;
if (id) {
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
ctx.body = markModel;
} else {
ctx.throw(400, 'id is required');
// id 不存在获取当天的title为 日期的一条数据
// const [markModel, created] = await MarkModel.findOrCreate({
// where: {
// uid: tokenUser.id,
// puid: tokenUser.uid,
// title: dayjs().format('YYYY-MM-DD'),
// },
// defaults: {
// title: dayjs().format('YYYY-MM-DD'),
// uid: tokenUser.id,
// markType: 'wallnote',
// tags: ['daily'],
// uname: tokenUser.username,
// puid: tokenUser.uid,
// version: 1,
// },
// });
// ctx.body = markModel;
}
})
.addTo(app);
app
.route({
path: 'mark',
key: 'update',
middleware: ['auth'],
isDebug: true,
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id, createdAt, updatedAt, uid: _, puid: _2, uname: _3, data, ...rest } = ctx.query.data || {};
let markModel: any;
if (id) {
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
...rest,
data: {
...(markModel.data as any || {}),
...data,
},
version,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.microMark.id, id))
.returning();
markModel = updated[0];
} else {
const inserted = await db.insert(schema.microMark).values({
id: nanoid(),
data: data || {},
...rest,
uname: tokenUser.username,
uid: tokenUser.id,
puid: tokenUser.uid,
}).returning();
markModel = inserted[0];
}
ctx.body = markModel;
})
.addTo(app);
app
.route({
path: 'mark',
key: 'updateNode',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const operate = ctx.query.operate || 'update';
const { id, node } = ctx.query.data || {};
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
// Update JSON node logic with Drizzle
const currentData = markModel.data as any || {};
const nodes = currentData.nodes || [];
const nodeIndex = nodes.findIndex((n: any) => n.id === node.id);
let updatedNodes;
if (operate === 'delete') {
updatedNodes = nodes.filter((n: any) => n.id !== node.id);
} else if (nodeIndex >= 0) {
updatedNodes = [...nodes];
updatedNodes[nodeIndex] = { ...nodes[nodeIndex], ...node };
} else {
updatedNodes = [...nodes, node];
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
data: { ...currentData, nodes: updatedNodes },
version,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.microMark.id, id))
.returning();
ctx.body = updated[0];
})
.addTo(app);
app
.route({
path: 'mark',
key: 'updateNodes',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id, nodeOperateList } = ctx.query.data || {};
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
if (!nodeOperateList || !Array.isArray(nodeOperateList) || nodeOperateList.length === 0) {
ctx.throw(400, 'nodeOperateList is required');
}
if (nodeOperateList.some((item: any) => !item.node)) {
ctx.throw(400, 'nodeOperateList node is required');
}
// Update multiple JSON nodes logic with Drizzle
const currentData = markModel.data as any || {};
let nodes = currentData.nodes || [];
for (const item of nodeOperateList) {
const { node, operate = 'update' } = item;
const nodeIndex = nodes.findIndex((n: any) => n.id === node.id);
if (operate === 'delete') {
nodes = nodes.filter((n: any) => n.id !== node.id);
} else if (nodeIndex >= 0) {
nodes[nodeIndex] = { ...nodes[nodeIndex], ...node };
} else {
nodes.push(node);
}
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
data: { ...currentData, nodes },
version,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.microMark.id, id))
.returning();
ctx.body = updated[0];
})
.addTo(app);
app
.route({
path: 'mark',
key: 'delete',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query;
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
await db.delete(schema.microMark).where(eq(schema.microMark.id, id));
ctx.body = markModel;
})
.addTo(app);
app
.route({ path: 'mark', key: 'getMenu', description: '获取菜单', middleware: ['auth'] })
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const [rows, totalResult] = await Promise.all([
db.select({
id: schema.microMark.id,
title: schema.microMark.title,
summary: schema.microMark.summary,
tags: schema.microMark.tags,
thumbnail: schema.microMark.thumbnail,
link: schema.microMark.link,
createdAt: schema.microMark.createdAt,
updatedAt: schema.microMark.updatedAt,
}).from(schema.microMark).where(eq(schema.microMark.uid, tokenUser.id)),
db.select({ count: count() }).from(schema.microMark).where(eq(schema.microMark.uid, tokenUser.id))
]);
ctx.body = {
list: rows,
total: totalResult[0]?.count || 0,
};
})
.addTo(app);

View File

@@ -0,0 +1,327 @@
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<InstanceType<typeof MarkModel>>;
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 markedAt: Date; // 标记时间
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<T = any> = {
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 <T = any>(opts: MarkInitOpts<T>, 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,
},
markedAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '标记时间',
},
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);
};

5
src/routes/mark/model.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from '@kevisual/code-center-module/src/mark/mark-model.ts';
import { markModelInit, MarkModel, syncMarkModel } from '@kevisual/code-center-module/src/mark/mark-model.ts';
export { markModelInit, MarkModel };
syncMarkModel({ sync: true, alter: true, logging: false });

View File

@@ -0,0 +1,85 @@
import { eq, desc, asc, and, like, or, count } from 'drizzle-orm';
import { app, db, schema } from '../../../app.ts';
export class MarkServices {
static getList = async (opts: {
/** 查询用户的 */
uid?: string;
query?: {
page?: number;
pageSize?: number;
search?: string;
markType?: string;
sort?: string;
};
/**
* 查询类型
* simple: 简单查询 默认
*/
queryType?: string;
}) => {
const { uid, query = {} } = opts;
const { page = 1, pageSize = 999, search, sort = 'DESC' } = query;
const conditions = [];
if (uid) {
conditions.push(eq(schema.microMark.uid, uid));
}
if (search) {
conditions.push(
or(
like(schema.microMark.title, `%${search}%`),
like(schema.microMark.summary, `%${search}%`)
)
);
}
if (opts.query?.markType) {
conditions.push(eq(schema.microMark.markType, opts.query.markType));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const queryType = opts.queryType || 'simple';
let selectFields: any = {};
if (queryType === 'simple') {
// Exclude data, config, cover, description
selectFields = {
id: schema.microMark.id,
title: schema.microMark.title,
tags: schema.microMark.tags,
uname: schema.microMark.uname,
uid: schema.microMark.uid,
createdAt: schema.microMark.createdAt,
updatedAt: schema.microMark.updatedAt,
thumbnail: schema.microMark.thumbnail,
link: schema.microMark.link,
summary: schema.microMark.summary,
markType: schema.microMark.markType,
puid: schema.microMark.puid,
deletedAt: schema.microMark.deletedAt,
version: schema.microMark.version,
fileList: schema.microMark.fileList,
key: schema.microMark.key,
};
}
const orderByField = sort === 'ASC' ? asc(schema.microMark.updatedAt) : desc(schema.microMark.updatedAt);
const [rows, totalResult] = await Promise.all([
queryType === 'simple'
? db.select(selectFields).from(schema.microMark).where(whereClause).orderBy(orderByField).limit(pageSize).offset((page - 1) * pageSize)
: db.select().from(schema.microMark).where(whereClause).orderBy(orderByField).limit(pageSize).offset((page - 1) * pageSize),
db.select({ count: count() }).from(schema.microMark).where(whereClause)
]);
return {
pagination: {
current: page,
pageSize,
total: totalResult[0]?.count || 0,
},
list: rows,
};
};
}