Write the Code. Change the World.

9月 10
精选文章

该后台使用 vite + ts + pnpm + vue3 + element-plus + tailwindcss 等技术栈构成。没有添加任意可视化图标等插件。以最小功能,最基础功能展现。用户可以额外添加可使用的插件逻辑。

该后台后端使用 php8.2 + laravel 10 + mysql

该后台后端 go 语言版本开发中。将使用 gframe2.5.2

源码: https://github.com/vini123/simpleAdmin

在线体验: https://www.zeipan.com/admin

权限以及密码一键复位: https://v3test.yuepaibao.com/admin/api/reset

测试账号以及密码: zhoulin@xiangrong.pro、 111111 (如果发现登录不了,可一键复位谢谢)

阅读全文 >>

8月 28

国密 sm2,sm3,sm3 在 php 中使用。国密 sm2,sm3,sm3 在 laravel 中使用。

Github

先准备 php docker 镜像

FROM xxxx

# 其他代码

WORKDIR /www

# 编译工具链(独立成层,成功后即缓存)
RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends git ca-certificates build-essential cmake pkg-config; \
    rm -rf /var/lib/apt/lists/*

# GmSSL底层库:源码编译安装(独立成层)
RUN set -eux; \
    cd /tmp; \
    git clone https://github.com/guanzhi/GmSSL --depth=1; \
    cd GmSSL; \
    mkdir build && cd build; \
    cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local/gmssl; \
    make -j$(nproc); \
    make install; \
    echo "/usr/local/gmssl/lib" > /etc/ld.so.conf.d/gmssl.conf; \
    ldconfig; \
    rm -rf /tmp/GmSSL

# GmSSL-PHP国密扩展:编译安装并启用(独立成层)
RUN set -eux; \
    cd /tmp; \
    git clone https://github.com/GmSSL/GmSSL-PHP.git --depth=1; \
    cd GmSSL-PHP; \
    phpize; \
    ./configure --with-gmssl=/usr/local/gmssl; \
    make -j$(nproc); \
    make install; \
    docker-php-ext-enable gmssl; \
    rm -rf /tmp/GmSSL-PHP

# 创建软链接,让系统可以直接找到 gmssl
RUN ln -sf /usr/local/gmssl/bin/gmssl /usr/local/bin/gmssl

# 验证安装
RUN gmssl version

环境准备好了,就可以在 laravel 项目中使用了。

使用

  1. 生成 sm4 需要的 key,iv 以及 sm2 需要的公钥私钥口令这些
#!/bin/bash

# 用来生成 GM SM4、SM2 的密钥(在已安装 GmSSL 的 Linux / docker 容器中执行)
# 使用方法: ./gmssl.sh
# 可选环境变量:
#   GMSSL_BIN  gmssl 命令路径(默认从 PATH 查找)
#   SM2_PASS   SM2 口令私钥的密码(默认随机生成24位hex,生产环境务必自行修改保管)

set -e  # 遇到错误立即退出

# ========== 配置区域 ==========
# gmssl 命令(默认安装到 /usr/local/bin/gmssl,从 PATH 查找即可)
GMSSL_BIN="${GMSSL_BIN:-gmssl}"

# 密钥存储目录(位于 storage/app 下,.env 中填相对路径即可)
KEY_DIR="./storage/app/gmssl"

# SM2 口令私钥的密码:未指定时随机生成
SM2_PASS="${SM2_PASS:-$(openssl rand -hex 12)}"

# ========== 开始生成 ==========

# 0. 检查 gmssl 命令是否可用
if ! command -v "$GMSSL_BIN" >/dev/null 2>&1; then
    echo "错误: 找不到 gmssl 命令,请确认容器内已安装 GmSSL,或通过 GMSSL_BIN 环境变量指定路径"
    exit 1
fi

# 1. 创建密钥目录
echo "创建密钥目录: $KEY_DIR"
mkdir -p "$KEY_DIR"

# 2. 生成 SM4 密钥和 IV (16字节 -> 32位十六进制)
echo "生成 SM4 密钥和 IV..."
GM_SM4_KEY=$(openssl rand -hex 16)
GM_SM4_IV=$(openssl rand -hex 16)

# 3. 生成 SM2 密钥对(口令加密私钥,公钥不加密)
echo "生成 SM2 密钥对..."
"$GMSSL_BIN" sm2keygen \
    -pass "$SM2_PASS" \
    -out "$KEY_DIR/sm2_private.pem" \
    -pubout "$KEY_DIR/sm2_public.pem"

# 4. 验证文件是否生成成功
if [ ! -f "$KEY_DIR/sm2_private.pem" ] || [ ! -f "$KEY_DIR/sm2_public.pem" ]; then
    echo "错误: SM2 密钥文件生成失败"
    exit 1
fi

# 5. 设置文件权限(安全:私钥仅属主可读写)
chmod 600 "$KEY_DIR/sm2_private.pem"
chmod 644 "$KEY_DIR/sm2_public.pem"

# 6. 输出结果(.env 中填相对于 storage/app 的路径,由 PHP 端 storage_path() 解析,避免依赖运行时工作目录)
echo ""
echo "=========================================="
echo "✅ 密钥生成完成!"
echo "=========================================="
echo ""
echo "📁 密钥文件位置:"
echo "  加密私钥: $KEY_DIR/sm2_private.pem"
echo "  公钥:     $KEY_DIR/sm2_public.pem"
echo ""
echo "📝 请将以下内容添加到 .env 文件:"
echo "----------------------------------------"
echo "GM_SM4_KEY=$GM_SM4_KEY"
echo "GM_SM4_IV=$GM_SM4_IV"
echo "GM_SM2_PRIV_KEY_FILE=gmssl/sm2_private.pem"
echo "GM_SM2_PUB_KEY_FILE=gmssl/sm2_public.pem"
echo "GM_SM2_PRIV_PASS=$SM2_PASS"
echo "----------------------------------------"
echo ""
echo "⚠️  安全提示:"
echo "  1. 请妥善保管 sm2_private.pem 及 GM_SM2_PRIV_PASS 口令,丢失后已加密数据无法恢复"
echo "  2. 不要将密钥文件和 .env 提交到 Git 仓库"
echo "  3. 生产环境请使用更强的口令,并通过密钥管理服务分发"
echo "=========================================="

# 7. 确保密钥目录被 Git 忽略
if [ -f ".gitignore" ]; then
    if ! grep -q "storage/app/gmssl" .gitignore 2>/dev/null; then
        echo "" >> .gitignore
        echo "# GMSSL 密钥文件" >> .gitignore
        echo "/storage/app/gmssl/" >> .gitignore
        echo "已添加 /storage/app/gmssl/ 到 .gitignore"
    fi
fi

echo ""
echo "🎉 完成!"

在容器内,执行 sh ./gmssl.sh 来生成上边说的这些。将上边对应的结果添加的 .env 文件中。记得在 .env.example 中增加对应的配置项。

  1. 增加 config/gm.php,填入下边的内容。
<?php

return [
    // SM4-CBC 密钥与IV:16字节原始密钥的32位hex字符串(由 gmssl.sh 生成)
    'sm4_key' => env('GM_SM4_KEY'),
    'sm4_iv'  => env('GM_SM4_IV'),

    // SM2 密钥文件路径:支持绝对路径;相对路径基于 storage/app 解析
    // 例如填 gmssl/sm2_private.pem 即 storage/app/gmssl/sm2_private.pem
    'sm2_priv_key_file' => env('GM_SM2_PRIV_KEY_FILE'),
    'sm2_pub_key_file'  => env('GM_SM2_PUB_KEY_FILE'),

    // SM2 加密私钥的口令(gmssl sm2keygen -pass 所用密码)
    'sm2_priv_pass' => env('GM_SM2_PRIV_PASS'),

    // SM2 签名/验签使用的签名者ID(与对接方约定一致,国密默认值为 1234567812345678)
    'sm2_id' => env('GM_SM2_ID', '1234567812345678'),
];
  1. 增加工具类
<?php

namespace App\Utils;

use RuntimeException;

/**
 * 国密算法工具类(SM2 / SM3 / SM4)
 *
 * 基于 gmssl PHP 扩展(https://github.com/GmSSL/GmSSL-PHP,依赖 GmSSL C 库 >= 3.1.0)
 *
 * 注意:官方扩展的函数签名与本类早期写法差异较大,以下为官方 API:
 * - gmssl_sm4_cbc_encrypt($key, $iv, $data) / gmssl_sm4_cbc_decrypt($key, $iv, $ciphertext)
 * - gmssl_sm2_sign($keypair, $id, $message),签名固定为 ASN.1 DER 编码
 * - gmssl_sm2_verify($public_key, $id, $message, $signature)
 * - gmssl_sm2_encrypt($public_key, $data) / gmssl_sm2_decrypt($keypair, $ciphertext)
 * - SM2 密钥对象必须通过文件路径加载:
 *   gmssl_sm2_private_key_info_decrypt_from_pem($file, $passphrase)
 *   gmssl_sm2_public_key_info_from_pem($file)
 */
class GmUtils
{
    /** SM4 二进制密钥(16字节),进程内缓存 */
    protected static ?string $sm4Key = null;

    /** SM4 二进制IV(16字节),进程内缓存 */
    protected static ?string $sm4Iv = null;

    /** SM2 私钥对象(96字节,含口令解密结果),进程内缓存 */
    protected static ?string $sm2PrivKey = null;

    /** SM2 公钥对象(96字节),进程内缓存 */
    protected static ?string $sm2PubKey = null;

    /**
     * SM3 摘要
     * @param string $data 原始数据
     * @return string 小写hex摘要(64位)
     */
    public static function sm3(string $data): string
    {
        return bin2hex(gmssl_sm3($data));
    }

    /**
     * SM4‑CBC 加密(带PKCS填充)
     * @param string $plainText 明文
     * @param string|null $keyHex 自定义密钥(32位hex),不传则用 GM_SM4_KEY
     * @param string|null $ivHex 自定义IV(32位hex),不传则用 GM_SM4_IV
     * @return string base64密文(适合http/json传输)
     */
    public static function sm4Encrypt(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
    {
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return base64_encode(gmssl_sm4_cbc_encrypt($key, $iv, $plainText));
    }

    /**
     * SM4‑CBC 解密
     * @param string $base64Cipher base64密文
     * @param string|null $keyHex 自定义密钥(32位hex),不传则用 GM_SM4_KEY
     * @param string|null $ivHex 自定义IV(32位hex),不传则用 GM_SM4_IV
     * @return string 原始明文
     */
    public static function sm4Decrypt(string $base64Cipher, ?string $keyHex = null, ?string $ivHex = null): string
    {
        $cipher = base64_decode($base64Cipher, true);
        if ($cipher === false) {
            throw new RuntimeException('SM4解密失败: 密文不是合法的base64');
        }
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return gmssl_sm4_cbc_decrypt($key, $iv, $cipher);
    }

    /**
     * SM4‑CBC 加密,返回hex密文(与小程序/前端 JS 库如 sm-crypto 对接用)
     */
    public static function sm4EncryptHex(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
    {
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return bin2hex(gmssl_sm4_cbc_encrypt($key, $iv, $plainText));
    }

    /**
     * SM4‑CBC 解密,入参为hex密文(与小程序/前端 JS 库如 sm-crypto 对接用)
     */
    public static function sm4DecryptHex(string $hexCipher, ?string $keyHex = null, ?string $ivHex = null): string
    {
        $hex = trim($hexCipher);
        if ($hex === '' || strlen($hex) % 2 !== 0 || !ctype_xdigit($hex)) {
            throw new RuntimeException('SM4解密失败: 密文不是合法的hex字符串');
        }
        [$key, $iv] = self::sm4Keys($keyHex, $ivHex);
        return gmssl_sm4_cbc_decrypt($key, $iv, hex2bin($hex));
    }

    /**
     * SM2签名(SM3withSM2),返回 base64 的 ASN.1 DER 签名
     * @param string $data 待签名原文
     * @param string|null $id 签名者ID,默认取 GM_SM2_ID 配置(1234567812345678)
     * @return string base64签名
     */
    public static function sm2Sign(string $data, ?string $id = null): string
    {
        $sig = gmssl_sm2_sign(self::getSm2PrivKey(), self::sm2Id($id), $data);
        return base64_encode($sig);
    }

    /**
     * SM2验签
     * @param string $data 原文
     * @param string $base64Sign base64编码的DER签名
     * @param string|null $id 签名者ID,需与签名时一致
     * @return bool
     */
    public static function sm2Verify(string $data, string $base64Sign, ?string $id = null): bool
    {
        $sig = base64_decode($base64Sign, true);
        if ($sig === false || $sig === '') {
            return false;
        }
        return gmssl_sm2_verify(self::getSm2PubKey(), self::sm2Id($id), $data, $sig);
    }

    /**
     * SM2公钥加密,返回base64(密文为ASN.1 DER,内部C1C3C2)
     * @param string $plain 明文(SM2加密适用于短数据/密钥材料保护)
     * @return string base64密文
     */
    public static function sm2Encrypt(string $plain): string
    {
        $cipher = gmssl_sm2_encrypt(self::getSm2PubKey(), $plain);
        return base64_encode($cipher);
    }

    /**
     * SM2私钥解密
     * @param string $base64Cipher base64密文
     * @return string 明文
     */
    public static function sm2Decrypt(string $base64Cipher): string
    {
        $cipher = base64_decode($base64Cipher, true);
        if ($cipher === false || $cipher === '') {
            throw new RuntimeException('SM2解密失败: 密文不是合法的base64');
        }
        return gmssl_sm2_decrypt(self::getSm2PrivKey(), $cipher);
    }

    /**
     * SM2解密兼容方法:解密第三方客户端(小程序/前端 sm-crypto 等)传来的裸 C1C3C2 hex 密文
     * 密文布局:[04] || C1(X 32字节 || Y 32字节) || C3(32字节SM3摘要) || C2
     * @param string $hexCipher hex密文,带不带 04 前缀均可,要求客户端使用 C1C3C2 顺序
     * @return string 明文
     */
    public static function sm2DecryptHex(string $hexCipher): string
    {
        $hex = trim($hexCipher);
        if ($hex === '' || strlen($hex) % 2 !== 0 || !ctype_xdigit($hex)) {
            throw new RuntimeException('SM2解密失败: 密文不是合法的hex字符串');
        }
        $bin = hex2bin($hex);
        // 兼容带 04 非压缩点前缀的写法
        if ($bin[0] === "\x04") {
            $bin = substr($bin, 1);
        }
        if (strlen($bin) < 97) {
            throw new RuntimeException('SM2解密失败: 密文长度不足,请确认客户端使用 C1C3C2 顺序');
        }
        $x  = substr($bin, 0, 32);
        $y  = substr($bin, 32, 32);
        $c3 = substr($bin, 64, 32);
        $c2 = substr($bin, 96);

        // gmssl 扩展只接受 ASN.1 DER 密文,需把裸密文组装成:
        // SEQUENCE { xCoordinate INTEGER, yCoordinate INTEGER, hash OCTET STRING, cipherText OCTET STRING }
        $body = self::derInt($x) . self::derInt($y) . self::derOctetString($c3) . self::derOctetString($c2);
        $der  = "\x30" . self::derLength(strlen($body)) . $body;
        return gmssl_sm2_decrypt(self::getSm2PrivKey(), $der);
    }

    /**
     * 导出SM2公钥为非压缩点hex(04||X||Y,130位字符),用于下发给客户端 JS 库(如 sm-crypto)
     * @return string
     */
    public static function sm2PubKeyHex(): string
    {
        $pub = self::getSm2PubKey();
        // 公钥对象为96字节,前64字节即 X||Y,后32字节为零填充的私钥位,不取
        return '04' . bin2hex(substr($pub, 0, 64));
    }

    /**
     * 从SM2私钥导出公钥PEM(公钥丢失时可随时由私钥重新导出,密钥对不变)
     * @param string $targetFile 目标公钥PEM路径
     * @return string 导出的公钥非压缩点hex(04||X||Y,130位)
     */
    public static function exportSm2PubPem(string $targetFile): string
    {
        $keypair = self::getSm2PrivKey();
        if (gmssl_sm2_public_key_info_to_pem($keypair, $targetFile) !== true) {
            throw new RuntimeException('SM2公钥导出失败: 请检查目标路径是否可写');
        }
        // 私钥对象前64字节即公钥点 X||Y,直接推导,无需再读公钥文件
        return '04' . bin2hex(substr($keypair, 0, 64));
    }

    /**
     * 解析 SM4 密钥/IV:未传自定义值时用配置默认值,传了则现场 hex 解码(不走缓存)
     * @return array [二进制key, 二进制iv]
     */
    protected static function sm4Keys(?string $keyHex, ?string $ivHex): array
    {
        $key = $keyHex === null ? self::getSm4Key() : self::hexToBin($keyHex, 'SM4自定义密钥');
        $iv  = $ivHex === null ? self::getSm4Iv() : self::hexToBin($ivHex, 'SM4自定义IV');
        return [$key, $iv];
    }

    /**
     * 获取SM4二进制key(16字节)
     */
    protected static function getSm4Key(): string
    {
        if (self::$sm4Key === null) {
            self::$sm4Key = self::hexToBin((string) config('gm.sm4_key'), 'GM_SM4_KEY');
        }
        return self::$sm4Key;
    }

    /**
     * 获取SM4二进制iv(16字节)
     */
    protected static function getSm4Iv(): string
    {
        if (self::$sm4Iv === null) {
            self::$sm4Iv = self::hexToBin((string) config('gm.sm4_iv'), 'GM_SM4_IV');
        }
        return self::$sm4Iv;
    }

    /**
     * 加载SM2私钥对象(从口令加密的PEM文件解密导入)
     */
    protected static function getSm2PrivKey(): string
    {
        if (self::$sm2PrivKey === null) {
            $file = self::resolveKeyPath((string) config('gm.sm2_priv_key_file'), 'GM_SM2_PRIV_KEY_FILE');
            $pass = (string) config('gm.sm2_priv_pass');
            if ($pass === '') {
                throw new RuntimeException('国密配置错误: 未配置 GM_SM2_PRIV_PASS(SM2私钥口令)');
            }
            self::$sm2PrivKey = gmssl_sm2_private_key_info_decrypt_from_pem($file, $pass);
        }
        return self::$sm2PrivKey;
    }

    /**
     * 加载SM2公钥对象
     */
    protected static function getSm2PubKey(): string
    {
        if (self::$sm2PubKey === null) {
            $file = self::resolveKeyPath((string) config('gm.sm2_pub_key_file'), 'GM_SM2_PUB_KEY_FILE');
            self::$sm2PubKey = gmssl_sm2_public_key_info_from_pem($file);
        }
        return self::$sm2PubKey;
    }

    /**
     * 解析密钥文件路径:绝对路径直接使用,相对路径基于 storage/app 解析
     */
    protected static function resolveKeyPath(string $path, string $envName): string
    {
        $path = trim($path);
        if ($path === '') {
            throw new RuntimeException("国密配置错误: 未配置 {$envName}");
        }
        if (!str_starts_with($path, '/') && !preg_match('/^[A-Za-z]:[\\\\\/]/', $path)) {
            $path = storage_path('app/' . ltrim($path, '/'));
        }
        if (!is_file($path)) {
            throw new RuntimeException("国密配置错误: {$envName} 指向的文件不存在: {$path}");
        }
        return $path;
    }

    /**
     * hex字符串转二进制,并校验长度为16字节
     */
    protected static function hexToBin(string $hex, string $envName): string
    {
        $hex = trim($hex);
        if ($hex === '' || !ctype_xdigit($hex) || strlen($hex) % 2 !== 0) {
            throw new RuntimeException("国密配置错误: {$envName} 必须是合法的hex字符串");
        }
        $bin = hex2bin($hex);
        if (strlen($bin) !== 16) {
            throw new RuntimeException("国密配置错误: {$envName} 必须是16字节(32位hex)");
        }
        return $bin;
    }

    /**
     * SM2签名/验签使用的签名者ID
     */
    protected static function sm2Id(?string $id): string
    {
        if ($id !== null && $id !== '') {
            return $id;
        }
        $configId = (string) config('gm.sm2_id', '');
        return $configId !== '' ? $configId : GMSSL_SM2_DEFAULT_ID;
    }

    /**
     * DER 长度编码
     */
    protected static function derLength(int $len): string
    {
        if ($len < 0x80) {
            return chr($len);
        }
        $bytes = '';
        while ($len > 0) {
            $bytes = chr($len & 0xFF) . $bytes;
            $len >>= 8;
        }
        return chr(0x80 | strlen($bytes)) . $bytes;
    }

    /**
     * DER INTEGER 编码(去前导零,高位为1时补 0x00)
     */
    protected static function derInt(string $bin): string
    {
        $bin = ltrim($bin, "\x00");
        if ($bin === '') {
            $bin = "\x00";
        }
        if (ord($bin[0]) & 0x80) {
            $bin = "\x00" . $bin;
        }
        return "\x02" . self::derLength(strlen($bin)) . $bin;
    }

    /**
     * DER OCTET STRING 编码
     */
    protected static function derOctetString(string $bin): string
    {
        return "\x04" . self::derLength(strlen($bin)) . $bin;
    }
}
  1. 在全局辅助函数 helpers.php 中增加函数。
use App\Utils\GmUtils;

/**
 * SM2签名,返回base64 der签名
 */
function sm2_sign(string $data): string
{
    return GmUtils::sm2Sign($data);
}

/**
 * SM2验签
 */
function sm2_verify(string $data, string $base64Sign): bool
{
    return GmUtils::sm2Verify($data, $base64Sign);
}

/**
 * SM2公钥加密 返回base64密文
 */
function sm2_encrypt(string $plain): string
{
    return GmUtils::sm2Encrypt($plain);
}

/**
 * SM2私钥解密
 */
function sm2_decrypt(string $base64Cipher): string
{
    return GmUtils::sm2Decrypt($base64Cipher);
}

/**
 * SM2解密:客户端(小程序/前端 sm-crypto)传来的裸 C1C3C2 hex 密文
 */
function sm2_decrypt_hex(string $hexCipher): string
{
    return GmUtils::sm2DecryptHex($hexCipher);
}

/**
 * SM3哈希摘要 返回小写hex
 */
function sm3_hash(string $data): string
{
    return GmUtils::sm3($data);
}

/**
 * SM4‑CBC加密 返回base64密文;可选自定义密钥/IV(32位hex),不传用配置默认值
 */
function sm4_encrypt(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4Encrypt($plainText, $keyHex, $ivHex);
}

/**
 * SM4‑CBC解密;可选自定义密钥/IV(32位hex),不传用配置默认值
 */
function sm4_decrypt(string $base64Cipher, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4Decrypt($base64Cipher, $keyHex, $ivHex);
}

/**
 * SM4‑CBC加密 返回hex密文(与小程序/前端对接用)
 */
function sm4_encrypt_hex(string $plainText, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4EncryptHex($plainText, $keyHex, $ivHex);
}

/**
 * SM4‑CBC解密 入参为hex密文(与小程序/前端对接用)
 */
function sm4_decrypt_hex(string $hexCipher, ?string $keyHex = null, ?string $ivHex = null): string
{
    return GmUtils::sm4DecryptHex($hexCipher, $keyHex, $ivHex);
}
  1. 到此,算是都准备好了。进行测试和具体的业务使用了。
# 在容器中,laravel 项目根目录,执行下边的命令,测试一下

php artisan tinker --execute="var_dump(sm4_decrypt(sm4_encrypt('国密测试')), sm2_verify('hello', sm2_sign('hello')), sm2_decrypt(sm2_encrypt('hello')), sm3_hash('abc'));"

# 输出
string(12) "国密测试"
bool(true)
string(5) "hello"
string(64) "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"
  1. 其他端,要使用非对称加密 sm2 的时候,可以把公钥给过去,签名者ID 约定好就可以。其他的东西不要给过去。

sm2 只能前端加密,后端加密。没办法反着来。如果想要双向的,只能结合 sm4。

  • 前端随机生成一个 16 字节 SM4 密钥(hex)
  • 用 SM2 公钥加密这个 SM4 密钥,随请求上传(服务端 sm2_decrypt_hex 解开)
  • 之后双方用这个 SM4 密钥走 GM_SM4_KEY/IV 相同模式的 CBC 加解密(服务端已有 sm4_encrypt/sm4_decrypt)

阅读全文 >>

8月 24

有些时候,删除和修改的文件都很多。但是只想提交删除的文件,就得只把删除的文件 add 进去。

# win powershell
git ls-files --deleted | ForEach-Object { git add $_ }

# linux
git ls-files --deleted -z | xargs -0 git add
  • git ls-files --deleted:列出所有已删除但未暂存的文件。

  • -z 和 xargs -0:处理文件名中的空格问题。

  • 这条命令会把所有删除状态的文件一次性加入暂存区。

阅读全文 >>

8月 19

为了让 git 版本控制和 goframe 的配置文件能和谐相处,引入了 gomplate。 git 只对模板进行控制,敏感数据只在本地。

https://github.com/hairyhenderson/gomplate

创建

  1. 初始化项目
gf init -u test

cd test 

gf up -a
  1. 创建 manifest/config/config.template, 填入以下配置
# https://goframe.org/docs/web/server-config-file-template
server:
  address: "{{(ds "env").SERVER_PORT}}"
  openapiPath: "/api.json"
  swaggerPath: "/swagger"

# https://goframe.org/docs/core/glog-config
logger:
  path: "storage/logs/daily" # 日志文件路径。默认为空,表示关闭,仅输出到终端
  file: "{Y-m-d}.log" # 日志文件格式。默认为"{Y-m-d}.log"
  prefix: "" # 日志内容输出前缀。默认为空
  level: "all" # 日志输出级别
  timeFormat: "2006-01-02T15:04:05" # 自定义日志输出的时间格式,使用Golang标准的时间格式配置
  ctxKeys: [] # 自定义Context上下文变量名称,自动打印Context的变量到日志中。默认为空
  header: true # 是否打印日志的头信息。默认true
  stdout: true # 日志是否同时输出到终端。默认true
  rotateSize: 0 # 按照日志文件大小对文件进行滚动切分。默认为0,表示关闭滚动切分特性
  rotateExpire: 0 # 按照日志文件时间间隔对文件滚动切分。默认为0,表示关闭滚动切分特性
  rotateBackupLimit: 0 # 按照切分的文件数量清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
  rotateBackupExpire: 0 # 按照切分的文件有效期清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
  rotateBackupCompress: 0 # 滚动切分文件的压缩比(0-9)。默认为0,表示不压缩
  rotateCheckInterval: "1h" # 滚动切分的时间检测间隔,一般不需要设置。默认为1小时
  stdoutColorDisabled: false # 关闭终端的颜色打印。默认开启
  writerColorEnable: false # 日志文件是否带上颜色。默认false,表示不带颜色
  error:
    path: "storage/logs/error/"
    file: "{Y-m-d}.log"
    stdout: true
  test:
    path: "storage/logs/test"
    file: "{Y-m-d}.log"
    stdout: true

plantform:
  tencent:
    secretId: {{(ds "env").TENCENT_SECRETID}}
    secretKey: {{(ds "env").TENCENT_SECRETKEY}}
  1. 在项目根目录下创建 .env.dev.env.prod.env.example 这三个文件。仅 .env.example 添加到版本控制中。内容如下。其他文件根据示例文件,按场景需要配置。
# 端口号
SERVER_PORT=:8090

# 平台信息
TENCENT_SECRETID=
TENCENT_SECRETKEY=
  1. 创建 hack/config.mk 文件。内容如下:
# 环境: dev / prod
# https://docs.gomplate.ca/
# https://github.com/hairyhenderson/gomplate
ENV ?= dev
ENV_FILE := .env.$(ENV)
TPL_SRC  := manifest/config/config.template
TPL_OUT  := manifest/config/config.yaml

.PHONY: config
config:
    @echo ----------------------------------------
    @echo create config template using $(ENV_FILE)
    @echo ----------------------------------------
    @echo. 
    gomplate -d env=$(ENV_FILE)?type=application/x-env -f $(TPL_SRC) -o $(TPL_OUT)
    @echo.
    @echo Config created: $(TPL_OUT)
    @echo.
  1. 在根目录下的 Makefile 文件追加配置。
…
include ./hack/config.mk
  1. 添加 git 版本控制
git init -b main

# 在 .gitignore 中增加忽略文件
.env.dev
.env.prod

# 添加配置
git add .
git commit -m 'initialize'

使用

# 生成测试环境配置
make config

# 生成正式服环境配置
make config ENV=prod

使用上边命令就可以在 manifest/config/ 下生成对应的配置文件。

阅读全文 >>

8月 17

不从零搞双因素认证(2FA),而是依靠 goauthentik 来搞。

官网:https://docs.goauthentik.io/install-config/install/docker-compose/

开始

  1. 官网已经为你准备好了 compose.yaml 文件,我们把它下载下来。
mkdir goauthentik-server

cd goauthentik-server

wget https://docs.goauthentik.io/compose.yml

# 还是喜欢用 docker-compose.yaml 作为文件名
mv compose.yml docker-compose.yaml
  1. 准备 .env 文件。
    生成 .env 文件,根据 docker-compose.yaml 和业务需要,设置 .env 的内容。
vim .env

# ==============================================
# Authentik 数据库配置 (PostgreSQL)
# ==============================================

# PostgreSQL 数据库名
PG_DB: authentik
# PostgreSQL 数据库账号
PG_USER: authentik
# PostgreSQL 数据库密码,【必须修改,不能为空】
PG_PASS: fa20260817

# ==============================================
# Authentik 核心密钥【生产务必重新生成!】
# ==============================================
# 生成命令: openssl rand -hex 16
# 这个密钥加密数据库内TOTP密钥、会话Cookie;丢失则现有用户MFA设备全部无法解密,需要全部重置MFA
AUTHENTIK_SECRET_KEY=94b3f57608c0392e417e21ec721e41dc

# ==============================================
# 镜像版本配置
# ==============================================
# Authentik镜像地址,默认官方ghcr镜像
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
# Authentik版本标签,和compose里保持一致,升级时修改此处
AUTHENTIK_TAG=2026.5.6

# ==============================================
# 端口映射配置(宿主机端口)
# ==============================================
# HTTP端口,生产环境建议不暴露,只用HTTPS
COMPOSE_PORT_HTTP=9000
# HTTPS访问端口
COMPOSE_PORT_HTTPS=9443
  1. 运行 docker compose up -d

阅读全文 >>

8月 04

即使如此,也只能在局域网使用。如果想广域网使用,还得拆分。将 srs 服务端部署到广域网,局域网拉取其他 rtmp 流,推送到服务端。广域网才能使用。 项目需要,还得将这个直播功能在其他广域网 web 中使用。

阅读全文 >>

8月 04

现在更狠,要求服务端拉前端的流,还不告诉地址。还得让服务端通过前端广播的 udp 消息来确定拉流地址,然后进行推流处理。

修改后,代码结构如下:

pico rtmp 推流/
├── .env                          # 部署配置(Pico 地址 + CANDIDATE)
├── .gitignore                    ← 新增:忽略缓存/IDE/OS 残留
├── docker-compose.yaml           # srs + puller + controller 三服务
├── index.html                    # H5 播放页
├── srs.conf                      # SRS 配置
└── src/controller/
    ├── controller.py             # UDP 监听 + 健康检查 + 写共享地址
    ├── puller-wrapper.sh         # 监控地址变化、重启 ffmpeg
    ├── start-controller.ps1      # 原生启动脚本(备用)
    └── test-broadcast.py         # 广播模拟测试工具

阅读全文 >>

8月 04

现在是客户端不推流到服务端,反而是服务端拉取客户端的流,再分发出去。

https://blog.vini123.com/1414

上边这个链接客户端推流模式,这个简单,实用。但有时候,却说推不了流,只能被动拉流。 这个会增加复杂度,使用范围变成了局域网。

开始

文件目录:

├── .env                 # 配置文件
├── srs.conf             # SRS 5.x:RTMP 接流 + HLS/HTTP-FLV/WebRTC 三协议输出
├── docker-compose.yaml  # 一键启动,CANDIDATE 环境变量注入 WebRTC 候选 IP
└── index.html           # H5 播放页:WebRTC 优先 → HLS 兜底,断线自动重试

阅读全文 >>

8月 04

客户端向服务端推流,前端 h5 播放流。

https://ossrs.io/lts/zh-cn/

SRS(Simple Realtime Server)是一个简单高效的实时视频服务器,支持RTMP、WebRTC、HLS、HTTP-FLV、SRT等多种实时流媒体协议。Oryx是一个一体化、开箱即用、开源的视频解决方案,可部署在云上或自建机房,以直播和WebRTC等能力赋能你的业务。

阅读全文 >>

8月 03

php artisan tinker; 使用时,报错 Error Class "Psy\Readline\Interactive\Helper\TokenHelper" not found. 处理。

https://packagist.org/packages/psy/psysh

psy 版本:Psy Shell v0.12.24

root@a2129439b046:/www/tcrj.laravel# php artisan tinker;
Psy Shell v0.12.24 (PHP 8.5.5 — cli) by Justin Hileman
New PHP manual is available (latest: 3.1.1). Update with `doc --update-manual`

tinker 版本:

root@a2129439b046:/www/tcrj.laravel# composer show laravel/tinker
name : laravel/tinker
descrip. : Powerful REPL for the Laravel framework.
keywords : REPL, Tinker, laravel, psysh
versions : * v3.0.2
released : 2026-03-17, 4 months ago
type : library
license : MIT License (MIT) (OSI approved) https://spdx.org/licenses/MIT.html#licenseText
homepage :
source : [git] https://github.com/laravel/tinker.git 4faba77764bd33411735936acdf30446d058c78b
dist : [zip] https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b 4faba77764bd33411735936acdf30446d058c78b
path : /www/tcrj.laravel/vendor/laravel/tinker
names : laravel/tinker

进入 vendor 中,确实没看到 TokenHelper 这个类。手动去官网复制一份下来。
https://github.com/bobthecow/psysh/blob/main/src/Readline/Interactive/Helper/TokenHelper.php

进入到 vendor 目录,创建 TokenHelper.php 文件,复制代码进去。

<?php

/*
 * This file is part of Psy Shell.
 *
 * (c) 2012-2026 Justin Hileman
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Psy\Readline\Interactive\Helper;

/**
 * Shared token analysis utilities.
 */
class TokenHelper
{
    /** @var int[] Token types that indicate a statement continues on the next line */
    private const TRAILING_TOKEN_OPS = [
        \T_OBJECT_OPERATOR, \T_PAAMAYIM_NEKUDOTAYIM,
        \T_BOOLEAN_AND, \T_BOOLEAN_OR, \T_LOGICAL_AND, \T_LOGICAL_OR,
        \T_DOUBLE_ARROW, \T_COALESCE, \T_SPACESHIP,
    ];

    /** @var string[] Single-character operators that indicate continuation */
    private const TRAILING_CHAR_OPS = ['+', '-', '*', '/', '%', '.', '=', '&', '|', '^', '<', '>', ','];

    /**
     * Check whether the last non-whitespace token is a trailing operator.
     *
     * Used to determine whether a statement continues onto the next line.
     *
     * @param array $tokens token_get_all tokens
     */
    public static function hasTrailingOperator(array $tokens): bool
    {
        if (empty($tokens)) {
            return false;
        }

        $lastToken = null;
        for ($i = \count($tokens) - 1; $i >= 0; $i--) {
            $token = $tokens[$i];
            if (\is_array($token) && $token[0] === \T_WHITESPACE) {
                continue;
            }

            $lastToken = $token;
            break;
        }

        if ($lastToken === null) {
            return false;
        }

        if (\is_array($lastToken)) {
            return \in_array($lastToken[0], self::TRAILING_TOKEN_OPS, true);
        }

        return \in_array($lastToken, self::TRAILING_CHAR_OPS, true);
    }
}

再重启 composer。

composer dump-autoload -o

再使用 tinker 就可以了。

阅读全文 >>

7月 28

现在有这样一个需求。本地推送前端代码到 codeberg 后,服务端拉取代码,打包后,放到对应的位置。

服务器服务,全是由 docker 构建,文件复制移动全靠 volume 。

构建 api 接口服务

新建 webhook-server 目录,里边有下边几个文件。

.
├── Dockerfile
├── package.json
└── server.js

Dockerfile

FROM node:latest

WORKDIR /app

# 新增:安装docker客户端
RUN apt-get update && apt-get install -y ca-certificates curl \
    && curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/static/stable/x86_64/docker-27.3.1.tgz \
    | tar -xz -C /usr/local/bin --strip-components=1 docker/docker \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

COPY package.json ./
RUN npm install --omit=dev

COPY server.js ./

EXPOSE 9000
CMD ["node", "server.js"]

package.json

{
  "name": "webhook-server",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "express": "^5.0"
  }
}

server.js

/**
 * Webhook 接收服务
 *
 * 接收 Codeberg (Gitea) 的 push 通知,验证签名后触发 deploy.sh
 */
const express = require('express');
const crypto = require('crypto');
const { spawn } = require('child_process');

const PORT = parseInt(process.env.WEBHOOK_PORT || '9000', 10);
const SECRET = process.env.WEBHOOK_SECRET || '';
const DEPLOY_SCRIPT = process.env.DEPLOY_SCRIPT || '/opt/frontend-cicd/deploy.sh';
const TARGET_BRANCH = `refs/heads/${process.env.BRANCH || 'main'}`;

if (!SECRET || SECRET === 'CHANGE_ME') {
  console.error('[ERROR] 请先设置 WEBHOOK_SECRET');
  process.exit(1);
}

const app = express();
app.use('/webhook', express.raw({ type: 'application/json' }));

app.post('/webhook/deploy', (req, res) => {
  const body = req.body.toString('utf8');
  const sig = req.headers['x-gitea-signature'] || '';
  const event = req.headers['x-gitea-event'] || '';

  // 验证 HMAC-SHA256 签名
  const expected = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
  if (sig !== expected) {
    console.warn(`[${ts()}] 签名验证失败`);
    return res.status(403).json({ error: 'Invalid signature' });
  }

  if (event !== 'push') return res.json({ msg: `忽略事件: ${event}` });

  let payload;
  try { payload = JSON.parse(body); } catch { return res.status(400).end(); }

  const ref = payload.ref || '';
  if (ref !== TARGET_BRANCH) return res.json({ msg: `忽略分支: ${ref}` });

  const pusher = payload.pusher?.login || '?';
  const commits = (payload.commits || []).length;
  console.log(`[${ts()}] ${pusher} 推送了 ${commits} 个提交到 ${ref},开始部署...`);

  // 先返回 200,避免 Codeberg 超时
  res.json({ msg: 'deploy triggered' });

        console.log(`[${ts()}] 开始执行部署脚本: ${DEPLOY_SCRIPT}`);
        const child = spawn('bash', [DEPLOY_SCRIPT], { timeout: 600000 });

        child.stdout.on('data', (data) => {
          console.log(`[${ts()}] [部署stdout] ${data.toString()}`);
        });
        child.stderr.on('data', (data) => {
          console.error(`[${ts()}] [部署stderr] ${data.toString()}`);
        });

        child.on('close', (code) => {
          if (code === 0) {
            console.log(`[${ts()}] 部署完成,退出码:${code}`);
          } else {
            console.error(`[${ts()}] 部署失败,脚本退出码:${code}`);
          }
        });

        child.on('error', (err) => {
          console.error(`[${ts()}] 启动脚本异常:`, err);
        });
});

app.get('/health', (_, res) => {
  res.json({ ok: true, uptime: process.uptime(), time: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Webhook 服务已启动,端口 ${PORT}`);
  console.log(`  POST /webhook/deploy  (分支: ${TARGET_BRANCH})`);
  console.log(`  GET  /health`);
});

function ts() { return new Date().toISOString(); }

运行 api 接口服务

这里是完整的目录和文件。

.
├── deploy.sh
├── docker-compose.yml
├── setup.sh
└── webhook-server
    ├── Dockerfile
    ├── package.json
    └── server.js

deploy.sh

#!/bin/bash
# ============================================================
# 自动部署脚本 —— 兼容 webhook docker 容器运行
# ============================================================
set -euo pipefail

# ======================== 配置(修改路径!) ========================
PROJECT_DIR="/alidata/code/editor.manhuariji.com"
DEPLOY_DIR="/alidata/www/editor.manhuariji.com/public"
DOCKER_IMAGE="xr-node:20"
CONTAINER_NAME="editor-mhrj"
BUILD_CMD="npm run build:h5"
BUILD_OUTPUT="dist"
DETAIL_OUTPUT="build/h5"
BRANCH="main"
# 【修改点1】日志放到容器内/app目录,拥有写入权限!
LOG_FILE="/app/editor-mhrj-deploy.log"
LOCK_FILE="/tmp/editor-mhrj-deploy.lock"
# =====================================================

# ---- 防并发 ----
if [ -f "$LOCK_FILE" ]; then
  echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] 有部署正在运行,跳过" >> "$LOG_FILE"
  exit 0
fi
touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT

# log函数:同时输出到日志文件 AND 控制台(调试关键!)
log() {
    local msg="$(date '+%Y-%m-%d %H:%M:%S') $1"
    echo "$msg" >> "$LOG_FILE"
    echo "$msg"   # 打印到终端,Node服务可以捕获
}

log "========================================"
log "开始部署"
log "========================================"

# ---- 1. Git 拉取代码 ----
log "[1/3] cd $PROJECT_DIR && git pull ..."
cd "$PROJECT_DIR"

PREV=$(git rev-parse --short HEAD 2>/dev/null || echo "none")

# 强制同步远端代码,丢弃本地所有修改(适合自动部署服务器)
log "[INFO] 重置本地代码,对齐远端origin/${BRANCH}"
git fetch origin "${BRANCH}" >> "$LOG_FILE" 2>&1
git reset --hard "origin/${BRANCH}" >> "$LOG_FILE" 2>&1

NEW=$(git rev-parse --short HEAD)

#if [ "$PREV" = "$NEW" ]; then
#   log "没有新提交 ($NEW),跳过构建"
#   exit 0
# fi

log "代码更新: $PREV -> $NEW"

# ---- 2. Docker 容器内构建 ----
# 新增:清理宿主机缓存,腾出内存
log "[INFO] 清理系统缓存释放内存"
sync; echo 3 > /proc/sys/vm/drop_caches

log "[2/3] Docker 容器构建 ($DOCKER_IMAGE) ..."
log "  npm install + $BUILD_CMD"

docker run --rm \
  --memory=2G \
  --memory-swap=3G \
  --name "$CONTAINER_NAME" \
  -v "${PROJECT_DIR}":/www \
  -w /www \
  "$DOCKER_IMAGE" \
  bash -c "
    npm config set registry https://registry.npmmirror.com
    npm install
    NODE_OPTIONS='--max-old-space-size=1400' npm run build:h5
  " >> "$LOG_FILE" 2>&1

if [ $? -ne 0 ]; then
  log "[ERROR] 构建失败!"
  exit 1
fi

if [ ! -d "${PROJECT_DIR}/${BUILD_OUTPUT}" ]; then
  log "[ERROR] 构建产物目录 '${BUILD_OUTPUT}' 不存在"
  exit 1
fi

log "构建完成,产物: ${PROJECT_DIR}/${BUILD_OUTPUT}"

# ---- 3. 部署 ----
log "[3/3] 部署到 $DEPLOY_DIR ..."

# 备份逻辑保留(可选)
#if [ -d "$DEPLOY_DIR" ] && [ "$(ls -A "$DEPLOY_DIR" 2>/dev/null)" ]; then
#  BACKUP="${DEPLOY_DIR}.backup"
#  rm -rf "$BACKUP"
#  mv "$DEPLOY_DIR" "$BACKUP"
#  log "旧版本备份到 $BACKUP"
#fi

mkdir -p "$DEPLOY_DIR"
SRC="${PROJECT_DIR}/${BUILD_OUTPUT}/${DETAIL_OUTPUT}"

# 使用 cp -r 源头目录/. 方案,完美规避所有通配符问题
if [ -d "$SRC" ]; then
  cp -r "${SRC}/." "${DEPLOY_DIR}/"
else
  log "[ERROR] 构建目录不存在: $SRC"
  exit 1
fi

log "========================================"
log "部署完成!($PREV -> $NEW)"
log "========================================"

# ---- 日志截断 ----
MAX_LINES=3000
if [ -f "$LOG_FILE" ]; then
  LINES=$(wc -l < "$LOG_FILE")
  if [ "$LINES" -gt "$MAX_LINES" ]; then
    tail -n "$MAX_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp"
    mv "${LOG_FILE}.tmp" "$LOG_FILE"
  fi
fi

bash.sh

#!/bin/bash
# ============================================================
# 一键初始化脚本
#
# 用法:
#   1. scp -r frontend-cicd root@服务器IP:/opt/
#   2. 编辑 /opt/frontend-cicd/.env(填仓库地址和 Secret)
#   3. bash /opt/frontend-cicd/setup.sh
# ============================================================
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/.env"

echo "=========================================="
echo " editor.manhuariji.com 自动部署初始化"
echo "=========================================="

# ---- 1. 检查环境 ----
echo ""
echo "[1/5] 检查环境 ..."
for cmd in git docker; do
  if ! command -v $cmd &>/dev/null; then
    echo "[ERROR] $cmd 未安装"
    exit 1
  fi
done
echo "  git:    $(git --version)"
echo "  docker: $(docker --version)"

if [ "$WEBHOOK_SECRET" = "CHANGE_ME" ]; then
  echo ""
  echo "[ERROR] 请先编辑 .env,把 WEBHOOK_SECRET 改成随机字符串"
  echo "  生成方法: openssl rand -hex 24"
  exit 1
fi

# ---- 2. 克隆项目 ----
echo ""
echo "[2/5] 项目代码 ..."
if [ -d "$PROJECT_DIR/.git" ]; then
  echo "  已存在,git pull ..."
  cd "$PROJECT_DIR" && git pull
else
  echo "  克隆 $REPO_URL ..."
  mkdir -p "$(dirname "$PROJECT_DIR")"
  git clone "$REPO_URL" "$PROJECT_DIR"
fi

# ---- 3. 首次构建验证 ----
echo ""
echo "[3/5] 首次构建验证 ..."
cd "$PROJECT_DIR"

docker run --rm \
  --name "$CONTAINER_NAME" \
  -v "${PROJECT_DIR}":/www \
  -w /www \
  "$DOCKER_IMAGE" \
  bash -c "npm install && $BUILD_CMD"

if [ -d "$BUILD_OUTPUT" ]; then
  mkdir -p "$(dirname "$DEPLOY_DIR")"
  rm -rf "$DEPLOY_DIR"
  mv "$BUILD_OUTPUT" "$DEPLOY_DIR"
  echo "  首次部署完成 -> $DEPLOY_DIR"
else
  echo "[WARN] 构建产物不存在,跳过部署"
fi

# ---- 4. 准备 Webhook 镜像依赖 ----
echo ""
echo "[4/5] 准备 Webhook 镜像 ..."
# 把宿主机的 docker CLI 复制到 webhook-server 目录,Dockerfile 会 COPY 进去
# 这样构建镜像时不需要拉任何外部镜像
cp "$(which docker)" "$SCRIPT_DIR/webhook-server/docker"
echo "  已复制 docker CLI -> webhook-server/docker"

# ---- 5. 启动 Webhook ----
echo ""
echo "[5/5] 启动 Webhook 服务 ..."
cd "$SCRIPT_DIR"
docker compose up -d --build

echo ""
echo "=========================================="
echo " 初始化完成!"
echo "=========================================="
echo ""
echo " Webhook: http://服务器IP:$WEBHOOK_PORT/webhook/deploy"
echo " Secret:  $WEBHOOK_SECRET"
echo ""
echo " 验证:"
echo "   curl http://localhost:$WEBHOOK_PORT/health"
echo "   docker logs editor-mhrj-webhook"
echo ""
echo " 下一步:"
echo "   1. 阿里云安全组放行 $WEBHOOK_PORT 端口"
echo "   2. Codeberg → Settings → Webhooks → Add Webhook (Gitea)"
echo "      URL:    http://你的服务器IP:$WEBHOOK_PORT/webhook/deploy"
echo "      Secret: $WEBHOOK_SECRET"
echo "      Event:  Push Events"
echo "   3. 本地 git push 验证"
echo ""

docker-compose.yaml

"docker-compose.yml" 25L, 851C                                                                                                                                                                                                              7,5          全部
services:
  webhook-server:
    build: ./webhook-server
    container_name: editor-mhrj-webhook
    restart: always
    privileged: true
    volumes:
      - ./webhook-server/server.js:/app/server.js
      # 挂载 docker.sock,让容器能调用宿主机的 docker 执行 deploy.sh
      - /var/run/docker.sock:/var/run/docker.sock
      - /root/.ssh:/root/.ssh:ro
      # 挂载部署脚本(只读)
      - ./deploy.sh:/app/deploy.sh:ro
      - /alidata/code/editor.manhuariji.com:/alidata/code/editor.manhuariji.com
      - /alidata/www/editor.manhuariji.com/public:/alidata/www/editor.manhuariji.com/public
    environment:
      - WEBHOOK_SECRET=${WEBHOOK_SECRET}
      - WEBHOOK_PORT=${WEBHOOK_PORT:-9000}
      - BRANCH=${BRANCH:-main}
      - DEPLOY_SCRIPT=/app/deploy.sh
    networks:
      - xrnet
networks:
  xrnet:
    external: true

.env

# ======================== 按需修改 ========================
# Codeberg 仓库 SSH 地址(仅 setup.sh 首次 clone 用,deploy.sh 走已有的 remote)
REPO_URL=git@codeberg.org:manhuariji/manhuariji.h5.git

# 项目在宿主机上的路径
PROJECT_DIR=/alidata/code/editor.manhuariji.com

# 部署目标(Nginx public 目录)
DEPLOY_DIR=/alidata/www/editor.manhuariji.com/public

# 构建用的 Docker 镜像(你自己的镜像)
DOCKER_IMAGE=xr-node:20

# 容器名
CONTAINER_NAME=editor-mhrj

# 构建命令
BUILD_CMD=npm run build:h5

# 构建产物目录名
BUILD_OUTPUT=dist

# 打包输出详情目录
DETAIL_OUTPUT=build/h5/*

# 部署分支
BRANCH=main

# ---- WEBHOOK SECRET(和 git 无关!)----
# 这是 Codeberg 和你的 webhook 服务之间的"暗号",用来验证 HTTP 请求确实来自 Codeberg。
# 防止别人往你的 webhook 地址随便发 POST 触发部署。
# 生成方法:openssl rand -hex 24
# 这个值要和 Codeberg Webhook 设置页填的 Secret 一致。
# WEBHOOK_SECRET=CHANGE_ME
WEBHOOK_SECRET=b85cb03fd42507f5f921ac4534e76155c0045aed49d0236a

# Webhook 监听端口
WEBHOOK_PORT=9000

运行起来: docker compose up -d

nginx 代理

    location /cicd/ {
        proxy_pass http://webhook-server:9000/; 

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host:$server_port;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Forwarded-Context /cicd;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_connect_timeout 60s;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }

    location = /cicd {
        return 301 /cicd/;
    }

然后通过域名就可以开始部署了: https://xxx.com/cicd/webhook/deploy

codeberg 配置 Forgejo

登录 codeberg,进入项目,点击设置,切换到 web钩子页签。再点击添加web钩子, 选择 Forgejo。开始进行配置。

密钥文件和服务器上的文件一致哈。

可以点击 测试推送进行测试,也可以主动推送代码进行测试。

阅读全文 >>