Compare commits

...

6 Commits

Author SHA1 Message Date
wangjia 2d84bda99a chore: release server-v1.0.58
Deploy Server / release-deploy-server (push) Successful in 48s
RefreshTokens 续登路径补发首次试用,修复自动续登老用户授权停留「未激活」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 19:14:25 +08:00
wangjia e3c8350d03 chore(deploy): 接入 Ed25519 license 签发密钥到 production.env 渲染
render-env.sh 修正 Bitwarden 条目名为「jiu db password」,SECRET_KEYS
加入 LICENSE_ED25519_PRIVATE_KEY;模板补公钥(明文可提交)与私钥占位。
修复未激活门店因生产缺少签发私钥而无法自动发放试用授权的问题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:57:30 +08:00
wangjia a8e29a641a feat(client): 退出登录归入用户名下拉菜单
侧栏底部用户名行改为可点击的 PopupMenuButton,点击弹出账号菜单,
退出登录移入其中;移除原用户名行直接打开门店信息面板的入口。
设备管理保持在侧栏导航不变。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:57:30 +08:00
wangjia e3e8cf7037 chore: release client-v1.0.58
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-macos (push) Successful in 2m13s
Deploy Client / build-android (push) Successful in 1m35s
Deploy Client / build-ios (push) Successful in 3m0s
Deploy Client / release-deploy-client (push) Successful in 1m19s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:01:50 +08:00
wangjia 435e02278d chore: release server-v1.0.57
Deploy Server / release-deploy-server (push) Successful in 40s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:00:34 +08:00
wangjia 64a64e7c0a feat(backend): 首次登录无有效授权时自动发放 30 天试用
未激活门店(seed/手动建店/老数据,无任何 license 行)此前被当作永久授权,
既不拦截也无提示。现在登录时若门店无有效授权,自动签发 30 天 trial,
之后照常走 grace/readonly/locked 降级链。须在 issueTokens 之前发放,
使 JWT 的 lic_exp 带上新到期日。幂等(已有有效授权不重发),签发失败
仅记日志不阻断登录。复用注册路径的发放逻辑(抽出 issueTrialLicense)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 07:59:44 +08:00
8 changed files with 145 additions and 70 deletions
+5
View File
@@ -5,6 +5,11 @@ 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.58] - 2026-06-18
### 修复
- 恢复 Windows 桌面版的自动构建与发布:上一版因构建环境问题未能产出 Windows 安装包,本版已修复,Windows 用户可正常下载安装
## [1.0.57] - 2026-06-17
### 新功能
+10
View File
@@ -5,6 +5,16 @@
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.58] - 2026-06-18
### 修复
- 修复已登录用户授权长期停留在「未激活」的问题:此前仅全新登录才会自动发放 30 天试用,靠本地令牌自动续登的老用户始终拿不到,现在续登时也会补发
## [1.0.57] - 2026-06-18
### 新功能
- 未激活门店首次登录自动获得 30 天试用:此前手动建店或老数据无授权时既不提示也不限制,现在登录即开通试用,到期前会正常提醒并按阶段降级
## [1.0.56] - 2026-06-17
### 新功能
+30
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"log"
"sync"
"time"
@@ -127,6 +128,11 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
return nil, nil, err
}
// 首次使用(门店尚无任何有效授权)自动发 30 天试用:seed/手动建店或老数据
// 登录即转为 trial,「未激活」不再静默无限制。须在 issueTokens 之前,使 JWT
// 的 lic_exp 带上新试用到期日。
s.ensureTrialOnFirstUse(shop.ID)
// 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。
pclass := model.PlatformClass(dev.Platform)
quota := s.effectiveQuota(&shop, pclass)
@@ -413,9 +419,33 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
Update("last_seen_at", time.Now())
}
// 自动续登(refresh)路径同样补发首次试用:老用户用本地 refresh token 自动登录、
// 从不走 Login(),否则永远停留在「未激活」。须在 issueTokens 之前,使新 JWT 的
// lic_exp 带上试用到期日。
s.ensureTrialOnFirstUse(user.ShopID)
return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID)
}
// ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial,
// 使「未激活」门店在首次登录后即转为试用版;后续到期降级(grace/readonly/locked)链路照常生效。
// 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。
// 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。
func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) {
var count int64
if err := s.db.Model(&model.License{}).
Where("shop_id = ? AND is_active = 1", shopID).Count(&count).Error; err != nil {
log.Printf("[license] count licenses for shop %d failed: %v", shopID, err)
return
}
if count > 0 {
return
}
if err := issueTrialLicense(s.db, shopID); err != nil {
log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err)
}
}
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
var lic model.License
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
+40
View File
@@ -2,10 +2,14 @@ package service
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
"github.com/wangjia/jiu/backend/testutil"
)
@@ -25,6 +29,42 @@ func TestAuthService_Login_Success(t *testing.T) {
assert.Equal(t, "admin", user.Username)
}
func TestAuthService_Login_AutoTrialOnFirstUse(t *testing.T) {
db := testutil.SetupTestDB()
priv, _, err := util.GenerateEd25519KeyPair()
require.NoError(t, err)
config.C.License.Ed25519PrivateKey = priv
shop := testutil.CreateTestShop(db, "TRIAL001")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
// 前置:门店「未激活」——无任何授权行
var before int64
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&before)
require.EqualValues(t, 0, before)
svc := NewAuthService(db)
// 首次登录 → 自动签发 30 天 trial
_, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
var lic model.License
require.NoError(t, db.Where("shop_id = ? AND is_active = 1", shop.ID).First(&lic).Error)
assert.Equal(t, "trial", lic.Type)
assert.True(t, lic.IsActive)
require.NotNil(t, lic.ExpiresAt)
days := time.Until(*lic.ExpiresAt).Hours() / 24
assert.InDelta(t, 30, days, 1, "试用期应约为 30 天")
// 再次登录不重复发放
_, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
var count int64
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&count)
assert.EqualValues(t, 1, count, "已有有效授权时不应再发放 trial")
}
func TestAuthService_Login_WrongPassword(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "HOTEL002")
+16 -7
View File
@@ -143,12 +143,13 @@ func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
Delete(&model.LicenseDevice{}).Error
}
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。
func createTrialLicense(tx *gorm.DB, shopID uint64) {
// issueTrialLicense 为门店签发并写入一条 30 天 trial license。
// 私钥未配置或签发/落库失败时返回 error,由调用方决定如何处理(注册路径 Fatal、
// 登录路径仅记日志)。
func issueTrialLicense(db *gorm.DB, shopID uint64) error {
privKey := config.C.License.Ed25519PrivateKey
if privKey == "" {
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
return fmt.Errorf("ed25519 private key not configured")
}
expiresAt := time.Now().Add(30 * 24 * time.Hour)
@@ -162,8 +163,7 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) {
}
token, err := util.IssueLicenseToken(payload, privKey)
if err != nil {
log.Printf("[license] failed to issue trial token for shop %d: %v", shopID, err)
return
return err
}
lic := model.License{
@@ -174,7 +174,16 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) {
IsActive: true,
MaxDevices: 1,
}
if err := tx.Create(&lic).Error; err != nil {
return db.Create(&lic).Error
}
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。
func createTrialLicense(tx *gorm.DB, shopID uint64) {
if config.C.License.Ed25519PrivateKey == "" {
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
}
if err := issueTrialLicense(tx, shopID); err != nil {
log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err)
}
}
+40 -61
View File
@@ -300,68 +300,37 @@ class _AppShellState extends ConsumerState<AppShell> {
}).toList(),
),
),
// 当前登录账号(顶栏移下来的用户名):点击弹门店/账号/版本面板
// 当前登录账号:点击弹下拉菜单(退出登录)
if (user != null) ...[
const Divider(height: 1, color: Colors.white24),
SizedBox(
height: 48,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showShopPanel(context, user,
version: appVersion),
hoverColor: Colors.white.withAlpha(13),
splashColor: Colors.white.withAlpha(26),
child: Row(
children: [
SizedBox(
width: _sidebarExpanded ? 16 : 3),
Expanded(
child: Row(
mainAxisAlignment: _sidebarExpanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
const Icon(Icons.person_outline,
color: Colors.white60,
size: 20),
if (_sidebarExpanded) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
user.username,
style: const TextStyle(
color: Colors.white70,
fontSize: 14),
overflow:
TextOverflow.ellipsis,
),
),
],
],
),
),
],
),
),
),
),
],
// 退出登录(侧栏底部常驻;统一为左侧导航唯一退出入口)
const Divider(height: 1, color: Colors.white24),
SizedBox(
height: 48,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
PopupMenuButton<String>(
tooltip: '账号菜单',
position: PopupMenuPosition.over,
offset: const Offset(0, -8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)),
color: Colors.white,
elevation: 8,
onSelected: (v) {
if (v == 'logout') {
ref
.read(authStateProvider.notifier)
.logout();
context.go('/login');
},
hoverColor: Colors.white.withAlpha(13),
splashColor: Colors.white.withAlpha(26),
}
},
itemBuilder: (context) => const [
PopupMenuItem<String>(
value: 'logout',
padding: EdgeInsets.zero,
child: _HoverMenuItem(
icon: Icons.logout,
label: '退出登录',
),
),
],
child: SizedBox(
height: 48,
child: Row(
children: [
SizedBox(width: _sidebarExpanded ? 16 : 3),
@@ -371,14 +340,24 @@ class _AppShellState extends ConsumerState<AppShell> {
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
const Icon(Icons.logout,
const Icon(Icons.person_outline,
color: Colors.white60, size: 20),
if (_sidebarExpanded) ...[
const SizedBox(width: 12),
const Text('退出登录',
style: TextStyle(
Expanded(
child: Text(
user.username,
style: const TextStyle(
color: Colors.white70,
fontSize: 14)),
fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
const Icon(
Icons.keyboard_arrow_up,
color: Colors.white38,
size: 18),
const SizedBox(width: 12),
],
],
),
@@ -387,7 +366,7 @@ class _AppShellState extends ConsumerState<AppShell> {
),
),
),
),
],
const SizedBox(height: 8),
],
),
+2
View File
@@ -6,6 +6,8 @@ JWT_SECRET=CHANGE_ME_RANDOM_32CHARS
JWT_ACCESS_EXPIRE_MIN=60
JWT_REFRESH_EXPIRE_H=168
LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
LICENSE_ED25519_PUBLIC_KEY=zFkRV0E+oMjHbixiBamf2ZAq+n7En2V7EJRIurgtVxo=
LICENSE_ED25519_PRIVATE_KEY=CHANGE_ME_ED25519_PRIVATE_FROM_BITWARDEN
STORAGE_UPLOAD_DIR=/opt/jiu/images
STORAGE_BASE_URL=https://jiu.51yanmei.com/images
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
+2 -2
View File
@@ -15,8 +15,8 @@ set -euo pipefail
# ---- 可配置项 ----
RBW="${RBW_BIN:-/opt/homebrew/bin/rbw}"
BW_ITEM="${JIU_BW_ITEM:-DB_PASSWORD}" # 存放 jiu 密钥字段的 Bitwarden 条目
SECRET_KEYS="DATABASE_DSN JWT_SECRET LICENSE_HMAC_SECRET DB_PASSWORD"
BW_ITEM="${JIU_BW_ITEM:-jiu db password}" # 存放 jiu 密钥字段的 Bitwarden 条目
SECRET_KEYS="DATABASE_DSN JWT_SECRET LICENSE_HMAC_SECRET LICENSE_ED25519_PRIVATE_KEY DB_PASSWORD"
EC2_HOST="${EC2_HOST:-18.136.60.128}"
EC2_USER="${EC2_USER:-ec2-user}"