first commit

This commit is contained in:
zzc
2026-01-09 11:21:29 +08:00
commit 24e6484860
29 changed files with 1930 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/dist
/output
/tools-build
node_modules

5
.pnpm-debug.log Normal file
View File

@@ -0,0 +1,5 @@
{
"0 debug pnpm:scope": {
"selected": 1
}
}

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:16.17
WORKDIR /app
COPY package.json yarn.lock app.js /app/
RUN apt-get update \
&& apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev -y
ARG PORT=6000
ENV PORT $PORT
EXPOSE $PORT
COPY package.json yarn.lock app.js /app/
COPY util/ /app/util
COPY public/ /app/public
COPY protos/ /app/protos
COPY bin/ /app/bin
COPY app/ /app/app
RUN yarn install
CMD ["yarn", "start"]

1
README.md Normal file
View File

@@ -0,0 +1 @@
# grpc_picture

30
app.js Normal file
View File

@@ -0,0 +1,30 @@
const path = require("path");
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const promisify = require(path.join(__dirname, "util/grpc-promisify"));
global.__pgQuery = require(path.join(__dirname, "util/pg")).pgQuery;
process.on("uncaughtException", (reason, p) => {
console.log("error:", reason, p);
// Sentry.captureException({ reason, p });
// logger.error({ reason, p }, 'uncaughtException error')
});
process.on("unhandledRejection", (reason, p) => {
console.log("error:", reason, p);
// Sentry.captureException({ reason, p });
// logger.error({ reason, p }, 'unhandledRejection error')
});
const Image_PATH = path.join(__dirname, "protos/image.proto");
const imageDefinition = protoLoader.loadSync(Image_PATH, {
defaults: true,
});
module.exports = {
imageProto: grpc.loadPackageDefinition(imageDefinition).image,
imageImpl: require(path.join(__dirname, "/app/controller")),
grpc,
promisify,
};

26
app/controller/image.js Normal file
View File

@@ -0,0 +1,26 @@
const { getLogger, imageUtil, qiniuUtil } = require("../../util");
const logger = getLogger("image");
const path = require("path");
const Merge = async (call, callback) => {
const { name } = call.request;
logger.info({ name }, "参数");
const buffer = await imageUtil.mergeImage(name);
console.log(11111, buffer);
// const file = await qiniuUtil.uploadFileByBuffer(buffer)
callback(null, { path: "success" });
};
const CreateAvatarOrnament = async (call, callback) => {
const { avatarPath, ornamentPath } = call.request;
logger.info({ avatarPath, ornamentPath }, "参数");
const buffer = await imageUtil.avatarOrnament(avatarPath, ornamentPath);
// console.log(11111, buffer);
// const file = await qiniuUtil.uploadFileByBuffer(buffer)
callback(null, { path: "success" });
};
module.exports = {
Merge,
CreateAvatarOrnament,
};

4
app/controller/index.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
Merge: require("./image").Merge,
CreateAvatarOrnament: require("./image").CreateAvatarOrnament,
};

42
bin/rpc.js Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
if (process.env.NODE_ENV !== "production") require("dotenv").config();
const app = require("../app");
const server = new app.grpc.Server();
const client = new app.imageProto.Image(
`${process.env.PORT || 10010}`,
app.grpc.credentials.createSsl()
);
app.promisify(client);
try {
let address = `0.0.0.0:${process.env.PORT || 10010}`;
server.addService(app.imageProto.Image.service, app.imageImpl);
server.bindAsync(
address,
app.grpc.ServerCredentials.createInsecure(),
(err, port) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`🚀 gRPC listening on ${port}`);
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down gRPC server...");
server.tryShutdown(() => {
console.log("gRPC server closed");
process.exit(0);
});
});
}
);
// server.start()
// console.info(`Image gRPC server: Listening on ${address}`)
} catch (error) {
console.error(`Image gRPC server error: ${error.message}`);
process.exit(1);
}

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "grpc_picture",
"version": "1.0.0",
"main": "index.js",
"repository": "git@github.com:MrZhangZc/grpc_picture.git",
"author": "zzc <1761997216@qq.com>",
"license": "MIT",
"standard": {
"globals": [
"__pgQuery"
]
},
"scripts": {
"start": "NODE_ENV=production node ./bin/rpc",
"start:dev": "nodemon ./bin/rpc"
},
"dependencies": {
"@grpc/grpc-js": "^1.7.0",
"@grpc/proto-loader": "^0.7.2",
"canvas": "^2.10.1",
"dotenv": "^16.0.2",
"pg": "^8.8.0",
"pg-pool": "^3.5.2",
"pino": "^8.6.0",
"pino-pretty": "^9.1.0",
"qiniu": "^7.7.0"
}
}

1362
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

20
protos/image.proto Normal file
View File

@@ -0,0 +1,20 @@
syntax = "proto3";
package image;
service Image {
rpc Merge (MergeReq) returns (Reply) {};
rpc CreateAvatarOrnament (CreateAvatarOrnamentReq) returns (Reply) {};
}
message MergeReq {
string name = 1;
}
message CreateAvatarOrnamentReq {
string avatar_path = 1;
string ornament_path = 2;
}
message Reply {
string path = 1;
}

BIN
public/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
public/merge/base.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
public/merge/base_card1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

BIN
util/66.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

38
util/file.js Normal file
View File

@@ -0,0 +1,38 @@
const fs = require('fs')
const getFileBuffer = (path) => fs.readFileSync(path)
const getFilePath = (prefixStr = '', postfixStr = '') => {
// 文件名称
const uuid = () => {
function s4 () {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1)
}
return s4() + s4() + s4() + s4() + s4() + s4() + s4() + s4()
}
// 文件扩展名 toLowerCase 转小写
let prefix = prefixStr
if (prefixStr && prefix.substr(prefix.length - 1, 1) !== '/') { prefix += '/' }
// 后缀
let postfix = ''
if (postfixStr) { postfix = '_' + postfixStr }
return prefix + uuid() + postfix + '.jpg'
}
const uuid = () => {
function s4 () {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1)
}
return s4() + s4() + s4()
}
module.exports = {
getFileBuffer,
getFilePath,
uuid
}

27
util/grpc-promisify.js Normal file
View File

@@ -0,0 +1,27 @@
function promisify (client) {
Object.keys(Object.getPrototypeOf(client)).forEach(functionName => {
if (functionName.startsWith('$')) {
return
}
const originalFunction = client[functionName]
client[functionName] = (request, callback) => {
if (callback && typeof callback === 'function') {
return originalFunction.call(client, request, (error, response) => {
callback(error, response)
})
}
return new Promise((resolve, reject) => {
originalFunction.call(client, request, (error, response) => {
if (error) {
reject(error)
} else {
resolve(response)
}
})
})
}
client[functionName].interceptors = originalFunction.interceptors
})
}
module.exports = promisify

120
util/image.js Normal file
View File

@@ -0,0 +1,120 @@
const { createCanvas, loadImage, registerFont } = require("canvas");
const fs = require("fs");
const path = require("path");
registerFont(path.join(process.cwd(), `/public/font/PINGFANG MEDIUM.TTF`), {
family: "pf",
});
const mergeImage = async (name) => {
const WIDTH = 1080;
const HEIGHT = 1920;
const canvas = createCanvas(WIDTH, HEIGHT);
const context = canvas.getContext("2d");
const bg = await loadImage(
path.join(process.cwd(), "/public/merge/base_card1.jpg")
);
context.drawImage(bg, 0, 0, 1080, 1920);
context.font = "48px px";
context.fillStyle = "#282828";
context.textAlign = "center"; //文字水平居中
context.fillText(name, 566 / 2, 422);
context.font = `400 80px pf`;
context.textAlign = "center";
context.fillStyle = "red";
context.fillText(`龙马精神`, WIDTH / 2, 366 + 4);
// return canvas.toBuffer();
const out = fs.createWriteStream(__dirname + "/test2.png");
const stream = canvas.createPNGStream();
stream.pipe(out);
out.on("finish", () => console.log("The PNG file was created."));
};
// const avatarOrnament = async (avatarPath, ornamentPath, size = 512) => {
// // 1⃣ 加载图片
// const avatarImg = await loadImage(avatarPath);
// const ornamentImg = await loadImage(ornamentPath);
// const width = avatarImg.width;
// const height = avatarImg.height;
// const canvas = createCanvas(width, height);
// const ctx = canvas.getContext("2d");
// // 3⃣ 先画头像
// ctx.drawImage(avatarImg, 0, 0, width, height);
// // 4⃣ 计算挂饰尺寸(取短边的 25%
// const baseSize = Math.min(width, height);
// const ornamentSize = Math.floor(baseSize * 0.25);
// const ornamentRatio = ornamentImg.width / ornamentImg.height;
// const ornamentWidth =
// ornamentRatio >= 1 ? ornamentSize : ornamentSize * ornamentRatio;
// const ornamentHeight =
// ornamentRatio >= 1 ? ornamentSize / ornamentRatio : ornamentSize;
// // 5⃣ 位置:右下角 + 内边距
// const padding = Math.floor(baseSize * 0.05);
// const x = width - ornamentWidth - padding;
// const y = height - ornamentHeight - padding;
// // 6⃣ 阴影(非常关键:让合成“自然”)
// ctx.save();
// ctx.shadowColor = "rgba(0, 0, 0, 0.25)";
// ctx.shadowBlur = Math.floor(baseSize * 0.03);
// ctx.shadowOffsetX = Math.floor(baseSize * 0.01);
// ctx.shadowOffsetY = Math.floor(baseSize * 0.01);
// ctx.drawImage(ornamentImg, x, y, ornamentWidth, ornamentHeight);
// ctx.restore();
// // 6. 输出文件
// // const buffer = canvas.toBuffer("image/png");
// // fs.writeFileSync(outputPath, buffer);
// const out = fs.createWriteStream(__dirname + "/test3.png");
// const stream = canvas.createPNGStream();
// stream.pipe(out);
// out.on("finish", () => console.log("The PNG file was created."));
// return "success";
// };
const avatarOrnament = async (avatarPath, ornamentPath, size = 512) => {
// 1. 读取头像
const avatar = await loadImage(avatarPath);
// 2. Canvas 尺寸 = 头像尺寸(不裁剪)
const width = 500;
const height = 500;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// 3. 绘制头像
ctx.drawImage(avatar, 0, 0, width, height);
// 4. 读取装饰外框PNG透明
// const frame = await loadImage(ornamentPath);
const framePath = path.resolve(__dirname + "/66.png");
const frame = await loadImage(framePath);
// 5. 覆盖绘制外框
ctx.drawImage(frame, 0, 0, width, height);
// 6. 输出文件
// const buffer = canvas.toBuffer("image/png");
// fs.writeFileSync(outputPath, buffer);
const out = fs.createWriteStream(__dirname + "/test6.png");
const stream = canvas.createPNGStream();
stream.pipe(out);
out.on("finish", () => console.log("The PNG file was created."));
return "success";
};
module.exports = {
mergeImage,
avatarOrnament,
};

12
util/index.js Normal file
View File

@@ -0,0 +1,12 @@
const promisify = require('./grpc-promisify')
const imageUtil = require('./image')
// const fileUtil = require('./file')
const qiniuUtil = require('./qiniu')
const getLogger = require('./logger')
module.exports = {
imageUtil,
promisify,
qiniuUtil,
getLogger
}

25
util/logger.js Normal file
View File

@@ -0,0 +1,25 @@
const pino = require('pino')
let log
if (process.env.NODE_ENV === 'production') {
log = pino()
} else if (process.env.NODE_ENV === 'development') {
const transport = pino.transport({
target: 'pino-pretty',
options: { colorize: true }
})
log = pino({ level: 'trace'}, transport)
} else {
const transport = pino.transport({
target: 'pino-pretty',
options: { colorize: true }
})
log = pino({level: 'debug'},transport)
}
module.exports = getLogger = name => {
const logger = log.child({ name })
return logger
}

26
util/pg.js Normal file
View File

@@ -0,0 +1,26 @@
const Pool = require('pg-pool');
const pgConnection = new Pool({
user: process.env.POSTGRESQL_USERNAME,
password: process.env.POSTGRESQL_PASSWORD,
host: process.env.POSTGRESQL_HOST,
port: 5432,
database: process.env.POSTGRESQL_DATABASE
})
module.exports = {
pgQuery: async (sql, values) => {
let connection = false
try {
connection = await pgConnection.connect()
let results = await connection.query(sql, values)
return results
} catch (error) {
throw error
} finally {
if (connection) {
connection.release()
}
}
},
}

135
util/qiniu.js Normal file
View File

@@ -0,0 +1,135 @@
const qiniu = require('qiniu');
const { unlinkSync } = require('fs');
const { resolve } = require('path');
const getFileKey = (file, prefix) => {
const uuid = () => {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + s4() + s4() + s4() + s4() + s4() + s4();
};
const splitted = file ? file.originalname.split('.') : '';
const extension = file ? splitted[splitted.length - 1] : 'png';
return {
fileName: uuid() + '.' + extension,
key: prefix
? `${prefix}/` + uuid() + '.' + extension
: 'resource/' + uuid() + '.' + extension,
};
};
const getFileName = (file, prefix) => {
const uuid = () => {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + s4() + s4() + s4() + s4() + s4() + s4();
};
return uuid() + '.png'
};
const deleteFile = (name) => {
const path = resolve(__dirname, `../public/${name}`);
unlinkSync(path);
};
const saveToQiNIu = (fileName) => {
const mac = new qiniu.auth.digest.Mac(
process.env.ACCESS_KEY,
process.env.SECRET_KEY,
);
const options = { scope: process.env.BUCKET };
const putPolicy = new qiniu.rs.PutPolicy(options);
const uploadToken = putPolicy.uploadToken(mac);
const config = new qiniu.conf.Config();
config.zone = qiniu.zone.Zone_z2;
const localFile = resolve(__dirname, `../public/${fileName}`);
const putExtra = new qiniu.form_up.PutExtra();
const formUploader = new qiniu.form_up.FormUploader(config);
return new Promise((resolve, reject) => {
formUploader.putFile(
uploadToken,
fileName,
localFile,
putExtra,
function (respErr, respBody, respInfo) {
if (respErr) {
reject(respErr);
}
if (respInfo.statusCode == 200) {
resolve(respBody);
}
},
);
});
};
const deleteQiNiuSource = (key) => {
const mac = new qiniu.auth.digest.Mac(
process.env.ACCESS_KEY,
process.env.SECRET_KEY,
);
const config = new qiniu.conf.Config();
config.zone = qiniu.zone.Zone_z2;
const bucketManager = new qiniu.rs.BucketManager(mac, config);
return new Promise((resolve, reject) => {
bucketManager.delete(
process.env.BUCKET,
key,
function (err, respBody, respInfo) {
if (err) {
reject(err);
} else {
resolve(respInfo);
}
},
);
});
};
const uploadFileByBuffer = (buffer, prefix) => {
const { key } = getFileKey('', prefix);
const mac = new qiniu.auth.digest.Mac(
process.env.ACCESS_KEY,
process.env.SECRET_KEY,
);
const options = { scope: process.env.BUCKET };
const putPolicy = new qiniu.rs.PutPolicy(options);
const uploadToken = putPolicy.uploadToken(mac);
const config = new qiniu.conf.Config();
config.zone = qiniu.zone.Zone_z2;
const putExtra = new qiniu.form_up.PutExtra();
const formUploader = new qiniu.form_up.FormUploader(config);
return new Promise((resolve, reject) => {
formUploader.put(
uploadToken,
key,
buffer,
putExtra,
function (respErr, respBody, respInfo) {
if (respErr) {
reject(respErr);
}
if (respInfo.statusCode == 200) {
resolve(key);
}
},
);
});
};
module.exports = {
saveToQiNIu,
deleteQiNiuSource,
getFileName,
deleteFile,
uploadFileByBuffer
}

BIN
util/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
util/test2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
util/test3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

BIN
util/test5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

BIN
util/test6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB