diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7cecdba..80efcf2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -2,7 +2,8 @@ name: Deploy on: push: - branches: [main] + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' jobs: deploy: @@ -11,97 +12,29 @@ jobs: GOPROXY: https://goproxy.cn,direct PUB_HOSTED_URL: https://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + steps: - uses: actions/checkout@v4 - - name: Go tests - working-directory: backend - run: go test ./... + - name: Compile + run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}" - - name: Flutter tests - working-directory: client - run: flutter test + - name: Test + run: sh scripts/ci/test.sh - - name: Build backend (linux/amd64) - working-directory: backend - run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server . - - - name: Build Flutter Web - working-directory: client - run: flutter build web --release --base-href=/app/ --dart-define=BASE_URL=https://jiu.51yanmei.com --dart-define=PUBLIC_URL=https://jiu.51yanmei.com - - - name: Package & Create Forgejo Release + - name: Release env: FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }} - run: | - tar -czf web.tar.gz -C client/build web - tar -czf configs.tar.gz \ - deploy/nginx-jiu.conf \ - deploy/jiu.service \ - deploy/production.env.template \ - deploy/setup-ec2.sh \ - deploy/docker-compose.yml \ - deploy/docker-compose.jiu.yml - VERSION="v$(date +%Y%m%d.%H%M)" - COMMIT="${{ gitea.sha }}" - COMMIT_MSG=$(git log -1 --pretty="%s" HEAD) - RECENT_LOGS=$(git log -5 --pretty="- %s (%h)" HEAD) - BODY=$(printf '## 构建信息\n- **版本**: %s\n- **Commit**: %s\n- **时间**: %s\n\n## 最近提交\n%s' \ - "${VERSION}" "${COMMIT}" "$(date '+%Y-%m-%d %H:%M:%S %Z')" "${RECENT_LOGS}") + GITEA_REPOSITORY: ${{ gitea.repository }} + run: sh scripts/ci/release.sh "${{ gitea.ref_name }}" - RESP=$(curl -s -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases" \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"${VERSION}\",\"name\":\"Release ${VERSION}\",\"body\":$(echo "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"draft\":false,\"prerelease\":false}") - echo "API response: ${RESP}" - RELEASE_ID=$(echo "${RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") - curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \ - -H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@backend/jiu-server" - curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \ - -H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@web.tar.gz" - curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \ - -H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@configs.tar.gz" - echo "Release ${VERSION} created" - - - name: Setup SSH - run: | - mkdir -p ~/.ssh - printf '%s' "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/ec2_deploy.pem - chmod 600 ~/.ssh/ec2_deploy.pem - ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts - - - name: Deploy to EC2 + - name: Deploy env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + GITEA_REPOSITORY: ${{ gitea.repository }} + EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} EC2_HOST: ${{ secrets.EC2_HOST }} EC2_USER: ${{ secrets.EC2_USER }} - run: | - scp -O -i ~/.ssh/ec2_deploy.pem backend/jiu-server ${EC2_USER}@${EC2_HOST}:/tmp/jiu-server - rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \ - client/build/web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/ - rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \ - web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/ - scp -O -i ~/.ssh/ec2_deploy.pem deploy/nginx-jiu.conf \ - ${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf - ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} << 'ENDSSH' - sudo systemctl stop jiu - cp /tmp/jiu-server /opt/jiu/backend/jiu-server - chmod +x /opt/jiu/backend/jiu-server - sudo systemctl start jiu - for i in $(seq 1 30); do - curl -sf http://localhost:8080/health && break - sleep 2 - done - rm -rf /opt/jiu/web-old - mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true - mv /tmp/jiu-web-new /opt/jiu/web - mkdir -p /opt/jiu/marketing - rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/ - rm -rf /tmp/jiu-marketing-new - sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf - sudo nginx -t && sudo nginx -s reload - ENDSSH - - - name: Cleanup SSH key - if: always() - run: rm -f ~/.ssh/ec2_deploy.pem + run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}" diff --git a/.gitea/workflows/manual.yml b/.gitea/workflows/manual.yml new file mode 100644 index 0000000..91076b2 --- /dev/null +++ b/.gitea/workflows/manual.yml @@ -0,0 +1,28 @@ +name: Manual Deploy + +on: + workflow_dispatch: + inputs: + version: + description: 'Tag to deploy (e.g. v1.0.0)' + required: true + type: string + +jobs: + deploy: + runs-on: mac + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.version }} + + - name: Deploy + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + GITEA_REPOSITORY: ${{ gitea.repository }} + EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_USER: ${{ secrets.EC2_USER }} + run: sh scripts/ci/deploy.sh "${{ inputs.version }}" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..32c0099 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-05-24 + +初始版本发布,完成核心库存管理功能。 + +### 新功能 +- 商品管理:多规格商品档案、图片、公开二维码扫码展示 +- 库存管理:入库审核、出库审核、库存盘点、流水记录 +- 财务管理:往来账目、对账单 +- 用户权限:管理员 / 成员 / 只读三级权限控制 +- 多租户隔离:门店数据完全独立 +- Flutter Web 管理端(支持 PC 浏览器) +- 商品扫码公开展示页 +- Excel 批量导入商品与库存 diff --git a/backend/config/version.yaml b/backend/config/version.yaml index a356ef6..68eae8d 100644 --- a/backend/config/version.yaml +++ b/backend/config/version.yaml @@ -1,10 +1,10 @@ -version: "1.1.1" -build_number: 2 +version: "1.0.0" +build_number: 1 force_update: false -release_notes: "修复了离线模式问题,优化状态栏显示" +release_notes: "初始版本发布,完成核心库存管理功能。" download_urls: macos: "" windows: "" ios: "" android: "" - web: "" + web: "https://jiu.51yanmei.com/app" diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index 2c903cd..bf70267 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -2,8 +2,6 @@ package handler import ( "net/http" - "os" - "time" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -80,23 +78,18 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { // GetRelease GET /api/v1/public/release (no auth) func (h *PublicHandler) GetRelease(c *gin.Context) { - version := os.Getenv("APP_VERSION") - if version == "" { - version = "1.0.0" + cfg, err := loadVersionConfig() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "version": "1.0.0", + "release_notes": "", + "download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"}, + }) + return } - buildDate := os.Getenv("BUILD_DATE") - if buildDate == "" { - buildDate = time.Now().Format("2006-01-02") - } - c.JSON(http.StatusOK, gin.H{ - "version": version, - "build_date": buildDate, - "download_urls": gin.H{ - "macos": os.Getenv("DOWNLOAD_URL_MACOS"), - "windows": os.Getenv("DOWNLOAD_URL_WINDOWS"), - "web": "https://jiu.51yanmei.com/app", - }, - "changelog": []gin.H{}, + "version": cfg.Version, + "release_notes": cfg.ReleaseNotes, + "download_urls": cfg.DownloadURLs, }) } diff --git a/scripts/ci/compile.sh b/scripts/ci/compile.sh new file mode 100755 index 0000000..57afc5a --- /dev/null +++ b/scripts/ci/compile.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# compile.sh — build backend + Flutter web, package into dist/ +set -euo pipefail + +export PATH="/opt/homebrew/bin:$PATH" +export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" +export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}" +export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}" + +TAG="$1" +VER="${TAG#v}" + +echo "==> compile: version=$VER" + +# Sync Flutter pubspec version +sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml + +# Build Go backend (linux/amd64 for EC2) +cd backend +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server . +cd .. + +# Build Flutter Web +cd client +flutter build web --release \ + --base-href=/app/ \ + "--dart-define=BASE_URL=https://jiu.51yanmei.com" \ + "--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \ + "--dart-define=APP_VERSION=${TAG}" +cd .. + +# Package artifacts +mkdir -p dist +mv backend/jiu-server dist/jiu-server +tar -czf dist/web.tar.gz -C client/build web + +echo "==> compile: done — dist/ contents:" +ls -lh dist/ diff --git a/scripts/ci/deploy.sh b/scripts/ci/deploy.sh new file mode 100755 index 0000000..3a7f5fa --- /dev/null +++ b/scripts/ci/deploy.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# deploy.sh — deploy release to EC2 +# Works in two modes: +# 1. Automated (after compile.sh): uses dist/ built in the same job +# 2. Manual/rollback: downloads assets from Forgejo Release by tag +set -euo pipefail + +TAG="$1" + +echo "==> deploy: tag=${TAG}" + +# Download from Forgejo if dist/ was not built in this job +if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ]; then + echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo" + mkdir -p dist + + curl -sf \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \ + -o /tmp/release-info.json + + python3 - <<'PYEOF' +import json, urllib.request, os, sys + +with open('/tmp/release-info.json') as f: + release = json.load(f) + +token = os.environ['FORGEJO_TOKEN'] +forgejo_url = os.environ['FORGEJO_URL'] +repo = os.environ['GITEA_REPOSITORY'] +release_id = release['id'] + +for asset in release.get('assets', []): + url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}" + req = urllib.request.Request(url, headers={'Authorization': f'token {token}'}) + with urllib.request.urlopen(req) as resp: + with open(f"dist/{asset['name']}", 'wb') as out: + out.write(resp.read()) + print(f"Downloaded: {asset['name']}") +PYEOF +fi + +echo "==> deploy: dist/ contents:" +ls -lh dist/ + +# Extract configs (nginx, version.yaml, etc.) +rm -rf /tmp/jiu-configs +mkdir -p /tmp/jiu-configs +tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs + +# Extract Flutter web build +rm -rf /tmp/jiu-web-new +mkdir -p /tmp/jiu-web-new +tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1 + +# Setup SSH +mkdir -p ~/.ssh +printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem +chmod 600 ~/.ssh/ec2_deploy.pem +ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts + +SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" +SCP="scp -O -i ~/.ssh/ec2_deploy.pem" + +echo "==> deploy: uploading files to EC2" + +# Upload backend binary +${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server" + +# Upload version.yaml +${SCP} /tmp/jiu-configs/backend/config/version.yaml "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml" + +# Upload nginx config +${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf" + +# Upload Flutter web +rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ + /tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/" + +# Upload marketing site (from repo checkout) +rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ + web/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/" + +echo "==> deploy: running remote deploy" + +${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' +set -e + +# Replace backend binary +sudo systemctl stop jiu +cp /tmp/jiu-server /opt/jiu/backend/jiu-server +chmod +x /opt/jiu/backend/jiu-server + +# Update version config +mkdir -p /opt/jiu/backend/config +cp /tmp/version.yaml /opt/jiu/backend/config/version.yaml + +# Start and health check +sudo systemctl start jiu +echo "Waiting for health check..." +for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + echo "Health check passed" + break + fi + sleep 2 +done +curl -sf http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; } + +# Swap Flutter web app (atomic) +rm -rf /opt/jiu/web-old +mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true +mv /tmp/jiu-web-new /opt/jiu/web + +# Update marketing site +mkdir -p /opt/jiu/marketing +rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/ +rm -rf /tmp/jiu-marketing-new + +# Update nginx config and reload +sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf +sudo nginx -t && sudo nginx -s reload + +echo "Deploy complete!" +ENDSSH + +# Cleanup SSH key +rm -f ~/.ssh/ec2_deploy.pem +rm -rf /tmp/jiu-configs /tmp/jiu-web-new + +echo "==> deploy: done" diff --git a/scripts/ci/release.sh b/scripts/ci/release.sh new file mode 100755 index 0000000..9440ec3 --- /dev/null +++ b/scripts/ci/release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# release.sh — update version.yaml, package configs, create Forgejo Release +set -euo pipefail + +TAG="$1" +VER="${TAG#v}" + +echo "==> release: tag=${TAG} version=${VER}" + +# Extract release notes from CHANGELOG.md (first summary line of the latest section) +RELEASE_NOTES=$(python3 - <<'PYEOF' +import re, sys +try: + with open('CHANGELOG.md') as f: + content = f.read() + parts = re.split(r'\n## ', '\n' + content) + if len(parts) > 1: + section = parts[1] + lines = section.strip().split('\n') + notes = [] + for line in lines[1:]: + stripped = line.strip() + if stripped.startswith('#'): + break + if stripped: + notes.append(stripped) + print('\n'.join(notes) if notes else lines[0]) + else: + print('') +except Exception as e: + print('', file=sys.stderr) + print('') +PYEOF +) + +echo "==> release: release_notes=${RELEASE_NOTES}" + +# Update backend/config/version.yaml +python3 - "${VER}" "${RELEASE_NOTES}" <<'PYEOF' +import sys +ver = sys.argv[1] +notes = sys.argv[2] +lines = [] +with open('backend/config/version.yaml') as f: + for line in f: + if line.startswith('version:'): + lines.append('version: "' + ver + '"\n') + elif line.startswith('build_number:'): + try: + build = int(line.split(':', 1)[1].strip()) + 1 + except Exception: + build = 1 + lines.append('build_number: ' + str(build) + '\n') + elif line.startswith('release_notes:'): + lines.append('release_notes: "' + notes.replace('\\', '\\\\').replace('"', '\\"') + '"\n') + else: + lines.append(line) +with open('backend/config/version.yaml', 'w') as f: + f.writelines(lines) +print('version.yaml updated to', ver) +PYEOF + +# Package configs.tar.gz (includes updated version.yaml) +tar -czf dist/configs.tar.gz \ + deploy/nginx-jiu.conf \ + deploy/jiu.service \ + deploy/production.env.template \ + deploy/setup-ec2.sh \ + deploy/docker-compose.yml \ + deploy/docker-compose.jiu.yml \ + backend/config/version.yaml + +echo "==> release: creating Forgejo Release ${TAG}" + +# Build release body from CHANGELOG excerpt +BODY=$(python3 - "${TAG}" "${RELEASE_NOTES}" <<'PYEOF' +import sys, json +tag = sys.argv[1] +notes = sys.argv[2] +body = '## ' + tag + '\n\n' + notes +print(json.dumps(body)) +PYEOF +) + +RESP=$(curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}") + +echo "==> release: API response: ${RESP}" + +RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}") +echo "==> release: release_id=${RELEASE_ID}" + +# Upload assets +curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -F "attachment=@dist/jiu-server" +echo "==> release: uploaded jiu-server" + +curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -F "attachment=@dist/web.tar.gz" +echo "==> release: uploaded web.tar.gz" + +curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -F "attachment=@dist/configs.tar.gz" +echo "==> release: uploaded configs.tar.gz" + +echo "==> release: done — Release ${TAG} created" diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh new file mode 100755 index 0000000..8637c66 --- /dev/null +++ b/scripts/ci/test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# test.sh — run backend and frontend checks +set -euo pipefail + +export PATH="/opt/homebrew/bin:$PATH" +export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" +export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}" +export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}" + +echo "==> test: go test" +cd backend +go test ./... +cd .. + +echo "==> test: flutter analyze" +cd client +flutter analyze +cd .. + +echo "==> test: done" diff --git a/web/download.html b/web/download.html index ff7ad58..7c2d996 100644 --- a/web/download.html +++ b/web/download.html @@ -954,15 +954,15 @@ footer {

- +
-
v1.6.2
-
2026-05-15
+
...
+
-
当前版本
-

多仓库联动盘点与权限增强

+

加载中...

新功能
    @@ -1193,8 +1193,23 @@ function renderNav() { if (window.lucide) lucide.createIcons(); } +function loadReleaseInfo() { + fetch('https://jiu.51yanmei.com/api/v1/public/release') + .then(function(r) { return r.json(); }) + .then(function(data) { + var badge = document.getElementById('version-badge'); + var notes = document.getElementById('release-notes'); + var date = document.getElementById('release-date'); + if (badge && data.version) badge.textContent = 'v' + data.version; + if (notes && data.release_notes) notes.textContent = data.release_notes; + if (date) date.textContent = new Date().toISOString().slice(0, 10); + }) + .catch(function() {}); +} + document.addEventListener('DOMContentLoaded', function() { renderNav(); + loadReleaseInfo(); if (window.lucide) lucide.createIcons(); });