Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5282eb57c9 | |||
| f59bdf056f | |||
| 01946282ed | |||
| 0d4c9cced2 | |||
| e9a6543a8b | |||
| 5f2248001d | |||
| 2dbc89b902 | |||
| 44ffedb56a | |||
| 93878511e5 | |||
| 503ee7372e | |||
| 62dc54fe1c |
@@ -5,6 +5,13 @@ 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.1] - 2026-05-25
|
||||
|
||||
### 改进
|
||||
- 库存搜索支持拼音和拼音首字母(例:搜"mt"或"maotai"可匹配"茅台")
|
||||
- 库存搜索改为回车/点击触发,减少无效请求
|
||||
- 新增异常自动上报机制,线上错误可实时追踪
|
||||
|
||||
## [1.0.0] - 2026-05-24
|
||||
|
||||
初始版本发布,完成核心库存管理功能。
|
||||
|
||||
@@ -150,6 +150,45 @@ security: 修复入库单多租户隔离漏洞
|
||||
|
||||
---
|
||||
|
||||
## 开发完成标准(Definition of Done)
|
||||
|
||||
**任何功能或修复,必须满足以下全部条件才算完成:**
|
||||
|
||||
### 后端变更
|
||||
|
||||
```bash
|
||||
# 1. 编译通过
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd backend && go build ./...
|
||||
|
||||
# 2. 所有测试通过(不得有 FAIL)
|
||||
cd backend && go test ./...
|
||||
|
||||
# 3. 无编译警告(vet 检查)
|
||||
cd backend && go vet ./...
|
||||
```
|
||||
|
||||
### 前端变更
|
||||
|
||||
```bash
|
||||
# 1. 静态分析无 error(warning/info 允许存在)
|
||||
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
|
||||
|
||||
# 2. 所有测试通过
|
||||
cd client && flutter test
|
||||
```
|
||||
|
||||
### 通用要求
|
||||
|
||||
- [ ] 代码已按 Git 提交规范提交(feat/fix/test/...)
|
||||
- [ ] 涉及新接口:已在对应测试文件中覆盖核心路径
|
||||
- [ ] 涉及数据库变更:schema.sql 和 model 同步更新
|
||||
- [ ] 多租户隔离未被破坏(所有查询含 shop_id 条件)
|
||||
|
||||
**测试未通过前,禁止提交代码、禁止打 tag 发版。**
|
||||
|
||||
---
|
||||
|
||||
## 代码质量门禁
|
||||
|
||||
以下情况**不得提交**:
|
||||
|
||||
@@ -44,6 +44,7 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/go-pinyin v0.21.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
||||
@@ -73,6 +73,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
|
||||
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ErrorReportHandler struct{ db *gorm.DB }
|
||||
|
||||
func NewErrorReportHandler(db *gorm.DB) *ErrorReportHandler {
|
||||
return &ErrorReportHandler{db: db}
|
||||
}
|
||||
|
||||
type submitErrorRequest struct {
|
||||
ErrorType string `json:"error_type" binding:"required"`
|
||||
AppVersion string `json:"app_version"`
|
||||
Platform string `json:"platform"`
|
||||
Username string `json:"username"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
ShopNo string `json:"shop_no"`
|
||||
Role string `json:"role"`
|
||||
ErrorMsg string `json:"error_msg" binding:"required"`
|
||||
StackTrace string `json:"stack_trace"`
|
||||
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
|
||||
}
|
||||
|
||||
// Submit POST /api/v1/public/errors
|
||||
func (h *ErrorReportHandler) Submit(c *gin.Context) {
|
||||
var req submitErrorRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
occurredAt := time.Now()
|
||||
if req.OccurredAt > 0 {
|
||||
occurredAt = time.UnixMilli(req.OccurredAt)
|
||||
}
|
||||
|
||||
stack := req.StackTrace
|
||||
const maxStack = 4000
|
||||
if len(stack) > maxStack {
|
||||
stack = stack[:maxStack]
|
||||
}
|
||||
|
||||
report := model.ErrorReport{
|
||||
ErrorType: req.ErrorType,
|
||||
AppVersion: req.AppVersion,
|
||||
Platform: req.Platform,
|
||||
Username: req.Username,
|
||||
ShopID: req.ShopID,
|
||||
ShopNo: req.ShopNo,
|
||||
Role: req.Role,
|
||||
ClientIP: c.ClientIP(),
|
||||
ErrorMsg: req.ErrorMsg,
|
||||
StackTrace: stack,
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&report).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "submit failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": report.ID})
|
||||
}
|
||||
|
||||
// List GET /api/v1/admin/errors (superadmin only)
|
||||
func (h *ErrorReportHandler) List(c *gin.Context) {
|
||||
var q struct {
|
||||
ErrorType string `form:"error_type"`
|
||||
Username string `form:"username"`
|
||||
Platform string `form:"platform"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=50"`
|
||||
}
|
||||
if err := c.ShouldBindQuery(&q); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if q.PageSize <= 0 || q.PageSize > 200 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
|
||||
db := h.db.Model(&model.ErrorReport{})
|
||||
if q.ErrorType != "" {
|
||||
db = db.Where("error_type = ?", q.ErrorType)
|
||||
}
|
||||
if q.Username != "" {
|
||||
db = db.Where("username LIKE ?", "%"+q.Username+"%")
|
||||
}
|
||||
if q.Platform != "" {
|
||||
db = db.Where("platform = ?", q.Platform)
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var reports []model.ErrorReport
|
||||
db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&reports)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": reports,
|
||||
"total": total,
|
||||
"page": q.Page,
|
||||
"page_size": q.PageSize,
|
||||
})
|
||||
}
|
||||
@@ -64,9 +64,9 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
||||
inStock := c.Query("in_stock")
|
||||
|
||||
if keyword != "" {
|
||||
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)"
|
||||
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)"
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like)
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if warehouseIDStr != "" {
|
||||
baseWhere += " AND inv.warehouse_id = ?"
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
type ProductHandler struct {
|
||||
@@ -70,6 +71,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
|
||||
}
|
||||
product.ShopID = shopID
|
||||
product.PublicID = uuid.New().String()
|
||||
product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name)
|
||||
|
||||
// Auto-generate product code if not provided (e.g. P001, P002)
|
||||
// Retry up to 5 times on duplicate key to handle concurrent creates
|
||||
@@ -122,6 +124,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
namePinyin, nameInitials := util.ToPinyin(req.Name)
|
||||
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
|
||||
if err := h.db.Model(&product).Updates(map[string]interface{}{
|
||||
"code": req.Code,
|
||||
@@ -138,6 +141,8 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
||||
"description": req.Description,
|
||||
"remark": req.Remark,
|
||||
"custom_fields": req.CustomFields,
|
||||
"name_pinyin": namePinyin,
|
||||
"name_initials": nameInitials,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -226,13 +231,16 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
|
||||
|
||||
var count int64
|
||||
h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count)
|
||||
namePinyin, nameInitials := util.ToPinyin(req.Name)
|
||||
product = model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Series: req.Series,
|
||||
Spec: req.Spec,
|
||||
Code: fmt.Sprintf("P%03d", count+1),
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Series: req.Series,
|
||||
Spec: req.Spec,
|
||||
Code: fmt.Sprintf("P%03d", count+1),
|
||||
NamePinyin: namePinyin,
|
||||
NameInitials: nameInitials,
|
||||
}
|
||||
if createErr := h.db.Create(&product).Error; createErr != nil {
|
||||
// Race condition: try to find the record created by another request
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ErrorReport 客户端错误上报记录(系统级,不做多租户隔离)
|
||||
type ErrorReport struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception
|
||||
AppVersion string `gorm:"size:30" json:"app_version"`
|
||||
Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos
|
||||
Username string `gorm:"size:50;index" json:"username"`
|
||||
ShopID uint64 `gorm:"index" json:"shop_id"`
|
||||
ShopNo string `gorm:"size:50" json:"shop_no"`
|
||||
Role string `gorm:"size:20" json:"role"`
|
||||
ClientIP string `gorm:"size:60" json:"client_ip"`
|
||||
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
|
||||
StackTrace string `gorm:"type:text" json:"stack_trace"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type Product struct {
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
NamePinyin string `gorm:"size:400;index" json:"-"`
|
||||
NameInitials string `gorm:"size:100;index" json:"-"`
|
||||
|
||||
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
||||
|
||||
@@ -33,6 +33,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
publicH := handler.NewPublicHandler(db)
|
||||
adminH := handler.NewAdminHandler(db)
|
||||
shopH := handler.NewShopHandler(db)
|
||||
errorReportH := handler.NewErrorReportHandler(db)
|
||||
|
||||
// 健康检查(无需认证,用于前端连通性探测)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
@@ -56,6 +57,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
public.GET("/products/:public_id", publicH.GetProduct)
|
||||
public.GET("/release", publicH.GetRelease)
|
||||
public.POST("/errors", errorReportH.Submit)
|
||||
}
|
||||
|
||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||
@@ -213,6 +215,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
superAdmin.Use(middleware.SuperAdminOnly())
|
||||
{
|
||||
superAdmin.POST("/clear-data", adminH.ClearData)
|
||||
superAdmin.GET("/errors", errorReportH.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
gp "github.com/mozillazg/go-pinyin"
|
||||
)
|
||||
|
||||
var (
|
||||
fullArgs = func() gp.Args {
|
||||
a := gp.NewArgs()
|
||||
a.Style = gp.Normal
|
||||
a.Fallback = func(r rune, _ gp.Args) []string {
|
||||
return []string{strings.ToLower(string(r))}
|
||||
}
|
||||
return a
|
||||
}()
|
||||
initialArgs = func() gp.Args {
|
||||
a := gp.NewArgs()
|
||||
a.Style = gp.FirstLetter
|
||||
a.Fallback = func(r rune, _ gp.Args) []string {
|
||||
s := strings.ToLower(string(r))
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{string(s[0])}
|
||||
}
|
||||
return a
|
||||
}()
|
||||
)
|
||||
|
||||
// ToPinyin converts a Chinese name to full pinyin and initials.
|
||||
// Non-Chinese characters are kept as-is (lowercased).
|
||||
// Example: "茅台酒" → ("maotaijiu", "mtj")
|
||||
func ToPinyin(s string) (full, initials string) {
|
||||
var fb, ib strings.Builder
|
||||
for _, row := range gp.Pinyin(s, fullArgs) {
|
||||
if len(row) > 0 {
|
||||
fb.WriteString(row[0])
|
||||
}
|
||||
}
|
||||
for _, row := range gp.Pinyin(s, initialArgs) {
|
||||
if len(row) > 0 {
|
||||
ib.WriteString(row[0])
|
||||
}
|
||||
}
|
||||
return fb.String(), ib.String()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/router"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -27,6 +28,9 @@ func main() {
|
||||
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
|
||||
autoMigrate(db)
|
||||
|
||||
// 回填存量商品的拼音索引(一次性,已有值的跳过)
|
||||
backfillPinyin(db)
|
||||
|
||||
// 启动 Gin
|
||||
gin.SetMode(config.C.Server.Mode)
|
||||
r := gin.New()
|
||||
@@ -104,9 +108,26 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.ProductSeriesOption{},
|
||||
&model.ProductSpecOption{},
|
||||
&model.ProductImage{},
|
||||
&model.ErrorReport{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
log.Println("AutoMigrate completed")
|
||||
}
|
||||
|
||||
func backfillPinyin(db *gorm.DB) {
|
||||
var products []model.Product
|
||||
db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products)
|
||||
if len(products) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range products {
|
||||
full, initials := util.ToPinyin(products[i].Name)
|
||||
db.Model(&products[i]).Updates(map[string]interface{}{
|
||||
"name_pinyin": full,
|
||||
"name_initials": initials,
|
||||
})
|
||||
}
|
||||
log.Printf("backfillPinyin: updated %d products", len(products))
|
||||
}
|
||||
|
||||
@@ -121,7 +121,9 @@ func SetupTestDB() *gorm.DB {
|
||||
min_stock INTEGER DEFAULT 0,
|
||||
description TEXT,
|
||||
custom_fields TEXT,
|
||||
remark TEXT
|
||||
remark TEXT,
|
||||
name_pinyin TEXT DEFAULT '',
|
||||
name_initials TEXT DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS warehouses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.yanmei.jiu"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.yanmei.jiu"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="jiu_client"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.yanmei.jiu
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
@@ -74,6 +75,16 @@ class ApiClient {
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
// 服务端 5xx 错误上报(4xx 是业务/权限错误,不上报)
|
||||
final statusCode = e.response?.statusCode ?? 0;
|
||||
if (statusCode >= 500) {
|
||||
reportError(
|
||||
'[${e.requestOptions.method}] ${e.requestOptions.path} → $statusCode',
|
||||
e.stackTrace,
|
||||
errorType: ErrorType.apiError,
|
||||
);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class ErrorType {
|
||||
static const flutterError = 'flutter_error';
|
||||
static const zoneError = 'zone_error';
|
||||
static const caughtException = 'caught_exception';
|
||||
static const apiError = 'api_error';
|
||||
}
|
||||
|
||||
class ErrorReporter {
|
||||
ErrorReporter._();
|
||||
static final ErrorReporter instance = ErrorReporter._();
|
||||
|
||||
static const _dedupWindowMs = 60 * 1000;
|
||||
static const _maxStackLength = 3000;
|
||||
|
||||
final Map<String, int> _recentMessages = {};
|
||||
Dio? _dio;
|
||||
String? _cachedVersion;
|
||||
|
||||
Dio get _client {
|
||||
_dio ??= Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
return _dio!;
|
||||
}
|
||||
|
||||
Future<String> _getVersion() async {
|
||||
if (_cachedVersion != null) return _cachedVersion!;
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_cachedVersion = info.version;
|
||||
} catch (_) {
|
||||
_cachedVersion = 'unknown';
|
||||
}
|
||||
return _cachedVersion!;
|
||||
}
|
||||
|
||||
String _getPlatform() {
|
||||
if (kIsWeb) return 'web';
|
||||
try {
|
||||
if (Platform.isIOS) return 'ios';
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isMacOS) return 'macos';
|
||||
if (Platform.isWindows) return 'windows';
|
||||
if (Platform.isLinux) return 'linux';
|
||||
} catch (_) {}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _getUserFields() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return {
|
||||
'username': prefs.getString('username') ?? '',
|
||||
'shop_id': int.tryParse(prefs.getString('shop_id') ?? '') ?? 0,
|
||||
'shop_no': prefs.getString('shop_no') ?? '',
|
||||
'role': prefs.getString('role') ?? '',
|
||||
};
|
||||
} catch (_) {
|
||||
return {'username': '', 'shop_id': 0, 'shop_no': '', 'role': ''};
|
||||
}
|
||||
}
|
||||
|
||||
bool _isDuplicate(String message) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final lastTime = _recentMessages[message];
|
||||
if (lastTime != null && now - lastTime < _dedupWindowMs) return true;
|
||||
_recentMessages[message] = now;
|
||||
if (_recentMessages.length > 100) {
|
||||
_recentMessages.removeWhere((_, t) => now - t >= _dedupWindowMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void report({
|
||||
required String errorType,
|
||||
required String errorMsg,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (_isDuplicate(errorMsg)) return;
|
||||
unawaited(_doReport(
|
||||
errorType: errorType,
|
||||
errorMsg: errorMsg,
|
||||
stackTrace: stackTrace,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _doReport({
|
||||
required String errorType,
|
||||
required String errorMsg,
|
||||
StackTrace? stackTrace,
|
||||
}) async {
|
||||
try {
|
||||
final version = await _getVersion();
|
||||
final userFields = await _getUserFields();
|
||||
|
||||
String? stack;
|
||||
if (stackTrace != null) {
|
||||
final raw = stackTrace.toString();
|
||||
stack = raw.length > _maxStackLength ? raw.substring(0, _maxStackLength) : raw;
|
||||
}
|
||||
|
||||
await _client.post('/public/errors', data: {
|
||||
'error_type': errorType,
|
||||
'app_version': version,
|
||||
'platform': _getPlatform(),
|
||||
'username': userFields['username'],
|
||||
'shop_id': userFields['shop_id'],
|
||||
'shop_no': userFields['shop_no'],
|
||||
'role': userFields['role'],
|
||||
'error_msg': errorMsg,
|
||||
'stack_trace': stack ?? '',
|
||||
'occurred_at': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
} catch (_) {
|
||||
// 上报失败静默处理,不影响用户
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷顶层函数,供 catch 块一行调用
|
||||
void reportError(
|
||||
Object error,
|
||||
StackTrace? stack, {
|
||||
String errorType = ErrorType.caughtException,
|
||||
}) {
|
||||
ErrorReporter.instance.report(
|
||||
errorType: errorType,
|
||||
errorMsg: error.toString(),
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
@@ -189,7 +190,12 @@ Future<void> printProductLabelImpl({
|
||||
),
|
||||
));
|
||||
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
try {
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
}
|
||||
}
|
||||
|
||||
pw.Widget _label2ColRow(
|
||||
@@ -424,7 +430,12 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
|
||||
),
|
||||
],
|
||||
));
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
try {
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
@@ -474,5 +485,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
),
|
||||
],
|
||||
));
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
try {
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
import 'core/auth/auth_state.dart';
|
||||
import 'core/errors/error_reporter.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'providers/connectivity_provider.dart';
|
||||
@@ -15,6 +16,11 @@ void main() {
|
||||
debugPrint('═══ FlutterError ════════════════════════════');
|
||||
debugPrint(details.exceptionAsString());
|
||||
debugPrint(details.stack.toString());
|
||||
ErrorReporter.instance.report(
|
||||
errorType: ErrorType.flutterError,
|
||||
errorMsg: details.exceptionAsString(),
|
||||
stackTrace: details.stack,
|
||||
);
|
||||
};
|
||||
|
||||
runZonedGuarded(
|
||||
@@ -23,6 +29,11 @@ void main() {
|
||||
debugPrint('═══ Zone Error ═══════════════════════════════');
|
||||
debugPrint(error.toString());
|
||||
debugPrint(stack.toString());
|
||||
ErrorReporter.instance.report(
|
||||
errorType: ErrorType.zoneError,
|
||||
errorMsg: error.toString(),
|
||||
stackTrace: stack,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,21 +28,16 @@ class InventoryListScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
Timer? _debounce;
|
||||
Set<String> _filterWarehouse = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
ref.read(inventoryListProvider.notifier).setKeyword(value);
|
||||
});
|
||||
void _triggerSearch() {
|
||||
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
|
||||
}
|
||||
|
||||
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
||||
@@ -333,15 +328,20 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
width: 220,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索商品名/编码',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: '名称/编码/拼音,回车搜索',
|
||||
prefixIcon: const Icon(Icons.search, size: 16),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
tooltip: '搜索',
|
||||
onPressed: _triggerSearch,
|
||||
),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (_) => _triggerSearch(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -247,8 +247,16 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
await printStockInOrder(order);
|
||||
try {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
await printStockInOrder(order);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
@@ -690,7 +698,17 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
||||
tooltip: '打印',
|
||||
onPressed: () => printStockInOrder(_loadedOrder!),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await printStockInOrder(_loadedOrder!);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
|
||||
@@ -254,8 +254,16 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||
await printStockOutOrder(order);
|
||||
try {
|
||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||
await printStockOutOrder(order);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
@@ -682,7 +690,17 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
||||
tooltip: '打印',
|
||||
onPressed: () => printStockOutOrder(_loadedOrder!),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await printStockOutOrder(_loadedOrder!);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
|
||||
+46
-118
@@ -10,9 +10,9 @@
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| Gitea Web UI | `http://192.168.3.200:3000` |
|
||||
| Gitea SSH | `ssh://192.168.3.200:2222` |
|
||||
| Actions 面板 | `http://192.168.3.200:3000/wangjia/jiu/actions` |
|
||||
| Gitea Web UI | `https://git.51yanmei.com` |
|
||||
| Gitea SSH | `ssh://git@git.51yanmei.com:2222` |
|
||||
| Actions 面板 | `https://git.51yanmei.com/wangjia/jiu/actions` |
|
||||
| 线上应用 | `https://jiu.51yanmei.com` |
|
||||
| 管理端 Web | `https://jiu.51yanmei.com/app/` |
|
||||
|
||||
@@ -28,8 +28,8 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
|
||||
|------|------|
|
||||
| `/Users/wangjia/bin/act_runner` | act_runner 二进制(v0.2.11) |
|
||||
| `/Users/wangjia/bin/act_runner_start.sh` | 启动脚本(含 TCP 中继逻辑) |
|
||||
| `/Users/wangjia/bin/tcp_relay.py` | Python3 TCP 中继:`127.0.0.1:13000 → 192.168.3.200:3000` |
|
||||
| `/Users/wangjia/.act_runner` | Runner 注册信息(地址指向 `127.0.0.1:13000`) |
|
||||
| `/Users/wangjia/bin/tcp_relay.py` | Python3 TCP 中继(旧内网方案,现已通过域名直连) |
|
||||
| `/Users/wangjia/.act_runner` | Runner 注册信息 |
|
||||
| `/Users/wangjia/.act_runner_config.yaml` | Runner 配置(label: `mac:host`,capacity: 1) |
|
||||
| `~/Library/LaunchAgents/com.jiu.act-runner.plist` | LaunchAgent plist,开机自启 |
|
||||
| `/tmp/act_runner.log` | 运行日志 |
|
||||
@@ -38,12 +38,6 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机
|
||||
|
||||
`.gitea/workflows/deploy.yml` 和 `ci.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`。
|
||||
|
||||
### 为什么需要 TCP 中继(重要)
|
||||
|
||||
Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动的 Go 二进制发出的 TCP 连接,即使 Shadowrocket 内已配置直连规则。现象:Go gRPC 报 `no route to host`,而同进程里的 Python3 socket 可以正常连接。
|
||||
|
||||
**解决方案:** 用 Python3 做透明 TCP 中继。`tcp_relay.py` 监听 `127.0.0.1:13000`,将连接转发到 `192.168.3.200:3000`。act_runner 的注册地址改为 `http://127.0.0.1:13000`,完全绕开 Shadowrocket 拦截。
|
||||
|
||||
### 检查 Runner 状态
|
||||
|
||||
```bash
|
||||
@@ -51,7 +45,7 @@ Shadowrocket(代理工具)的 Network Extension 会拦截 LaunchAgent 启动
|
||||
tail -f /tmp/act_runner.log
|
||||
|
||||
# 确认进程在跑
|
||||
ps aux | grep -E "act_runner|tcp_relay" | grep -v grep
|
||||
ps aux | grep act_runner | grep -v grep
|
||||
|
||||
# 手动重启(LaunchAgent 会自动重启,一般不需要)
|
||||
kill $(pgrep -f act_runner_start.sh)
|
||||
@@ -59,12 +53,10 @@ kill $(pgrep -f act_runner_start.sh)
|
||||
|
||||
### Runner 不工作时的排查步骤
|
||||
|
||||
1. **确认 NAS 可达**:`curl -sf http://192.168.3.200:3000/api/v1/version`
|
||||
2. **确认 relay 在跑**:`ps aux | grep tcp_relay`
|
||||
3. **确认 relay 端口监听**:`nc -zv 127.0.0.1 13000`
|
||||
4. **查看最近日志**:`tail -30 /tmp/act_runner.log`
|
||||
5. **手动测试**:`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
|
||||
6. **强制重启**:`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
|
||||
1. **确认 Gitea 可达**:`curl -sf https://git.51yanmei.com/api/v1/version`
|
||||
2. **查看最近日志**:`tail -30 /tmp/act_runner.log`
|
||||
3. **手动测试**:`/Users/wangjia/bin/act_runner daemon --config ~/.act_runner_config.yaml`(从终端运行应直接成功)
|
||||
4. **强制重启**:`kill $(pgrep -f act_runner_start.sh)` — LaunchAgent 会在几秒内自动重拉
|
||||
|
||||
### 重新注册 Runner(token 失效时)
|
||||
|
||||
@@ -81,15 +73,13 @@ rm ~/.act_runner
|
||||
# 4. 注册(从终端运行,不要从 LaunchAgent)
|
||||
/Users/wangjia/bin/act_runner register \
|
||||
--no-interactive \
|
||||
--instance http://192.168.3.200:3000 \
|
||||
--instance https://git.51yanmei.com \
|
||||
# SSH remote: ssh://git@git.51yanmei.com:2222/wangjia/jiu.git
|
||||
--token <新token> \
|
||||
--name mac-runner \
|
||||
--labels mac:host
|
||||
|
||||
# 5. 把注册文件里的地址改回 127.0.0.1:13000
|
||||
sed -i '' 's|http://192.168.3.200:3000|http://127.0.0.1:13000|' ~/.act_runner
|
||||
|
||||
# 6. 重启(LaunchAgent 自动接管)
|
||||
# 5. 重启(LaunchAgent 自动接管)
|
||||
kill $(pgrep -f act_runner_start.sh)
|
||||
```
|
||||
|
||||
@@ -101,19 +91,19 @@ kill $(pgrep -f act_runner_start.sh)
|
||||
|
||||
### `deploy.yml` — 主部署流程
|
||||
|
||||
**触发条件:** push 到 `main` 分支
|
||||
**触发条件:** push `v*.*.*` tag
|
||||
|
||||
**步骤:**
|
||||
1. `go test ./...` — 跑后端测试
|
||||
2. `flutter test` — 跑前端测试
|
||||
3. 编译后端:`GOOS=linux GOARCH=amd64 go build` 生成 `jiu-server`
|
||||
4. 编译 Flutter Web:`flutter build web --release --base-href=/app/`
|
||||
5. 创建 Forgejo Release(版本号格式 `vYYYYMMDD.HHMM`),上传 `jiu-server` 和 `web.tar.gz`
|
||||
6. SSH 到 EC2 部署:
|
||||
- 停止 jiu 服务 → 替换二进制 → 启动 → 等待健康检查通过
|
||||
- 替换 Flutter Web 静态文件到 `/opt/jiu/web/`
|
||||
- 同步营销站点到 `/opt/jiu/marketing/`
|
||||
- `nginx -s reload`
|
||||
1. `compile.sh` — 编译 Go 后端(linux/amd64)+ Flutter Web,打包到 `dist/`
|
||||
2. `test.sh` — `go test` + `flutter analyze`
|
||||
3. `release.sh` — 解析 CHANGELOG.md,更新 version.yaml,创建 Forgejo Release,上传 3 个 asset
|
||||
4. `deploy.sh` — SSH 到 EC2 部署,完成后发 Telegram 通知
|
||||
|
||||
### `manual.yml` — 手动部署 / 回滚
|
||||
|
||||
**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入目标 tag(如 `v1.0.0`)
|
||||
|
||||
**步骤:** 只运行 `deploy.sh`,从 Forgejo Release 下载已有产物直接部署,跳过编译和测试。
|
||||
|
||||
### `ci.yml` — PR 测试流程
|
||||
|
||||
@@ -121,12 +111,6 @@ kill $(pgrep -f act_runner_start.sh)
|
||||
|
||||
**步骤:** 只跑测试(`go test` + `flutter test`),不构建不部署。
|
||||
|
||||
### `backup.yml` — 每日备份
|
||||
|
||||
**触发条件:** 每天凌晨 2:00(cron)
|
||||
|
||||
**步骤:** SSH 到 EC2,导出 MySQL 数据库备份,上传到指定目录。
|
||||
|
||||
---
|
||||
|
||||
## Secrets 配置
|
||||
@@ -138,26 +122,23 @@ kill $(pgrep -f act_runner_start.sh)
|
||||
| `EC2_SSH_KEY` | EC2 服务器的 SSH 私钥(PEM 格式,完整内容) |
|
||||
| `EC2_HOST` | EC2 服务器 IP 或域名 |
|
||||
| `EC2_USER` | EC2 SSH 用户名(如 `ec2-user`、`ubuntu`) |
|
||||
| `DB_PASSWORD` | MySQL root 密码(备份 workflow 使用) |
|
||||
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release) |
|
||||
| `FORGEJO_URL` | Gitea 内网地址,如 `http://192.168.3.200:3000` |
|
||||
| `FORGEJO_TOKEN` | Gitea 个人访问 Token(用于创建 Release、下载 asset) |
|
||||
| `FORGEJO_URL` | Gitea 地址:`https://git.51yanmei.com` |
|
||||
| `TELEGRAM_TOKEN` | Telegram Bot Token(部署通知) |
|
||||
| `TELEGRAM_CHAT_ID` | Telegram Chat ID(接收通知的对话) |
|
||||
|
||||
---
|
||||
|
||||
## 触发部署方式
|
||||
|
||||
日常开发流程:
|
||||
|
||||
```bash
|
||||
# 开发 → 本地测试
|
||||
go test ./...
|
||||
flutter test
|
||||
|
||||
# 用户确认后推送到 NAS Gitea(触发自动部署)
|
||||
git push nas main
|
||||
# 开发完成,确认测试通过后打 tag
|
||||
git tag -a v1.1.0 -m "v1.1.0"
|
||||
git push nas main && git push nas v1.1.0
|
||||
# → CI 自动:compile → test → release → deploy → Telegram 通知
|
||||
```
|
||||
|
||||
推送到 `nas` remote(指向 `http://192.168.3.200:3000/wangjia/jiu.git`)后,Gitea Actions 自动运行 `deploy.yml`,全流程约 5~8 分钟。
|
||||
**回滚:** Forgejo UI → Actions → Manual Deploy → 输入旧 tag → Run
|
||||
|
||||
---
|
||||
|
||||
@@ -166,17 +147,16 @@ git push nas main
|
||||
```
|
||||
/opt/jiu/
|
||||
├── backend/
|
||||
│ └── jiu-server # Go 可执行文件
|
||||
│ ├── jiu-server # Go 可执行文件
|
||||
│ └── config/
|
||||
│ └── version.yaml # 版本信息(由 CI 自动更新)
|
||||
├── web/ # Flutter Web 构建产物(/app/ 路径)
|
||||
├── marketing/ # 营销站点静态 HTML
|
||||
│ ├── assets/ # CSS / SVG
|
||||
│ ├── assets/
|
||||
│ ├── index.html
|
||||
│ ├── docs.html
|
||||
│ ├── download.html
|
||||
│ ├── scan.html
|
||||
│ └── features/
|
||||
│ ├── inventory.html
|
||||
│ └── approval.html
|
||||
│ └── scan.html
|
||||
├── images/ # 商品图片上传目录
|
||||
└── web-old/ # 上一版本 Flutter Web(回滚备用)
|
||||
```
|
||||
@@ -188,64 +168,12 @@ Systemd 服务名:`jiu`
|
||||
|
||||
## Nginx 路由说明
|
||||
|
||||
配置文件:`deploy/nginx-jiu.conf`(部署时手动 scp 至服务器)
|
||||
配置文件:`deploy/nginx-jiu.conf`(CI 部署时自动更新)
|
||||
|
||||
| 路径 | 服务 | 目录 |
|
||||
|------|------|------|
|
||||
| `/api/*`, `/health`, `/version` | Go 后端 API | proxy → `localhost:8080` |
|
||||
| `/images/*` | 商品图片 | `/opt/jiu/images/` |
|
||||
| `/app/` | Flutter 管理端 SPA | `/opt/jiu/web/` |
|
||||
| `/` | 营销首页 | `/opt/jiu/marketing/index.html` |
|
||||
| `/docs` | 文档页 | `/opt/jiu/marketing/docs.html` |
|
||||
| `/download` | 下载页 | `/opt/jiu/marketing/download.html` |
|
||||
| `/scan/<id>` | 商品防伪扫码页 | `/opt/jiu/marketing/scan.html` |
|
||||
| `/features/*` | 功能介绍页 | `/opt/jiu/marketing/features/` |
|
||||
| `/assets/*` | 营销站点静态资源 | `/opt/jiu/marketing/assets/` |
|
||||
|
||||
> **注意:** `nginx-jiu.conf` 只包含 `jiu.51yanmei.com` 的 server block。服务器上其他服务的 nginx 配置不受影响。更新 nginx 配置时只需替换此文件,然后 `nginx -t && nginx -s reload`。
|
||||
|
||||
---
|
||||
|
||||
## 回滚方式
|
||||
|
||||
### 后端回滚
|
||||
|
||||
```bash
|
||||
# SSH 到 EC2
|
||||
sudo systemctl stop jiu
|
||||
# 从上一个 Forgejo Release 下载 jiu-server
|
||||
cp /path/to/old-jiu-server /opt/jiu/backend/jiu-server
|
||||
chmod +x /opt/jiu/backend/jiu-server
|
||||
sudo systemctl start jiu
|
||||
```
|
||||
|
||||
### Flutter Web 回滚
|
||||
|
||||
```bash
|
||||
# 恢复上一版本
|
||||
mv /opt/jiu/web /opt/jiu/web-broken
|
||||
mv /opt/jiu/web-old /opt/jiu/web
|
||||
sudo nginx -s reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境变量(jiu 服务)
|
||||
|
||||
Systemd service 文件(`/etc/systemd/system/jiu.service`)中配置:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=DB_HOST=127.0.0.1
|
||||
Environment=DB_PORT=3306
|
||||
Environment=DB_NAME=jiu
|
||||
Environment=DB_USER=jiu
|
||||
Environment=DB_PASSWORD=<从 secrets 获取>
|
||||
Environment=JWT_SECRET=<随机字符串>
|
||||
Environment=APP_VERSION=1.0.0
|
||||
Environment=BUILD_DATE=2025-05-23
|
||||
Environment=DOWNLOAD_URL_MACOS=
|
||||
Environment=DOWNLOAD_URL_WINDOWS=
|
||||
```
|
||||
|
||||
`APP_VERSION` 和 `BUILD_DATE` 在每次部署时通过 deploy.yml 更新(预留,目前读取 Forgejo Release tag)。
|
||||
| 路径 | 服务 |
|
||||
|------|------|
|
||||
| `/api/*`, `/health`, `/version` | Go 后端 API |
|
||||
| `/images/*` | 商品图片静态文件 |
|
||||
| `/app/` | Flutter 管理端 SPA |
|
||||
| `/` | 营销首页 |
|
||||
| `/scan/<id>` | 商品扫码页 |
|
||||
|
||||
@@ -14,13 +14,17 @@ if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.t
|
||||
echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo"
|
||||
mkdir -p dist
|
||||
|
||||
curl -sf \
|
||||
curl -kf \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \
|
||||
-o /tmp/release-info.json
|
||||
|
||||
python3 - <<'PYEOF'
|
||||
import json, urllib.request, os, sys
|
||||
import json, urllib.request, ssl, os, sys
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
with open('/tmp/release-info.json') as f:
|
||||
release = json.load(f)
|
||||
@@ -33,7 +37,7 @@ release_id = release['id']
|
||||
for asset in release.get('assets', []):
|
||||
url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}"
|
||||
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
with urllib.request.urlopen(req, context=ctx) as resp:
|
||||
with open(f"dist/{asset['name']}", 'wb') as out:
|
||||
out.write(resp.read())
|
||||
print(f"Downloaded: {asset['name']}")
|
||||
@@ -99,13 +103,13 @@ cp /tmp/version.yaml /opt/jiu/backend/config/version.yaml
|
||||
sudo systemctl start jiu
|
||||
echo "Waiting for health check..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
|
||||
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
|
||||
echo "Health check passed"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
curl -sf http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
|
||||
curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
|
||||
|
||||
# Swap Flutter web app (atomic)
|
||||
rm -rf /opt/jiu/web-old
|
||||
|
||||
@@ -16,7 +16,7 @@ fi
|
||||
MSG="${ICON} 岩美 ${TAG} ${LABEL}
|
||||
https://jiu.51yanmei.com"
|
||||
|
||||
curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
|
||||
curl -f -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
--data-urlencode "text=${MSG}" \
|
||||
> /dev/null
|
||||
|
||||
+24
-16
@@ -20,7 +20,8 @@ try:
|
||||
notes = []
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('#'):
|
||||
# 只在遇到同级 ## 时停止,### 子标题保留
|
||||
if stripped.startswith('## '):
|
||||
break
|
||||
if stripped:
|
||||
notes.append(stripped)
|
||||
@@ -82,30 +83,37 @@ print(json.dumps(body))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
RESP=$(curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
||||
HTTP_CODE=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
|
||||
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")
|
||||
|
||||
echo "==> release: API response: ${RESP}"
|
||||
RESP=$(cat /tmp/release_resp.json)
|
||||
echo "==> release: API response (HTTP ${HTTP_CODE}): ${RESP}"
|
||||
|
||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "==> release: FAILED — HTTP ${HTTP_CODE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}")
|
||||
echo "==> release: release_id=${RELEASE_ID}"
|
||||
|
||||
# Upload assets
|
||||
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@dist/jiu-server"
|
||||
echo "==> release: uploaded jiu-server"
|
||||
upload_asset() {
|
||||
local file="$1"
|
||||
local code
|
||||
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
|
||||
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@${file}")
|
||||
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
|
||||
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then exit 1; fi
|
||||
}
|
||||
|
||||
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@dist/web.tar.gz"
|
||||
echo "==> release: uploaded web.tar.gz"
|
||||
|
||||
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@dist/configs.tar.gz"
|
||||
echo "==> release: uploaded configs.tar.gz"
|
||||
upload_asset dist/jiu-server
|
||||
upload_asset dist/web.tar.gz
|
||||
upload_asset dist/configs.tar.gz
|
||||
|
||||
echo "==> release: done — Release ${TAG} created"
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ cd "$(dirname "$0")/.."
|
||||
EC2_HOST="${EC2_HOST:-$(grep EC2_HOST ~/.env 2>/dev/null | cut -d= -f2)}"
|
||||
EC2_USER="${EC2_USER:-$(grep EC2_USER ~/.env 2>/dev/null | cut -d= -f2)}"
|
||||
EC2_KEY="${EC2_KEY:-~/.ssh/wangjia.pem}"
|
||||
FORGEJO_URL="http://192.168.3.200:3000"
|
||||
FORGEJO_URL="https://git.51yanmei.com"
|
||||
FORGEJO_TOKEN="${FORGEJO_TOKEN:-$(grep FORGEJO_TOKEN ~/.env 2>/dev/null | cut -d= -f2)}"
|
||||
|
||||
echo "==> 1/5 Go 测试"
|
||||
|
||||
Reference in New Issue
Block a user