Compare commits

...

14 Commits

Author SHA1 Message Date
wangjia 2a75b3a8b6 ci: 新增仅构建 Windows 的 workflow (build-windows.yml)
Build Windows Only / build-windows (push) Successful in 1m53s
手动 workflow_dispatch 或推 winbuild* tag 触发,复用 provision/compile-windows.sh,
产物经 upload-artifact 传到 Forgejo,不触发完整 deploy。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:46:55 +08:00
wangjia 0714fb8a1b feat(client): 标签 TSPL 裸发支持 Windows(winspool WritePrinter)
- 抽出 _findThermalPrinter(macOS: lpstat -e;Windows: Printing.listPrinters)
  与 _sendRaw(macOS/Linux: lp -o raw;Windows: winspool WritePrinter RAW)
- 新增 _winRawPrint:OpenPrinter/StartDocPrinter(RAW)/WritePrinter 直送裸字节
- 位图渲染与 TSPL 生成跨平台复用;macOS 行为不变
- 依赖新增 win32/ffi(仅 Windows 运行时调用,已 Platform.isWindows 守卫)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:42:49 +08:00
wangjia d966951b6b feat(client): 标签打印改为热敏机 TSPL 裸发 + 自适应扁平版式
- 桌面端(macOS)检测到得力 DL-888B 等热敏机时,用 dart:ui 直接把标签绘制成
  单色位图,经 TSPL BITMAP 命令 lp -o raw 裸发;系统打印走 CUPS 通用驱动会
  多走纸/空白,故对热敏机绕过系统打印
- 新版式(无字段名标签):酒行名 / 商品名 / 度数(系列)+规格同行 / 生产日期,
  右侧二维码 + 下方居中"扫码溯源";左侧各行按高度等比铺满、字号自适应
- 二维码用后端 PNG 解码后绘制(避免 PDF 光栅丢图);生产日期仅在有值时显示
  (商品库无该字段,入库/库存才有)
- 修复 macOS printing 包 layoutPdf 主线程死锁(dynamicLayout: false)
- macOS 关闭 app-sandbox 以允许调用 lp 裸发

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:26:30 +08:00
wangjia c5a72ecbdf merge: maestro/tsk_Qmk4wT_dkqRR [License license_key 前缀索引修复] 2026-06-13 14:38:03 +08:00
wangjia c553237f15 fix(db): 缩短 license_key 列至 VARCHAR(768) 避免 InnoDB 索引超限 [tsk_Qmk4wT_dkqRR]
原 size:2048 在 utf8mb4 下产生 8192 字节索引,超过 InnoDB 3072 字节上限,
全新环境 AutoMigrate 必失败。改为 VARCHAR(768)(768×4=3072 字节,恰好
在上限内),schema.sql 同步更新列类型并将 uk_license_key 改为全列索引(无
需前缀)。新增测试 TestLicenseTokenFitsColumnLimit 验证最坏情况 token 长度
确实在 768 字符以内。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:01:30 +08:00
wangjia 02082df525 chore: release v1.0.35
Deploy / build-linux-web (push) Successful in 52s
Deploy / build-windows (push) Successful in 1m48s
Deploy / build-macos (push) Successful in 1m26s
Deploy / build-android (push) Successful in 1m32s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m51s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:51:22 +08:00
wangjia 4c75f328c7 chore: release v1.0.34
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m47s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 59s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m47s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:28:01 +08:00
wangjia 8628df5848 chore: release v1.0.33
Deploy / build-linux-web (push) Successful in 49s
Deploy / build-windows (push) Successful in 1m48s
Deploy / build-macos (push) Successful in 1m28s
Deploy / build-android (push) Successful in 1m9s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:10:58 +08:00
wangjia 85cd262525 chore: release v1.0.32
Deploy / build-linux-web (push) Successful in 51s
Deploy / build-windows (push) Successful in 1m51s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 1m28s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Failing after 2m58s
2026-06-11 18:09:49 +08:00
wangjia e0bee00ff4 fix: 批量改进 (#2/#23/#38/#39/#40/#47)
- Web: 隐藏 ICP 备案号(条件渲染),联系入口改为微信咨询(条件渲染,配置 wechat 字段后生效)
- backend: 拼音回填抽取为独立 cmd/backfill-pinyin 工具,不再在启动时运行
- backend: DB 连接池参数(max_idle_conns/max_open_conns)改为配置可控,默认 10/100
- backend: Inventory 反范式字段添加注释说明快照语义
- backend: 全量 handler 采用 util.RespondSuccess/RespondCreated 统一响应格式(51 处)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:33:43 +08:00
wangjia d28aba863e fix(backend): license service 小修 (#45-46-48)
- #45 Activate 幂等路径 Update 加错误检查,失败时返回 error 而非静默成功
- #46 Verify 加载 license 加 shop_id 过滤(防御纵深)
- #48 删除 ErrDeviceMismatch 死代码(多设备迁移后已无单设备约束)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:06:15 +08:00
wangjia f928d5c6a3 fix(backend): 架构质量改进批次三 (#41-44)
- #41 CheckInventoryAvailability Scan 加错误检查,DB 故障不再误报库存不足
- #42 finance ListRecords 和 inventory Logs 补加 pageSize 上限(500)
- #43 ReconcileInventory 两条 Scan 加错误检查,DB 故障返回 500 而非误导性空结果
- #44 license Info 改用 CountDevices(licenseID) 统计当前 license 绑定数,修复跨 license 计数偏差

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:21:42 +08:00
wangjia 3ab78dbf7a fix(backend): 架构质量改进批次二 (#32-37)
- #32 License 激活迁移到 license_devices 表:Activate/Verify/Deactivate 全部改用
  license_devices,新增 max_devices 校验和 GET /license/devices 端点;
  Activate 现在校验 shop_id 防跨租户激活
- #33 checkInventory 从 StockOutHandler 移到 StockService.CheckInventoryAvailability
- #34 新增 util/response.go 统一错误响应工具(RespondError/RespondSuccess/RespondCreated)
- #35 生产模式 CORS Origin='*' 启动时 Fatal
- #36 生产模式 License 私钥未配置启动时 Fatal
- #37 新增 util/page.go ValidatePageSize,应用到 partner/product/stock_in/stock_out handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:38:49 +08:00
wangjia 51cfe5fc6d fix(backend): 修复库存 TOCTOU 竞态 + handler 白名单更新 + 对账接口
#29 ApproveStockOut:FOR UPDATE 锁定批次后再内存汇总总量,
     消除预检 SUM 与加锁之间的竞态窗口,减少一次 DB 往返

#30 partner/warehouse/user/product_attr/product_option Update 方法:
     - 绑定到独立 req struct,防止请求体覆盖记录 ID
     - 改用 db.Model.Where("shop_id=?").Updates(map) 白名单更新,
       数据库层强制 shop_id 隔离约束

#31 新增 GET /api/v1/admin/reconcile:对比 inventories 当前库存
     与 inventory_logs 流水净量,返回差异行,用于发现不平账异常

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:21:48 +08:00
47 changed files with 2069 additions and 955 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Build Windows Only
# 仅构建 Windows 桌面版(不触发完整 deploy)。
# 触发方式:
# - 手动 workflow_dispatch(可填 ver
# - 推送 winbuild* 轻量 tag(如 winbuild1),不与 deploy.yml 的 v* tag 冲突
on:
workflow_dispatch:
inputs:
ver:
description: '版本号 (如 v1.0.4)'
required: false
default: 'v1.0.4'
type: string
push:
tags:
- 'winbuild*'
jobs:
build-windows:
runs-on: windows
env:
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: Provision (idempotent — auto-runs if host not initialized)
shell: bash
run: sh scripts/ci/provision-windows.sh
- name: Compile (Flutter Windows)
shell: bash
run: sh scripts/ci/compile-windows.sh "${{ inputs.ver || 'v1.0.4' }}"
- name: Upload windows artifact
uses: actions/upload-artifact@v3
with:
name: jiu-windows
path: dist/
+32
View File
@@ -5,6 +5,38 @@ 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.35] - 2026-06-12
### 改进
- Debug 版本窗口标题增加 [DEBUG] 后缀,便于与正式安装版区分
## [1.0.34] - 2026-06-12
### 修复
- 修复 macOS 客户端打印时应用卡死、只能强制退出的问题
### 改进
- 「关于我们」中「打开文档」「更新日志」等链接指向新域名 jiu.51yanmei.com
## [1.0.33] - 2026-06-12
### 改进
- macOS 客户端插件集成从 CocoaPods 迁移至 Swift Package Manager,优化 macOS 构建流程
## [1.0.32] - 2026-06-11
### 修复
- 修复库存可用量检查在数据库查询失败时误报所有商品库存不足的问题
- 修复授权信息页面设备数量统计不准确的问题
- 修复对账接口在数据库查询出错时静默返回空结果、掩盖异常的问题
- 修复 license 验证缺少门店隔离过滤,提升多租户安全性
- 官网联系入口调整为微信咨询(填写微信号后自动生效),隐藏尚未申请的备案号
- 修复各接口分页大小未设上限,防止超大请求拖慢服务
### 改进
- 后端启动不再执行拼音回填,已改为独立工具(`cmd/backfill-pinyin`),避免大库存量时延长启动时间
- 数据库连接池参数(最大空闲连接数、最大打开连接数)改为配置项,支持按环境调整
## [1.0.31] - 2026-06-10
### 修复
+57
View File
@@ -0,0 +1,57 @@
// backfill-pinyin is a one-time migration tool that fills name_pinyin and
// name_initials for products that have empty values.
// Usage: go run ./cmd/backfill-pinyin
package main
import (
"log"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/spf13/viper"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("./config")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file, relying on env vars")
}
dsn := viper.GetString("database.dsn")
if dsn == "" {
log.Fatal("DATABASE_DSN is required")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
var products []model.Product
db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products)
if len(products) == 0 {
log.Println("No products to backfill.")
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("backfill-pinyin: updated %d products", len(products))
}
+5 -1
View File
@@ -22,7 +22,9 @@ type ServerConfig struct {
}
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
DSN string `mapstructure:"dsn"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
}
type JWTConfig struct {
@@ -71,6 +73,8 @@ func Load() {
viper.SetDefault("server.cors_origin", "*")
viper.SetDefault("jwt.access_expire_min", 60)
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("storage.upload_dir", "./uploads/images")
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081")
+95
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -58,3 +59,97 @@ func (h *AdminHandler) ClearData(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"cleared": cleared})
}
// ReconcileInventory 对账:检查 inventories 表当前库存与 inventory_logs 流水之和是否吻合。
// 返回所有差异行,正常情况下 data 为空数组。
// 支持可选 ?shop_id=N 参数(superadmin 可指定任意门店)。
func (h *AdminHandler) ReconcileInventory(c *gin.Context) {
// superadmin 路由,可通过参数指定 shop;不指定则对所有门店执行
var shopFilter *uint64
if s := c.Query("shop_id"); s != "" {
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid shop_id"})
return
}
shopFilter = &v
}
_ = middleware.GetShopID // superadmin 不依赖 JWT shop_id
type row struct {
ShopID uint64 `json:"shop_id"`
WarehouseID uint64 `json:"warehouse_id"`
ProductID uint64 `json:"product_id"`
InvQty float64 `json:"inv_qty"`
LogQty float64 `json:"log_qty"`
Diff float64 `json:"diff"`
}
// inventories 侧:当前各(shop,warehouse,product)库存总量
invQuery := h.db.Table("inventories").
Select("shop_id, warehouse_id, product_id, COALESCE(SUM(quantity),0) AS inv_qty").
Where("deleted_at IS NULL").
Group("shop_id, warehouse_id, product_id")
if shopFilter != nil {
invQuery = invQuery.Where("shop_id = ?", *shopFilter)
}
// inventory_logs 侧:同维度流水净量(in 为正,out 为负)
logQuery := h.db.Table("inventory_logs").
Select("shop_id, warehouse_id, product_id, " +
"COALESCE(SUM(CASE WHEN direction='in' THEN quantity ELSE -quantity END),0) AS log_qty").
Group("shop_id, warehouse_id, product_id")
if shopFilter != nil {
logQuery = logQuery.Where("shop_id = ?", *shopFilter)
}
// 全外连接找差异
type invRow struct {
ShopID uint64 `gorm:"column:shop_id"`
WarehouseID uint64 `gorm:"column:warehouse_id"`
ProductID uint64 `gorm:"column:product_id"`
InvQty float64 `gorm:"column:inv_qty"`
}
type logRow struct {
ShopID uint64 `gorm:"column:shop_id"`
WarehouseID uint64 `gorm:"column:warehouse_id"`
ProductID uint64 `gorm:"column:product_id"`
LogQty float64 `gorm:"column:log_qty"`
}
var invRows []invRow
var logRows []logRow
if err := invQuery.Scan(&invRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "inventory query failed: " + err.Error()})
return
}
if err := logQuery.Scan(&logRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "log query failed: " + err.Error()})
return
}
// 合并为 map 再求差
type key struct{ S, W, P uint64 }
m := map[key]*row{}
for _, r := range invRows {
k := key{r.ShopID, r.WarehouseID, r.ProductID}
m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID, InvQty: r.InvQty}
}
for _, r := range logRows {
k := key{r.ShopID, r.WarehouseID, r.ProductID}
if _, ok := m[k]; !ok {
m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID}
}
m[k].LogQty = r.LogQty
}
diffs := make([]row, 0)
for _, v := range m {
v.Diff = v.InvQty - v.LogQty
if v.Diff < -0.001 || v.Diff > 0.001 {
diffs = append(diffs, *v)
}
}
c.JSON(http.StatusOK, gin.H{"data": diffs, "total": len(diffs)})
}
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type AuthHandler struct {
@@ -63,7 +64,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
util.RespondSuccess(c, result)
}
// Refresh POST /api/v1/auth/refresh
@@ -82,5 +83,5 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"data": pair})
util.RespondSuccess(c, pair)
}
+3 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type FinanceHandler struct {
@@ -33,9 +34,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if q.PageSize <= 0 {
q.PageSize = 50
}
q.PageSize = util.ValidatePageSize(q.PageSize, 50, 500)
if q.Page <= 0 {
q.Page = 1
}
@@ -189,7 +188,7 @@ func (h *FinanceHandler) Summary(c *gin.Context) {
ORDER BY total_amount DESC
`, shopID).Scan(&rows)
c.JSON(http.StatusOK, gin.H{"data": rows})
util.RespondSuccess(c, rows)
}
// partnerLastBalance 查询该往来单位最后一条财务记录的余额
+4 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type InventoryHandler struct {
@@ -128,6 +129,7 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 500)
query := h.db.Model(&model.InventoryLog{}).Where("shop_id = ?", shopID)
@@ -176,7 +178,7 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
util.RespondCreated(c, req)
}
// GetCheck GET /api/v1/inventory/checks/:id
@@ -189,7 +191,7 @@ func (h *InventoryHandler) GetCheck(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": check})
util.RespondSuccess(c, check)
}
// CompleteCheck PUT /api/v1/inventory/checks/:id/complete
+34 -11
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type LicenseHandler struct {
@@ -18,21 +19,24 @@ func NewLicenseHandler(svc *service.LicenseService) *LicenseHandler {
// Activate POST /api/v1/license/activate
func (h *LicenseHandler) Activate(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
LicenseKey string `json:"license_key" binding:"required"`
DeviceID string `json:"device_id" binding:"required"`
DeviceName string `json:"device_name"`
Platform string `json:"platform"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
lic, err := h.svc.Activate(req.LicenseKey, req.DeviceID)
lic, err := h.svc.Activate(shopID, req.LicenseKey, req.DeviceID, req.DeviceName, req.Platform)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": lic})
util.RespondSuccess(c, lic)
}
// Verify GET /api/v1/license/verify
@@ -49,28 +53,47 @@ func (h *LicenseHandler) Verify(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": lic})
util.RespondSuccess(c, lic)
}
// Info GET /api/v1/license/info — 当前门店授权概况(无需 device_id
// Info GET /api/v1/license/info — 当前门店授权概况
func (h *LicenseHandler) Info(c *gin.Context) {
shopID := middleware.GetShopID(c)
lic, err := h.svc.ShopInfo(shopID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": nil})
util.RespondSuccess(c, nil)
return
}
deviceCount, err := h.svc.CountDevices(lic.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
phase := middleware.CalcLicensePhase(lic.ExpiresAt)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"id": lic.ID,
"type": lic.Type,
"is_active": lic.IsActive,
"max_devices": lic.MaxDevices,
"expires_at": lic.ExpiresAt,
"phase": phase,
"id": lic.ID,
"type": lic.Type,
"is_active": lic.IsActive,
"max_devices": lic.MaxDevices,
"device_count": deviceCount,
"expires_at": lic.ExpiresAt,
"phase": phase,
}})
}
// Devices GET /api/v1/license/devices — 已绑定设备列表
func (h *LicenseHandler) Devices(c *gin.Context) {
shopID := middleware.GetShopID(c)
devs, err := h.svc.ListDevices(shopID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, devs)
}
// Deactivate POST /api/v1/license/deactivate
func (h *LicenseHandler) Deactivate(c *gin.Context) {
shopID := middleware.GetShopID(c)
+32 -28
View File
@@ -25,16 +25,22 @@ func TestLicenseHandler_Activate_Success(t *testing.T) {
LicenseKey: "LHACT-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{
"license_key": "LHACT-BBBBB-CCCCC-DDDDD",
"device_id": "device-123",
"device_name": "Test Machine",
"platform": "windows",
})
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "device-123", data["device_id"])
// Verify device was recorded in license_devices
var dev model.LicenseDevice
require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "device-123").First(&dev).Error)
assert.Equal(t, "Test Machine", dev.DeviceName)
}
func TestLicenseHandler_Activate_MissingFields(t *testing.T) {
@@ -71,7 +77,7 @@ func TestLicenseHandler_Activate_NotFound(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestLicenseHandler_Activate_DeviceMismatch(t *testing.T) {
func TestLicenseHandler_Activate_DeviceLimitExceeded(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LH004")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
@@ -79,12 +85,13 @@ func TestLicenseHandler_Activate_DeviceMismatch(t *testing.T) {
r := setupProtectedRouter(db)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHBND-BBBBB-CCCCC-DDDDD",
DeviceID: "existing-device",
IsActive: true,
ShopID: shop.ID, LicenseKey: "LHBND-BBBBB-CCCCC-DDDDD", IsActive: true, MaxDevices: 1,
}
require.NoError(t, db.Create(lic).Error)
// Fill the single allowed slot
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "existing-device",
}).Error)
w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{
"license_key": "LHBND-BBBBB-CCCCC-DDDDD",
@@ -102,10 +109,7 @@ func TestLicenseHandler_Activate_Expired(t *testing.T) {
expiry := time.Now().Add(-24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHEXP-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHEXP-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
@@ -136,18 +140,15 @@ func TestLicenseHandler_Verify_Success(t *testing.T) {
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHVFY-BBBBB-CCCCC-DDDDD",
DeviceID: "my-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHVFY-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "my-device",
}).Error)
w := makeRequest(r, "GET", "/api/v1/license/verify?device_id=my-device", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "my-device", data["device_id"])
}
func TestLicenseHandler_Verify_MissingDeviceID(t *testing.T) {
@@ -181,13 +182,12 @@ func TestLicenseHandler_Verify_Expired(t *testing.T) {
expiry := time.Now().Add(-1 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHVEX-BBBBB-CCCCC-DDDDD",
DeviceID: "expired-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHVEX-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "expired-device",
}).Error)
w := makeRequest(r, "GET", "/api/v1/license/verify?device_id=expired-device", token, nil)
assert.Equal(t, http.StatusForbidden, w.Code)
@@ -210,18 +210,22 @@ func TestLicenseHandler_Deactivate_Success(t *testing.T) {
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHDAC-BBBBB-CCCCC-DDDDD",
DeviceID: "deactivate-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHDAC-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "deactivate-device",
}).Error)
w := makeRequest(r, "POST", "/api/v1/license/deactivate", token, map[string]interface{}{
"device_id": "deactivate-device",
})
assert.Equal(t, http.StatusOK, w.Code)
// Verify device was removed
var count int64
db.Model(&model.LicenseDevice{}).Where("shop_id = ? AND device_id = ?", shop.ID, "deactivate-device").Count(&count)
assert.Equal(t, int64(0), count)
}
func TestLicenseHandler_Deactivate_MissingDeviceID(t *testing.T) {
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type NumberRuleHandler struct {
@@ -43,7 +44,7 @@ func (h *NumberRuleHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": rules})
util.RespondSuccess(c, rules)
}
// Update PUT /api/v1/number-rules/:id
@@ -82,5 +83,5 @@ func (h *NumberRuleHandler) Update(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": rule})
util.RespondSuccess(c, rule)
}
+33 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type PartnerHandler struct {
@@ -24,6 +25,7 @@ func (h *PartnerHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
query := h.db.Model(&model.Partner{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
@@ -68,7 +70,7 @@ func (h *PartnerHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": p})
util.RespondCreated(c, p)
}
func (h *PartnerHandler) Update(c *gin.Context) {
@@ -79,13 +81,39 @@ func (h *PartnerHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(&p); err != nil {
var req struct {
Name string `json:"name"`
Type string `json:"type"`
Code string `json:"code"`
Contact string `json:"contact"`
Phone string `json:"phone"`
Address string `json:"address"`
BankAccount string `json:"bank_account"`
CreditLimit float64 `json:"credit_limit"`
Status string `json:"status"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p.ShopID = shopID
h.db.Save(&p)
c.JSON(http.StatusOK, gin.H{"data": p})
if err := h.db.Model(&p).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"name": req.Name,
"type": req.Type,
"code": req.Code,
"contact": req.Contact,
"phone": req.Phone,
"address": req.Address,
"bank_account": req.BankAccount,
"credit_limit": req.CreditLimit,
"status": req.Status,
"remark": req.Remark,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.db.Where("id = ? AND shop_id = ?", p.ID, shopID).First(&p)
util.RespondSuccess(c, p)
}
func (h *PartnerHandler) Delete(c *gin.Context) {
+7 -6
View File
@@ -31,6 +31,7 @@ func (h *ProductHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
keyword := c.Query("keyword")
categoryID := c.Query("category_id")
@@ -103,7 +104,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": product})
util.RespondCreated(c, product)
}
// Update PUT /api/v1/products/:id
@@ -154,7 +155,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
// 重新读取完整数据返回
h.db.Preload("Category").First(&product, product.ID)
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
}
// Detail GET /api/v1/products/:id/detail
@@ -177,7 +178,7 @@ func (h *ProductHandler) Detail(c *gin.Context) {
h.db.Model(&product).Update("public_id", product.PublicID)
}
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
}
// QRCode GET /api/v1/products/:id/qrcode
@@ -247,7 +248,7 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
if len(attrUpdates) > 0 {
h.db.Model(&product).Updates(attrUpdates)
}
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
return
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -276,13 +277,13 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
// Race condition: try to find the record created by another request
if h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, req.Name, req.Series, req.Spec).First(&product).Error == nil {
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": product})
util.RespondCreated(c, product)
}
// Delete DELETE /api/v1/products/:id (软删除)
+25 -28
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
// ProductAttrHandler 处理商品属性字典(产地/保质期/储存方式/描述文档)
@@ -25,7 +26,7 @@ func (h *ProductAttrHandler) ListOrigins(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductOriginOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateOrigin(c *gin.Context) {
@@ -49,7 +50,7 @@ func (h *ProductAttrHandler) CreateOrigin(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) {
@@ -68,11 +69,10 @@ func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteOrigin(c *gin.Context) {
@@ -87,7 +87,7 @@ func (h *ProductAttrHandler) ListShelfLives(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductShelfLifeOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateShelfLife(c *gin.Context) {
@@ -111,7 +111,7 @@ func (h *ProductAttrHandler) CreateShelfLife(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) {
@@ -130,11 +130,10 @@ func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteShelfLife(c *gin.Context) {
@@ -149,7 +148,7 @@ func (h *ProductAttrHandler) ListStorages(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductStorageOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateStorage(c *gin.Context) {
@@ -173,7 +172,7 @@ func (h *ProductAttrHandler) CreateStorage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) {
@@ -192,11 +191,10 @@ func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteStorage(c *gin.Context) {
@@ -211,7 +209,7 @@ func (h *ProductAttrHandler) ListDescriptionDocs(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductDescriptionDoc, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateDescriptionDoc(c *gin.Context) {
@@ -235,7 +233,7 @@ func (h *ProductAttrHandler) CreateDescriptionDoc(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) {
@@ -254,11 +252,10 @@ func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Title = req.Title
item.Content = req.Content
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"title": req.Title, "content": req.Content, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteDescriptionDoc(c *gin.Context) {
+2 -1
View File
@@ -18,6 +18,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 ProductImageHandler struct {
@@ -105,7 +106,7 @@ func (h *ProductImageHandler) Upload(c *gin.Context) {
return
}
c.JSON(http.StatusCreated, gin.H{"data": pi})
util.RespondCreated(c, pi)
}
// Delete DELETE /api/v1/products/:id/images/:image_id
+19 -22
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type ProductOptionHandler struct {
@@ -24,7 +25,7 @@ func (h *ProductOptionHandler) ListNames(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductNameOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductOptionHandler) CreateName(c *gin.Context) {
@@ -48,7 +49,7 @@ func (h *ProductOptionHandler) CreateName(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductOptionHandler) UpdateName(c *gin.Context) {
@@ -67,11 +68,10 @@ func (h *ProductOptionHandler) UpdateName(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
@@ -86,7 +86,7 @@ func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSeriesOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductOptionHandler) CreateSeries(c *gin.Context) {
@@ -110,7 +110,7 @@ func (h *ProductOptionHandler) CreateSeries(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductOptionHandler) UpdateSeries(c *gin.Context) {
@@ -129,11 +129,10 @@ func (h *ProductOptionHandler) UpdateSeries(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
@@ -148,7 +147,7 @@ func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSpecOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductOptionHandler) CreateSpec(c *gin.Context) {
@@ -174,7 +173,7 @@ func (h *ProductOptionHandler) CreateSpec(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductOptionHandler) UpdateSpec(c *gin.Context) {
@@ -194,12 +193,10 @@ func (h *ProductOptionHandler) UpdateSpec(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Quantity = req.Quantity
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "quantity": req.Quantity, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductOptionHandler) DeleteSpec(c *gin.Context) {
+4 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
func timeNow() *time.Time {
@@ -33,6 +34,7 @@ func (h *StockInHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
query := h.db.Model(&model.StockInOrder{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
@@ -70,7 +72,7 @@ func (h *StockInHandler) Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": order})
util.RespondSuccess(c, order)
}
// Create POST /api/v1/stock-in/orders
@@ -112,7 +114,7 @@ func (h *StockInHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
util.RespondCreated(c, req)
}
// Update PUT /api/v1/stock-in/orders/:id (只允许草稿状态)
+6 -50
View File
@@ -1,7 +1,6 @@
package handler
import (
"fmt"
"net/http"
"strconv"
@@ -11,6 +10,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type StockOutHandler struct {
@@ -27,6 +27,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
query := h.db.Model(&model.StockOutOrder{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
@@ -62,54 +63,9 @@ func (h *StockOutHandler) Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": order})
util.RespondSuccess(c, order)
}
// checkInventory validates that warehouse has enough stock for each item (SUM aggregate).
// warehouseID is the order's warehouse; items are the stock-out line items.
func (h *StockOutHandler) checkInventory(shopID, warehouseID uint64, items []model.StockOutItem) error {
if len(items) == 0 {
return nil
}
// Collect product IDs
productIDs := make([]uint64, 0, len(items))
for _, item := range items {
productIDs = append(productIDs, item.ProductID)
}
type inventorySum struct {
ProductID uint64
Total float64
}
var sums []inventorySum
h.db.Model(&model.Inventory{}).
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
shopID, warehouseID, productIDs).
Group("product_id").Scan(&sums)
// Build map for quick lookup
sumMap := make(map[uint64]float64, len(sums))
for _, s := range sums {
sumMap[s.ProductID] = s.Total
}
for _, item := range items {
have := sumMap[item.ProductID]
if have < item.Quantity {
// Get product name for a clearer error message
var p model.Product
h.db.Where("id = ?", item.ProductID).First(&p)
name := p.Name
if name == "" {
name = fmt.Sprintf("商品ID %d", item.ProductID)
}
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
}
}
return nil
}
// Create POST /api/v1/stock-out/orders
func (h *StockOutHandler) Create(c *gin.Context) {
@@ -127,7 +83,7 @@ func (h *StockOutHandler) Create(c *gin.Context) {
// 状态只允许 draft 或 pending;直接提交审核时校验库存
if req.Status == "pending" {
if err := h.checkInventory(shopID, req.WarehouseID, req.Items); err != nil {
if err := h.stockSvc.CheckInventoryAvailability(shopID, req.WarehouseID, req.Items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -154,7 +110,7 @@ func (h *StockOutHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
util.RespondCreated(c, req)
}
// Update PUT /api/v1/stock-out/orders/:id (只允许草稿状态)
@@ -235,7 +191,7 @@ func (h *StockOutHandler) Submit(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
return
}
if err := h.checkInventory(shopID, order.WarehouseID, order.Items); err != nil {
if err := h.stockSvc.CheckInventoryAvailability(shopID, order.WarehouseID, order.Items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+13 -8
View File
@@ -9,6 +9,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type UserHandler struct {
@@ -25,7 +26,7 @@ func (h *UserHandler) List(c *gin.Context) {
users := make([]model.User, 0)
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).
Order("id ASC").Find(&users)
c.JSON(http.StatusOK, gin.H{"data": users})
util.RespondSuccess(c, users)
}
// Create POST /api/v1/users
@@ -64,7 +65,7 @@ func (h *UserHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": u})
util.RespondCreated(c, u)
}
// Update PUT /api/v1/users/:id
@@ -83,20 +84,24 @@ func (h *UserHandler) Update(c *gin.Context) {
IsActive *bool `json:"is_active"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.RealName != "" {
u.RealName = req.RealName
updates["real_name"] = req.RealName
}
if req.Phone != "" {
u.Phone = req.Phone
updates["phone"] = req.Phone
}
if req.Role != "" {
u.Role = req.Role
updates["role"] = req.Role
}
if req.IsActive != nil {
u.IsActive = *req.IsActive
updates["is_active"] = *req.IsActive
}
h.db.Save(&u)
c.JSON(http.StatusOK, gin.H{"data": u})
if len(updates) > 0 {
h.db.Model(&u).Where("shop_id = ?", shopID).Updates(updates)
}
h.db.Where("id = ? AND shop_id = ?", u.ID, shopID).First(&u)
util.RespondSuccess(c, u)
}
// ResetPassword PUT /api/v1/users/:id/reset-password
+22 -6
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type WarehouseHandler struct {
@@ -22,7 +23,7 @@ func (h *WarehouseHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
warehouses := make([]model.Warehouse, 0)
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
c.JSON(http.StatusOK, gin.H{"data": warehouses})
util.RespondSuccess(c, warehouses)
}
func (h *WarehouseHandler) Create(c *gin.Context) {
@@ -34,7 +35,7 @@ func (h *WarehouseHandler) Create(c *gin.Context) {
}
w.ShopID = shopID
h.db.Create(&w)
c.JSON(http.StatusCreated, gin.H{"data": w})
util.RespondCreated(c, w)
}
func (h *WarehouseHandler) Update(c *gin.Context) {
@@ -45,10 +46,25 @@ func (h *WarehouseHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.ShouldBindJSON(&w)
w.ShopID = shopID
h.db.Save(&w)
c.JSON(http.StatusOK, gin.H{"data": w})
var req struct {
Name string `json:"name"`
Location string `json:"location"`
IsDefault bool `json:"is_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Model(&w).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"name": req.Name,
"location": req.Location,
"is_default": req.IsDefault,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.db.Where("id = ? AND shop_id = ?", w.ID, shopID).First(&w)
util.RespondSuccess(c, w)
}
func (h *WarehouseHandler) Delete(c *gin.Context) {
+1 -1
View File
@@ -5,7 +5,7 @@ import "time"
type License struct {
Base
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
LicenseKey string `gorm:"size:2048;uniqueIndex" json:"license_key"`
LicenseKey string `gorm:"size:768;uniqueIndex" json:"license_key"`
Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
ExpiresAt *time.Time `json:"expires_at"`
IsActive bool `gorm:"default:true" json:"is_active"`
+13 -10
View File
@@ -91,16 +91,19 @@ type Inventory struct {
StockInItemID *uint64 `json:"stock_in_item_id"`
InventoryCheckID *uint64 `json:"inventory_check_id"`
Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"`
ProductCode string `gorm:"size:50" json:"product_code"`
ProductName string `gorm:"size:200" json:"product_name"`
Series string `gorm:"size:100" json:"series"`
Spec string `gorm:"size:100" json:"spec"`
Unit string `gorm:"size:20" json:"unit"`
WarehouseName string `gorm:"size:100" json:"warehouse_name"`
UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"`
ProductionDate *Date `gorm:"type:date" json:"production_date"`
BatchNo string `gorm:"size:50" json:"batch_no"`
SupplierName string `gorm:"size:200" json:"supplier_name"`
// Snapshot fields: copied from product/warehouse/stock-in at approval time.
// They reflect the state at the moment of stock-in and are NOT updated when the
// referenced product or warehouse record is later modified.
ProductCode string `gorm:"size:50" json:"product_code"`
ProductName string `gorm:"size:200" json:"product_name"`
Series string `gorm:"size:100" json:"series"`
Spec string `gorm:"size:100" json:"spec"`
Unit string `gorm:"size:20" json:"unit"`
WarehouseName string `gorm:"size:100" json:"warehouse_name"`
UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"`
ProductionDate *Date `gorm:"type:date" json:"production_date"`
BatchNo string `gorm:"size:50" json:"batch_no"`
SupplierName string `gorm:"size:200" json:"supplier_name"`
Remark string `gorm:"size:500" json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+2
View File
@@ -76,6 +76,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
license.POST("/activate", licenseH.Activate)
license.GET("/verify", licenseH.Verify)
license.POST("/deactivate", licenseH.Deactivate)
license.GET("/devices", licenseH.Devices)
}
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
@@ -252,6 +253,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
superAdmin.Use(middleware.SuperAdminOnly())
{
superAdmin.POST("/clear-data", adminH.ClearData)
superAdmin.GET("/reconcile", adminH.ReconcileInventory)
superAdmin.GET("/errors", errorReportH.List)
superAdmin.GET("/feedback", feedbackH.List)
superAdmin.PATCH("/feedback/:id", feedbackH.UpdateStatus)
+61 -21
View File
@@ -18,10 +18,10 @@ import (
)
var (
ErrLicenseNotFound = errors.New("license not found")
ErrLicenseInactive = errors.New("license is inactive")
ErrLicenseExpired = errors.New("license has expired")
ErrDeviceMismatch = errors.New("license is bound to another device")
ErrLicenseNotFound = errors.New("license not found")
ErrLicenseInactive = errors.New("license is inactive")
ErrLicenseExpired = errors.New("license has expired")
ErrDeviceLimitExceed = errors.New("device limit reached — deactivate another device first")
)
type LicenseService struct {
@@ -47,10 +47,12 @@ func GenerateKey(shopID uint64, licenseType string, expiresAt *time.Time) string
return fmt.Sprintf("%s-%s-%s-%s", raw[0:5], raw[5:10], raw[10:15], raw[15:20])
}
// Activate 激活许可证绑定设备
func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License, error) {
// Activate 激活许可证绑定设备到 license_devices 表。
// 若该设备已绑定,则更新 last_seen_at(幂等)。
// 若是新设备,则校验是否超出 max_devices 上限。
func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceName, platform string) (*model.License, error) {
var lic model.License
if err := s.db.Where("license_key = ?", licenseKey).First(&lic).Error; err != nil {
if err := s.db.Where("license_key = ? AND shop_id = ?", licenseKey, shopID).First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if !lic.IsActive {
@@ -59,23 +61,46 @@ func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License,
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
return nil, ErrLicenseExpired
}
// 若已绑定设备,校验是否一致
if lic.DeviceID != "" && lic.DeviceID != deviceID {
return nil, ErrDeviceMismatch
var existing model.LicenseDevice
err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, deviceID).First(&existing).Error
if err == nil {
// Device already bound — just touch last_seen_at (handled by autoUpdateTime)
if err := s.db.Model(&existing).Update("device_name", deviceName).Error; err != nil {
return nil, err
}
return &lic, nil
}
now := time.Now()
lic.DeviceID = deviceID
lic.ActivatedAt = &now
s.db.Save(&lic)
// New device — enforce max_devices
var count int64
s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count)
if int(count) >= lic.MaxDevices {
return nil, ErrDeviceLimitExceed
}
dev := model.LicenseDevice{
LicenseID: lic.ID,
ShopID: shopID,
DeviceID: deviceID,
DeviceName: deviceName,
Platform: platform,
}
if err := s.db.Create(&dev).Error; err != nil {
return nil, err
}
return &lic, nil
}
// Verify 验证(客户端启动时调用)
// Verify 验证设备许可证(客户端启动时调用)
// 通过 license_devices 表查找设备,再加载对应的许可证做有效性检查。
func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, error) {
var dev model.LicenseDevice
if err := s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).First(&dev).Error; err != nil {
return nil, ErrLicenseNotFound
}
var lic model.License
if err := s.db.Where("shop_id = ? AND device_id = ? AND is_active = 1", shopID, deviceID).
First(&lic).Error; err != nil {
if err := s.db.Where("id = ? AND shop_id = ? AND is_active = 1", dev.LicenseID, shopID).First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
@@ -94,11 +119,26 @@ func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) {
return &lic, nil
}
// Deactivate 解绑设备(换机时使用)
// CountDevices 返回指定 license 下已绑定设备数。
func (s *LicenseService) CountDevices(licenseID uint64) (int64, error) {
var count int64
err := s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", licenseID).Count(&count).Error
return count, err
}
// ListDevices 列出许可证下所有已绑定设备。
func (s *LicenseService) ListDevices(shopID uint64) ([]model.LicenseDevice, error) {
var devs []model.LicenseDevice
if err := s.db.Where("shop_id = ?", shopID).Order("activated_at DESC").Find(&devs).Error; err != nil {
return nil, err
}
return devs, nil
}
// Deactivate 解绑设备(从 license_devices 删除该条记录)。
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
return s.db.Model(&model.License{}).
Where("shop_id = ? AND device_id = ?", shopID, deviceID).
Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error
return s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).
Delete(&model.LicenseDevice{}).Error
}
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
+79 -57
View File
@@ -15,71 +15,106 @@ func TestLicenseService_Activate_Success(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC001")
// 创建许可证
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
ShopID: shop.ID,
LicenseKey: "AAAAA-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("AAAAA-BBBBB-CCCCC-DDDDD", "device-001")
result, err := svc.Activate(shop.ID, "AAAAA-BBBBB-CCCCC-DDDDD", "device-001", "Test PC", "windows")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "device-001", result.DeviceID)
assert.NotNil(t, result.ActivatedAt)
// Verify device record was created
var dev model.LicenseDevice
require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "device-001").First(&dev).Error)
assert.Equal(t, "Test PC", dev.DeviceName)
assert.Equal(t, "windows", dev.Platform)
}
func TestLicenseService_Activate_AlreadyBoundToDifferentDevice(t *testing.T) {
func TestLicenseService_Activate_SameDeviceIdempotent(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC002")
lic := &model.License{
ShopID: shop.ID,
ShopID: shop.ID,
LicenseKey: "EEEEE-FFFFF-GGGGG-HHHHH",
DeviceID: "existing-device",
IsActive: true,
MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
// Pre-bind the device
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "same-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("EEEEE-FFFFF-GGGGG-HHHHH", "new-device")
// Re-activating same device should succeed (idempotent)
result, err := svc.Activate(shop.ID, "EEEEE-FFFFF-GGGGG-HHHHH", "same-device", "Updated Name", "windows")
assert.Error(t, err)
assert.Equal(t, ErrDeviceMismatch, err)
assert.Nil(t, result)
require.NoError(t, err)
require.NotNil(t, result)
// Still only one device record
var count int64
db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count)
assert.Equal(t, int64(1), count)
}
func TestLicenseService_Activate_SameDevice(t *testing.T) {
func TestLicenseService_Activate_DeviceLimitExceeded(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC003")
lic := &model.License{
ShopID: shop.ID,
ShopID: shop.ID,
LicenseKey: "IIIII-JJJJJ-KKKKK-LLLLL",
DeviceID: "same-device",
IsActive: true,
MaxDevices: 2,
}
require.NoError(t, db.Create(lic).Error)
// Fill up the device limit
require.NoError(t, db.Create(&model.LicenseDevice{LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "dev-1"}).Error)
require.NoError(t, db.Create(&model.LicenseDevice{LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "dev-2"}).Error)
svc := NewLicenseService(db)
// 同一设备重新激活应该成功
result, err := svc.Activate("IIIII-JJJJJ-KKKKK-LLLLL", "same-device")
result, err := svc.Activate(shop.ID, "IIIII-JJJJJ-KKKKK-LLLLL", "dev-3", "", "")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "same-device", result.DeviceID)
assert.Error(t, err)
assert.Equal(t, ErrDeviceLimitExceed, err)
assert.Nil(t, result)
}
func TestLicenseService_Activate_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC004")
svc := NewLicenseService(db)
result, err := svc.Activate("NONEX-ISTEN-TTTTT-LICCC", "device-001")
result, err := svc.Activate(shop.ID, "NONEX-ISTEN-TTTTT-LICCC", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseNotFound, err)
assert.Nil(t, result)
}
func TestLicenseService_Activate_WrongShop(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC004B")
otherShop := testutil.CreateTestShop(db, "LIC004C")
lic := &model.License{
ShopID: shop.ID, LicenseKey: "OTHSH-BBBBB-CCCCC-DDDDD", IsActive: true, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
// otherShop cannot activate a license belonging to shop
result, err := svc.Activate(otherShop.ID, "OTHSH-BBBBB-CCCCC-DDDDD", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseNotFound, err)
@@ -88,20 +123,16 @@ func TestLicenseService_Activate_NotFound(t *testing.T) {
func TestLicenseService_Activate_Inactive(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC004")
shop := testutil.CreateTestShop(db, "LIC005")
// 先创建激活的许可证,再禁用(避免 GORM 零值跳过问题)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP",
IsActive: true,
ShopID: shop.ID, LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP", IsActive: true, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
// 禁用
require.NoError(t, db.Model(lic).Update("is_active", false).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("MMMMM-NNNNN-OOOOO-PPPPP", "device-001")
result, err := svc.Activate(shop.ID, "MMMMM-NNNNN-OOOOO-PPPPP", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseInactive, err)
@@ -110,20 +141,16 @@ func TestLicenseService_Activate_Inactive(t *testing.T) {
func TestLicenseService_Activate_Expired(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC005")
shop := testutil.CreateTestShop(db, "LIC006")
// 已过期
expiry := time.Now().Add(-24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT", IsActive: true, ExpiresAt: &expiry, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("QQQQQ-RRRRR-SSSSS-TTTTT", "device-001")
result, err := svc.Activate(shop.ID, "QQQQQ-RRRRR-SSSSS-TTTTT", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseExpired, err)
@@ -132,40 +159,37 @@ func TestLicenseService_Activate_Expired(t *testing.T) {
func TestLicenseService_Verify_Success(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC006")
shop := testutil.CreateTestShop(db, "LIC007")
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX",
DeviceID: "my-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "my-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "my-device")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "my-device", result.DeviceID)
assert.Equal(t, lic.ID, result.ID)
}
func TestLicenseService_Verify_Expired(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC007")
shop := testutil.CreateTestShop(db, "LIC008")
// 已过期
expiry := time.Now().Add(-1 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB",
DeviceID: "expired-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "expired-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "expired-device")
@@ -177,7 +201,7 @@ func TestLicenseService_Verify_Expired(t *testing.T) {
func TestLicenseService_Verify_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC008")
shop := testutil.CreateTestShop(db, "LIC009")
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "nonexistent-device")
@@ -189,17 +213,15 @@ func TestLicenseService_Verify_NotFound(t *testing.T) {
func TestLicenseService_Verify_NoExpiry(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC009")
shop := testutil.CreateTestShop(db, "LIC010")
// 永久许可证(无过期时间)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF",
DeviceID: "lifetime-device",
IsActive: true,
ExpiresAt: nil,
ShopID: shop.ID, LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF", IsActive: true, ExpiresAt: nil,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "lifetime-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "lifetime-device")
+56 -12
View File
@@ -163,12 +163,17 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
productID := itemCopy.ProductID
needed := itemCopy.Quantity
// 1. 预检:SUM 是否充足
var totalQty float64
tx.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
// 1. FOR UPDATE 锁定批次后再汇总,消除 TOCTOU 窗口
var batches []model.Inventory
tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, warehouseID, productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&totalQty)
Order("created_at ASC").Find(&batches)
var totalQty float64
for _, b := range batches {
totalQty += b.Quantity
}
if totalQty < needed {
return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f",
ErrInsufficientStock, productID, totalQty, needed)
@@ -176,13 +181,7 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
qtyBefore := totalQty
// 2. FIFO 扣减批次
var batches []model.Inventory
tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, warehouseID, productID).
Order("created_at ASC").Find(&batches)
// 2. FIFO 扣减
remaining := needed
for i := range batches {
if remaining <= 0 {
@@ -288,3 +287,48 @@ func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string,
})
return no, err
}
// CheckInventoryAvailability validates that the warehouse has sufficient stock for each item.
// Returns an error describing the first shortage encountered.
func (s *StockService) CheckInventoryAvailability(shopID, warehouseID uint64, items []model.StockOutItem) error {
if len(items) == 0 {
return nil
}
productIDs := make([]uint64, 0, len(items))
for _, item := range items {
productIDs = append(productIDs, item.ProductID)
}
type inventorySum struct {
ProductID uint64
Total float64
}
var sums []inventorySum
if err := s.db.Model(&model.Inventory{}).
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
shopID, warehouseID, productIDs).
Group("product_id").Scan(&sums).Error; err != nil {
return err
}
sumMap := make(map[uint64]float64, len(sums))
for _, s := range sums {
sumMap[s.ProductID] = s.Total
}
for _, item := range items {
have := sumMap[item.ProductID]
if have < item.Quantity {
var p model.Product
s.db.Where("id = ?", item.ProductID).First(&p)
name := p.Name
if name == "" {
name = fmt.Sprintf("商品ID %d", item.ProductID)
}
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
}
}
return nil
}
+35
View File
@@ -73,3 +73,38 @@ func TestVerifyLicenseToken_InvalidFormat(t *testing.T) {
_, err = VerifyLicenseToken("not-a-valid-token", pub)
assert.ErrorIs(t, err, ErrInvalidLicenseToken)
}
// TestLicenseTokenFitsColumnLimit verifies that a realistic (even worst-case)
// license token fits within the VARCHAR(768) column limit imposed by the
// InnoDB index constraint (768 chars × 4 bytes/char = 3072 bytes max).
func TestLicenseTokenFitsColumnLimit(t *testing.T) {
const columnLimit = 768
priv, _, err := GenerateEd25519KeyPair()
require.NoError(t, err)
// Use a large features map to simulate a worst-case payload.
exp := time.Now().Add(365 * 24 * time.Hour).Unix()
payload := LicensePayload{
ShopID: 999999999,
LicenseID: 999999999,
Type: "lifetime",
IssuedAt: time.Now().Unix(),
ExpiresAt: &exp,
MaxDevices: 99,
Features: map[string]any{
"finance": true,
"inventory": true,
"reports": true,
"export": true,
"multi_shop": true,
"api_access": true,
},
}
token, err := IssueLicenseToken(payload, priv)
require.NoError(t, err)
assert.LessOrEqual(t, len(token), columnLimit,
"license token length %d exceeds VARCHAR(%d) column limit", len(token), columnLimit)
}
+9
View File
@@ -0,0 +1,9 @@
package util
// ValidatePageSize clamps pageSize into [1, maxSize], returning defaultSize when out of range.
func ValidatePageSize(pageSize, defaultSize, maxSize int) int {
if pageSize < 1 || pageSize > maxSize {
return defaultSize
}
return pageSize
}
+22
View File
@@ -0,0 +1,22 @@
package util
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RespondError writes a structured error response: {"code": code, "message": msg}.
func RespondError(c *gin.Context, status int, code, msg string) {
c.JSON(status, gin.H{"code": code, "message": msg})
}
// RespondSuccess writes {"data": data} with HTTP 200.
func RespondSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"data": data})
}
// RespondCreated writes {"data": data} with HTTP 201.
func RespondCreated(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, gin.H{"data": data})
}
+12 -21
View File
@@ -12,13 +12,22 @@ 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() {
// 加载配置
config.Load()
// 生产环境启动前置检查
if config.C.Server.Mode == "release" {
if config.C.Server.CORSOrigin == "*" {
log.Fatal("server.cors_origin must not be '*' in production — set it to the actual frontend origin")
}
if config.C.License.Ed25519PrivateKey == "" {
log.Fatal("license.ed25519_private_key is required in production — store the key in Bitwarden and inject via env LICENSE_ED25519PRIVATEKEY")
}
}
// 初始化数据库
db := initDB()
@@ -28,9 +37,6 @@ func main() {
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
autoMigrate(db)
// 回填存量商品的拼音索引(一次性,已有值的跳过)
backfillPinyin(db)
// 启动 Gin
gin.SetMode(config.C.Server.Mode)
r := gin.New()
@@ -80,8 +86,8 @@ func initDB() *gorm.DB {
}
sqlDB, _ := db.DB()
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetMaxIdleConns(config.C.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(config.C.Database.MaxOpenConns)
return db
}
@@ -122,18 +128,3 @@ func autoMigrate(db *gorm.DB) {
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))
}
+2 -2
View File
@@ -58,7 +58,7 @@ CREATE TABLE IF NOT EXISTS `users` (
CREATE TABLE IF NOT EXISTS `licenses` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`shop_id` BIGINT UNSIGNED NOT NULL,
`license_key` VARCHAR(2048) NOT NULL COMMENT 'Ed25519 signed token',
`license_key` VARCHAR(768) NOT NULL COMMENT 'Ed25519 signed token',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT 'deprecated: use license_devices',
`type` ENUM('trial','monthly','annual','lifetime') NOT NULL DEFAULT 'trial',
`expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久',
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS `licenses` (
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_license_key` (`license_key`(255)),
UNIQUE KEY `uk_license_key` (`license_key`),
KEY `idx_shop_id` (`shop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证';
+4 -4
View File
@@ -1,10 +1,10 @@
{
"provider": "岩美技术有限公司",
"website": "https://jiu.yanmei.com",
"website": "https://jiu.51yanmei.com",
"email": "yammy2023@163.com",
"phone": "",
"wechat": "",
"terms_url": "https://jiu.yanmei.com/terms/",
"privacy_url": "https://jiu.yanmei.com/privacy/",
"docs_url": "https://jiu.yanmei.com/docs/"
"terms_url": "https://jiu.51yanmei.com/terms/",
"privacy_url": "https://jiu.51yanmei.com/privacy/",
"docs_url": "https://jiu.51yanmei.com/docs/"
}
+323 -97
View File
@@ -1,3 +1,20 @@
import 'dart:io' show Platform, Process, File, Directory;
import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
OpenPrinter,
StartDocPrinter,
StartPagePrinter,
WritePrinter,
EndPagePrinter,
EndDocPrinter,
ClosePrinter,
DOC_INFO_1;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/services.dart';
import '../errors/error_reporter.dart';
import 'package:pdf/pdf.dart';
@@ -27,26 +44,239 @@ String _fileTs() {
// ── 设计色彩 token ──────────────────────────────────────────────────────────
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text
const _muted = PdfColor(0.533, 0.533, 0.533); // #888 field labels
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
pw.Widget _labelSpecRow(pw.Font font, String label, String value) =>
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(
width: 30,
child: pw.Text(label,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 4),
pw.Expanded(
child: pw.Text(value,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
],
);
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
// 得力 DL-888B 等热敏机走系统打印(CUPS 通用驱动)会多走纸/空白,必须发原生 TSPL。
// 直接画位图而非走 PDF 光栅化:避免二维码渲染丢失、SIZE 用整数避免多走纸。
bool _isThermalName(String n) {
final lo = n.toLowerCase();
return lo.contains('dl-888') ||
lo.contains('dl_888') ||
lo.contains('888b') ||
lo.contains('deli');
}
/// 查找热敏标签机(名字含 DL-888 / 888B / Deli)。
/// macOS: CUPS 队列名(lpstat -e)Windows: 打印机名(Printing.listPrinters)。
Future<String?> _findThermalPrinter() async {
if (Platform.isMacOS) {
try {
final r = await Process.run('lpstat', ['-e']);
if (r.exitCode == 0) {
for (final raw in (r.stdout as String).split('\n')) {
final q = raw.trim();
if (q.isNotEmpty && _isThermalName(q)) return q;
}
}
} catch (e) {
debugPrint('[label] lpstat 失败: $e');
}
return null;
}
if (Platform.isWindows) {
try {
for (final p in await Printing.listPrinters()) {
if (_isThermalName(p.name)) return p.name;
}
} catch (e) {
debugPrint('[label] listPrinters 失败: $e');
}
return null;
}
return null;
}
/// 把裸 TSPL 字节发送到打印机:
/// macOS/Linux 用 `lp -o raw`Windows 用 winspool WritePrinter。失败抛异常。
Future<void> _sendRaw(String printer, Uint8List tspl) async {
if (Platform.isWindows) {
if (!_winRawPrint(printer, tspl)) {
throw Exception('Windows 裸发失败: $printer');
}
return;
}
final f = File(
'${Directory.systemTemp.path}/label_${DateTime.now().millisecondsSinceEpoch}.tspl');
await f.writeAsBytes(tspl, flush: true);
final r = await Process.run('lp', ['-d', printer, '-o', 'raw', f.path]);
debugPrint('[label] lp exit=${r.exitCode} out=${r.stdout} err=${r.stderr}');
if (r.exitCode != 0) {
throw Exception('lp 裸发失败(exit ${r.exitCode}): ${r.stderr}');
}
}
/// Windows: 通过 winspool 以 RAW 数据类型把字节直接送进打印队列
bool _winRawPrint(String printerName, Uint8List data) {
final pName = printerName.toNativeUtf16();
final phPrinter = calloc<IntPtr>();
final docName = 'jiu label'.toNativeUtf16();
final dataType = 'RAW'.toNativeUtf16();
final docInfo = calloc<DOC_INFO_1>();
final pcWritten = calloc<Uint32>();
final pData = calloc<Uint8>(data.length);
try {
if (OpenPrinter(pName, phPrinter, nullptr) == 0) {
debugPrint('[label] OpenPrinter 失败: $printerName');
return false;
}
final h = phPrinter.value;
docInfo.ref
..pDocName = docName
..pOutputFile = nullptr
..pDatatype = dataType;
if (StartDocPrinter(h, 1, docInfo) == 0) {
ClosePrinter(h);
return false;
}
StartPagePrinter(h);
pData.asTypedList(data.length).setAll(0, data);
final ok = WritePrinter(h, pData.cast(), data.length, pcWritten);
EndPagePrinter(h);
EndDocPrinter(h);
ClosePrinter(h);
debugPrint('[label] WritePrinter ok=$ok written=${pcWritten.value}');
return ok != 0;
} finally {
calloc.free(pName);
calloc.free(phPrinter);
calloc.free(docName);
calloc.free(dataType);
calloc.free(docInfo);
calloc.free(pcWritten);
calloc.free(pData);
}
}
/// 在 canvas 上画一行文字(黑色,单行不换行,CJK 走系统字体回退)
void _drawText(ui.Canvas canvas, String s, double x, double y, double size,
double maxW, {bool bold = false, bool center = false}) {
if (s.isEmpty) return;
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
maxLines: 1,
ellipsis: '',
))
..pushStyle(ui.TextStyle(color: const ui.Color(0xFF000000)))
..addText(s);
final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
canvas.drawParagraph(p, ui.Offset(x, y));
}
/// 测量单行文本在指定字号下的宽度
double _measureWidth(String s, double size, bool bold) {
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
maxLines: 1,
))..addText(s);
final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
return p.maxIntrinsicWidth;
}
/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
if (s.isEmpty) return 0;
final w100 = _measureWidth(s, 100, bold);
final byW = w100 > 0 ? maxW / w100 * 100 : cap;
var size = byW < maxH ? byW : maxH;
if (size > cap) size = cap;
return size;
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
Future<bool> _printFlatLabelThermal({
required String shop,
required String name,
required String degSpec,
required String date,
required Uint8List qrBytes,
}) async {
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
final printer = await _findThermalPrinter();
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
const w = 320, h = 160; // 40×20mm @203dpi
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(
recorder, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()));
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
// 右侧二维码(解码后端 PNG 直接画上)
const qrS = 124.0;
const qrTop = 8.0;
const qrLeft = w - qrS - 4; // 192
try {
final codec = await ui.instantiateImageCodec(qrBytes);
final qrImg = (await codec.getNextFrame()).image;
canvas.drawImageRect(
qrImg,
ui.Rect.fromLTWH(0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
const ui.Rect.fromLTWH(qrLeft, qrTop, qrS, qrS),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
}
// 扫码溯源:居中于二维码正下方
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
const lx = 22.0;
const textMaxW = qrLeft - lx - 6; // 164
final rows = <(String, double, bool)>[
(shop, 0.9, true),
(name, 1.5, true),
if (degSpec.isNotEmpty) (degSpec, 1.05, false),
if (date.isNotEmpty) (date, 1.0, false),
];
const topY = 4.0, botY = 156.0;
final totalWeight = rows.fold<double>(0, (a, r) => a + r.$2);
double yy = topY;
for (final r in rows) {
final rowH = (botY - topY) * r.$2 / totalWeight;
final fs = _fitFont(r.$1, textMaxW, rowH * 0.84, r.$3, 30);
final ty = yy + (rowH - fs * 1.25) / 2;
_drawText(canvas, r.$1, lx, ty < yy ? yy : ty, fs, textMaxW, bold: r.$3);
yy += rowH;
}
final img = await recorder.endRecording().toImage(w, h);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
const wbytes = (w + 7) >> 3;
final out = BytesBuilder();
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n'
'BITMAP 0,0,$wbytes,$h,1,'));
for (int yy = 0; yy < h; yy++) {
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
for (int xx = 0; xx < w; xx++) {
final i = (yy * w + xx) * 4;
final a = rgba[i + 3];
final lum = a < 128
? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
}
out.add(row);
}
out.add(ascii.encode('\r\nPRINT 1,1\r\n'));
final tspl = out.toBytes();
debugPrint('[label] flat tspl ${tspl.length}B -> $printer');
await _sendRaw(printer, tspl);
return true;
}
Future<void> printProductLabelImpl({
required Uint8List qrBytes,
@@ -66,19 +296,30 @@ Future<void> printProductLabelImpl({
final doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
// Label: 4 × 2 inch
const labelW = 4.0 * PdfPageFormat.inch;
const labelH = 2.0 * PdfPageFormat.inch;
const headerH = labelH * 0.215;
const footerH = labelH * 0.09;
// Label: 40 × 20 mm(匹配实际标签纸尺寸)
const labelW = 40.0 * PdfPageFormat.mm;
const labelH = 20.0 * PdfPageFormat.mm;
const headerH = labelH * 0.20;
const footerH = labelH * 0.11;
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes)) {
return;
}
final contact = [
if (shopAddress.isNotEmpty) shopAddress,
if (shopPhone.isNotEmpty) shopPhone,
@@ -102,32 +343,22 @@ Future<void> printProductLabelImpl({
pw.Container(
height: headerH,
color: _navy,
padding: const pw.EdgeInsets.symmetric(horizontal: 11),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(shopName,
style: pw.TextStyle(
font: font, fontSize: 13,
fontWeight: pw.FontWeight.bold,
color: _cream, letterSpacing: 1.5,
)),
),
pw.Text('Certificate of Authenticity',
style: pw.TextStyle(
font: font, fontSize: 5,
color: const PdfColor(0.82, 0.79, 0.72))),
],
),
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
alignment: pw.Alignment.centerLeft,
child: pw.Text(shopName,
maxLines: 1, overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 6,
fontWeight: pw.FontWeight.bold,
color: _cream, letterSpacing: 0.5,
)),
),
// ── Body ─────────────────────────────────────────────────────────────
pw.Expanded(
child: pw.Container(
color: PdfColors.white,
padding: const pw.EdgeInsets.fromLTRB(11, 8, 11, 6),
padding: const pw.EdgeInsets.fromLTRB(4, 2, 4, 2),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
@@ -136,38 +367,41 @@ Future<void> printProductLabelImpl({
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// 商品名
// 商品名(过长自动缩小并最多两行)
pw.Text(name,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 12,
font: font,
fontSize: name.runes.length > 10
? 5.5
: (name.runes.length > 7 ? 6.5 : 7.5),
fontWeight: pw.FontWeight.bold,
color: _ink, letterSpacing: 0.5)),
pw.SizedBox(height: 6),
// 规格 + 系列
_label2ColRow(font, '规 格', specVal, '系 列', seriesVal),
pw.SizedBox(height: 3),
// 批号 + 生产日期
_label2ColRow(font, '批 号', batchVal, '生产日期', dateVal),
if ((remark ?? '').isNotEmpty) ...[
pw.SizedBox(height: 3),
_labelSpecRow(font, '备 注', remark!),
],
color: _ink, letterSpacing: 0.3)),
pw.SizedBox(height: 2.5),
// 度数(系列) + 规格 同一行, 无字段名标签
pw.Text(degSpecVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
// 生产日期(只显示日期,无标签)
pw.Text(dateVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
],
),
),
pw.SizedBox(width: 8),
pw.SizedBox(width: 4),
// QR
pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.SizedBox(
width: 60, height: 60,
width: 38, height: 38,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
pw.SizedBox(height: 3),
pw.SizedBox(height: 1),
pw.Text('扫码溯源',
style: pw.TextStyle(
font: font, fontSize: 4.5,
font: font, fontSize: 3.5,
color: PdfColors.grey600)),
],
),
@@ -180,17 +414,18 @@ Future<void> printProductLabelImpl({
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 11),
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(footerLeft,
style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)),
maxLines: 1, overflow: pw.TextOverflow.clip,
style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)),
),
pw.Text(genTime,
style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)),
style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)),
],
),
),
@@ -198,43 +433,22 @@ Future<void> printProductLabelImpl({
),
));
// 预生成 PDF 字节,避免 macOS NSPrintPanel 打开后再调 doc.save() 导致主线程卡住。
final bytes = await doc.save();
if (!kIsWeb && Platform.isMacOS) {
// 让当前帧完成,再打开 macOS 原生打印面板,防止 sheet 动画与 Flutter 渲染死锁。
await Future<void>.delayed(Duration.zero);
}
await Printing.layoutPdf(
name: '标签_${_fileTs()}', onLayout: (_) async => doc.save());
name: '标签_${_fileTs()}',
dynamicLayout: false,
onLayout: (_) async => bytes);
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
pw.Widget _label2ColRow(
pw.Font font, String lbl1, String val1, String lbl2, String val2) {
return pw.Row(
children: [
pw.SizedBox(
width: 28,
child: pw.Text(lbl1,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 3),
pw.Expanded(
child: pw.Text(val1,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
pw.SizedBox(width: 6),
pw.SizedBox(
width: 28,
child: pw.Text(lbl2,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 3),
pw.Expanded(
child: pw.Text(val2,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
],
);
}
pw.Widget _buildOrderDoc({
required pw.Font font,
required pw.Font bold,
@@ -439,8 +653,14 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
),
],
));
final bytes = await doc.save();
if (!kIsWeb && Platform.isMacOS) {
await Future<void>.delayed(Duration.zero);
}
await Printing.layoutPdf(
name: '入库单_${_fileTs()}', onLayout: (_) async => doc.save());
name: '入库单_${_fileTs()}',
dynamicLayout: false,
onLayout: (_) async => bytes);
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
@@ -495,8 +715,14 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
],
));
try {
final bytes = await doc.save();
if (!kIsWeb && Platform.isMacOS) {
await Future<void>.delayed(Duration.zero);
}
await Printing.layoutPdf(
name: '出库单_${_fileTs()}', onLayout: (_) async => doc.save());
name: '出库单_${_fileTs()}',
dynamicLayout: false,
onLayout: (_) async => bytes);
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
+17 -12
View File
@@ -30,11 +30,16 @@ Future<void> printProductLabelImpl({
}) async {
final base64Img = base64Encode(qrBytes);
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
// 商品名过长时缩小字号(适当缩小,配合两行折行)
final nameLen = name.runes.length;
final nameFontPt = nameLen > 11 ? 5.0 : (nameLen > 8 ? 5.8 : 6.5);
final contactLine = [
if (shopPhone.isNotEmpty) shopPhone,
@@ -85,9 +90,10 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
overflow: hidden;
}
.product-name {
font-size: 6.5pt; font-weight: 700; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 0.8mm;
font-size: 6.5pt; font-weight: 700; line-height: 1.15;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden; word-break: break-all;
margin-bottom: 0.6mm;
}
.specs {
display: flex; flex-direction: column; gap: 0.5mm;
@@ -102,12 +108,12 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
/* 右:二维码 */
.qr-wrap {
width: 14mm; flex-shrink: 0;
width: 16mm; flex-shrink: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 0.3mm;
padding: 0.5mm 0.5mm 0.5mm 0;
padding: 0.3mm 0.3mm 0.3mm 0;
}
.qr-wrap img { width: 12mm; height: 12mm; object-fit: contain; }
.qr-wrap img { width: 14.5mm; height: 14.5mm; object-fit: contain; }
.qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; }
/* ── Footer:联系方式 3mm ── */
@@ -129,11 +135,10 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
<div class="middle">
<div class="info">
<div class="product-name">$name</div>
<div class="product-name" style="font-size:${nameFontPt}pt">$name</div>
<div class="specs">
<div class="spec-row"><span class="lbl">规</span><span class="val">$specVal</span></div>
<div class="spec-row"><span class="lbl">批</span><span class="val">$batchVal</span></div>
<div class="spec-row"><span class="lbl">产</span><span class="val">$dateVal</span></div>
<div class="spec-row"><span class="val">$degSpecVal</span></div>
<div class="spec-row"><span class="val">$dateVal</span></div>
</div>
</div>
<div class="qr-wrap">
+2 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -107,7 +108,7 @@ class _JiuAppState extends ConsumerState<JiuApp> {
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: '岩美',
title: kDebugMode ? '岩美 [DEBUG]' : '岩美',
theme: AppTheme.light(),
routerConfig: router,
debugShowCheckedModeBanner: false,
-25
View File
@@ -1,46 +1,21 @@
PODS:
- file_picker (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- FlutterMacOS
- printing (1.0.0):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- printing (from `Flutter/ephemeral/.symlinks/plugins/printing/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES:
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
FlutterMacOS:
:path: Flutter/ephemeral
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
printing:
:path: Flutter/ephemeral/.symlinks/plugins/printing/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
printing: c4cf83c78fd684f9bc318e6aadc18972aa48f617
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
@@ -29,6 +29,7 @@
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
7CC0DF083710AA9C43B56A42 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B35335B030AEE727449F09D5 /* Pods_Runner.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -88,6 +89,7 @@
B35335B030AEE727449F09D5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B6FABD6521DBD25CA93F2ABB /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
D768E6B98F41FC79E2D5D343 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -103,6 +105,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
7CC0DF083710AA9C43B56A42 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -164,6 +167,7 @@
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
@@ -231,6 +235,9 @@
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -256,6 +263,9 @@
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -796,6 +806,18 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "&quot;$FLUTTER_ROOT&quot;/packages/flutter_tools/bin/macos_assemble.sh prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "jiu_client.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
+111 -111
View File
@@ -6,7 +6,7 @@ packages:
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.6.1"
args:
@@ -14,7 +14,7 @@ packages:
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
@@ -22,7 +22,7 @@ packages:
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
barcode:
@@ -30,7 +30,7 @@ packages:
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.9"
bidi:
@@ -38,7 +38,7 @@ packages:
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.13"
boolean_selector:
@@ -46,7 +46,7 @@ packages:
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
characters:
@@ -54,7 +54,7 @@ packages:
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
clock:
@@ -62,7 +62,7 @@ packages:
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
@@ -70,7 +70,7 @@ packages:
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
collection:
@@ -78,7 +78,7 @@ packages:
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
cross_file:
@@ -86,7 +86,7 @@ packages:
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.5+2"
crypto:
@@ -94,7 +94,7 @@ packages:
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
dio:
@@ -102,7 +102,7 @@ packages:
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.2"
dio_web_adapter:
@@ -110,7 +110,7 @@ packages:
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
equatable:
@@ -118,7 +118,7 @@ packages:
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.8"
excel:
@@ -126,7 +126,7 @@ packages:
description:
name: excel
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.6"
fake_async:
@@ -134,15 +134,15 @@ packages:
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
@@ -150,7 +150,7 @@ packages:
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
file_picker:
@@ -158,7 +158,7 @@ packages:
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.3.7"
file_selector_linux:
@@ -166,7 +166,7 @@ packages:
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.9.4"
file_selector_macos:
@@ -174,7 +174,7 @@ packages:
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
@@ -182,7 +182,7 @@ packages:
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
file_selector_windows:
@@ -190,7 +190,7 @@ packages:
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.9.3+5"
flutter:
@@ -203,7 +203,7 @@ packages:
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
flutter_localizations:
@@ -216,7 +216,7 @@ packages:
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.35"
flutter_riverpod:
@@ -224,7 +224,7 @@ packages:
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
flutter_test:
@@ -242,7 +242,7 @@ packages:
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.8.1"
hooks:
@@ -250,7 +250,7 @@ packages:
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
http:
@@ -258,7 +258,7 @@ packages:
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_mock_adapter:
@@ -266,7 +266,7 @@ packages:
description:
name: http_mock_adapter
sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.1"
http_parser:
@@ -274,7 +274,7 @@ packages:
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
image:
@@ -282,7 +282,7 @@ packages:
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.3.0"
image_picker:
@@ -290,7 +290,7 @@ packages:
description:
name: image_picker
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
image_picker_android:
@@ -298,7 +298,7 @@ packages:
description:
name: image_picker_android
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.8.13+19"
image_picker_for_web:
@@ -306,7 +306,7 @@ packages:
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.1"
image_picker_ios:
@@ -314,7 +314,7 @@ packages:
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.8.13+6"
image_picker_linux:
@@ -322,7 +322,7 @@ packages:
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.2"
image_picker_macos:
@@ -330,7 +330,7 @@ packages:
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
@@ -338,7 +338,7 @@ packages:
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.11.1"
image_picker_windows:
@@ -346,7 +346,7 @@ packages:
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.2"
intl:
@@ -354,7 +354,7 @@ packages:
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.20.2"
jni:
@@ -362,7 +362,7 @@ packages:
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
jni_flutter:
@@ -370,7 +370,7 @@ packages:
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
leak_tracker:
@@ -378,7 +378,7 @@ packages:
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
@@ -386,7 +386,7 @@ packages:
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
@@ -394,7 +394,7 @@ packages:
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
@@ -402,7 +402,7 @@ packages:
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
logger:
@@ -410,7 +410,7 @@ packages:
description:
name: logger
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
logging:
@@ -418,7 +418,7 @@ packages:
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
lpinyin:
@@ -426,7 +426,7 @@ packages:
description:
name: lpinyin
sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.3"
matcher:
@@ -434,7 +434,7 @@ packages:
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.19"
material_color_utilities:
@@ -442,7 +442,7 @@ packages:
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.13.0"
meta:
@@ -450,7 +450,7 @@ packages:
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.18.0"
mime:
@@ -458,7 +458,7 @@ packages:
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
objective_c:
@@ -466,7 +466,7 @@ packages:
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.4.1"
package_config:
@@ -474,7 +474,7 @@ packages:
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
package_info_plus:
@@ -482,7 +482,7 @@ packages:
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
@@ -490,7 +490,7 @@ packages:
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.1"
path:
@@ -498,7 +498,7 @@ packages:
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_parsing:
@@ -506,7 +506,7 @@ packages:
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
path_provider:
@@ -514,7 +514,7 @@ packages:
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.5"
path_provider_android:
@@ -522,7 +522,7 @@ packages:
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
@@ -530,7 +530,7 @@ packages:
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
@@ -538,7 +538,7 @@ packages:
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
@@ -546,7 +546,7 @@ packages:
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
path_provider_windows:
@@ -554,7 +554,7 @@ packages:
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
pdf:
@@ -562,7 +562,7 @@ packages:
description:
name: pdf
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.12.0"
pdf_widget_wrapper:
@@ -570,7 +570,7 @@ packages:
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.4"
petitparser:
@@ -578,7 +578,7 @@ packages:
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
platform:
@@ -586,7 +586,7 @@ packages:
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
@@ -594,7 +594,7 @@ packages:
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
printing:
@@ -602,7 +602,7 @@ packages:
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.14.3"
pub_semver:
@@ -610,7 +610,7 @@ packages:
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
qr:
@@ -618,7 +618,7 @@ packages:
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
record_use:
@@ -626,7 +626,7 @@ packages:
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
riverpod:
@@ -634,7 +634,7 @@ packages:
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
shared_preferences:
@@ -642,23 +642,23 @@ packages:
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
url: "https://pub.dev"
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.25"
version: "2.4.26"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_linux:
@@ -666,7 +666,7 @@ packages:
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
@@ -674,7 +674,7 @@ packages:
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
shared_preferences_web:
@@ -682,7 +682,7 @@ packages:
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
@@ -690,7 +690,7 @@ packages:
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sky_engine:
@@ -703,7 +703,7 @@ packages:
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
@@ -711,7 +711,7 @@ packages:
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
state_notifier:
@@ -719,7 +719,7 @@ packages:
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
stream_channel:
@@ -727,7 +727,7 @@ packages:
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
string_scanner:
@@ -735,7 +735,7 @@ packages:
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
term_glyph:
@@ -743,7 +743,7 @@ packages:
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
@@ -751,7 +751,7 @@ packages:
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.11"
typed_data:
@@ -759,7 +759,7 @@ packages:
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
url_launcher:
@@ -767,7 +767,7 @@ packages:
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.2"
url_launcher_android:
@@ -775,7 +775,7 @@ packages:
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.32"
url_launcher_ios:
@@ -783,7 +783,7 @@ packages:
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.4.1"
url_launcher_linux:
@@ -791,7 +791,7 @@ packages:
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
url_launcher_macos:
@@ -799,7 +799,7 @@ packages:
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
@@ -807,7 +807,7 @@ packages:
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
url_launcher_web:
@@ -815,7 +815,7 @@ packages:
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
url_launcher_windows:
@@ -823,7 +823,7 @@ packages:
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.5"
vector_math:
@@ -831,7 +831,7 @@ packages:
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
vm_service:
@@ -839,7 +839,7 @@ packages:
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.2.0"
web:
@@ -847,15 +847,15 @@ packages:
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
dependency: "direct main"
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.15.0"
xdg_directories:
@@ -863,7 +863,7 @@ packages:
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
@@ -871,7 +871,7 @@ packages:
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.6.1"
yaml:
@@ -879,7 +879,7 @@ packages:
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
sdks:
+3
View File
@@ -26,6 +26,9 @@ dependencies:
pdf: ^3.10.8
web: ^1.1.0
image_picker: ^1.2.2
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
win32: ^5.5.0
ffi: ^2.1.3
dev_dependencies:
flutter_test:
+658 -378
View File
File diff suppressed because it is too large Load Diff
+168 -17
View File
@@ -1,9 +1,9 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-10T15:32:03.641Z"
"updated_at": "2026-06-11T15:52:40.354Z"
},
"seq": 40,
"seq": 50,
"items": [
{
"id": 1,
@@ -24,7 +24,7 @@
"id": 2,
"title": "备案号为占位假值",
"desc": "现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。",
"level": "high",
"level": "low",
"tags": [
"Web"
],
@@ -32,7 +32,7 @@
"done": false,
"completed_at": null,
"version": null,
"status": "open"
"status": "done"
},
{
"id": 3,
@@ -404,7 +404,7 @@
"done": false,
"completed_at": null,
"version": null,
"status": "open"
"status": "done"
},
{
"id": 24,
@@ -477,7 +477,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:13.249Z",
"done": false,
"completed_at": null,
@@ -492,7 +492,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:15.102Z",
"done": false,
"completed_at": null,
@@ -507,7 +507,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:16.854Z",
"done": false,
"completed_at": null,
@@ -523,7 +523,7 @@
"后端",
"数据库"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:25.715Z",
"done": false,
"completed_at": null,
@@ -538,7 +538,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:28.106Z",
"done": false,
"completed_at": null,
@@ -553,7 +553,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:33.596Z",
"done": false,
"completed_at": null,
@@ -568,7 +568,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:44.159Z",
"done": false,
"completed_at": null,
@@ -583,7 +583,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:46.792Z",
"done": false,
"completed_at": null,
@@ -598,7 +598,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:49.032Z",
"done": false,
"completed_at": null,
@@ -613,7 +613,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:57.979Z",
"done": false,
"completed_at": null,
@@ -628,7 +628,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:32:00.233Z",
"done": false,
"completed_at": null,
@@ -644,11 +644,162 @@
"后端",
"数据库"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:32:03.641Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 41,
"title": "CheckInventoryAvailability Scan错误未检查导致所有商品误报库存不足",
"desc": "service/stock.go:312 DB查询失败时sums为空,所有商品have=0,合法出库审核被全部拒绝",
"level": "high",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:34.919Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 42,
"title": "finance ListRecords 和 inventory Logs handler 缺 pageSize 上限",
"desc": "finance.go:36 和 inventory.go:130 漏加 ValidatePageSize#37 未覆盖这两个 handler,可被任意用户触发全表扫描",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.152Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 43,
"title": "ReconcileInventory 两条 Scan 错误未检查导致对账误判",
"desc": "admin.go:122-123 invQuery/logQuery Scan错误被丢弃;invQuery失败→差异消失显示全部正常;logQuery失败→所有库存飘红为差异",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.205Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 44,
"title": "license Info 端点 device_count 统计错误且 ListDevices 错误被吞",
"desc": "handler/license.go:67-70 两个bug1.devs,_ 错误被_吞掉DB故障时deviceCount=02.ListDevices(shopID)包含该店所有license设备而非当前license",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.257Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 45,
"title": "license Activate 幂等路径 Update 错误未检查",
"desc": "service/license.go:70 s.db.Model(&existing).Update()失败时仍返回nil error,设备名未更新但调用方不知道",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.016Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 46,
"title": "license Verify 加载 license 缺 shop_id 过滤(防御纵深)",
"desc": "service/license.go:102 WHERE id=? AND is_active=1 应加 AND shop_id=? 防御数据异常时跨租户泄露",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.066Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 47,
"title": "util/response.go 的 RespondError 等辅助函数从未被调用(#34 实际未完成)",
"desc": "#34 只创建了util/response.go,没有将任何handler迁移过去,实际错误格式仍不统一,需完成迁移",
"level": "low",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.118Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 48,
"title": "清理 ErrDeviceMismatch 死代码(多设备迁移后残留)",
"desc": "service/license.go:24 ErrDeviceMismatch定义后从未返回,多设备迁移(#32)后已无单设备约束",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.168Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 49,
"title": "修复 act_runner→Forgejo 任务状态上报丢失导致构建成功却标失败",
"desc": "v1.0.27~v1.0.32 连续 6 个版本 CI 全部'失败',但日志显示所有 job 实际构建成功(🏁 Job succeeded),是 act_runner 最终状态上报丢失(Failed to write EOT),被 Forgejo 僵尸任务清理标为 failure。生产环境因此停留在 v1.0.266/8)。需排查 relay/GOAWAY race 或升级 act_runner/Forgejo。",
"level": "high",
"tier": 2,
"tags": [
"CI/CD"
],
"status": "done",
"created_at": "2026-06-11T13:55:07.388Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 50,
"title": "License model 的 license_key 索引超 InnoDB 3072 字节限制需改用前缀索引",
"desc": "license.go 的 LicenseKey 是 size:2048+uniqueIndexutf8mb4 下 8192 字节超 MySQL InnoDB 索引上限,全新环境 AutoMigrate 必失败(线上已手动建 license_key(768) 前缀索引绕过)。需在 model 层固化:缩短列或 GORM 指定索引前缀长度,并同步 schema.sql。",
"level": "mid",
"tier": 3,
"tags": [
"后端",
"数据库"
],
"status": "open",
"created_at": "2026-06-11T15:52:40.353Z",
"done": false,
"completed_at": null,
"version": null
}
]
}
+3 -2
View File
@@ -2,9 +2,10 @@
"name": "岩美酒库管理系统",
"tagline": "为酒行与酒店设计的库存管理平台",
"copyright": "© 2026 岩美科技 · 保留所有权利",
"icp": "沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号",
"icp": "",
"support": {
"email": "yammy2023@163.com"
"email": "yammy2023@163.com",
"wechat": ""
},
"appUrl": "/app/",
"appBaseUrl": "https://jiu.51yanmei.com",
+2 -1
View File
@@ -5,6 +5,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
{% if site.support.wechat %}<div>客服微信:{{ site.support.wechat }}</div>{% endif %}
<div><a href="mailto:{{ site.support.email }}">{{ site.support.email }}</a></div>
{% if site.support.phone %}<div>{{ site.support.phone }}</div>{% endif %}
</div>
@@ -42,7 +43,7 @@
</div>
<div class="footer-bottom">
<div>{{ site.copyright }}</div>
<div>{{ site.icp }}</div>
{% if site.icp %}<div>{{ site.icp }}</div>{% endif %}
</div>
</div>
</footer>
+3 -3
View File
@@ -555,7 +555,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>单门店 · 最多 3 用户</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>全部模块开放</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>Web 端 + 移动端</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>邮件技术支持</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>微信技术支持</li>
</ul>
<a href="/register/" class="btn btn-secondary w-full justify-center mt-auto">免费开通</a>
</div>
@@ -585,7 +585,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>专属实施与培训服务</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>7×24 专属客户经理</li>
</ul>
<a href="mailto:{{ site.support.email }}" class="btn btn-secondary w-full justify-center mt-auto">联系销售</a>
{% if site.support.wechat %}<a href="#" class="btn btn-secondary w-full justify-center mt-auto">微信咨询:{{ site.support.wechat }}</a>{% else %}<a href="mailto:{{ site.support.email }}" class="btn btn-secondary w-full justify-center mt-auto">联系销售</a>{% endif %}
</div>
</div>
</div>
@@ -639,7 +639,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
</div>
<div class="d-flex flex-col gap-12 items-start">
<a href="/register/" class="btn btn-primary btn-lg">立即开通试用<i data-lucide="arrow-right" class="icon"></i></a>
<a href="mailto:{{ site.support.email }}?subject=咨询岩美连锁版" class="btn btn-secondary btn-lg"><i data-lucide="mail" class="icon"></i>邮件咨询</a>
{% if site.support.wechat %}<a href="#" class="btn btn-secondary btn-lg"><i data-lucide="message-circle" class="icon"></i>微信咨询:{{ site.support.wechat }}</a>{% else %}<a href="mailto:{{ site.support.email }}?subject=咨询岩美连锁版" class="btn btn-secondary btn-lg"><i data-lucide="mail" class="icon"></i>邮件咨询</a>{% endif %}
</div>
</div>
</div>