diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5aae79d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# ===== 版本控制 ===== +.git +.gitignore + +# ===== 文档与设计稿(不参与编译)===== +*.md +docs/ +design/ + +# ===== 本地运行产物(日志、上传临时文件)===== +storage/ + +# ===== 配置与密钥:配置在运行时挂载,禁止进入构建上下文与镜像层 ===== +configs/ + +# ===== Python 推理服务与模型权重(独立部署,体积大)===== +thirdparty/ + +# ===== 其他与编译无关资产 ===== +resources/ +sql/ +scripts/ +Dockerfile +.dockerignore diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..e60f416 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,122 @@ +name: 构建并部署 + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: deploy + cancel-in-progress: false + +env: + REGISTRY: git.lzcwy.cn + IMAGE: git.lzcwy.cn/zhengchiliu/zt-closet-backend + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: 拉取代码 + uses: actions/checkout@v4 + + - name: 确认 docker 可用 + run: docker version --format 'Server: {{.Server.Version}}' + + - name: 计算镜像标签 + id: meta + run: | + case "$GITEA_REF" in + refs/tags/*) TAG="${GITEA_REF#refs/tags/}" ;; + *) TAG="sha-$(echo "$GITEA_SHA" | cut -c1-7)" ;; + esac + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "镜像标签: $TAG" + + - name: 登录 Gitea 镜像仓库 + env: + REGISTRY_USER: ${{ secrets.ZT_CLOSET_REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.ZT_CLOSET_REGISTRY_TOKEN }} + run: | + echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin + + - name: 构建并推送 + run: | + # 生产机若是 ARM(如甲骨文 ARM 机),去掉下一行注释并改成 linux/arm64 + # PLATFORM="--platform linux/arm64" + docker build \ + --build-arg GOPROXY=https://goproxy.cn,direct \ + -f scripts/dockerfile/Dockerfile \ + -t "$IMAGE:${{ steps.meta.outputs.tag }}" \ + -t "$IMAGE:latest" \ + . + docker push "$IMAGE:${{ steps.meta.outputs.tag }}" + docker push "$IMAGE:latest" + + - name: SSH 部署到生产机 + env: + SSH_KEY: ${{ secrets.ZT_CLOSET_PROD_SSH_KEY }} + SSH_HOST: ${{ vars.ZT_CLOSET_PROD_SSH_HOST }} + SSH_USER: ${{ vars.ZT_CLOSET_PROD_SSH_USER }} + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + SSHOPT="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + ssh $SSHOPT "$SSH_USER@$SSH_HOST" 'bash -s' <<'REMOTE' + set -e + APP=zt-closet-backend + IMAGE=git.lzcwy.cn/zhengchiliu/zt-closet-backend + DIR=/opt/zt-closet + + mkdir -p "$DIR/configs" "$DIR/storage/logs" "$DIR/storage/uploads" + # 容器内进程是 uid 10001,日志/上传目录要给它写权限 + chown -R 10001:10001 "$DIR/storage" + + # 记住旧镜像,用于失败回滚 + OLD_IMAGE=$(docker inspect -f '{{.Image}}' $APP 2>/dev/null || echo "") + + docker pull $IMAGE:latest + docker rm -f $APP 2>/dev/null || true + + docker run -d \ + --name $APP \ + --network host \ + --restart unless-stopped \ + -v $DIR/configs:/app/configs:ro \ + -v $DIR/storage/logs:/app/storage/logs \ + -v $DIR/storage/uploads:/app/storage/uploads \ + -e TZ=Asia/Shanghai \ + --log-opt max-size=50m \ + --log-opt max-file=5 \ + $IMAGE:latest + + sleep 5 + if (command -v curl >/dev/null && curl -fsS http://127.0.0.1:8080/ping) \ + || (command -v wget >/dev/null && wget -qO- http://127.0.0.1:8080/ping); then + echo " 健康检查通过" + docker image prune -f + else + echo " 健康检查失败,回滚" + docker logs --tail 50 $APP + docker rm -f $APP + if [ -n "$OLD_IMAGE" ]; then + docker run -d \ + --name $APP \ + --network host \ + --restart unless-stopped \ + -v $DIR/configs:/app/configs:ro \ + -v $DIR/storage/logs:/app/storage/logs \ + -v $DIR/storage/uploads:/app/storage/uploads \ + -e TZ=Asia/Shanghai \ + "$OLD_IMAGE" + fi + exit 1 + fi + REMOTE + + - name: 部署结果 + if: always() + run: echo "镜像 ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }} 部署流程结束" diff --git a/.gitea/workflows/mysqldown.yaml b/.gitea/workflows/mysqldown.yaml new file mode 100644 index 0000000..3d4d55c --- /dev/null +++ b/.gitea/workflows/mysqldown.yaml @@ -0,0 +1,82 @@ +name: 数据库迁移 - 回滚(危险) + +on: + workflow_dispatch: + inputs: + confirm: + description: '将删除所有迁移创建的表及其数据,不可恢复。确认请输入 DELETE(大写)' + required: true + default: '' + +concurrency: + group: migrate + cancel-in-progress: false + +env: + REGISTRY: git.lzcwy.cn + IMAGE: git.lzcwy.cn/zhengchiliu/zt-closet-backend + MIGRATE_TAG: migrate + +jobs: + migrate-down: + runs-on: ubuntu-latest + steps: + - name: 校验确认词 + env: + IN_CONFIRM: ${{ gitea.event.inputs.confirm }} + run: | + if [ "${IN_CONFIRM:-}" != "DELETE" ]; then + echo "确认词不对(收到 '${IN_CONFIRM:-空}')。必须输入 DELETE 才会执行。" >&2 + exit 1 + fi + echo "确认通过" + + - name: 拉取代码 + uses: actions/checkout@v4 + + - name: 确认 docker 可用 + run: docker version --format 'Server: {{.Server.Version}}' + + - name: 登录 Gitea 镜像仓库 + env: + REGISTRY_USER: ${{ secrets.ZT_CLOSET_REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.ZT_CLOSET_REGISTRY_TOKEN }} + run: | + echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin + + - name: 构建并推送迁移镜像 + run: | + docker build \ + -f scripts/dockerfile/Dockerfile.migrate \ + -t "$IMAGE:$MIGRATE_TAG" \ + . + docker push "$IMAGE:$MIGRATE_TAG" + + - name: 生产机执行回滚(结束即删容器) + env: + SSH_KEY: ${{ secrets.ZT_CLOSET_PROD_SSH_KEY }} + SSH_HOST: ${{ vars.ZT_CLOSET_PROD_SSH_HOST }} + SSH_USER: ${{ vars.ZT_CLOSET_PROD_SSH_USER }} + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + SSHOPT="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + ssh $SSHOPT "$SSH_USER@$SSH_HOST" 'bash -s' <<'REMOTE' + set -e + DIR=/opt/zt-closet + IMAGE=git.lzcwy.cn/zhengchiliu/zt-closet-backend:migrate + + cd "$DIR" || { echo "目录不存在: $DIR"; exit 1; } + [ -f configs/config.yaml ] || { echo "缺 configs/config.yaml"; exit 1; } + + docker pull "$IMAGE" + + docker run --rm \ + --network host \ + -v "$DIR/configs:/app/configs:ro" \ + -v zt-go-build-cache:/root/.cache/go-build \ + -e TZ=Asia/Shanghai \ + "$IMAGE" down + REMOTE diff --git a/.gitea/workflows/mysqlup.yaml b/.gitea/workflows/mysqlup.yaml new file mode 100644 index 0000000..0070a1b --- /dev/null +++ b/.gitea/workflows/mysqlup.yaml @@ -0,0 +1,88 @@ +name: 数据库迁移 - 升级 + +on: + workflow_dispatch: + inputs: + check_only: + description: '只查看当前版本和脏库状态(执行 version,不改动数据库)' + required: false + default: false + type: boolean + +concurrency: + group: migrate + cancel-in-progress: false + +env: + REGISTRY: git.lzcwy.cn + IMAGE: git.lzcwy.cn/zhengchiliu/zt-closet-backend + MIGRATE_TAG: migrate + +jobs: + migrate-up: + runs-on: ubuntu-latest + steps: + - name: 拉取代码 + uses: actions/checkout@v4 + + - name: 确认 docker 可用 + run: docker version --format 'Server: {{.Server.Version}}' + + - name: 决定动作 + id: args + env: + IN_CHECK: ${{ gitea.event.inputs.check_only }} + run: | + if [ "${IN_CHECK:-false}" = "true" ]; then + echo "action=version" >> "$GITHUB_OUTPUT" + echo "动作: version(只读)" + else + echo "action=up" >> "$GITHUB_OUTPUT" + echo "动作: up" + fi + + - name: 登录 Gitea 镜像仓库 + env: + REGISTRY_USER: ${{ secrets.ZT_CLOSET_REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.ZT_CLOSET_REGISTRY_TOKEN }} + run: | + echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin + + - name: 构建并推送迁移镜像 + run: | + docker build \ + -f scripts/dockerfile/Dockerfile.migrate \ + -t "$IMAGE:$MIGRATE_TAG" \ + . + docker push "$IMAGE:$MIGRATE_TAG" + + - name: 生产机执行(结束即删容器) + env: + SSH_KEY: ${{ secrets.ZT_CLOSET_PROD_SSH_KEY }} + SSH_HOST: ${{ vars.ZT_CLOSET_PROD_SSH_HOST }} + SSH_USER: ${{ vars.ZT_CLOSET_PROD_SSH_USER }} + ACTION: ${{ steps.args.outputs.action }} + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + SSHOPT="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + ssh $SSHOPT "$SSH_USER@$SSH_HOST" "bash -s -- '$ACTION'" <<'REMOTE' + set -e + ACTION="$1" + DIR=/opt/zt-closet + IMAGE=git.lzcwy.cn/zhengchiliu/zt-closet-backend:migrate + + cd "$DIR" || { echo "目录不存在: $DIR"; exit 1; } + [ -f configs/config.yaml ] || { echo "缺 configs/config.yaml"; exit 1; } + + docker pull "$IMAGE" + + docker run --rm \ + --network host \ + -v "$DIR/configs:/app/configs:ro" \ + -v zt-go-build-cache:/root/.cache/go-build \ + -e TZ=Asia/Shanghai \ + "$IMAGE" "$ACTION" + REMOTE diff --git a/scripts/dockerfile/Dockerfile b/scripts/dockerfile/Dockerfile new file mode 100644 index 0000000..f765b0d --- /dev/null +++ b/scripts/dockerfile/Dockerfile @@ -0,0 +1,62 @@ +ARG GO_VERSION=1.27 +ARG ALPINE_VERSION=3.22 + +# ============================== 构建阶段 ============================== +FROM golang:${GO_VERSION}-alpine AS builder + +# TARGETOS/TARGETARCH 由 BuildKit 在 --platform 下自动注入,此处默认值保证 +# 未启用 buildx 时也能正常构建(默认 linux/amd64)。 +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +# Go 模块代理:默认官方代理;国内或受限网络可覆盖: +# --build-arg GOPROXY=https://goproxy.cn,direct +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=${GOPROXY} + +WORKDIR /src + +# 先只复制依赖清单,源码未变动时复用 go mod 下载缓存,加快重建。 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# 静态编译:CGO_ENABLED=0 使二进制不依赖 libc,可运行在任意精简基础镜像; +# -trimpath 去除本地绝对路径保证构建可复现,-s -w 去除符号表与调试信息以减小体积。 +ENV CGO_ENABLED=0 +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/zt-closet-backend . + +# ============================== 运行阶段 ============================== +FROM alpine:${ALPINE_VERSION} + +# ca-certificates:访问 COS / 微信支付 / 大模型网关等 HTTPS 端点需要根证书; +# tzdata:业务按本地时区计算「每日 0 点」额度重置与运势生成,需与 TZ 配合使用; +# 运行进程使用非 root 用户 app(uid/gid 10001)。 +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -g 10001 -S app \ + && adduser -u 10001 -S -G app app + +WORKDIR /app + +COPY --from=builder /out/zt-closet-backend /app/zt-closet-backend + +# configs/ :配置文件挂载点(config.yaml 含密钥,不打入镜像,必须运行时挂载) +# storage/logs:日志目录(对应配置 Log.LogSavePath) +# storage/uploads:上传临时目录(对应配置 Upload.TmpSavePath) +RUN mkdir -p /app/configs /app/storage/logs /app/storage/uploads \ + && chown -R app:app /app + +ENV TZ=Asia/Shanghai +# 仅用于 EXPOSE 与健康检查:真实监听端口由 configs/config.yaml 的 Server.HttpPort 决定,两者需保持一致。 +ENV APP_PORT=8080 + +USER app + +EXPOSE ${APP_PORT} + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -q -O - "http://127.0.0.1:${APP_PORT}/ping" || exit 1 + +# 启动服务(数据库迁移在容器外通过 go run ./cmd/migrate 手动执行) +CMD ["/app/zt-closet-backend"]