Compare commits

...

34 Commits

Author SHA1 Message Date
wangjia b6223b3816 chore: release v1.0.2
Deploy / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:25:05 +08:00
wangjia 117cec431b docs(claude): 补充发版流程、异常上报、拼音搜索规范
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:09:21 +08:00
wangjia d8295d14da chore: 添加 /release 本地发版 slash command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:52:54 +08:00
wangjia 5282eb57c9 fix(ci): 对 Forgejo API 的 curl 加 -k,跳过自签名证书验证
Deploy / deploy (push) Successful in 1m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:47:15 +08:00
wangjia f59bdf056f fix(ci): 去掉 curl -s,显示完整错误输出;修复 CHANGELOG ### 子标题解析
Deploy / deploy (push) Failing after 43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:41:15 +08:00
wangjia 01946282ed chore: 更新 CHANGELOG v1.0.1
Deploy / deploy (push) Failing after 44s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:31:56 +08:00
wangjia 0d4c9cced2 fix(inventory): 补回 dart:async import(Timer 仍被导入进度弹框使用)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:29:24 +08:00
wangjia e9a6543a8b feat(search): 库存搜索支持拼音/首字母,按回车触发
- 新增 util.ToPinyin(),使用 go-pinyin 生成全拼和首字母
- Product model 新增 name_pinyin / name_initials 列(AutoMigrate)
- 启动时自动回填存量商品拼音
- Create / Update / FindOrCreate 写入时同步生成拼音
- 库存搜索 SQL 加入拼音/首字母 LIKE 条件
- 前端去掉 300ms 防抖,改为回车/点击搜索按钮触发

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:27:54 +08:00
wangjia 5f2248001d feat(client): Dio 拦截器统一上报 5xx 错误,新增 api_error 类型
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:09:50 +08:00
wangjia 2dbc89b902 feat: 错误自动上报机制
- 后端:error_reports 表 + POST /api/v1/public/errors + GET /api/v1/admin/errors
- Flutter:ErrorReporter 单例,60s 去重,fire-and-forget,不影响用户
- main.dart:FlutterError 和 Zone 错误自动上报
- 打印异常:catch 块加 reportError,Windows 打印问题可自动采集堆栈

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:08:03 +08:00
wangjia 44ffedb56a fix(client): 打印异常加 try-catch,错误以 SnackBar 提示用户
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:41:15 +08:00
wangjia 93878511e5 docs: 补充 SSH remote 地址
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:51:12 +08:00
wangjia 503ee7372e chore: git remote 迁移至 git.51yanmei.com,新增 Android 平台支持
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:49:22 +08:00
wangjia 62dc54fe1c docs: 补充开发完成标准(DoD),明确测试通过前不得提交
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:37:34 +08:00
wangjia 626efa9c34 fix(ci): flutter analyze 忽略 warning/info,只在 error 时失败
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:20:02 +08:00
wangjia b69c23c88a feat(deploy): 部署完成后发送 Telegram 通知
Deploy / deploy (push) Failing after 48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:09:09 +08:00
wangjia 53f56ce086 feat(deploy): SemVer 版本管理 + 自动化发版流水线
- deploy.yml: tag 触发(v*.*.*),四阶段调脚本(compile/test/release/deploy)
- manual.yml: workflow_dispatch 支持手动回滚,checkout 指定 tag
- scripts/ci/compile.sh: 构建 Go 二进制 + Flutter Web,打包 dist/
- scripts/ci/test.sh: go test + flutter analyze
- scripts/ci/release.sh: 解析 CHANGELOG.md → 更新 version.yaml → 创建 Forgejo Release
- scripts/ci/deploy.sh: 从 Release 下载产物(自动/手动均可)→ 部署到 EC2
- CHANGELOG.md: Keep a Changelog 格式,初始 v1.0.0 条目
- backend: GetRelease 改读 version.yaml,移除 ENV 变量依赖
- backend/config/version.yaml: 重置为 v1.0.0
- web/download.html: 动态拉取 /api/v1/public/release 展示版本号和更新内容

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:41:08 +08:00
wangjia d48f9f4bd0 fix(client): 商品公开页暂无图片时显示门店名首字,替换硬编码岩美
Deploy / deploy (push) Successful in 1m20s
_EmptyGalleryContent 接收 shopName 参数,无图片时展示
门店名第一个字符的文字头像,不再显示品牌名。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:59:17 +08:00
wangjia b591bc522d fix(web): 禁用深色模式自动适配,修复渲染叠层问题
Deploy / deploy (push) Successful in 2m9s
Flutter Web canvas 在系统深色模式下背景透明,导致底层 HTML
内容透出形成叠层。营销站点未声明 color-scheme,浏览器自动调色
也会破坏白色背景设计。

- client/web/index.html: 添加 color-scheme=light meta 及 body 白色背景
- web/index.html: 添加 color-scheme=light meta
- web/assets/colors_and_type.css: :root 声明 color-scheme: light

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:40:03 +08:00
wangjia b15e422953 fix(nginx): /product/ 路径转发给 Flutter Web,修复扫码跳转营销页问题
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:34:28 +08:00
wangjia 634162e0d9 fix(nginx): index.html 禁用缓存,确保发版后立即生效
Deploy / deploy (push) Successful in 1m17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:28:48 +08:00
wangjia fbb4f90ebd fix(test): shops 测试表补加 logo_url 列
Deploy / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:15:39 +08:00
wangjia a05f9bd4ec feat(shop): 门店 logo 上传 + 侧边栏品牌替换
Deploy / deploy (push) Failing after 8s
- shops 表新增 logo_url 字段,AutoMigrate 自动建列
- 后端新增 POST /api/v1/shop/logo 接口(管理员),图片裁剪为 256×256 JPEG 存储
- UpdateInfo 支持传入 logo_url
- 侧边栏「岩美」替换为门店真实名称 + 自定义 logo
- 无 logo 时显示店名首字文字头像(深蓝底白字)
- 设置页「酒行信息」展示 logo 预览,编辑弹窗新增「更换 Logo」上传按钮
- 修复库存导入计数逻辑:total/imported/updated/errors 四项分别统计
- 库存导入去重改为双路索引(编号 + 名称|系列|规格),避免 key 不一致误判新增
- settings 页导入结果统一显示「重复 X 条」,兼容 skipped 和 updated 两个字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:03:43 +08:00
wangjia 831dbc5959 fix(import): 库存列头动态识别 + 格式不匹配诊断信息
Deploy / deploy (push) Failing after 59s
商品名称/编号/系列/规格/单位列索引从固定 0-4 改为从表头动态识别,
支持更多列名变体(品名/名称/货品名称/编码等)。

全部跳过时在 errors 中返回识别到的表头,便于诊断列格式问题。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:45:39 +08:00
wangjia d5dc499c6b fix(import): 修复导入 504 超时 + 添加上传进度显示
Deploy / deploy (push) Successful in 1m15s
nginx:
- 新增 /api/v1/import/ 专属 location,proxy_read_timeout 延长至 300s

后端:
- ImportInventory 预加载商品/仓库/库存(3 次 bulk query 替代 N+1)
- 批量写 inventory_log(CreateInBatches 替代逐行 Create)

前端:
- settings 导入行:显示"上传 XX%"进度条和"导入数据(Ns)"计时
- Dio 超时设置为 send=120s / receive=300s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:12:59 +08:00
wangjia 5725e84971 fix(nginx): 增加上传文件大小限制至 20MB
Deploy / deploy (push) Successful in 1m16s
默认 1MB 导致 Excel 文件上传返回 413。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:03:21 +08:00
wangjia 86baaf3a1c fix(model): users 唯一索引改为 (shop_id, username) 复合索引
Deploy / deploy (push) Successful in 1m13s
原来 uniqueIndex:uk_shop_username 只在 Username 字段上,导致用户名全局唯一。
S002/S003 的 admin 用户与 S001 冲突,INSERT 被静默跳过,无法登录。

将 User 的 TenantBase 改为直接定义 ShopID,使其参与复合唯一索引。
需要 Reset DB 使新索引生效。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:50:34 +08:00
wangjia fcf92675b1 fix(seeds): 修复 clear_shop.sql collation 冲突
Deploy / deploy (push) Successful in 1m13s
用 BINARY 比较绕过 utf8mb4_unicode_ci 与 utf8mb4_0900_ai_ci 的冲突。
shop code 均为纯 ASCII,BINARY 比较结果完全一致。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:34:14 +08:00
wangjia d81b2d1fac fix(seed.yml): 两步走避免 S001 因 id 冲突被跳过
Deploy / deploy (push) Successful in 1m17s
清空和写入分两个独立循环:先清空所有门店,再依次写入。
避免 S001(硬编码 id=1) 在 S002 尚未清除时被 INSERT IGNORE 跳过。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:30:46 +08:00
wangjia acf2b22201 fix(seeds): 修复多门店种子数据互不干扰
Deploy / deploy (push) Successful in 1m14s
- S001.sql: 修复 INSERT IGNORE INTO 缺少空格的语法错误
- S002.sql: 重写为动态 @shop_id 模式,移除 TRUNCATE,改用 INSERT IGNORE INTO
- clear_shop.sql: 新增按 @shop_code 清空单店数据的参数化脚本
- scripts/dev.sh: seed 命令支持 --clear 参数(先清空再写入)
- seed.yml: 新增 clear 输入参数,支持按需清空后写入

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:52:47 +08:00
wangjia 8adb94a9a4 fix(deploy): 修复多 shop seed 互相 TRUNCATE 问题 + 补 utf8mb4 charset
Deploy / deploy (push) Successful in 1m12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:41:34 +08:00
wangjia a6e0edbc00 chore(deploy): 新增 debug-db workflow,查询线上数据库内容
Deploy / deploy (push) Successful in 1m14s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:19:38 +08:00
wangjia 2b180be926 chore(deploy): 新增 reset-db workflow,清库重建表结构并写入 seed 数据
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:58:34 +08:00
wangjia 1bf1f7563f chore(deploy): 新增 seed workflow,支持手动触发写入门店测试数据
Deploy / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:51:08 +08:00
77 changed files with 2494 additions and 580 deletions
+66
View File
@@ -0,0 +1,66 @@
执行本地发版流程,版本号为 $ARGUMENTS。
按以下步骤顺序执行,任意步骤失败立即停止并报告错误。
## 1. 检查工作区
检查是否有未提交的改动(`git status`)。如果有,列出文件并询问用户是否继续。
## 2. 本地构建
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./...
```
## 3. 运行测试
```bash
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go test ./...
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
```
## 4. 更新 CHANGELOG.md
检查 CHANGELOG.md 中是否已有 `## [$ARGUMENTS]` 版本节。
- **已有**:直接使用,不修改。
- **没有**:读取 `git log` 从上一个 tag 到 HEAD 的提交记录,自动生成一个版本节插入到文件顶部(位于已有版本节之前),格式如下:
```
## [$ARGUMENTS] - <今天日期>
### 新功能
- ...
### 改进
- ...
### 修复
- ...
```
只保留有内容的分类(新功能/改进/修复),根据 commit message 的类型(feat/fix/refactor 等)归类。
## 5. 提交代码
将所有改动(包括 CHANGELOG.md)用以下格式提交到 main
```
chore: release v$ARGUMENTS
```
## 6. 打 tag
```bash
git tag v$ARGUMENTS
```
## 7. 推送
```bash
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main v$ARGUMENTS
```
推送成功后提示用户:CI/CD 已触发,等待 Telegram 通知。
+47
View File
@@ -0,0 +1,47 @@
name: Debug DB
on:
workflow_dispatch:
jobs:
debug:
runs-on: mac
steps:
- 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: Query DB
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} bash << EOF
echo "=== Docker containers ==="
docker ps --format "{{.Names}}\t{{.Status}}"
echo ""
echo "=== Databases ==="
docker exec jiu_mysql mysql -uroot -p${DB_PASSWORD} -e "SHOW DATABASES;" 2>/dev/null \
|| echo "jiu_mysql container not found or DB_PASSWORD wrong"
echo ""
echo "=== Tables in jiu_db ==="
docker exec jiu_mysql mysql -uroot -p${DB_PASSWORD} jiu_db -e "SHOW TABLES;" 2>/dev/null
echo ""
echo "=== shops ==="
docker exec jiu_mysql mysql -uroot -p${DB_PASSWORD} jiu_db -e "SELECT id, name, code FROM shops;" 2>/dev/null
echo ""
echo "=== users ==="
docker exec jiu_mysql mysql -uroot -p${DB_PASSWORD} jiu_db -e "SELECT id, shop_id, username, role, is_active FROM users;" 2>/dev/null
EOF
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/ec2_deploy.pem
+21 -81
View File
@@ -2,7 +2,8 @@ name: Deploy
on:
push:
branches: [main]
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
jobs:
deploy:
@@ -11,97 +12,36 @@ 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
run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}"
- name: Cleanup SSH key
- name: Notify
if: always()
run: rm -f ~/.ssh/ec2_deploy.pem
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
+28
View File
@@ -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 }}"
+73
View File
@@ -0,0 +1,73 @@
name: Reset DB
on:
workflow_dispatch:
inputs:
shops:
description: '写入的门店(逗号分隔,如 S001,S002,S003'
required: true
default: 'S001,S002,S003'
jobs:
reset:
runs-on: mac
steps:
- uses: actions/checkout@v4
- 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: Reset database & reseed
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
SHOPS: ${{ github.event.inputs.shops }}
run: |
# 1. 清库重建
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"docker exec jiu_mysql mysql -uroot -p${DB_PASSWORD} -e 'DROP DATABASE IF EXISTS jiu_db; CREATE DATABASE jiu_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'"
echo "✓ Database reset"
# 2. 重启 jiu-serverAutoMigrate 重建表结构
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"sudo systemctl restart jiu"
echo "✓ jiu-server restarted, waiting for AutoMigrate..."
# 3. 等待健康检查通过
for i in $(seq 1 30); do
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"curl -sf http://localhost:8080/health" && break
sleep 2
done
echo "✓ Health check passed"
# 4. 写入 seed 数据(第一个 shop 全量,后续跳过 TRUNCATE 行避免互相清空)
FIRST=true
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
FILE="backend/seeds/${SHOP}.sql"
if [ ! -f "$FILE" ]; then
echo "⚠️ $FILE not found, skipping"
continue
fi
echo "→ Seeding $SHOP ..."
if [ "$FIRST" = "true" ]; then
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} --default-character-set=utf8mb4 jiu_db" \
< "$FILE"
FIRST=false
else
grep -v "^TRUNCATE" "$FILE" | \
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} --default-character-set=utf8mb4 jiu_db"
fi
echo "✓ $SHOP done"
done
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/ec2_deploy.pem
+66
View File
@@ -0,0 +1,66 @@
name: Seed DB
on:
workflow_dispatch:
inputs:
shops:
description: '要写入的门店(逗号分隔,如 S001,S002,S003'
required: true
default: 'S001,S002,S003'
clear:
description: '写入前先清空各门店数据(true/false'
required: false
default: 'false'
jobs:
seed:
runs-on: mac
steps:
- uses: actions/checkout@v4
- 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: Seed databases
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
SHOPS: ${{ github.event.inputs.shops }}
CLEAR: ${{ github.event.inputs.clear }}
run: |
# 第一步:若需要清空,先把所有门店都清掉再写入
# 避免 S001(id=1 硬编码) 因 S002 占用 id=1 而被 INSERT IGNORE 跳过
if [ "$CLEAR" = "true" ]; then
echo "=== 清空阶段 ==="
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
echo " 清空 ${SHOP}..."
printf "SET @shop_code='%s';\n" "$SHOP" | cat - backend/seeds/clear_shop.sql | \
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} --default-character-set=utf8mb4 jiu_db"
done
echo "✓ 清空完成"
fi
# 第二步:依次写入所有门店
echo "=== 写入阶段 ==="
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
FILE="backend/seeds/${SHOP}.sql"
if [ ! -f "$FILE" ]; then
echo "⚠️ $FILE not found, skipping"
continue
fi
echo "→ Seeding $SHOP ..."
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} --default-character-set=utf8mb4 jiu_db" \
< "$FILE"
echo "✓ $SHOP done"
done
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/ec2_deploy.pem
+35
View File
@@ -0,0 +1,35 @@
# 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.2] - 2026-05-25
### 改进
- 添加 /release 本地发版 slash command,一键完成构建、测试、打 tag 和推送
### 修复
- Web 版打印弹窗被浏览器拦截时,由静默失败改为给出明确提示,并自动上报错误
## [1.0.1] - 2026-05-25
### 改进
- 库存搜索支持拼音和拼音首字母(例:搜"mt"或"maotai"可匹配"茅台"
- 库存搜索改为回车/点击触发,减少无效请求
- 新增异常自动上报机制,线上错误可实时追踪
## [1.0.0] - 2026-05-24
初始版本发布,完成核心库存管理功能。
### 新功能
- 商品管理:多规格商品档案、图片、公开二维码扫码展示
- 库存管理:入库审核、出库审核、库存盘点、流水记录
- 财务管理:往来账目、对账单
- 用户权限:管理员 / 成员 / 只读三级权限控制
- 多租户隔离:门店数据完全独立
- Flutter Web 管理端(支持 PC 浏览器)
- 商品扫码公开展示页
- Excel 批量导入商品与库存
+91
View File
@@ -150,6 +150,45 @@ security: 修复入库单多租户隔离漏洞
---
## 开发完成标准(Definition of Done
**任何功能或修复,必须满足以下全部条件才算完成:**
### 后端变更
```bash
# 1. 编译通过
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./...
# 2. 所有测试通过(不得有 FAIL)
cd backend && go test ./...
# 3. 无编译警告(vet 检查)
cd backend && go vet ./...
```
### 前端变更
```bash
# 1. 静态分析无 errorwarning/info 允许存在)
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
# 2. 所有测试通过
cd client && flutter test
```
### 通用要求
- [ ] 代码已按 Git 提交规范提交(feat/fix/test/...
- [ ] 涉及新接口:已在对应测试文件中覆盖核心路径
- [ ] 涉及数据库变更:schema.sql 和 model 同步更新
- [ ] 多租户隔离未被破坏(所有查询含 shop_id 条件)
**测试未通过前,禁止提交代码、禁止打 tag 发版。**
---
## 代码质量门禁
以下情况**不得提交**
@@ -212,3 +251,55 @@ security: 修复入库单多租户隔离漏洞
tx.Set("gorm:query_option", "FOR UPDATE").Where(...).First(&model)
```
- 适用场景:单号生成(`number_rules`)、库存扣减(`inventories`)、任何 check-then-act 模式
### 发版流程
使用 `/release <version>` slash command
```
/release 1.0.2
```
执行顺序:本地 build → test → 更新 CHANGELOG.md → git commit → tag → push main+tag。
CI/CDForgejo)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
**CHANGELOG 格式**Keep a Changelog):
```markdown
## [1.0.2] - YYYY-MM-DD
### 新功能
- ...
### 改进
- ...
### 修复
- ...
```
只保留有内容的分类。`/release` 会检查 CHANGELOG 是否已有该版本节,没有则从 git log 自动生成。
### 异常上报
客户端异常已在两处统一捕获,**无需**在每个业务层重复处理:
- `main.dart``FlutterError.onError` + `runZonedGuarded` 捕获所有未处理异常
- `api_client.dart`:Dio 拦截器自动上报所有 HTTP 5xx 错误
需要**手动调用** `reportError(e, st)` 的场景:
- 技术性异常(JSON 解析失败、第三方 SDK 崩溃等)
- **不需要**上报:`AppException` 及其子类(已知业务错误)
```dart
} catch (e, st) {
reportError(e, st); // 一行,fire-and-forget
rethrow;
}
```
### 搜索实现(拼音)
商品名搜索同时支持汉字、全拼、首字母:
- `products` 表有 `name_pinyin`(全拼)和 `name_initials`(首字母)两列
- Create / Update / FindOrCreate 写入时自动调用 `util.ToPinyin()` 生成,**无需手动维护**
- 启动时对 `name_pinyin = ''` 的存量数据自动回填(`backfillPinyin`
- 库存搜索 SQL 同时 LIKE 匹配 name、code、name_pinyin、name_initials
- 新增其他需要拼音搜索的实体时,复用 `backend/internal/util/pinyin.go` 的 `ToPinyin()`
+3 -3
View File
@@ -132,21 +132,21 @@ func main() {
hash := mustHash("password123")
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
return &model.User{
TenantBase: model.TenantBase{ShopID: shop.ID},
ShopID: shop.ID,
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
Phone: "13800000001", Role: "admin", IsActive: true,
}
}).(model.User)
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
return &model.User{
TenantBase: model.TenantBase{ShopID: shop.ID},
ShopID: shop.ID,
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
Phone: "13800000002", Role: "operator", IsActive: true,
}
}).(model.User)
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
return &model.User{
TenantBase: model.TenantBase{ShopID: shop.ID},
ShopID: shop.ID,
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
Phone: "13800000003", Role: "readonly", IsActive: true,
}
+4 -4
View File
@@ -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"
+1
View File
@@ -44,6 +44,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-pinyin v0.21.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
+2
View File
@@ -73,6 +73,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+116
View File
@@ -0,0 +1,116 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
)
type ErrorReportHandler struct{ db *gorm.DB }
func NewErrorReportHandler(db *gorm.DB) *ErrorReportHandler {
return &ErrorReportHandler{db: db}
}
type submitErrorRequest struct {
ErrorType string `json:"error_type" binding:"required"`
AppVersion string `json:"app_version"`
Platform string `json:"platform"`
Username string `json:"username"`
ShopID uint64 `json:"shop_id"`
ShopNo string `json:"shop_no"`
Role string `json:"role"`
ErrorMsg string `json:"error_msg" binding:"required"`
StackTrace string `json:"stack_trace"`
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
}
// Submit POST /api/v1/public/errors
func (h *ErrorReportHandler) Submit(c *gin.Context) {
var req submitErrorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
occurredAt := time.Now()
if req.OccurredAt > 0 {
occurredAt = time.UnixMilli(req.OccurredAt)
}
stack := req.StackTrace
const maxStack = 4000
if len(stack) > maxStack {
stack = stack[:maxStack]
}
report := model.ErrorReport{
ErrorType: req.ErrorType,
AppVersion: req.AppVersion,
Platform: req.Platform,
Username: req.Username,
ShopID: req.ShopID,
ShopNo: req.ShopNo,
Role: req.Role,
ClientIP: c.ClientIP(),
ErrorMsg: req.ErrorMsg,
StackTrace: stack,
OccurredAt: occurredAt,
}
if err := h.db.Create(&report).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "submit failed"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": report.ID})
}
// List GET /api/v1/admin/errors (superadmin only)
func (h *ErrorReportHandler) List(c *gin.Context) {
var q struct {
ErrorType string `form:"error_type"`
Username string `form:"username"`
Platform string `form:"platform"`
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=50"`
}
if err := c.ShouldBindQuery(&q); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if q.PageSize <= 0 || q.PageSize > 200 {
q.PageSize = 50
}
if q.Page <= 0 {
q.Page = 1
}
db := h.db.Model(&model.ErrorReport{})
if q.ErrorType != "" {
db = db.Where("error_type = ?", q.ErrorType)
}
if q.Username != "" {
db = db.Where("username LIKE ?", "%"+q.Username+"%")
}
if q.Platform != "" {
db = db.Where("platform = ?", q.Platform)
}
var total int64
db.Count(&total)
var reports []model.ErrorReport
db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&reports)
c.JSON(http.StatusOK, gin.H{
"data": reports,
"total": total,
"page": q.Page,
"page_size": q.PageSize,
})
}
+151 -78
View File
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
@@ -512,66 +513,126 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
type importResult struct {
total int
imported int
updated int
skipped int
errors []string
}
var res importResult
// 仓库缓存,只查找不创建
warehouseCache := map[string]*uint64{}
// 预加载仓库(1 次查询)
var warehouses []model.Warehouse
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
warehouseByName := make(map[string]uint64, len(warehouses))
for _, wh := range warehouses {
warehouseByName[wh.Name] = wh.ID
}
findWarehouse := func(name string) *uint64 {
if name == "" {
return nil
}
if idPtr, ok := warehouseCache[name]; ok {
return idPtr
if id, ok := warehouseByName[name]; ok {
return &id
}
var wh model.Warehouse
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
warehouseCache[name] = nil
return nil
return nil
}
// 预加载商品(1 次查询)
var allProducts []model.Product
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&allProducts)
productByCode := make(map[string]*model.Product, len(allProducts))
productByNSS := make(map[string]*model.Product, len(allProducts))
for i := range allProducts {
p := &allProducts[i]
if p.Code != "" {
productByCode[p.Code] = p
}
id := wh.ID
warehouseCache[name] = &id
return &id
productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p
}
// 预加载已有导入库存(1 次查询)
// 同时建两套索引:编号索引 + 名称|系列|规格索引,供后续双路查找
var allInvs []model.Inventory
h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs)
invByCode := make(map[string]*model.Inventory, len(allInvs)) // key: productCode|warehouseID
invByNSS := make(map[string]*model.Inventory, len(allInvs)) // key: name|series|spec|warehouseID
for i := range allInvs {
inv := &allInvs[i]
whID := uint64(0)
if inv.WarehouseID != nil {
whID = *inv.WarehouseID
}
if inv.ProductCode != "" {
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
}
nssKey := fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whID)
invByNSS[nssKey] = inv
}
lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory {
if productCode != "" {
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
return inv
}
}
return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)]
}
// Dynamic column detection from header row
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
if len(rows) > 0 {
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
for j, h := range rows[0] {
switch strings.TrimSpace(h) {
case "库存数量", "数量":
case "商品编号", "商品编码", "编号", "编码", "商品条码":
colProductCode = j
case "商品名称", "品名", "名称", "货品名称":
colProductName = j
case "系列", "品牌系列":
colSeries = j
case "规格", "规格型号":
colSpec = j
case "单位":
colUnit = j
case "库存数量", "数量", "库存":
colQty = j
case "单价":
case "单价", "进价", "采购单价":
colPrice = j
case "生产日期":
case "生产日期", "生产年月":
colProductionDate = j
case "批次", "批次号":
colBatchNo = j
case "所在仓库", "仓库":
case "所在仓库", "仓库", "库位":
colWarehouse = j
case "供应商":
case "供应商", "供应商名称":
colSupplier = j
case "备注":
colRemark = j
}
}
}
log.Printf("[import-inv] total rows=%d, colProductName=%d, colProductCode=%d, colQty=%d",
len(rows), colProductName, colProductCode, colQty)
// 如果没有匹配到任何列头,返回诊断信息
detectedHeader := strings.Join(rows[0], " | ")
var logsToCreate []model.InventoryLog
for i, row := range rows[1:] {
productName := cell(row, 1)
productName := cell(row, colProductName)
if i < 5 {
log.Printf("[import-inv] row[%d] len=%d | productName=%q productCode=%q qty=%q",
i+2, len(row), productName, cell(row, colProductCode), cell(row, colQty))
}
if productName == "" {
res.skipped++
continue
continue // 空行(文件末尾填充行),不计入 total
}
productCode := cell(row, 0)
series := cell(row, 2)
spec := cell(row, 3)
unit := cell(row, 4)
productCode := cell(row, colProductCode)
series := cell(row, colSeries)
spec := cell(row, colSpec)
unit := cell(row, colUnit)
qtyStr := cell(row, colQty)
priceStr := cell(row, colPrice)
productionDateStr := cell(row, colProductionDate)
@@ -586,20 +647,32 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
price, _ := strconv.ParseFloat(priceStr, 64)
// 只查找商品,不强制创建
var prod model.Product
if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))",
shopID, productCode, productName, series, spec).First(&prod).Error != nil {
// 若找不到则创建
res.total++ // 有商品名称的行才计入总数
// 从缓存查商品,找不到才创建
var prod *model.Product
if productCode != "" {
prod = productByCode[productCode]
}
if prod == nil {
prod = productByNSS[productName+"|"+series+"|"+spec]
}
if prod == nil {
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
if createErr != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
continue
}
prod = newProd
allProducts = append(allProducts, newProd)
prod = &allProducts[len(allProducts)-1]
if prod.Code != "" {
productByCode[prod.Code] = prod
}
productByNSS[prod.Name+"|"+prod.Series+"|"+prod.Spec] = prod
}
if unit != "" && prod.Unit == "" {
h.db.Model(&prod).Update("unit", unit)
h.db.Model(prod).Update("unit", unit)
prod.Unit = unit
}
// 解析生产日期
@@ -609,7 +682,6 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
productionDate = &d
}
// 查找仓库(只查,不创建)
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
@@ -617,30 +689,24 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
unitPricePtr = &price
}
productIDCopy := prod.ID
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
var existing model.Inventory
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
shopID, prod.Code)
whIDVal := uint64(0)
if whIDPtr != nil {
q = q.Where("warehouse_id = ?", *whIDPtr)
} else {
q = q.Where("warehouse_id IS NULL")
whIDVal = *whIDPtr
}
found := q.First(&existing).Error == nil
if found {
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
if existing != nil {
updates := map[string]interface{}{
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
}
if unitPricePtr != nil {
updates["unit_price"] = *unitPricePtr
@@ -651,11 +717,18 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
if batchNo != "" {
updates["batch_no"] = batchNo
}
if err := h.db.Model(&existing).Updates(updates).Error; err != nil {
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
continue
}
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
RefType: "import", RefID: 0,
})
res.updated++
} else {
productIDCopy := prod.ID
inv := model.Inventory{
ShopID: shopID,
WarehouseID: whIDPtr,
@@ -678,38 +751,38 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
continue
}
}
// 写流水
warehouseID := uint64(0)
if whIDPtr != nil {
warehouseID = *whIDPtr
}
qtyBefore := 0.0
if found {
qtyBefore = existing.Quantity
res.updated++
} else {
// 写入两套缓存,防止同文件后续行重复插入
if inv.ProductCode != "" {
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whIDVal)] = &inv
}
invByNSS[fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whIDVal)] = &inv
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty,
RefType: "import", RefID: 0,
})
res.imported++
}
log := model.InventoryLog{
ShopID: shopID,
WarehouseID: warehouseID,
ProductID: prod.ID,
Direction: "in",
Quantity: qty,
QtyBefore: qtyBefore,
QtyAfter: qty,
RefType: "import",
RefID: 0,
}
h.db.Create(&log)
}
// 批量写流水
if len(logsToCreate) > 0 {
h.db.CreateInBatches(&logsToCreate, 100)
}
// total=0 说明列格式不匹配,没有解析到任何有效行
if res.total == 0 {
res.errors = append(res.errors,
fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
}
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d",
res.total, res.imported, res.updated, len(res.errors))
c.JSON(http.StatusOK, gin.H{
"total": res.total,
"imported": res.imported,
"updated": res.updated,
"skipped": res.skipped,
"errors": res.errors,
})
}
+2 -2
View File
@@ -64,9 +64,9 @@ func (h *InventoryHandler) List(c *gin.Context) {
inStock := c.Query("in_stock")
if keyword != "" {
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)"
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like)
args = append(args, like, like, like, like)
}
if warehouseIDStr != "" {
baseWhere += " AND inv.warehouse_id = ?"
+14 -6
View File
@@ -15,6 +15,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type ProductHandler struct {
@@ -70,6 +71,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
}
product.ShopID = shopID
product.PublicID = uuid.New().String()
product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name)
// Auto-generate product code if not provided (e.g. P001, P002)
// Retry up to 5 times on duplicate key to handle concurrent creates
@@ -122,6 +124,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
return
}
namePinyin, nameInitials := util.ToPinyin(req.Name)
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
if err := h.db.Model(&product).Updates(map[string]interface{}{
"code": req.Code,
@@ -138,6 +141,8 @@ func (h *ProductHandler) Update(c *gin.Context) {
"description": req.Description,
"remark": req.Remark,
"custom_fields": req.CustomFields,
"name_pinyin": namePinyin,
"name_initials": nameInitials,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -226,13 +231,16 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
var count int64
h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count)
namePinyin, nameInitials := util.ToPinyin(req.Name)
product = model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Name: req.Name,
Series: req.Series,
Spec: req.Spec,
Code: fmt.Sprintf("P%03d", count+1),
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Name: req.Name,
Series: req.Series,
Spec: req.Spec,
Code: fmt.Sprintf("P%03d", count+1),
NamePinyin: namePinyin,
NameInitials: nameInitials,
}
if createErr := h.db.Create(&product).Error; createErr != nil {
// Race condition: try to find the record created by another request
+11 -18
View File
@@ -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,
})
}
+57
View File
@@ -1,11 +1,19 @@
package handler
import (
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"net/http"
"os"
"path/filepath"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
@@ -38,6 +46,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
Address string `json:"address"`
Phone string `json:"phone"`
ManagerName string `json:"manager_name"`
LogoURL string `json:"logo_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -50,6 +59,9 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
"phone": req.Phone,
"manager_name": req.ManagerName,
}
if req.LogoURL != "" {
updates["logo_url"] = req.LogoURL
}
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -59,3 +71,48 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
h.db.First(&shop, shopID)
c.JSON(http.StatusOK, shop)
}
// UploadLogo POST /api/v1/shop/logo (admin only)
func (h *ShopHandler) UploadLogo(c *gin.Context) {
shopID := middleware.GetShopID(c)
if err := c.Request.ParseMultipartForm(2 << 20); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 2MB 限制"})
return
}
file, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file"})
return
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
return
}
// 裁剪为正方形后缩放到 256×256
resized := imaging.Fill(img, 256, 256, imaging.Center, imaging.Lanczos)
subdir := filepath.Join(config.C.Storage.UploadDir, "shops", fmt.Sprintf("%d", shopID))
if err := os.MkdirAll(subdir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
return
}
fullPath := filepath.Join(subdir, "logo.jpg")
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(90)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
return
}
logoURL := fmt.Sprintf("/images/shops/%d/logo.jpg", shopID)
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Update("logo_url", logoURL).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logo_url": logoURL})
}
+1 -1
View File
@@ -52,7 +52,7 @@ func (h *UserHandler) Create(c *gin.Context) {
role = "operator"
}
u := model.User{
TenantBase: model.TenantBase{ShopID: shopID},
ShopID: shopID,
Username: req.Username,
PasswordHash: string(hash),
RealName: req.RealName,
+20
View File
@@ -0,0 +1,20 @@
package model
import "time"
// ErrorReport 客户端错误上报记录(系统级,不做多租户隔离)
type ErrorReport struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `json:"created_at"`
ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception
AppVersion string `gorm:"size:30" json:"app_version"`
Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos
Username string `gorm:"size:50;index" json:"username"`
ShopID uint64 `gorm:"index" json:"shop_id"`
ShopNo string `gorm:"size:50" json:"shop_no"`
Role string `gorm:"size:20" json:"role"`
ClientIP string `gorm:"size:60" json:"client_ip"`
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
StackTrace string `gorm:"type:text" json:"stack_trace"`
OccurredAt time.Time `json:"occurred_at"`
}
+2
View File
@@ -24,6 +24,8 @@ type Product struct {
Description string `gorm:"type:text" json:"description"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
NamePinyin string `gorm:"size:400;index" json:"-"`
NameInitials string `gorm:"size:100;index" json:"-"`
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
+1
View File
@@ -8,6 +8,7 @@ type Shop struct {
Phone string `gorm:"size:30" json:"phone"`
BusinessHours string `gorm:"size:100" json:"business_hours"`
ManagerName string `gorm:"size:50" json:"manager_name"`
LogoURL string `gorm:"column:logo_url;size:500" json:"logo_url"`
BusinessLicense string `gorm:"size:500" json:"business_license"`
ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
+2 -1
View File
@@ -1,7 +1,8 @@
package model
type User struct {
TenantBase
Base
ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"`
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
PasswordHash string `gorm:"size:255" json:"-"`
RealName string `gorm:"size:50" json:"real_name"`
+4
View File
@@ -33,6 +33,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
publicH := handler.NewPublicHandler(db)
adminH := handler.NewAdminHandler(db)
shopH := handler.NewShopHandler(db)
errorReportH := handler.NewErrorReportHandler(db)
// 健康检查(无需认证,用于前端连通性探测)
r.GET("/health", func(c *gin.Context) {
@@ -56,6 +57,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
{
public.GET("/products/:public_id", publicH.GetProduct)
public.GET("/release", publicH.GetRelease)
public.POST("/errors", errorReportH.Submit)
}
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
@@ -165,6 +167,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
{
shop.GET("/info", shopH.GetInfo)
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
}
// 编号规则
@@ -212,6 +215,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
superAdmin.Use(middleware.SuperAdminOnly())
{
superAdmin.POST("/clear-data", adminH.ClearData)
superAdmin.GET("/errors", errorReportH.List)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package util
import (
"strings"
gp "github.com/mozillazg/go-pinyin"
)
var (
fullArgs = func() gp.Args {
a := gp.NewArgs()
a.Style = gp.Normal
a.Fallback = func(r rune, _ gp.Args) []string {
return []string{strings.ToLower(string(r))}
}
return a
}()
initialArgs = func() gp.Args {
a := gp.NewArgs()
a.Style = gp.FirstLetter
a.Fallback = func(r rune, _ gp.Args) []string {
s := strings.ToLower(string(r))
if s == "" {
return nil
}
return []string{string(s[0])}
}
return a
}()
)
// ToPinyin converts a Chinese name to full pinyin and initials.
// Non-Chinese characters are kept as-is (lowercased).
// Example: "茅台酒" → ("maotaijiu", "mtj")
func ToPinyin(s string) (full, initials string) {
var fb, ib strings.Builder
for _, row := range gp.Pinyin(s, fullArgs) {
if len(row) > 0 {
fb.WriteString(row[0])
}
}
for _, row := range gp.Pinyin(s, initialArgs) {
if len(row) > 0 {
ib.WriteString(row[0])
}
}
return fb.String(), ib.String()
}
+21
View File
@@ -12,6 +12,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/router"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
@@ -27,6 +28,9 @@ func main() {
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
autoMigrate(db)
// 回填存量商品的拼音索引(一次性,已有值的跳过)
backfillPinyin(db)
// 启动 Gin
gin.SetMode(config.C.Server.Mode)
r := gin.New()
@@ -104,9 +108,26 @@ func autoMigrate(db *gorm.DB) {
&model.ProductSeriesOption{},
&model.ProductSpecOption{},
&model.ProductImage{},
&model.ErrorReport{},
)
if err != nil {
log.Fatalf("auto migrate failed: %v", err)
}
log.Println("AutoMigrate completed")
}
func backfillPinyin(db *gorm.DB) {
var products []model.Product
db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products)
if len(products) == 0 {
return
}
for i := range products {
full, initials := util.ToPinyin(products[i].Name)
db.Model(&products[i]).Updates(map[string]interface{}{
"name_pinyin": full,
"name_initials": initials,
})
}
log.Printf("backfillPinyin: updated %d products", len(products))
}
+1
View File
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` (
`address` VARCHAR(255) DEFAULT NULL,
`phone` VARCHAR(30) DEFAULT NULL,
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
`logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL',
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
`custom_fields` JSON DEFAULT NULL COMMENT '扩展字段',
+38 -60
View File
@@ -2,39 +2,15 @@
-- 测试门店 S001 种子数据
-- 用法: sh scripts/dev.sh seed S001
-- 前置条件: 数据库表结构已创建(启动一次后端即可)
-- 说明: 每次执行会清空并重新写入所有业务数据
-- 说明: 仅写入数据,不清空。加 --clear 参数先清空再写入
-- =============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ── 清空(子表先清)────────────────────────────────────────
TRUNCATE TABLE inventory_check_items;
TRUNCATE TABLE inventory_checks;
TRUNCATE TABLE inventory_logs;
TRUNCATE TABLE inventories;
TRUNCATE TABLE stock_out_items;
TRUNCATE TABLE stock_out_orders;
TRUNCATE TABLE stock_in_items;
TRUNCATE TABLE stock_in_orders;
TRUNCATE TABLE finance_records;
TRUNCATE TABLE number_rules;
TRUNCATE TABLE product_images;
TRUNCATE TABLE partners;
TRUNCATE TABLE warehouses;
TRUNCATE TABLE products;
TRUNCATE TABLE product_spec_options;
TRUNCATE TABLE product_series_options;
TRUNCATE TABLE product_name_options;
TRUNCATE TABLE product_categories;
TRUNCATE TABLE users;
TRUNCATE TABLE shops;
SET FOREIGN_KEY_CHECKS = 1;
-- ── 门店 ────────────────────────────────────────────────────
-- id=1
INSERT INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
INSERT IGNORE INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
VALUES (1, '盛世名酿酒行', 'S001', '北京市朝阳区建国路88号华贸中心B座101室', '010-65882266', '张建国', NOW(), NOW());
-- ── 用户(密码均为 password123)────────────────────────────
@@ -42,26 +18,26 @@ VALUES (1, '盛世名酿酒行', 'S001', '北京市朝阳区建国路88号华贸
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
-- id=1 admin, id=2 operator, id=3 readonly
INSERT INTO users (id, shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at) VALUES
INSERT IGNORE INTO users (id, shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at) VALUES
(1, 1, 'admin', @pwd, '张三(管理员)', '13800000001', 'admin', 1, NOW(), NOW()),
(2, 1, 'operator', @pwd, '李四(操作员)', '13800000002', 'operator', 1, NOW(), NOW()),
(3, 1, 'test', @pwd, '王五(只读)', '13800000003', 'readonly', 1, NOW(), NOW());
-- ── 仓库 ────────────────────────────────────────────────────
-- id=1 主仓库, id=2 进口酒专库
INSERT INTO warehouses (id, shop_id, name, location, is_default, created_at, updated_at) VALUES
INSERT IGNORE INTO warehouses (id, shop_id, name, location, is_default, created_at, updated_at) VALUES
(1, 1, '主仓库', 'A栋1层东侧', 1, NOW(), NOW()),
(2, 1, '进口酒专库', 'B栋2层恒温区', 0, NOW(), NOW());
-- ── 商品分类 ────────────────────────────────────────────────
-- id=1 白酒, id=2 进口烈酒
INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updated_at) VALUES
INSERT IGNORE INTO product_categories (id, shop_id, name, sort_order, created_at, updated_at) VALUES
(1, 1, '白酒', 1, NOW(), NOW()),
(2, 1, '进口烈酒', 2, NOW(), NOW());
-- ── 商品 ────────────────────────────────────────────────────
-- id=1~6 白酒, id=7~8 进口烈酒
INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
INSERT IGNORE INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
purchase_price, sale_price, min_stock, remark, description, created_at, updated_at) VALUES
(1, 1, 'a1b2c3d4-0001-0001-0001-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', '飞天茅台采用优质高粱、小麦,经传统酱香工艺精心酿制。酒体醇厚丰满,酱香突出,幽雅细腻,空杯留香持久。53度黄金度数,是馈赠佳品的首选。', NOW(), NOW()),
(2, 1, 'a1b2c3d4-0001-0001-0001-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', '五粮液以高粱、大米、糯米、小麦、玉米五种粮食为原料,经地窖发酵精酿而成。香气悠久,味醇厚,入口甘美,入喉净爽,浓香典范。', NOW(), NOW()),
@@ -73,7 +49,7 @@ INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec,
(8, 1, 'a1b2c3d4-0001-0001-0001-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP', '人头马', '700ml/瓶', '', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', '人头马VSOP精选法国干邑地区Fine Champagne产区葡萄,经二次蒸馏后在法国橡木桶中陈年至少四年。口感丝滑,带有香草、杏干与蜂蜜的复杂香气,是干邑入门经典。', NOW(), NOW());
-- ── 商品名称选项 ────────────────────────────────────────────
INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES
INSERT IGNORE INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES
(1, 'NA001', '飞天茅台 53度', NOW(), NOW()),
(1, 'NA002', '五粮液 52度', NOW(), NOW()),
(1, 'NA003', '洋河梦之蓝 M6', NOW(), NOW()),
@@ -84,7 +60,7 @@ INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) V
(1, 'NA008', '人头马 VSOP', NOW(), NOW());
-- ── 商品系列选项 ────────────────────────────────────────────
INSERT INTO product_series_options (shop_id, code, name, created_at, updated_at) VALUES
INSERT IGNORE INTO product_series_options (shop_id, code, name, created_at, updated_at) VALUES
(1, 'SE001', '茅台', NOW(), NOW()),
(1, 'SE002', '五粮液', NOW(), NOW()),
(1, 'SE003', '洋河', NOW(), NOW()),
@@ -95,14 +71,14 @@ INSERT INTO product_series_options (shop_id, code, name, created_at, updated_at)
(1, 'SE008', '人头马', NOW(), NOW());
-- ── 商品规格选项 ────────────────────────────────────────────
INSERT INTO product_spec_options (shop_id, code, name, quantity, created_at, updated_at) VALUES
INSERT IGNORE INTO product_spec_options (shop_id, code, name, quantity, created_at, updated_at) VALUES
(1, 'GG001', '500ml/瓶', 1, NOW(), NOW()),
(1, 'GG002', '750ml/瓶', 1, NOW(), NOW()),
(1, 'GG003', '700ml/瓶', 1, NOW(), NOW());
-- ── 往来单位 ────────────────────────────────────────────────
-- id=1~4 供应商, id=5~7 客户
INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account,
INSERT IGNORE INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account,
credit_limit, balance, remark, created_at, updated_at) VALUES
(1, 1, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
(2, 1, 'SUP002', '四川五粮液股份有限公司', 'supplier', '王总监', '0831-33333001', '四川省宜宾市翠屏区', '建设银行宜宾支行 6227 0000 0001 0002', 3000000, 0, '五粮液系列授权经销', NOW(), NOW()),
@@ -113,125 +89,125 @@ INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, ba
(7, 1, 'CUS003', '广州白天鹅宾馆', 'customer', '吴采购', '020-81886001', '广州市荔湾区沙面南街1号', '招商银行广州荔湾支行 6225 0000 0002 0003', 300000, 0, '高端宾馆,月结30天', NOW(), NOW());
-- ── 编号规则 ────────────────────────────────────────────────
INSERT INTO number_rules (id, shop_id, type, prefix, current_no, date_format, updated_at) VALUES
INSERT IGNORE INTO number_rules (id, shop_id, type, prefix, current_no, date_format, updated_at) VALUES
(1, 1, 'stock_in', 'RK', 5, 'YYYYMMDD', NOW()),
(2, 1, 'stock_out', 'CK', 4, 'YYYYMMDD', NOW()),
(3, 1, 'inventory_check', 'PD', 1, 'YYYYMMDD', NOW());
-- ── 入库单 ──────────────────────────────────────────────────
-- #1 已审核: 飞天茅台+五粮液,主仓库,茅台供应商 | 总额=339000
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(1, 1, 'RK20260401001', 'purchase', 1, 1, 1,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 6 DAY), 339000,
DATE_SUB(NOW(), INTERVAL 5 DAY), '茅台系列首批入库,含飞天茅台及五粮液', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(1, 1, 1, 1, 120, 2350, 282000, 'BATCH-MT-2024001', '飞天茅台 2024年第一批次', NOW(), NOW()),
(2, 1, 1, 2, 60, 950, 57000, 'BATCH-WLY-2024001', '五粮液普五 2024年批次', NOW(), NOW());
-- #2 已审核: 洋河+泸州+剑南春,主仓库,五粮液供应商 | 总额=51360
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(2, 1, 'RK20260403001', 'purchase', 1, 2, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 4 DAY), 51360,
DATE_SUB(NOW(), INTERVAL 3 DAY), '浓香型白酒补货入库', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(3, 2, 1, 3, 48, 560, 26880, 'BATCH-YH-2024003', '洋河梦之蓝 M6 3月批次', NOW(), NOW()),
(4, 2, 1, 4, 36, 420, 15120, 'BATCH-LZ-2024002', '泸州老窖特曲 春季批次', NOW(), NOW()),
(5, 2, 1, 5, 24, 390, 9360, 'BATCH-JNC-2024001', '剑南春水晶剑 首批', NOW(), NOW());
-- #3 已审核: 拉菲+人头马,进口酒专库,郎酒供应商(代理进口)| 总额=108480
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(3, 1, 'RK20260404001', 'purchase', 2, 4, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 108480,
DATE_SUB(NOW(), INTERVAL 2 DAY), '进口烈酒专库入库,含拉菲及人头马', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(6, 3, 1, 7, 24, 3800, 91200, 'BATCH-LF-2018001', '拉菲古堡2018,原箱', NOW(), NOW()),
(7, 3, 1, 8, 36, 480, 17280, 'BATCH-RTM-2024001', '人头马VSOP2024年进口', NOW(), NOW());
-- #4 待审核: 郎酒补货,主仓库 | 总额=19200
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(4, 1, 'RK20260406001', 'purchase', 1, 4, 2,
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 19200,
'郎酒红花郎补货,待仓库管理员审核', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(8, 4, 1, 6, 60, 320, 19200, 'BATCH-LJ-2024002', '郎酒红花郎10 第二批次', NOW(), NOW());
-- #5 草稿: 追加茅台,主仓库 | 总额=142800
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(5, 1, 'RK20260407001', 'purchase', 1, 1, 2,
'draft', CURDATE(), 142800,
'茅台追加订货,草稿中', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(9, 5, 1, 1, 60, 2380, 142800, 'BATCH-MT-2024002', '飞天茅台 第二批次', NOW(), NOW());
-- ── 出库单 ──────────────────────────────────────────────────
-- #1 已审核: 君悦大酒店,主仓库 | 总额=48960
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(1, 1, 'CK20260404001', 'sale', 1, 5, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 48960,
DATE_SUB(NOW(), INTERVAL 2 DAY), '北京君悦大酒店 4月份定期供货', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(1, 1, 1, 1, 12, 2800, 33600, '飞天茅台 12瓶,整箱出库', NOW(), NOW()),
(2, 1, 1, 2, 6, 1200, 7200, '五粮液普五 6瓶', NOW(), NOW()),
(3, 1, 1, 3, 12, 680, 8160, '洋河梦之蓝 M6 12瓶', NOW(), NOW());
-- #2 已审核: 外滩华尔道夫,进口酒专库 | 总额=39360
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(2, 1, 'CK20260405001', 'sale', 2, 6, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 2 DAY), 39360,
DATE_SUB(NOW(), INTERVAL 1 DAY), '上海外滩华尔道夫 进口酒专属采购', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(4, 2, 1, 7, 6, 5200, 31200, '拉菲古堡2018 6瓶', NOW(), NOW()),
(5, 2, 1, 8, 12, 680, 8160, '人头马VSOP 12瓶', NOW(), NOW());
-- #3 待审核: 广州白天鹅宾馆,主仓库 | 总额=23280
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(3, 1, 'CK20260406001', 'sale', 1, 7, 2,
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 23280,
'广州白天鹅宾馆 月度供货申请', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(6, 3, 1, 4, 24, 520, 12480, '泸州老窖特曲 24瓶', NOW(), NOW()),
(7, 3, 1, 5, 12, 480, 5760, '剑南春水晶剑 12瓶', NOW(), NOW()),
(8, 3, 1, 6, 12, 420, 5040, '郎酒红花郎10 12瓶', NOW(), NOW());
-- #4 草稿: 君悦追加,主仓库 | 总额=16800
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(4, 1, 'CK20260407001', 'sale', 1, 5, 2,
'draft', CURDATE(), 16800,
'北京君悦追加订单,草稿中', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(9, 4, 1, 1, 6, 2800, 16800, '飞天茅台 追加 6瓶', NOW(), NOW());
-- ── 库存(已审核单据计算后的实时库存)────────────────────
-- wh1: 飞天茅台 120-12=108, 五粮液 60-6=54, 洋河 48-12=36, 泸州 36, 剑南春 24
-- wh2: 拉菲 24-6=18, 人头马 36-12=24
INSERT INTO inventories (id, shop_id, warehouse_id, product_id, quantity, updated_at) VALUES
INSERT IGNORE INTO inventories (id, shop_id, warehouse_id, product_id, quantity, updated_at) VALUES
(1, 1, 1, 1, 108, NOW()),
(2, 1, 1, 2, 54, NOW()),
(3, 1, 1, 3, 36, NOW()),
@@ -242,33 +218,33 @@ INSERT INTO inventories (id, shop_id, warehouse_id, product_id, quantity, update
-- ── 库存流水 ────────────────────────────────────────────────
-- 入库单 #1 (ref_id=1): 飞天茅台+五粮液 → wh1
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 1, 1, 1, 1, 'in', 120, 0, 120, 'stock_in', 1, 1, NOW()),
( 2, 1, 1, 2, 'in', 60, 0, 60, 'stock_in', 1, 1, NOW());
-- 入库单 #2 (ref_id=2): 洋河+泸州+剑南春 → wh1
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 3, 1, 1, 3, 'in', 48, 0, 48, 'stock_in', 2, 1, NOW()),
( 4, 1, 1, 4, 'in', 36, 0, 36, 'stock_in', 2, 1, NOW()),
( 5, 1, 1, 5, 'in', 24, 0, 24, 'stock_in', 2, 1, NOW());
-- 入库单 #3 (ref_id=3): 拉菲+人头马 → wh2
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 6, 1, 2, 7, 'in', 24, 0, 24, 'stock_in', 3, 1, NOW()),
( 7, 1, 2, 8, 'in', 36, 0, 36, 'stock_in', 3, 1, NOW());
-- 出库单 #1 (ref_id=1): 飞天茅台+五粮液+洋河 ← wh1
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 8, 1, 1, 1, 'out', 12, 120, 108, 'stock_out', 1, 1, NOW()),
( 9, 1, 1, 2, 'out', 6, 60, 54, 'stock_out', 1, 1, NOW()),
(10, 1, 1, 3, 'out', 12, 48, 36, 'stock_out', 1, 1, NOW());
-- 出库单 #2 (ref_id=2): 拉菲+人头马 ← wh2
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
(11, 1, 2, 7, 'out', 6, 24, 18, 'stock_out', 2, 1, NOW()),
(12, 1, 2, 8, 'out', 12, 36, 24, 'stock_out', 2, 1, NOW());
@@ -278,7 +254,7 @@ INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, qu
-- 收款:出库单 #1 已回款
-- 应付账款:入库单 #1 (茅台 339000) + #2 (五粮液代理 51360) + #3 (郎酒 108480)
-- 付款:入库单 #1 已付款
INSERT INTO finance_records (id, shop_id, partner_id, type, amount, balance, ref_type, ref_id,
INSERT IGNORE INTO finance_records (id, shop_id, partner_id, type, amount, balance, ref_type, ref_id,
operator_id, record_date, remark, created_at, updated_at) VALUES
(1, 1, 5, 'receivable', 48960, 48960, 'stock_out', 1, 1,
DATE_SUB(CURDATE(), INTERVAL 6 DAY), '君悦大酒店4月供货应收款', NOW(), NOW()),
@@ -294,3 +270,5 @@ INSERT INTO finance_records (id, shop_id, partner_id, type, amount, balance, ref
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '进口烈酒专库入库应付款', NOW(), NOW()),
(7, 1, 1, 'payment', 100000, 0, 'stock_in', 1, 1,
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '预付茅台货款10万,银行转账', NOW(), NOW());
SET FOREIGN_KEY_CHECKS = 1;
+44 -56
View File
@@ -1,83 +1,71 @@
-- =============================================================
-- 测试门店 S002 种子数据(空库存版)
-- 用法: sh scripts/dev.sh seed S002
-- 说明: 基础数据与 S001 相同,入库/出库/库存数据为空,模拟新门店初始状态
-- 说明: 仅写入数据,不清空。加 --clear 参数先清空再写入
-- 模拟新门店初始状态,无历史入库/出库/库存数据
-- =============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ── 清空(子表先清)────────────────────────────────────────
TRUNCATE TABLE inventory_check_items;
TRUNCATE TABLE inventory_checks;
TRUNCATE TABLE inventory_logs;
TRUNCATE TABLE inventories;
TRUNCATE TABLE stock_out_items;
TRUNCATE TABLE stock_out_orders;
TRUNCATE TABLE stock_in_items;
TRUNCATE TABLE stock_in_orders;
TRUNCATE TABLE finance_records;
TRUNCATE TABLE number_rules;
TRUNCATE TABLE product_images;
TRUNCATE TABLE partners;
TRUNCATE TABLE warehouses;
TRUNCATE TABLE products;
TRUNCATE TABLE product_categories;
TRUNCATE TABLE users;
TRUNCATE TABLE shops;
SET FOREIGN_KEY_CHECKS = 1;
-- ── 门店 ────────────────────────────────────────────────────
INSERT INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
VALUES (1, '醇香汇酒业', 'S002', '上海市静安区南京西路1288号恒隆广场L1-06', '021-52088899', '李文博', NOW(), NOW());
INSERT IGNORE INTO shops (name, code, address, phone, manager_name, created_at, updated_at)
VALUES ('醇香汇酒业', 'S002', '上海市静安区南京西路1288号恒隆广场L1-06', '021-52088899', '李文博', NOW(), NOW());
SET @shop_id = (SELECT id FROM shops WHERE code = 'S002' LIMIT 1);
-- ── 用户(密码均为 password123)────────────────────────────
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
INSERT INTO users (id, shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at) VALUES
(1, 1, 'admin', @pwd, '张三(管理员)', '13800000001', 'admin', 1, NOW(), NOW()),
(2, 1, 'operator', @pwd, '四(操作员)', '13800000002', 'operator', 1, NOW(), NOW()),
(3, 1, 'test', @pwd, '王五(只读', '13800000003', 'readonly', 1, NOW(), NOW());
INSERT INTO users (shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at)
VALUES
(@shop_id, 'admin', @pwd, '文博(管理员)', '13900000001', 'admin', 1, NOW(), NOW()),
(@shop_id, 'operator', @pwd, '陈小红(操作员', '13900000002', 'operator', 1, NOW(), NOW()),
(@shop_id, 'test', @pwd, '王明(只读)', '13900000003', 'readonly', 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE password_hash = @pwd, is_active = 1, updated_at = NOW();
-- ── 仓库 ────────────────────────────────────────────────────
INSERT INTO warehouses (id, shop_id, name, location, is_default, created_at, updated_at) VALUES
(1, 1, '主仓库', 'A1层东侧', 1, NOW(), NOW()),
(2, 1, '进口酒专库', 'B栋2层恒温', 0, NOW(), NOW());
INSERT IGNORE INTO warehouses (shop_id, name, location, is_default, created_at, updated_at) VALUES
(@shop_id, '主仓库', 'A1层', 1, NOW(), NOW()),
(@shop_id, '进口酒专库', 'B区恒温展示', 0, NOW(), NOW());
-- ── 商品分类 ────────────────────────────────────────────────
INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updated_at) VALUES
(1, 1, '白酒', 1, NOW(), NOW()),
(2, 1, '进口烈酒', 2, NOW(), NOW());
INSERT IGNORE INTO product_categories (shop_id, name, sort_order, created_at, updated_at) VALUES
(@shop_id, '白酒', 1, NOW(), NOW()),
(@shop_id, '进口烈酒', 2, NOW(), NOW());
-- ── 商品 ────────────────────────────────────────────────────
INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
SET @cat_baijiu = (SELECT id FROM product_categories WHERE shop_id = @shop_id AND name = '白酒' LIMIT 1);
SET @cat_import = (SELECT id FROM product_categories WHERE shop_id = @shop_id AND name = '进口烈酒' LIMIT 1);
INSERT IGNORE INTO products (shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
purchase_price, sale_price, min_stock, remark, created_at, updated_at) VALUES
(1, 1, 'b2c3d4e5-0002-0002-0002-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
(2, 1, 'b2c3d4e5-0002-0002-0002-000000000002', 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
(3, 1, 'b2c3d4e5-0002-0002-0002-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
(4, 1, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
(5, 1, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
(6, 1, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
(7, 1, 'b2c3d4e5-0002-0002-0002-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
(8, 1, 'b2c3d4e5-0002-0002-0002-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '', @cat_baijiu, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '', @cat_baijiu, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6', '洋河', '500ml/瓶', '', @cat_baijiu, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲', '泸州老窖', '500ml/瓶', '', @cat_baijiu, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑', '剑南春', '500ml/瓶', '', @cat_baijiu, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10', '郎酒', '500ml/瓶', '', @cat_baijiu, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018', '波尔多', '750ml/瓶', '', @cat_import, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP', '人头马', '700ml/瓶', '', @cat_import, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
-- ── 往来单位 ────────────────────────────────────────────────
INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account,
INSERT IGNORE INTO partners (shop_id, code, name, type, contact, phone, address, bank_account,
credit_limit, balance, remark, created_at, updated_at) VALUES
(1, 1, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
(2, 1, 'SUP002', '四川五粮液股份有限公司', 'supplier', '王总监', '0831-33333001', '四川省宜宾市翠屏区', '建设银行宜宾支行 6227 0000 0001 0002', 3000000, 0, '五粮液系列授权经销', NOW(), NOW()),
(3, 1, 'SUP003', '江苏洋河酒厂股份有限公司', 'supplier', '赵主任', '0527-44444001', '江苏省宿迁市洋河新区', '农业银行宿迁支行 6228 0000 0001 0003', 2000000, 0, '洋河梦之蓝系列直供', NOW(), NOW()),
(4, 1, 'SUP004', '四川郎酒股份有限公司', 'supplier', '刘经理', '0830-55555001', '四川省古蔺县二郎镇', '中国银行古蔺支行 6013 0000 0001 0004', 1500000, 0, '郎酒红花郎系列', NOW(), NOW()),
(5, 1, 'CUS001', '北京君悦大酒店', 'customer', '采购', '010-65888001', '北京市朝阳区建国门外大街2号', '中信银行北京朝阳支行 6217 0000 0002 0001', 500000, 0, '五星级酒店,月结', NOW(), NOW()),
(6, 1, 'CUS002', '上海外滩华尔道夫酒店', 'customer', '陈主任', '021-62308001', '上海市黄浦区中山东一路2', '浦发银行上海黄浦支行 6210 0000 0002 0002', 800000, 0, '豪华酒店,季结', NOW(), NOW()),
(7, 1, 'CUS003', '广州白天鹅宾馆', 'customer', '吴采购', '020-81886001', '广州市荔湾区沙面南街1号', '招商银行广州荔湾支行 6225 0000 0002 0003', 300000, 0, '高端宾馆,月结30天', NOW(), NOW());
(@shop_id, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
(@shop_id, 'SUP002', '四川五粮液股份有限公司', 'supplier', '王总监', '0831-33333001', '四川省宜宾市翠屏区', '建设银行宜宾支行 6227 0000 0001 0002', 3000000, 0, '五粮液系列授权经销', NOW(), NOW()),
(@shop_id, 'SUP003', '江苏洋河酒厂股份有限公司', 'supplier', '赵主任', '0527-44444001', '江苏省宿迁市洋河新区', '农业银行宿迁支行 6228 0000 0001 0003', 2000000, 0, '洋河梦之蓝系列直供', NOW(), NOW()),
(@shop_id, 'SUP004', '四川郎酒股份有限公司', 'supplier', '刘经理', '0830-55555001', '四川省古蔺县二郎镇', '中国银行古蔺支行 6013 0000 0001 0004', 1500000, 0, '郎酒红花郎系列', NOW(), NOW()),
(@shop_id, 'CUS001', '上海外滩五号餐厅', 'customer', '采购', '021-63869001', '上海市黄浦区外滩5号', '浦发银行上海黄浦支行 6210 0000 0003 0001', 300000, 0, '高端餐厅,月结', NOW(), NOW()),
(@shop_id, 'CUS002', '苏州虎丘山庄度假酒店', 'customer', '周总', '0512-66888001', '江苏省苏州市虎丘区虎丘路168', '工商银行苏州支行 6222 0000 0003 0002', 200000, 0, '度假酒店,季结', NOW(), NOW());
-- ── 编号规则(序号从 0 开始,无历史单据)──────────────────
INSERT INTO number_rules (id, shop_id, type, prefix, current_no, date_format, updated_at) VALUES
(1, 1, 'stock_in', 'RK', 0, 'YYYYMMDD', NOW()),
(2, 1, 'stock_out', 'CK', 0, 'YYYYMMDD', NOW()),
(3, 1, 'inventory_check', 'PD', 0, 'YYYYMMDD', NOW());
INSERT IGNORE INTO number_rules (shop_id, type, prefix, current_no, date_format, updated_at) VALUES
(@shop_id, 'stock_in', 'RK', 0, 'YYYYMMDD', NOW()),
(@shop_id, 'stock_out', 'CK', 0, 'YYYYMMDD', NOW()),
(@shop_id, 'inventory_check', 'PD', 0, 'YYYYMMDD', NOW());
-- ── 入库单/出库单/库存/流水/财务:均为空 ──────────────────
-- 模拟新门店初始状态,无历史数据
-- 模拟新门店初始状态,无历史数据
SET FOREIGN_KEY_CHECKS = 1;
+32
View File
@@ -0,0 +1,32 @@
-- clear_shop.sql — 清空指定门店的所有业务数据
-- 用法(调用方负责传入 @shop_code 变量):
-- SET @shop_code = 'S001'; SOURCE clear_shop.sql;
-- 或由脚本拼接:
-- echo "SET @shop_code='S001';" | cat - clear_shop.sql | mysql ...
SET @sid = (SELECT id FROM shops WHERE BINARY code = BINARY @shop_code LIMIT 1);
SET FOREIGN_KEY_CHECKS = 0;
DELETE FROM inventory_check_items WHERE shop_id = @sid;
DELETE FROM inventory_checks WHERE shop_id = @sid;
DELETE FROM inventory_logs WHERE shop_id = @sid;
DELETE FROM inventories WHERE shop_id = @sid;
DELETE FROM stock_out_items WHERE shop_id = @sid;
DELETE FROM stock_out_orders WHERE shop_id = @sid;
DELETE FROM stock_in_items WHERE shop_id = @sid;
DELETE FROM stock_in_orders WHERE shop_id = @sid;
DELETE FROM finance_records WHERE shop_id = @sid;
DELETE FROM number_rules WHERE shop_id = @sid;
DELETE FROM product_images WHERE shop_id = @sid;
DELETE FROM partners WHERE shop_id = @sid;
DELETE FROM warehouses WHERE shop_id = @sid;
DELETE FROM product_spec_options WHERE shop_id = @sid;
DELETE FROM product_series_options WHERE shop_id = @sid;
DELETE FROM product_name_options WHERE shop_id = @sid;
DELETE FROM products WHERE shop_id = @sid;
DELETE FROM product_categories WHERE shop_id = @sid;
DELETE FROM users WHERE shop_id = @sid;
DELETE FROM shops WHERE id = @sid;
SET FOREIGN_KEY_CHECKS = 1;
+5 -5
View File
@@ -56,6 +56,7 @@ func SetupTestDB() *gorm.DB {
address TEXT,
phone TEXT,
manager_name TEXT,
logo_url TEXT DEFAULT '',
business_license TEXT,
business_hours TEXT,
shop_photos TEXT,
@@ -120,7 +121,9 @@ func SetupTestDB() *gorm.DB {
min_stock INTEGER DEFAULT 0,
description TEXT,
custom_fields TEXT,
remark TEXT
remark TEXT,
name_pinyin TEXT DEFAULT '',
name_initials TEXT DEFAULT ''
)`,
`CREATE TABLE IF NOT EXISTS warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -349,10 +352,7 @@ func hashPassword(plain string) string {
func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User {
hash := hashPassword(password)
user := &model.User{
TenantBase: model.TenantBase{
Base: model.Base{},
ShopID: shopID,
},
ShopID: shopID,
Username: username,
PasswordHash: hash,
RealName: "Test User " + username,
+1 -1
View File
@@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: web
- platform: android
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.yanmei.jiu"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.yanmei.jiu"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="jiu_client"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.yanmei.jiu
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+11
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
@@ -74,6 +75,16 @@ class ApiClient {
return handler.next(e);
}
// 服务端 5xx 错误上报(4xx 是业务/权限错误,不上报)
final statusCode = e.response?.statusCode ?? 0;
if (statusCode >= 500) {
reportError(
'[${e.requestOptions.method}] ${e.requestOptions.path}$statusCode',
e.stackTrace,
errorType: ErrorType.apiError,
);
}
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
try {
+143
View File
@@ -0,0 +1,143 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
class ErrorType {
static const flutterError = 'flutter_error';
static const zoneError = 'zone_error';
static const caughtException = 'caught_exception';
static const apiError = 'api_error';
}
class ErrorReporter {
ErrorReporter._();
static final ErrorReporter instance = ErrorReporter._();
static const _dedupWindowMs = 60 * 1000;
static const _maxStackLength = 3000;
final Map<String, int> _recentMessages = {};
Dio? _dio;
String? _cachedVersion;
Dio get _client {
_dio ??= Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
return _dio!;
}
Future<String> _getVersion() async {
if (_cachedVersion != null) return _cachedVersion!;
try {
final info = await PackageInfo.fromPlatform();
_cachedVersion = info.version;
} catch (_) {
_cachedVersion = 'unknown';
}
return _cachedVersion!;
}
String _getPlatform() {
if (kIsWeb) return 'web';
try {
if (Platform.isIOS) return 'ios';
if (Platform.isAndroid) return 'android';
if (Platform.isMacOS) return 'macos';
if (Platform.isWindows) return 'windows';
if (Platform.isLinux) return 'linux';
} catch (_) {}
return 'unknown';
}
Future<Map<String, dynamic>> _getUserFields() async {
try {
final prefs = await SharedPreferences.getInstance();
return {
'username': prefs.getString('username') ?? '',
'shop_id': int.tryParse(prefs.getString('shop_id') ?? '') ?? 0,
'shop_no': prefs.getString('shop_no') ?? '',
'role': prefs.getString('role') ?? '',
};
} catch (_) {
return {'username': '', 'shop_id': 0, 'shop_no': '', 'role': ''};
}
}
bool _isDuplicate(String message) {
final now = DateTime.now().millisecondsSinceEpoch;
final lastTime = _recentMessages[message];
if (lastTime != null && now - lastTime < _dedupWindowMs) return true;
_recentMessages[message] = now;
if (_recentMessages.length > 100) {
_recentMessages.removeWhere((_, t) => now - t >= _dedupWindowMs);
}
return false;
}
void report({
required String errorType,
required String errorMsg,
StackTrace? stackTrace,
}) {
if (_isDuplicate(errorMsg)) return;
unawaited(_doReport(
errorType: errorType,
errorMsg: errorMsg,
stackTrace: stackTrace,
));
}
Future<void> _doReport({
required String errorType,
required String errorMsg,
StackTrace? stackTrace,
}) async {
try {
final version = await _getVersion();
final userFields = await _getUserFields();
String? stack;
if (stackTrace != null) {
final raw = stackTrace.toString();
stack = raw.length > _maxStackLength ? raw.substring(0, _maxStackLength) : raw;
}
await _client.post('/public/errors', data: {
'error_type': errorType,
'app_version': version,
'platform': _getPlatform(),
'username': userFields['username'],
'shop_id': userFields['shop_id'],
'shop_no': userFields['shop_no'],
'role': userFields['role'],
'error_msg': errorMsg,
'stack_trace': stack ?? '',
'occurred_at': DateTime.now().millisecondsSinceEpoch,
});
} catch (_) {
// 上报失败静默处理,不影响用户
}
}
}
/// 便捷顶层函数,供 catch 块一行调用
void reportError(
Object error,
StackTrace? stack, {
String errorType = ErrorType.caughtException,
}) {
ErrorReporter.instance.report(
errorType: errorType,
errorMsg: error.toString(),
stackTrace: stack,
);
}
+19 -3
View File
@@ -1,4 +1,5 @@
import 'package:flutter/services.dart';
import '../errors/error_reporter.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
@@ -189,7 +190,12 @@ Future<void> printProductLabelImpl({
),
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
try {
await Printing.layoutPdf(onLayout: (_) async => doc.save());
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
pw.Widget _label2ColRow(
@@ -424,7 +430,12 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
),
],
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
try {
await Printing.layoutPdf(onLayout: (_) async => doc.save());
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
@@ -474,5 +485,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
),
],
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
try {
await Printing.layoutPdf(onLayout: (_) async => doc.save());
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
+23 -6
View File
@@ -4,13 +4,15 @@ import 'dart:typed_data';
import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
void _openPrintWindow(String html) {
final win = web.window.open('', '_blank');
if (win != null) {
win.document.write(html.toJS);
win.document.close();
if (win == null) {
throw Exception('浏览器已拦截弹窗,请在地址栏允许弹窗后重试');
}
win.document.write(html.toJS);
win.document.close();
}
Future<void> printProductLabelImpl({
@@ -187,7 +189,12 @@ body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
</body>
</html>''';
_openPrintWindow(html);
try {
_openPrintWindow(html);
} catch (e, st) {
reportError(e, st);
rethrow;
}
}
// ── 入库单 ──────────────────────────────────────────────────────────────────
@@ -293,7 +300,12 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
</body>
</html>''';
_openPrintWindow(html);
try {
_openPrintWindow(html);
} catch (e, st) {
reportError(e, st);
rethrow;
}
}
// ── 出库单 ──────────────────────────────────────────────────────────────────
@@ -396,5 +408,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
</body>
</html>''';
_openPrintWindow(html);
try {
_openPrintWindow(html);
} catch (e, st) {
reportError(e, st);
rethrow;
}
}
+11
View File
@@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'core/auth/auth_state.dart';
import 'core/errors/error_reporter.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
import 'providers/connectivity_provider.dart';
@@ -15,6 +16,11 @@ void main() {
debugPrint('═══ FlutterError ════════════════════════════');
debugPrint(details.exceptionAsString());
debugPrint(details.stack.toString());
ErrorReporter.instance.report(
errorType: ErrorType.flutterError,
errorMsg: details.exceptionAsString(),
stackTrace: details.stack,
);
};
runZonedGuarded(
@@ -23,6 +29,11 @@ void main() {
debugPrint('═══ Zone Error ═══════════════════════════════');
debugPrint(error.toString());
debugPrint(stack.toString());
ErrorReporter.instance.report(
errorType: ErrorType.zoneError,
errorMsg: error.toString(),
stackTrace: stack,
);
},
);
}
+3
View File
@@ -5,6 +5,7 @@ class ShopInfo {
final String address;
final String phone;
final String managerName;
final String logoUrl;
const ShopInfo({
required this.id,
@@ -13,6 +14,7 @@ class ShopInfo {
required this.address,
required this.phone,
required this.managerName,
this.logoUrl = '',
});
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
@@ -22,5 +24,6 @@ class ShopInfo {
address: json['address'] as String? ?? '',
phone: json['phone'] as String? ?? '',
managerName: json['manager_name'] as String? ?? '',
logoUrl: json['logo_url'] as String? ?? '',
);
}
@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../core/api/api_client.dart';
import '../core/exceptions.dart';
import '../models/shop.dart';
@@ -30,4 +31,19 @@ class ShopRepository {
);
}
}
Future<String> uploadLogo(Uint8List bytes, String filename) async {
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: filename),
});
final resp = await _client.post('/shop/logo', data: formData);
return (resp.data as Map<String, dynamic>)['logo_url'] as String? ?? '';
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? 'Logo 上传失败',
statusCode: e.response?.statusCode,
);
}
}
}
@@ -28,21 +28,16 @@ class InventoryListScreen extends ConsumerStatefulWidget {
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final _searchCtrl = TextEditingController();
Timer? _debounce;
Set<String> _filterWarehouse = {};
@override
void dispose() {
_searchCtrl.dispose();
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
ref.read(inventoryListProvider.notifier).setKeyword(value);
});
void _triggerSearch() {
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
}
Future<void> _editRemark(BuildContext context, Inventory item) async {
@@ -99,42 +94,97 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final file = result.files.first;
final bytes = file.bytes;
if (bytes == null) return;
if (!context.mounted) return;
final token = ref.read(authStateProvider).user?.accessToken ?? '';
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 300),
));
dio.options.headers['Authorization'] = 'Bearer $token';
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
final stateNotifier = ValueNotifier<_ImportState>(
const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0),
);
BuildContext? dialogCtx;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
dialogCtx = ctx;
return PopScope(
canPop: false,
child: _ImportProgressDialog(stateNotifier: stateNotifier),
);
},
);
Timer? processingTimer;
bool uploadDone = false;
int elapsed = 0;
void closeDialog() {
processingTimer?.cancel();
if (dialogCtx != null && dialogCtx!.mounted) {
Navigator.of(dialogCtx!).pop();
}
}
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: file.name),
});
final resp = await dio.post('/import/inventory', data: formData);
final data = resp.data as Map<String, dynamic>;
final imported = data['imported'] ?? 0;
final skipped = data['skipped'] ?? 0;
final errors = (data['errors'] as List?)?.cast<String>() ?? [];
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? '${errors.length} 条失败' : ''}'),
backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent,
duration: const Duration(seconds: 4),
));
ref.read(inventoryListProvider.notifier).reload();
final resp = await dio.post(
'/import/inventory',
data: formData,
onSendProgress: (sent, total) {
if (total <= 0) return;
if (sent >= total && !uploadDone) {
uploadDone = true;
elapsed = 0;
stateNotifier.value = const _ImportState(
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: 0,
);
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
elapsed++;
stateNotifier.value = _ImportState(
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: elapsed,
);
});
} else if (!uploadDone) {
final pct = (sent / total * 100).round().clamp(0, 99);
stateNotifier.value = _ImportState(stage: _ImportStage.uploading, uploadPercent: pct);
}
},
);
processingTimer?.cancel();
final data = resp.data as Map<String, dynamic>;
stateNotifier.value = _ImportState(
stage: _ImportStage.done,
uploadPercent: 100,
total: data['total'] ?? 0,
imported: data['imported'] ?? 0,
updated: data['updated'] ?? 0,
errors: (data['errors'] as List?)?.cast<String>() ?? [],
);
final hasErrors = (data['errors'] as List?)?.isNotEmpty ?? false;
if (!hasErrors) {
await Future.delayed(const Duration(seconds: 2));
closeDialog();
}
if (context.mounted) ref.read(inventoryListProvider.notifier).reload();
} on DioException catch (e) {
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误';
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入失败:$msg'),
backgroundColor: AppTheme.danger,
));
processingTimer?.cancel();
final msg = (e.response?.data is Map ? e.response!.data['error'] : null)
?? e.message ?? '未知错误';
stateNotifier.value = _ImportState(
stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg,
);
}
}
@@ -278,15 +328,20 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
const Spacer(),
SizedBox(
width: 200,
width: 220,
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
hintText: '搜索商品名/编码',
prefixIcon: Icon(Icons.search, size: 16),
hintStyle: TextStyle(fontSize: 13),
decoration: InputDecoration(
hintText: '名称/编码/拼音,回车搜索',
prefixIcon: const Icon(Icons.search, size: 16),
hintStyle: const TextStyle(fontSize: 12),
suffixIcon: IconButton(
icon: const Icon(Icons.search, size: 16),
tooltip: '搜索',
onPressed: _triggerSearch,
),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => _triggerSearch(),
),
),
],
@@ -865,3 +920,163 @@ class _DirectionBadge extends StatelessWidget {
);
}
}
// ── 导入进度对话框 ──────────────────────────────────────────
enum _ImportStage { uploading, processing, done, error }
class _ImportState {
final _ImportStage stage;
final int uploadPercent;
final int processingSeconds;
final int total;
final int imported;
final int updated;
final List<String> errors;
final String? errorMsg;
const _ImportState({
required this.stage,
required this.uploadPercent,
this.processingSeconds = 0,
this.total = 0,
this.imported = 0,
this.updated = 0,
this.errors = const [],
this.errorMsg,
});
}
class _ImportProgressDialog extends StatelessWidget {
final ValueNotifier<_ImportState> stateNotifier;
const _ImportProgressDialog({required this.stateNotifier});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('导入库存'),
content: ValueListenableBuilder<_ImportState>(
valueListenable: stateNotifier,
builder: (_, state, __) => SizedBox(
width: 320,
child: _buildContent(context, state),
),
),
actions: [
ValueListenableBuilder<_ImportState>(
valueListenable: stateNotifier,
builder: (ctx, state, __) {
if (state.stage == _ImportStage.done || state.stage == _ImportStage.error) {
return TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('关闭'),
);
}
return const SizedBox.shrink();
},
),
],
);
}
Widget _buildContent(BuildContext context, _ImportState state) {
switch (state.stage) {
case _ImportStage.uploading:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('上传文件...', style: TextStyle(fontSize: 14)),
const SizedBox(height: 12),
LinearProgressIndicator(
value: state.uploadPercent / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
const SizedBox(height: 6),
Text('${state.uploadPercent}%',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
case _ImportStage.processing:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
),
const SizedBox(width: 10),
Text(
'导入数据中...${state.processingSeconds > 0 ? "${state.processingSeconds}秒)" : ""}',
style: const TextStyle(fontSize: 14),
),
]),
const SizedBox(height: 8),
LinearProgressIndicator(
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
],
);
case _ImportStage.done:
final allFailed = state.total == 0 && state.errors.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
allFailed ? Icons.warning_amber_rounded : Icons.check_circle,
color: allFailed ? AppTheme.danger : AppTheme.success,
size: 20,
),
const SizedBox(width: 8),
Text(
allFailed ? '导入失败' : '导入完成',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
]),
const SizedBox(height: 10),
Text('${state.total}', style: const TextStyle(fontSize: 13)),
Text('成功插入:${state.imported}', style: const TextStyle(fontSize: 13)),
Text('重复更新:${state.updated}',
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
if (state.errors.isNotEmpty)
Text('失败:${state.errors.length}',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
if (state.errors.isNotEmpty) ...[
const SizedBox(height: 6),
...state.errors.map((e) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(e,
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
)),
],
],
);
case _ImportStage.error:
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, color: AppTheme.danger, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'导入失败:${state.errorMsg}',
style: TextStyle(fontSize: 13, color: AppTheme.danger),
),
),
],
);
}
}
}
@@ -130,7 +130,7 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
slivers: [
// 1. Gallery
SliverToBoxAdapter(
child: _HeroGallery(imageUrls: imageUrls),
child: _HeroGallery(imageUrls: imageUrls, shopName: shop?['name'] as String? ?? ''),
),
// 2. Verified ribbon
SliverToBoxAdapter(
@@ -227,7 +227,8 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
class _HeroGallery extends StatefulWidget {
final List<String> imageUrls;
const _HeroGallery({required this.imageUrls});
final String shopName;
const _HeroGallery({required this.imageUrls, this.shopName = ''});
@override
State<_HeroGallery> createState() => _HeroGalleryState();
@@ -280,7 +281,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
),
// Image or placeholder
if (urls.isEmpty)
_EmptyGalleryContent()
_EmptyGalleryContent(shopName: widget.shopName)
else
PageView.builder(
controller: _ctrl,
@@ -291,7 +292,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
child: Image.network(
urls[i],
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _EmptyGalleryContent(),
errorBuilder: (_, __, ___) => _EmptyGalleryContent(shopName: widget.shopName),
),
),
),
@@ -409,8 +410,12 @@ class _HeroGalleryState extends State<_HeroGallery> {
}
class _EmptyGalleryContent extends StatelessWidget {
final String shopName;
const _EmptyGalleryContent({this.shopName = ''});
@override
Widget build(BuildContext context) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -421,9 +426,9 @@ class _EmptyGalleryContent extends StatelessWidget {
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text('岩美',
style: TextStyle(color: Colors.white70, fontSize: 18, fontWeight: FontWeight.w700)),
child: Center(
child: Text(initial,
style: const TextStyle(color: Colors.white70, fontSize: 28, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 12),
+205 -20
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import '../../core/utils/dialog_util.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
@@ -126,14 +127,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShopInfoRow(
label: '门店编号',
value: shop.code.isNotEmpty ? shop.code : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '门店名称',
value: shop.name.isNotEmpty ? shop.name : ''),
const Divider(height: 16),
// Logo
Row(
children: [
_ShopLogoPreview(logoUrl: shop.logoUrl, shopName: shop.name, size: 64),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(shop.name.isNotEmpty ? shop.name : '',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(shop.code.isNotEmpty ? shop.code : '',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
],
),
],
),
const Divider(height: 24),
_ShopInfoRow(
label: '门店地址',
value: shop.address.isNotEmpty ? shop.address : ''),
@@ -1524,10 +1535,23 @@ class _ImportSlot {
int total = 0;
int imported = 0;
int skipped = 0;
int updated = 0;
//
int uploadPercent = 0;
bool isProcessing = false;
int processingSeconds = 0;
_ImportSlot(this.title, this.endpoint, this.hint);
bool get hasResult => success != null;
void resetProgress() {
uploadPercent = 0;
isProcessing = false;
processingSeconds = 0;
success = null;
error = null;
}
}
class _BatchImportWidget extends ConsumerStatefulWidget {
@@ -1657,14 +1681,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
setState(() {
_loading = true;
for (final s in _slots) {
if (s.file != null) {
s.success = null;
s.error = null;
}
if (s.file != null) s.resetProgress();
}
});
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 300),
));
dio.options.headers['Authorization'] = 'Bearer $token';
for (final slot in _slots) {
@@ -1674,29 +1699,57 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
continue;
}
Timer? processingTimer;
bool uploadDone = false;
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
});
final resp = await dio.post(slot.endpoint, data: formData);
final resp = await dio.post(
slot.endpoint,
data: formData,
onSendProgress: (sent, total) {
if (total <= 0 || !mounted) return;
if (sent >= total && !uploadDone) {
uploadDone = true;
setState(() { slot.isProcessing = true; slot.processingSeconds = 0; });
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => slot.processingSeconds++);
});
} else if (!uploadDone) {
final pct = (sent / total * 100).round().clamp(0, 99);
if (mounted) setState(() => slot.uploadPercent = pct);
}
},
);
processingTimer?.cancel();
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
if (mounted) setState(() {
slot.success = true;
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
slot.imported = (data['imported'] ?? 0) as int;
slot.skipped = (data['skipped'] ?? 0) as int;
slot.updated = (data['updated'] ?? 0) as int;
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
slot.isProcessing = false;
});
} on DioException catch (e) {
processingTimer?.cancel();
final raw = e.response?.data;
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
if (mounted) setState(() {
slot.success = false;
slot.error = msg.toString();
slot.isProcessing = false;
});
} catch (e) {
processingTimer?.cancel();
if (mounted) setState(() {
slot.success = false;
slot.error = e.toString();
slot.isProcessing = false;
});
}
}
@@ -2017,10 +2070,11 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
Widget? statusWidget;
if (slot.hasResult) {
if (slot.success == true) {
final duplicate = slot.skipped + slot.updated;
final parts = <String>[
'${slot.total}',
'新增 ${slot.imported}',
if (slot.skipped > 0) '重复跳过 ${slot.skipped}',
if (duplicate > 0) '重复 $duplicate',
];
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
@@ -2047,10 +2101,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
);
}
} else if (_loading && slot.file != null) {
statusWidget = const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
);
if (slot.isProcessing) {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12, height: 12,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
),
const SizedBox(width: 6),
Text(
'导入数据${slot.processingSeconds > 0 ? "${slot.processingSeconds}秒)" : ""}',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
);
} else {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 80,
child: LinearProgressIndicator(
value: slot.uploadPercent / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
minHeight: 6,
),
),
const SizedBox(width: 6),
Text(
'上传 ${slot.uploadPercent}%',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
);
}
}
return Padding(
@@ -2104,6 +2190,54 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
}
}
// Logo
class _ShopLogoPreview extends StatelessWidget {
final String logoUrl;
final String shopName;
final double size;
const _ShopLogoPreview({required this.logoUrl, required this.shopName, this.size = 64});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(size * 0.19);
if (logoUrl.isNotEmpty) {
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
return ClipRRect(
borderRadius: radius,
child: Image.network(
fullUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _initial(radius),
),
);
}
return _initial(radius);
}
Widget _initial(BorderRadius radius) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: radius,
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.5,
fontWeight: FontWeight.w600,
),
),
);
}
}
//
class _ShopInfoRow extends StatelessWidget {
final String label;
@@ -2149,6 +2283,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
late final TextEditingController _phoneCtrl;
late final TextEditingController _managerCtrl;
bool _saving = false;
bool _uploadingLogo = false;
@override
void initState() {
@@ -2168,6 +2303,37 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
super.dispose();
}
Future<void> _uploadLogo() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.bytes == null) return;
setState(() => _uploadingLogo = true);
try {
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
ref.invalidate(shopInfoProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Logo 已更新'),
backgroundColor: AppTheme.success,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('上传失败:$e'),
backgroundColor: AppTheme.danger,
));
}
} finally {
if (mounted) setState(() => _uploadingLogo = false);
}
}
Future<void> _save() async {
setState(() => _saving = true);
try {
@@ -2206,6 +2372,25 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo
Row(
children: [
_ShopLogoPreview(
logoUrl: ref.watch(shopInfoProvider).valueOrNull?.logoUrl ?? widget.shop.logoUrl,
shopName: _nameCtrl.text,
size: 56,
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _uploadingLogo ? null : _uploadLogo,
icon: _uploadingLogo
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.upload_outlined, size: 16),
label: const Text('更换 Logo'),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: '门店名称'),
+46 -56
View File
@@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'dart:async';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart';
class AppShell extends ConsumerStatefulWidget {
@@ -575,27 +577,31 @@ void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'
);
}
class _ShopButton extends StatelessWidget {
class _ShopButton extends ConsumerWidget {
final AuthUser? user;
final String version;
const _ShopButton({this.user, this.version = 'v1.0.0'});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final shopAsync = ref.watch(shopInfoProvider);
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
if (user != null) _showShopPanel(context, user!, version: version);
},
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_YanmeiMark(size: 28),
SizedBox(width: 10),
_ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28),
const SizedBox(width: 10),
Text(
'岩美',
style: TextStyle(
shopName.isEmpty ? '' : shopName,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
@@ -608,70 +614,54 @@ class _ShopButton extends StatelessWidget {
}
}
/// Brand mark widget approximates the logo SVG without flutter_svg.
/// Dark blue rounded rect, white mountain/wave strokes, bordeaux dot.
class _YanmeiMark extends StatelessWidget {
/// Logo
class _ShopLogo extends StatelessWidget {
final String logoUrl;
final String shopName;
final double size;
const _YanmeiMark({this.size = 32});
const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(size * 0.19);
if (logoUrl.isNotEmpty) {
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
return ClipRRect(
borderRadius: radius,
child: Image.network(
fullUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _initial(radius),
),
);
}
return _initial(radius);
}
Widget _initial(BorderRadius radius) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(size * 0.19),
borderRadius: radius,
),
child: CustomPaint(
painter: _YanmeiMarkPainter(),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.5,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _YanmeiMarkPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final paint = Paint()
..color = Colors.white
..strokeWidth = w * 0.055
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
// Mountain/wave path: M14 38 L22 22 L32 32 L42 22 L50 38 (on 64px grid)
final path = Path();
path.moveTo(w * 0.219, h * 0.594);
path.lineTo(w * 0.344, h * 0.344);
path.lineTo(w * 0.500, h * 0.500);
path.lineTo(w * 0.656, h * 0.344);
path.lineTo(w * 0.781, h * 0.594);
canvas.drawPath(path, paint);
// Horizontal baseline: M12 46 L52 46 (on 64px grid)
canvas.drawLine(
Offset(w * 0.1875, h * 0.719),
Offset(w * 0.8125, h * 0.719),
paint,
);
// Bordeaux dot: circle cx=32 cy=52 r=2.4 (on 64px grid)
canvas.drawCircle(
Offset(w * 0.500, h * 0.859),
w * 0.042,
Paint()
..color = const Color(0xFFC97B86)
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
@@ -247,8 +247,16 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
),
TextButton(
onPressed: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
await printStockInOrder(order);
try {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
await printStockInOrder(order);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
},
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
@@ -690,7 +698,17 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
IconButton(
icon: const Icon(Icons.print_outlined, color: Colors.white),
tooltip: '打印',
onPressed: () => printStockInOrder(_loadedOrder!),
onPressed: () async {
try {
await printStockInOrder(_loadedOrder!);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
},
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
@@ -254,8 +254,16 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
),
TextButton(
onPressed: () async {
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
await printStockOutOrder(order);
try {
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
await printStockOutOrder(order);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
},
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
@@ -682,7 +690,17 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
IconButton(
icon: const Icon(Icons.print_outlined, color: Colors.white),
tooltip: '打印',
onPressed: () => printStockOutOrder(_loadedOrder!),
onPressed: () async {
try {
await printStockOutOrder(_loadedOrder!);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
},
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
+7
View File
@@ -19,6 +19,13 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="岩美酒库管理系统 — 酒店饮品库存与采购管理平台">
<meta name="color-scheme" content="light">
<style>
html, body {
background-color: #ffffff;
color-scheme: light;
}
</style>
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
+24
View File
@@ -7,6 +7,8 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 20m;
# 商品图片静态文件
location ^~ /images/ {
alias /opt/jiu/images/;
@@ -14,6 +16,14 @@ server {
add_header Cache-Control "public, immutable";
}
# 文件导入接口(超时更长)
location ~ ^/api/v1/import/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
}
# API 反向代理
location ~ ^/(api|health|version) {
proxy_pass http://127.0.0.1:8080;
@@ -29,6 +39,20 @@ server {
try_files $uri $uri/ /app/index.html;
}
# index.html 不缓存,确保每次发版后浏览器加载最新版
location = /app/index.html {
alias /opt/jiu/web/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理
location ~ ^/product/ {
root /opt/jiu/web;
try_files $uri /app/index.html;
}
# 扫码页(/scan/<id> → scan.htmlJS 从 URL 读取 id
location /scan/ {
root /opt/jiu/marketing;
+46 -118
View File
@@ -10,9 +10,9 @@
| 服务 | 地址 |
|------|------|
| Gitea Web UI | `http://192.168.3.200:3000` |
| Gitea SSH | `ssh://192.168.3.200:2222` |
| Actions 面板 | `http://192.168.3.200:3000/wangjia/jiu/actions` |
| Gitea Web UI | `https://git.51yanmei.com` |
| Gitea SSH | `ssh://git@git.51yanmei.com:2222` |
| Actions 面板 | `https://git.51yanmei.com/wangjia/jiu/actions` |
| 线上应用 | `https://jiu.51yanmei.com` |
| 管理端 Web | `https://jiu.51yanmei.com/app/` |
@@ -28,8 +28,8 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
|------|------|
| `/Users/wangjia/bin/act_runner` | act_runner 二进制(v0.2.11 |
| `/Users/wangjia/bin/act_runner_start.sh` | 启动脚本(含 TCP 中继逻辑) |
| `/Users/wangjia/bin/tcp_relay.py` | Python3 TCP 中继`127.0.0.1:13000 → 192.168.3.200:3000` |
| `/Users/wangjia/.act_runner` | Runner 注册信息(地址指向 `127.0.0.1:13000` |
| `/Users/wangjia/bin/tcp_relay.py` | Python3 TCP 中继(旧内网方案,现已通过域名直连) |
| `/Users/wangjia/.act_runner` | Runner 注册信息 |
| `/Users/wangjia/.act_runner_config.yaml` | Runner 配置(label: `mac:host`capacity: 1 |
| `~/Library/LaunchAgents/com.jiu.act-runner.plist` | LaunchAgent plist,开机自启 |
| `/tmp/act_runner.log` | 运行日志 |
@@ -38,12 +38,6 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
`.gitea/workflows/deploy.yml``ci.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`
### 为什么需要 TCP 中继(重要)
Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动的 Go 二进制发出的 TCP 连接,即使 Shadowrocket 内已配置直连规则。现象:Go gRPC 报 `no route to host`,而同进程里的 Python3 socket 可以正常连接。
**解决方案:** 用 Python3 做透明 TCP 中继。`tcp_relay.py` 监听 `127.0.0.1:13000`,将连接转发到 `192.168.3.200:3000`。act_runner 的注册地址改为 `http://127.0.0.1:13000`,完全绕开 Shadowrocket 拦截。
### 检查 Runner 状态
```bash
@@ -51,7 +45,7 @@ Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动
tail -f /tmp/act_runner.log
# 确认进程在跑
ps aux | grep -E "act_runner|tcp_relay" | grep -v grep
ps aux | grep act_runner | grep -v grep
# 手动重启(LaunchAgent 会自动重启,一般不需要)
kill $(pgrep -f act_runner_start.sh)
@@ -59,12 +53,10 @@ kill $(pgrep -f act_runner_start.sh)
### Runner 不工作时的排查步骤
1. **确认 NAS 可达**`curl -sf http://192.168.3.200:3000/api/v1/version`
2. **确认 relay 在跑**`ps aux | grep tcp_relay`
3. **确认 relay 端口监听**`nc -zv 127.0.0.1 13000`
4. **查看最近日志**`tail -30 /tmp/act_runner.log`
5. **手动测试**`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
6. **强制重启**`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
1. **确认 Gitea 可达**`curl -sf https://git.51yanmei.com/api/v1/version`
2. **查看最近日志**`tail -30 /tmp/act_runner.log`
3. **手动测试**`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
4. **强制重启**`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
### 重新注册 Runnertoken 失效时)
@@ -81,15 +73,13 @@ rm ~/.act_runner
# 4. 注册(从终端运行,不要从 LaunchAgent
/Users/wangjia/bin/act_runner register \
--no-interactive \
--instance http://192.168.3.200:3000 \
--instance https://git.51yanmei.com \
# SSH remote: ssh://git@git.51yanmei.com:2222/wangjia/jiu.git
--token <新token> \
--name mac-runner \
--labels mac:host
# 5. 把注册文件里的地址改回 127.0.0.1:13000
sed -i '' 's|http://192.168.3.200:3000|http://127.0.0.1:13000|' ~/.act_runner
# 6. 重启(LaunchAgent 自动接管)
# 5. 重启(LaunchAgent 自动接管)
kill $(pgrep -f act_runner_start.sh)
```
@@ -101,19 +91,19 @@ kill $(pgrep -f act_runner_start.sh)
### `deploy.yml` — 主部署流程
**触发条件:** push `main` 分支
**触发条件:** push `v*.*.*` tag
**步骤:**
1. `go test ./...` — 跑后端测试
2. `flutter test` — 跑前端测试
3. 编译后端:`GOOS=linux GOARCH=amd64 go build` 生成 `jiu-server`
4. 编译 Flutter Web`flutter build web --release --base-href=/app/`
5. 创建 Forgejo Release(版本号格式 `vYYYYMMDD.HHMM`),上传 `jiu-server``web.tar.gz`
6. SSH 到 EC2 部署:
- 停止 jiu 服务 → 替换二进制 → 启动 → 等待健康检查通过
- 替换 Flutter Web 静态文件到 `/opt/jiu/web/`
- 同步营销站点到 `/opt/jiu/marketing/`
- `nginx -s reload`
1. `compile.sh` — 编译 Go 后端(linux/amd64+ Flutter Web,打包到 `dist/`
2. `test.sh``go test` + `flutter analyze`
3. `release.sh` — 解析 CHANGELOG.md,更新 version.yaml,创建 Forgejo Release,上传 3 个 asset
4. `deploy.sh` — SSH 到 EC2 部署,完成后发 Telegram 通知
### `manual.yml` — 手动部署 / 回滚
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入目标 tag(如 `v1.0.0`
**步骤:** 只运行 `deploy.sh`,从 Forgejo Release 下载已有产物直接部署,跳过编译和测试。
### `ci.yml` — PR 测试流程
@@ -121,12 +111,6 @@ kill $(pgrep -f act_runner_start.sh)
**步骤:** 只跑测试(`go test` + `flutter test`),不构建不部署。
### `backup.yml` — 每日备份
**触发条件:** 每天凌晨 2:00cron
**步骤:** SSH 到 EC2,导出 MySQL 数据库备份,上传到指定目录。
---
## Secrets 配置
@@ -138,26 +122,23 @@ kill $(pgrep -f act_runner_start.sh)
| `EC2_SSH_KEY` | EC2 服务器的 SSH 私钥(PEM 格式,完整内容) |
| `EC2_HOST` | EC2 服务器 IP 或域名 |
| `EC2_USER` | EC2 SSH 用户名(如 `ec2-user``ubuntu` |
| `DB_PASSWORD` | MySQL root 密码(备份 workflow 使用 |
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release |
| `FORGEJO_URL` | Gitea 内网地址,如 `http://192.168.3.200:3000` |
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release、下载 asset |
| `FORGEJO_URL` | Gitea 地址:`https://git.51yanmei.com` |
| `TELEGRAM_TOKEN` | Telegram Bot Token(部署通知) |
| `TELEGRAM_CHAT_ID` | Telegram Chat ID(接收通知的对话) |
---
## 触发部署方式
日常开发流程:
```bash
# 开发 → 本地测试
go test ./...
flutter test
# 用户确认后推送到 NAS Gitea(触发自动部署)
git push nas main
# 开发完成,确认测试通过后打 tag
git tag -a v1.1.0 -m "v1.1.0"
git push nas main && git push nas v1.1.0
# → CI 自动:compile → test → release → deploy → Telegram 通知
```
推送到 `nas` remote(指向 `http://192.168.3.200:3000/wangjia/jiu.git`)后,Gitea Actions 自动运行 `deploy.yml`,全流程约 5~8 分钟。
**回滚:** Forgejo UI → Actions → Manual Deploy → 输入旧 tag → Run
---
@@ -166,17 +147,16 @@ git push nas main
```
/opt/jiu/
├── backend/
── jiu-server # Go 可执行文件
── jiu-server # Go 可执行文件
│ └── config/
│ └── version.yaml # 版本信息(由 CI 自动更新)
├── web/ # Flutter Web 构建产物(/app/ 路径)
├── marketing/ # 营销站点静态 HTML
│ ├── assets/ # CSS / SVG
│ ├── assets/
│ ├── index.html
│ ├── docs.html
│ ├── download.html
── scan.html
│ └── features/
│ ├── inventory.html
│ └── approval.html
── scan.html
├── images/ # 商品图片上传目录
└── web-old/ # 上一版本 Flutter Web(回滚备用)
```
@@ -188,64 +168,12 @@ Systemd 服务名:`jiu`
## Nginx 路由说明
配置文件:`deploy/nginx-jiu.conf`部署时手动 scp 至服务器
配置文件:`deploy/nginx-jiu.conf`CI 部署时自动更新
| 路径 | 服务 | 目录 |
|------|------|------|
| `/api/*`, `/health`, `/version` | Go 后端 API | proxy → `localhost:8080` |
| `/images/*` | 商品图片 | `/opt/jiu/images/` |
| `/app/` | Flutter 管理端 SPA | `/opt/jiu/web/` |
| `/` | 营销首页 | `/opt/jiu/marketing/index.html` |
| `/docs` | 文档页 | `/opt/jiu/marketing/docs.html` |
| `/download` | 下载页 | `/opt/jiu/marketing/download.html` |
| `/scan/<id>` | 商品防伪扫码页 | `/opt/jiu/marketing/scan.html` |
| `/features/*` | 功能介绍页 | `/opt/jiu/marketing/features/` |
| `/assets/*` | 营销站点静态资源 | `/opt/jiu/marketing/assets/` |
> **注意:** `nginx-jiu.conf` 只包含 `jiu.51yanmei.com` 的 server block。服务器上其他服务的 nginx 配置不受影响。更新 nginx 配置时只需替换此文件,然后 `nginx -t && nginx -s reload`
---
## 回滚方式
### 后端回滚
```bash
# SSH 到 EC2
sudo systemctl stop jiu
# 从上一个 Forgejo Release 下载 jiu-server
cp /path/to/old-jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
sudo systemctl start jiu
```
### Flutter Web 回滚
```bash
# 恢复上一版本
mv /opt/jiu/web /opt/jiu/web-broken
mv /opt/jiu/web-old /opt/jiu/web
sudo nginx -s reload
```
---
## 环境变量(jiu 服务)
Systemd service 文件(`/etc/systemd/system/jiu.service`)中配置:
```ini
[Service]
Environment=DB_HOST=127.0.0.1
Environment=DB_PORT=3306
Environment=DB_NAME=jiu
Environment=DB_USER=jiu
Environment=DB_PASSWORD=<从 secrets 获取>
Environment=JWT_SECRET=<随机字符串>
Environment=APP_VERSION=1.0.0
Environment=BUILD_DATE=2025-05-23
Environment=DOWNLOAD_URL_MACOS=
Environment=DOWNLOAD_URL_WINDOWS=
```
`APP_VERSION``BUILD_DATE` 在每次部署时通过 deploy.yml 更新(预留,目前读取 Forgejo Release tag)。
| 路径 | 服务 |
|------|------|
| `/api/*`, `/health`, `/version` | Go 后端 API |
| `/images/*` | 商品图片静态文件 |
| `/app/` | Flutter 管理端 SPA |
| `/` | 营销首页 |
| `/scan/<id>` | 商品扫码页 |
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# compile.sh <tag> — 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/
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# deploy.sh <tag> — 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 -kf \
-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, ssl, os, sys
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
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, context=ctx) 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 -f http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -f 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"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# notify.sh <tag> <status> — send Telegram deploy notification
set -euo pipefail
TAG="$1"
STATUS="$2"
if [ "$STATUS" = "success" ]; then
ICON="✅"
LABEL="部署成功"
else
ICON="❌"
LABEL="部署失败"
fi
MSG="${ICON} 岩美 ${TAG} ${LABEL}
https://jiu.51yanmei.com"
curl -f -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${MSG}" \
> /dev/null
echo "==> notify: sent to Telegram (${STATUS})"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# release.sh <tag> — 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
)
HTTP_CODE=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
-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}")
RESP=$(cat /tmp/release_resp.json)
echo "==> release: API response (HTTP ${HTTP_CODE}): ${RESP}"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "==> release: FAILED — HTTP ${HTTP_CODE}"
exit 1
fi
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}")
echo "==> release: release_id=${RELEASE_ID}"
# Upload assets
upload_asset() {
local file="$1"
local code
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}")
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then exit 1; fi
}
upload_asset dist/jiu-server
upload_asset dist/web.tar.gz
upload_asset dist/configs.tar.gz
echo "==> release: done — Release ${TAG} created"
+20
View File
@@ -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 --no-fatal-infos --no-fatal-warnings
cd ..
echo "==> test: done"
+1 -1
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")/.."
EC2_HOST="${EC2_HOST:-$(grep EC2_HOST ~/.env 2>/dev/null | cut -d= -f2)}"
EC2_USER="${EC2_USER:-$(grep EC2_USER ~/.env 2>/dev/null | cut -d= -f2)}"
EC2_KEY="${EC2_KEY:-~/.ssh/wangjia.pem}"
FORGEJO_URL="http://192.168.3.200:3000"
FORGEJO_URL="https://git.51yanmei.com"
FORGEJO_TOKEN="${FORGEJO_TOKEN:-$(grep FORGEJO_TOKEN ~/.env 2>/dev/null | cut -d= -f2)}"
echo "==> 1/5 Go 测试"
+28 -6
View File
@@ -36,12 +36,22 @@ WEB_PID_FILE="$LOG_DIR/web.pid"
mkdir -p "$LOG_DIR"
# ── seed 命令 ────────────────────────────────────────────────
# 用法: cmd_seed <shop_code>
# 从 backend/config/config.yaml 读取 DSN,执行 backend/seeds/<shop>.sql
# 用法: cmd_seed [--clear] <shop_code>
# 从 docker-compose.yml 读取容器信息,执行 backend/seeds/<shop>.sql
# --clear: 写入前先清空该门店所有数据
cmd_seed() {
local shop="${1:-}"
local clear=false
local shop=""
while [ $# -gt 0 ]; do
case "$1" in
--clear) clear=true ;;
*) shop="$1" ;;
esac
shift
done
if [ -z "$shop" ]; then
error "用法: sh scripts/dev.sh seed <shop_code> 示例: sh scripts/dev.sh seed H001"
error "用法: sh scripts/dev.sh seed [--clear] <shop_code> 示例: sh scripts/dev.sh seed S001"
exit 1
fi
@@ -64,6 +74,17 @@ cmd_seed() {
exit 1
fi
if [ "$clear" = "true" ]; then
local clear_file="$ROOT/backend/seeds/clear_shop.sql"
if [ ! -f "$clear_file" ]; then
error "清空脚本不存在: $clear_file"
exit 1
fi
info "清空 ${shop} 数据 → 容器 ${container} / ${db_name}"
{ printf "SET @shop_code='%s';\n" "$shop"; cat "$clear_file"; } \
| docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name"
fi
info "写入 ${shop} 种子数据 → 容器 ${container} / ${db_name}"
docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name" < "$sql_file"
success "${shop} 种子数据写入完成"
@@ -107,7 +128,8 @@ show_help() {
echo " --web-only 仅启动前端(Web"
echo ""
echo "数据库命令:"
echo " seed <shop_code> 清空并写入指定门店测试数据(如 H001"
echo " seed <shop_code> 写入指定门店测试数据(如 S001"
echo " seed --clear <shop> 先清空再写入(单店)"
echo " reset 删表重建(AutoMigrate"
echo " clear 清空所有业务数据"
echo ""
@@ -135,7 +157,7 @@ case "$COMMAND" in
esac
;;
seed)
cmd_seed "${2:-}"
cmd_seed "${2:-}" "${3:-}"
exit 0
;;
reset)
+2
View File
@@ -5,6 +5,8 @@
========================================================================= */
:root {
color-scheme: light;
/* ---------- Brand: Primary (Slate Blue) ---------- */
/* Trustworthy enterprise blue with a slight slate cast. */
--brand-50: #EEF4FB;
+19 -4
View File
@@ -954,15 +954,15 @@ footer {
</p>
<div class="changelog">
<!-- v1.6.2 -->
<!-- 当前版本(由 JS 动态更新) -->
<div class="changelog-entry current">
<div class="changelog-meta">
<div class="changelog-version">v1.6.2</div>
<div class="changelog-date">2026-05-15</div>
<div class="changelog-version" id="version-badge">...</div>
<div class="changelog-date" id="release-date">-</div>
<span class="changelog-tag">当前版本</span>
</div>
<div class="changelog-body">
<h3>多仓库联动盘点与权限增强</h3>
<h3 id="release-notes">加载中...</h3>
<div class="changelog-cat">
<div class="changelog-cat-label"><span class="dot feature"></span>新功能</div>
<ul>
@@ -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();
});
</script>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>岩美酒库管理系统 — 为酒行与酒店设计的库存管理平台</title>
<link rel="stylesheet" href="assets/colors_and_type.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">