feat: 接入商务用户资料和全局统计偏好

This commit is contained in:
Yvan
2026-11-03 17:44:25 +08:00
parent 89cc7761b5
commit ce4b54a2e2
39 changed files with 2562 additions and 17 deletions
+8
View File
@@ -8,12 +8,14 @@ package main
import (
"github.com/go-kratos/kratos/v3"
business2 "kra/internal/biz/business"
"kra/internal/biz/integration"
payment3 "kra/internal/biz/payment"
system2 "kra/internal/biz/system"
task2 "kra/internal/biz/task"
"kra/internal/config"
"kra/internal/data"
"kra/internal/data/business"
integration3 "kra/internal/data/integration"
payment2 "kra/internal/data/payment"
"kra/internal/data/system"
@@ -33,6 +35,7 @@ import (
"kra/internal/server"
"kra/internal/server/handler"
"kra/internal/server/router"
business3 "kra/internal/service/business"
integration2 "kra/internal/service/integration"
payment4 "kra/internal/service/payment"
system3 "kra/internal/service/system"
@@ -78,6 +81,10 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
registry := taskRegistry()
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
userPreferenceService := business3.NewUserPreferenceService(userPreferenceUsecase)
handlerBusiness := handler.NewBusiness(userPreferenceService)
runtimeconfigStore := data.NewIntegrationRuntime(dataData)
smsSender := sms.NewSender(runtimeconfigStore)
smsUsecase := integration.NewSMSUsecase(smsSender)
@@ -176,6 +183,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
Business: handlerBusiness,
SMS: handlerSMS,
Authority: authority,
Menu: menu,
+47
View File
@@ -0,0 +1,47 @@
# 商务功能迁移执行记录
## 基线与方法
- 旧后端:`D:/Users/Yvan/projects/yzyz/sw`,`main`,`f9a5e3a90`。
- 框架基线:`d31082f4a7816d4c83544b8f54e6f42a3b2132b1`。
- 旧前端:`D:/Users/Yvan/projects/yzyz/sw/web`,`dev/dev1.0`,`ba284011c87fd3d5730e0e3dceffb14a7389730c`。
- 新实现分支:`codex/sw-business-migration`。
按基线至当前版本的最终差异提取功能,以当前 Java 实现、实际调用的前端接口和 SQL 为核对依据。提交记录用于解释修复和撤销,不逐提交搬运。旧前端中未接入的 mock、废弃接口不能作为已实现业务迁移。
沿用现有迁移 SQL 的 `/sw/` 路由与 `view/sw/` 菜单组件路径。认证、响应信封、权限、表格及图表采用 Kra;业务字段、统计口径、状态流转、可见范围保留旧实现。登录页、系统名称和品牌资源来自旧商务。新增代码注释与提交信息使用中文。
用户已有的 `configs/config.yaml` 修改与迁移 SQL 保留。未经最后阶段的数据库确认,不连接、复制或修改用户的旧数据库。
## 依赖顺序与完成标准
| 阶段 | 范围 | 状态 |
| --- | --- | --- |
| 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 |
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 待实现 |
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 待实现 |
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 待实现 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 |
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
每项完成需覆盖 DTO → service → biz → data、路由目录、权限、菜单页面和有意义的行为测试。只有真实实现并通过验证的项才标为完成。整项目标在实库验证之前保持未完成。
## 已核实的业务约束
- 超管创建一级商务,一级创建二级,二级创建三级;三级不能创建下级。层级与祖先链由服务器生成。
- 新增子商务最终版本默认正常,不采用残留注释中的“默认待审核”。
- 一/二级商务列表只显示下级树,不显示自己;三级列表为空。报表使用独立渠道可见范围,不能套用 Kra 的部门过滤替代。
- `includeSubPromote` 与 `idcardDedup` 无记录或数据库值非零时均为开启,新增账号同时初始化开启。
- SDK 商务绑定数量仅计算已通过记录;渠道计数依照当前直属渠道算法,跨平台 ID 不能混用。
- 用户审核状态有正常、停用、驳回、待审核四种,须保留 `business_status`,不能压成 `enable` 后丢失含义。
- 金额查询固定不做身份证去重;人数查询遵守全局偏好。
- SQL 已建立用户、角色、文件等 ID 映射;平台实体及业务主键保留。迁移验收应按映射比较,不能直接比较所有原始 ID。
## 验证记录
- 已落地商务用户七个扩展字段的存取及增量补列;登录和既有会话同时检查商务审核状态。
- 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。
- 已生成 `source-inventory.json`:现有 SQL 的 63 个接口契约、279 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。
- 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。
- 当前没有执行本轮真实数据库迁移验证。
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,7 @@
package biz
import (
"kra/internal/biz/business"
"kra/internal/biz/integration"
"kra/internal/biz/payment"
"kra/internal/biz/system"
@@ -10,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase)
+61
View File
@@ -0,0 +1,61 @@
package business
import (
"context"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/biz/system"
)
var (
ErrUnauthenticated = errors.Unauthorized("SW_UNAUTHENTICATED", "请先登录")
ErrPreferenceRequired = errors.BadRequest("SW_PREFERENCE_REQUIRED", "偏好参数不能为空")
ErrStorage = errors.InternalServer("SW_STORAGE_ERROR", "商务数据访问失败")
)
// UserPreference 使用新用户 ID,保留旧商务两个全局开关的含义。
type UserPreference struct {
IncludeSubPromote bool
IDCardDedup bool
}
type UserPreferenceRepo interface {
Find(context.Context, uint) (*UserPreference, error)
Save(context.Context, uint, UserPreference) error
}
type UserPreferenceUsecase struct{ repo UserPreferenceRepo }
func NewUserPreferenceUsecase(repo UserPreferenceRepo) *UserPreferenceUsecase {
return &UserPreferenceUsecase{repo: repo}
}
func (uc *UserPreferenceUsecase) Current(ctx context.Context) (*UserPreference, error) {
actor, ok := system.ActorFromContext(ctx)
if !ok || actor.UserID == 0 {
return nil, ErrUnauthenticated
}
return uc.ForUser(ctx, actor.UserID)
}
func (uc *UserPreferenceUsecase) ForUser(ctx context.Context, id uint) (*UserPreference, error) {
if id == 0 {
return nil, ErrUnauthenticated
}
pref, err := uc.repo.Find(ctx, id)
if err != nil {
return nil, err
}
if pref == nil {
return &UserPreference{IncludeSubPromote: true, IDCardDedup: true}, nil
}
return pref, nil
}
func (uc *UserPreferenceUsecase) UpdateCurrent(ctx context.Context, pref UserPreference) error {
actor, ok := system.ActorFromContext(ctx)
if !ok || actor.UserID == 0 {
return ErrUnauthenticated
}
return uc.repo.Save(ctx, actor.UserID, pref)
}
+43
View File
@@ -0,0 +1,43 @@
package business
import (
"context"
"kra/internal/biz/system"
"testing"
)
type preferenceRepoFake struct {
value *UserPreference
userID uint
calls int
}
func (r *preferenceRepoFake) Find(_ context.Context, id uint) (*UserPreference, error) {
r.calls++
r.userID = id
return r.value, nil
}
func (r *preferenceRepoFake) Save(_ context.Context, id uint, pref UserPreference) error {
r.calls++
r.userID, r.value = id, &pref
return nil
}
func TestPreferenceDefaultsAndCurrentIdentity(t *testing.T) {
r := &preferenceRepoFake{}
uc := NewUserPreferenceUsecase(r)
if _, err := uc.Current(context.Background()); err != ErrUnauthenticated || r.calls != 0 {
t.Fatalf("匿名请求访问了偏好: %v", err)
}
ctx := system.NewActorContext(context.Background(), system.Actor{UserID: 7})
pref, err := uc.Current(ctx)
if err != nil || !pref.IncludeSubPromote || !pref.IDCardDedup {
t.Fatalf("缺省开关应均为开启: %+v %v", pref, err)
}
if err = uc.UpdateCurrent(ctx, UserPreference{}); err != nil {
t.Fatal(err)
}
if r.userID != 7 || r.value.IncludeSubPromote || r.value.IDCardDedup {
t.Fatalf("未保存当前用户的关闭值: %+v", r)
}
}
+2 -2
View File
@@ -122,7 +122,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
uc.recordLogin(ctx, attempt, false, "用户名不存在或者密码错误", 0)
return nil, ErrInvalidCredentials
}
if user.Enable != 1 {
if user.Enable != 1 || user.BusinessStatus != 0 {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
uc.recordLogin(ctx, attempt, false, "用户被禁止登录", user.ID)
return nil, &UserDisabledError{UserID: user.ID}
@@ -183,7 +183,7 @@ func (uc *AuthenticationUsecase) currentTokenUser(ctx context.Context, claims *A
if err != nil || user == nil || user.ID != claims.ID || user.Username != claims.Username {
return nil, ErrTokenDisabled
}
if user.Enable != 1 {
if user.Enable != 1 || user.BusinessStatus != 0 {
return nil, &UserDisabledError{UserID: user.ID}
}
if !userHasAuthority(user, claims.AuthorityID) {
@@ -0,0 +1,27 @@
package system
import (
"context"
"errors"
"golang.org/x/crypto/bcrypt"
"testing"
"time"
)
func TestBusinessReviewStateBlocksPasswordAndExistingSession(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
for _, status := range []int{1, 2, 3} {
user := &User{ID: 7, Username: "business", Password: string(hash), Enable: 1, BusinessStatus: status}
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, nil)
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: user}), security, &authenticationIssuer{}, nil)
if _, err := uc.Login(context.Background(), &LoginAttempt{Username: "business", Password: "secret", IP: "127.0.0.1"}); !errors.Is(err, ErrUserDisabled) {
t.Fatalf("审核状态 %d 的登录应被拒绝: %v", status, err)
}
if _, err := uc.currentTokenUser(context.Background(), &AuthClaims{ID: 7, Username: "business", UUID: "uuid", IssuedAt: time.Now()}); !errors.Is(err, ErrUserDisabled) {
t.Fatalf("审核状态 %d 的现有会话应被拒绝: %v", status, err)
}
}
}
+8
View File
@@ -29,6 +29,14 @@ type User struct {
OriginSetting map[string]any
MustChangePassword bool
PasswordUpdatedAt *time.Time
// 商务资料与系统身份共用主键;平台商务 ID 另由绑定关系持有。
Level int
ParentID uint
IDPath string
RealName string
QQNumber string
BusinessStatus int
RejectReason string
}
type UserListFilter struct {
Username, NickName, Phone, Email, OrderKey string
+31
View File
@@ -0,0 +1,31 @@
package business
import (
"gorm.io/gorm"
"kra/pkg/database/migration"
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202611030001_business_preference", Migrate: func(db *gorm.DB) error {
// 旧表沿用;已迁移数据库不重建或重置用户偏好。
if err := migration.CreateMissingTables(db, &preferencePO{}); err != nil {
return err
}
if !db.Migrator().HasTable("sys_authorities") || !db.Migrator().HasTable("casbin_rule") {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
// 两个接口仅操作当前身份,旧系统对所有已登录用户开放。
for _, method := range []string{"GET", "PUT"} {
if err := tx.Exec(`INSERT INTO casbin_rule (ptype,v0,v1,v2,v3,v4,v5)
SELECT 'p', a.authority_id, ?, ?, '', '', '' FROM sys_authorities a
WHERE a.deleted_at IS NULL AND NOT EXISTS (
SELECT 1 FROM casbin_rule p WHERE p.ptype='p' AND p.v0=a.authority_id AND p.v1=? AND p.v2=?)`,
"/sw/user/preference", method, "/sw/user/preference", method).Error; err != nil {
return err
}
}
return nil
})
}}}
}
+62
View File
@@ -0,0 +1,62 @@
package business
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz/business"
)
// Provider 复用 Data 持有的连接,不在仓储中创建数据库客户端。
type Provider interface{ DB() *gorm.DB }
type preferencePO struct {
UserID uint `gorm:"primaryKey;autoIncrement:false"`
IncludeSubPromote *int
IDCardDedup *int `gorm:"column:idcard_dedup"`
UpdateTime time.Time
}
func (preferencePO) TableName() string { return "sys_user_preference" }
type userPreferenceRepo struct{ data Provider }
func NewUserPreferenceRepo(data Provider) business.UserPreferenceRepo {
return &userPreferenceRepo{data: data}
}
func (r *userPreferenceRepo) Find(ctx context.Context, id uint) (*business.UserPreference, error) {
var po preferencePO
err := r.data.DB().WithContext(ctx).First(&po, "user_id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, business.ErrStorage
}
return &business.UserPreference{
IncludeSubPromote: po.IncludeSubPromote == nil || *po.IncludeSubPromote != 0,
IDCardDedup: po.IDCardDedup == nil || *po.IDCardDedup != 0,
}, nil
}
func (r *userPreferenceRepo) Save(ctx context.Context, id uint, pref business.UserPreference) error {
include, dedup := 0, 0
if pref.IncludeSubPromote {
include = 1
}
if pref.IDCardDedup {
dedup = 1
}
po := preferencePO{UserID: id, IncludeSubPromote: &include, IDCardDedup: &dedup, UpdateTime: time.Now()}
if err := r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"include_sub_promote", "idcard_dedup", "update_time"}),
}).Create(&po).Error; err != nil {
return business.ErrStorage
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package business
import (
"context"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/biz/business"
"path/filepath"
"testing"
)
type testData struct{ db *gorm.DB }
func (d testData) DB() *gorm.DB { return d.db }
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "business.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
for _, step := range Migrations() {
if err := step.Migrate(db); err != nil {
t.Fatal(err)
}
}
return db
}
func TestPreferencePreservesLegacyValuesAndUpserts(t *testing.T) {
db := openTestDB(t)
r := NewUserPreferenceRepo(testData{db})
ctx := context.Background()
if pref, err := r.Find(ctx, 5); err != nil || pref != nil {
t.Fatalf("缺省记录错误: %+v %v", pref, err)
}
if err := db.Exec("INSERT INTO sys_user_preference(user_id,include_sub_promote,idcard_dedup) VALUES (5,NULL,2),(6,1,1)").Error; err != nil {
t.Fatal(err)
}
pref, err := r.Find(ctx, 5)
if err != nil || !pref.IncludeSubPromote || !pref.IDCardDedup {
t.Fatalf("旧 NULL 和非零值应为开启: %+v %v", pref, err)
}
for i := 0; i < 2; i++ {
if err := r.Save(ctx, 5, business.UserPreference{}); err != nil {
t.Fatal(err)
}
}
for _, step := range Migrations() {
if err := step.Migrate(db); err != nil {
t.Fatal(err)
}
}
pref, err = r.Find(ctx, 5)
if err != nil || pref.IncludeSubPromote || pref.IDCardDedup {
t.Fatalf("重复迁移不能重置偏好: %+v %v", pref, err)
}
other, err := r.Find(ctx, 6)
if err != nil || !other.IncludeSubPromote || !other.IDCardDedup {
t.Fatalf("影响其他用户: %+v %v", other, err)
}
var count int64
db.Model(&preferencePO{}).Where("user_id = ?", 5).Count(&count)
if count != 1 {
t.Fatalf("重复写入产生多条记录: %d", count)
}
}
+3
View File
@@ -13,6 +13,7 @@ import (
"go.mongodb.org/mongo-driver/mongo"
"gorm.io/gorm"
"kra/internal/config"
databusiness "kra/internal/data/business"
dataintegration "kra/internal/data/integration"
datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/system"
@@ -25,6 +26,8 @@ import (
var ProviderSet = wire.NewSet(
NewData,
wire.Bind(new(databusiness.Provider), new(*Data)),
databusiness.NewUserPreferenceRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 14 {
t.Fatalf("migration versions = %d, want 14", versions)
if versions != 16 {
t.Fatalf("migration versions = %d, want 16", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
+1 -1
View File
@@ -178,7 +178,7 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *system
value.Menus = nil
}
}
defaults := []struct{ path, method string }{{"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/base/login", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}}
defaults := []struct{ path, method string }{{"/sw/user/preference", "GET"}, {"/sw/user/preference", "PUT"}, {"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/base/login", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}}
rules := make([]casbinRulePO, 0, len(defaults))
for _, item := range defaults {
if strict {
@@ -0,0 +1,31 @@
package system
import (
"context"
"testing"
)
func TestBusinessFieldsSurviveMigrationAndIdentityLoad(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatal(err)
}
po := userPO{Username: "business", UUID: "business-uuid", Password: "original-hash", Enable: 1, Level: 2, ParentID: 11, IDPath: "0,11", RealName: "商务姓名", QQNumber: "12345", BusinessStatus: 3, RejectReason: "审核资料"}
if err = db.Create(&po).Error; err != nil {
t.Fatal(err)
}
if err = ensureBusinessUserFields(db); err != nil {
t.Fatal(err)
}
r := NewUserRepo(&Data{gormDB: newReloadableDB(db, nil)})
user, err := r.FindUserByUUID(context.Background(), "business-uuid")
if err != nil {
t.Fatal(err)
}
if user.Level != 2 || user.ParentID != 11 || user.IDPath != "0,11" || user.RealName != "商务姓名" || user.QQNumber != "12345" || user.BusinessStatus != 3 || user.RejectReason != "审核资料" || user.Password != "original-hash" {
t.Fatalf("迁移或身份加载丢失商务字段: %+v", user)
}
}
+16
View File
@@ -30,9 +30,25 @@ func Migrations() []migration.Step {
},
{ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface},
{ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface},
{ID: "202611030002_business_user_fields", Migrate: ensureBusinessUserFields},
}
}
func ensureBusinessUserFields(db *gorm.DB) error {
// 与已交付迁移 SQL 的字段保持一致,仅补缺列,保留导入值和密码。
for _, field := range []string{"Level", "ParentID", "IDPath", "RealName", "QQNumber", "BusinessStatus", "RejectReason"} {
if !db.Migrator().HasColumn(&userPO{}, field) {
if err := db.Migrator().AddColumn(&userPO{}, field); err != nil {
return err
}
}
}
if !db.Migrator().HasIndex(&userPO{}, "idx_sys_users_business_parent") {
return db.Migrator().CreateIndex(&userPO{}, "idx_sys_users_business_parent")
}
return nil
}
// Kept as a migration version for existing installations; the canonical
// communication surface seeder is idempotent and now owns the test endpoint.
func ensureCommunicationTestSurface(db *gorm.DB) error { return ensureCommunicationSurface(db) }
+7
View File
@@ -25,6 +25,13 @@ type userPO struct {
OriginSetting string `gorm:"type:text"`
PasswordUpdatedAt *time.Time
MustChangePassword bool
Level int `gorm:"type:tinyint unsigned;not null;default:0"`
ParentID uint `gorm:"not null;default:0;index:idx_sys_users_business_parent"`
IDPath string `gorm:"column:id_path;size:255;not null;default:0"`
RealName string `gorm:"size:30;not null;default:''"`
QQNumber string `gorm:"column:qq_number;size:20;not null;default:''"`
BusinessStatus int `gorm:"type:tinyint unsigned;not null;default:0"`
RejectReason string `gorm:"size:200;not null;default:''"`
}
func (userPO) TableName() string { return "sys_users" }
+2 -2
View File
@@ -93,7 +93,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
for _, item := range positionPOs {
positions = append(positions, *posFromPO(item))
}
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, Authority: toBizAuthority(authority), Authorities: authorities, DeptID: po.DeptID, Department: primary, Departments: departments, Positions: positions, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, Authority: toBizAuthority(authority), Authorities: authorities, DeptID: po.DeptID, Department: primary, Departments: departments, Positions: positions, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt, Level: po.Level, ParentID: po.ParentID, IDPath: po.IDPath, RealName: po.RealName, QQNumber: po.QQNumber, BusinessStatus: po.BusinessStatus, RejectReason: po.RejectReason}, nil
}
func toBizAuthority(po authorityPO) system.Authority {
@@ -116,7 +116,7 @@ func baseBizUser(po *userPO) (*system.User, error) {
if err != nil {
return nil, err
}
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt, Level: po.Level, ParentID: po.ParentID, IDPath: po.IDPath, RealName: po.RealName, QQNumber: po.QQNumber, BusinessStatus: po.BusinessStatus, RejectReason: po.RejectReason}, nil
}
func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User, error) {
+13
View File
@@ -0,0 +1,13 @@
package business
import (
databusiness "kra/internal/data/business"
"kra/internal/modules/surface"
"kra/pkg/module"
)
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
APIs: surface.APIsForPrefix("/sw/", 0),
}}
}
+2
View File
@@ -4,6 +4,7 @@
package modules
import (
businessmodule "kra/internal/modules/business"
integrationmodule "kra/internal/modules/integration"
paymentmodule "kra/internal/modules/payment"
systemmodule "kra/internal/modules/system"
@@ -20,5 +21,6 @@ func Catalog() module.Catalog {
integrationmodule.Definition(),
taskmodule.Definition(),
paymentmodule.Definition(),
businessmodule.Definition(),
}}
}
+7 -7
View File
@@ -16,25 +16,25 @@ func TestCatalogMigrationIDsAreUnique(t *testing.T) {
func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
catalog := Catalog()
if len(catalog.Definitions) != 4 {
t.Fatalf("definitions = %d, want 4", len(catalog.Definitions))
if len(catalog.Definitions) != 5 {
t.Fatalf("definitions = %d, want 5", len(catalog.Definitions))
}
want := []string{"system", "integration", "task", "payment"}
want := []string{"system", "integration", "task", "payment", "business"}
for index, name := range want {
if got := catalog.Definitions[index].Name; got != name {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 14 {
t.Fatalf("module migrations = %d, want 14", len(got))
if got := catalog.MigrationSteps(); len(got) != 16 {
t.Fatalf("module migrations = %d, want 16", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 4 || apis != 18 {
t.Fatalf("admin surface = %d menus/%d APIs, want 4/18", menus, apis)
if menus != 4 || apis != 20 {
t.Fatalf("admin surface = %d menus/%d APIs, want 4/20", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+2
View File
@@ -41,6 +41,8 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/user/preference": {group: "商务偏好", description: "获取当前用户商务偏好"},
"PUT /sw/user/preference": {group: "商务偏好", description: "更新当前用户商务偏好", audit: true},
"DELETE /api/deleteApisByIds": {group: "api", description: "批量删除api", audit: true},
"DELETE /dataAccessLog/deleteDataAccessLogByIds": {group: "数据权限审计", description: "批量删除数据权限审计日志", audit: true},
"DELETE /department/deleteDepartment": {group: "部门", description: "删除部门", audit: true},
+2 -1
View File
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
Business: &handler.Business{},
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
Permission: &handler.Permission{}, Organization: &handler.Organization{},
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
@@ -97,7 +98,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":197`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":199`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+38
View File
@@ -0,0 +1,38 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/service/business"
"kra/internal/service/dto"
)
type Business struct {
preference *business.UserPreferenceService
}
func NewBusiness(preference *business.UserPreferenceService) *Business {
return &Business{preference: preference}
}
func (h *Business) UserPreference(c *gin.Context) {
value, err := h.preference.Current(c.Request.Context())
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, value)
}
func (h *Business) UpdateUserPreference(c *gin.Context) {
var req dto.UserPreferenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "偏好参数格式错误")
return
}
if err := h.preference.UpdateCurrent(c.Request.Context(), &req); err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OK(c)
}
+1
View File
@@ -5,6 +5,7 @@ import "github.com/google/wire"
// ProviderSet wires the system HTTP handlers. Set itself is filled field by
// field so adding a handler only means adding its constructor here.
var ProviderSet = wire.NewSet(
NewBusiness,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Business *Business
SMS *SMS
Authority *Authority
Menu *Menu
+11
View File
@@ -0,0 +1,11 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterBusiness(private *gin.RouterGroup, h *handler.Business) {
private.GET("/sw/user/preference", h.UserPreference)
private.PUT("/sw/user/preference", h.UpdateUserPreference)
}
+1
View File
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() { RegisterBusiness(private, r.handlers.Business) },
func() { RegisterPublic(public, engine, r.handlers.Public) },
func() { RegisterUser(private, r.handlers.User) },
func() { RegisterNavigation(private, r.handlers.Navigation) },
+33
View File
@@ -0,0 +1,33 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
)
type PreferenceUsecase interface {
Current(context.Context) (*business.UserPreference, error)
UpdateCurrent(context.Context, business.UserPreference) error
}
type UserPreferenceService struct{ uc PreferenceUsecase }
func NewUserPreferenceService(uc *business.UserPreferenceUsecase) *UserPreferenceService {
return &UserPreferenceService{uc: uc}
}
func (s *UserPreferenceService) Current(ctx context.Context) (*dto.UserPreferenceResponse, error) {
pref, err := s.uc.Current(ctx)
if err != nil {
return nil, err
}
return &dto.UserPreferenceResponse{IncludeSubPromote: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, nil
}
func (s *UserPreferenceService) UpdateCurrent(ctx context.Context, req *dto.UserPreferenceRequest) error {
if req == nil || req.IncludeSubPromote == nil || req.IDCardDedup == nil {
return business.ErrPreferenceRequired
}
return s.uc.UpdateCurrent(ctx, business.UserPreference{IncludeSubPromote: *req.IncludeSubPromote, IDCardDedup: *req.IDCardDedup})
}
@@ -0,0 +1,42 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
"testing"
)
type preferenceFake struct {
calls int
value business.UserPreference
}
func (f *preferenceFake) Current(context.Context) (*business.UserPreference, error) {
return &f.value, nil
}
func (f *preferenceFake) UpdateCurrent(_ context.Context, p business.UserPreference) error {
f.calls++
f.value = p
return nil
}
func TestPreferenceRequiresBothFieldsButAcceptsFalse(t *testing.T) {
f := &preferenceFake{}
s := &UserPreferenceService{uc: f}
off := false
for _, req := range []*dto.UserPreferenceRequest{nil, {}, {IncludeSubPromote: &off}, {IDCardDedup: &off}} {
if err := s.UpdateCurrent(context.Background(), req); err != business.ErrPreferenceRequired {
t.Fatalf("缺字段应被拒绝: %v", err)
}
}
if f.calls != 0 {
t.Fatal("校验失败后不应调用用例")
}
if err := s.UpdateCurrent(context.Background(), &dto.UserPreferenceRequest{IncludeSubPromote: &off, IDCardDedup: &off}); err != nil {
t.Fatal(err)
}
if f.calls != 1 || f.value.IncludeSubPromote || f.value.IDCardDedup {
t.Fatalf("false 值未完整传递: %+v", f)
}
}
+11
View File
@@ -0,0 +1,11 @@
package dto
type UserPreferenceRequest struct {
IncludeSubPromote *bool `json:"includeSubPromote"`
IDCardDedup *bool `json:"idcardDedup"`
}
type UserPreferenceResponse struct {
IncludeSubPromote bool `json:"includeSubPromote"`
IDCardDedup bool `json:"idcardDedup"`
}
+7
View File
@@ -73,6 +73,13 @@ type SwitchAuthorityRequest struct {
}
type UserResponse struct {
Level int `json:"level"`
ParentID uint `json:"parentId"`
IDPath string `json:"idPath"`
RealName string `json:"realName"`
QQNumber string `json:"qqNumber"`
BusinessStatus int `json:"businessStatus"`
RejectReason string `json:"rejectReason"`
ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
+2
View File
@@ -1,6 +1,7 @@
package service
import (
businessservice "kra/internal/service/business"
integrationservice "kra/internal/service/integration"
paymentservice "kra/internal/service/payment"
systemservice "kra/internal/service/system"
@@ -11,6 +12,7 @@ import (
// ProviderSet aggregates all service modules for Wire.
var ProviderSet = wire.NewSet(
businessservice.NewUserPreferenceService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
+1 -1
View File
@@ -40,5 +40,5 @@ func convertUser(user *system.User) *dto.UserResponse {
if user.Department != nil {
department = departmentResponse(user.Department)
}
return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting, PasswordUpdatedAt: user.PasswordUpdatedAt}
return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting, PasswordUpdatedAt: user.PasswordUpdatedAt, Level: user.Level, ParentID: user.ParentID, IDPath: user.IDPath, RealName: user.RealName, QQNumber: user.QQNumber, BusinessStatus: user.BusinessStatus, RejectReason: user.RejectReason}
}
+48
View File
@@ -0,0 +1,48 @@
"""从指定旧仓库和现有迁移 SQL 生成可复核的迁移清单,不访问数据库。"""
import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
def git(repo, *args):
return subprocess.check_output(["git", "-C", str(repo), *args], text=True, encoding="utf-8").strip()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--baseline", default="d31082f4a7816d4c83544b8f54e6f42a3b2132b1")
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
source = args.source.resolve()
sql = (root / "docs/sw6-to-kra/sw_to_kra_migrate.sql").read_text(encoding="utf-8")
contracts = [dict(zip(("method", "source_path", "target_path", "permission", "controller"), row))
for row in re.findall(r"\('(GET|POST|PUT|DELETE)','([^']+)','([^']+)','([^']+)','([^']+)'\)", sql)]
changes = []
for path in git(source, "diff", "--name-only", args.baseline, "main").splitlines():
# 只登记路径与摘要,不把旧配置中的凭据写入新仓库。
if not (path.endswith(".java") or "/mapper/" in path and path.endswith(".xml")):
continue
file = source / path
changes.append({"path": path, "exists": file.is_file(),
"sha256": hashlib.sha256(file.read_bytes()).hexdigest() if file.is_file() else None})
custom = []
for file in sorted((source / "ruoyi-admin/src/main/java").rglob("*Controller.java")):
text = file.read_text(encoding="utf-8")
if file.name == "ForgotPasswordController.java":
custom.append({"path": file.relative_to(source).as_posix(), "source": text})
result = {"baseline": args.baseline, "backend_head": git(source, "rev-parse", "main"),
"frontend_head": git(source / "web", "rev-parse", "dev/dev1.0"),
"contracts": contracts, "changed_sources": changes,
"additional_controllers": [item["path"] for item in custom]}
target = root / "docs/sw6-to-kra/source-inventory.json"
target.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"已登记 {len(contracts)} 个 SQL 接口契约、{len(changes)} 个变更源文件。")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
import service from '@/utils/request'
export const getUserPreference = () => service({ url: '/sw/user/preference', method: 'get' })
export const updateUserPreference = (data) => service({ url: '/sw/user/preference', method: 'put', data })
@@ -0,0 +1,61 @@
<template>
<el-popover placement="bottom-end" :width="250" trigger="click" @show="load">
<template #reference>
<icon-button icon="lucide:sliders-horizontal" label="统计口径" />
</template>
<div v-loading="loading" class="space-y-3">
<div class="font-medium">全局统计口径</div>
<div class="flex items-center justify-between">
<span>包含下级渠道</span>
<el-switch :model-value="preference.includeSubPromote" :disabled="loading || !loaded" @change="save('includeSubPromote', $event)" />
</div>
<div class="flex items-center justify-between">
<span>身份证去重</span>
<el-switch :model-value="preference.idcardDedup" :disabled="loading || !loaded" @change="save('idcardDedup', $event)" />
</div>
<p class="text-xs text-gray-500">用于商务数据查询,充值金额不受身份证去重影响。</p>
</div>
</el-popover>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import IconButton from '@/components/iconButton/index.vue'
import { getUserPreference, updateUserPreference } from '@/api/business'
import { emitter } from '@/utils/bus'
const preference = ref({ includeSubPromote: true, idcardDedup: true })
const loading = ref(false)
const loaded = ref(false)
const load = async () => {
loading.value = true
loaded.value = false
try {
const res = await getUserPreference()
if (res.code === 0) {
preference.value = res.data
loaded.value = true
}
} finally {
loading.value = false
}
}
const save = async (key, value) => {
if (loading.value || !loaded.value) return
loading.value = true
try {
const next = { ...preference.value, [key]: value }
const res = await updateUserPreference(next)
if (res.code === 0) {
preference.value = next
ElMessage.success('统计口径已更新')
emitter.emit('reload')
}
} finally {
loading.value = false
}
}
</script>
+2
View File
@@ -1,6 +1,7 @@
<template>
<!-- 工具按钮组:扁平图标,无独立描边/阴影,hover 时图标 morph 为文案(取代 tooltip)。 -->
<div class="flex items-center gap-1 mx-3">
<business-preference />
<icon-button
v-if="settings.header.search.visible"
icon="lucide:search"
@@ -41,6 +42,7 @@
import { emitter } from '@/utils/bus.js'
import CommandMenu from '@/components/commandMenu/index.vue'
import IconButton from '@/components/iconButton/index.vue'
import BusinessPreference from '@/components/businessPreference/index.vue'
const themeStore = useThemeStore()
const { settings } = storeToRefs(themeStore)