From 260fec7114d5c33f4104df96c09a6f26242aeb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Sun, 11 Oct 2026 23:41:40 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC1=E6=89=B9=20M1=E3=80=81M4?= =?UTF-8?q?=EF=BD=9EM6=20=E7=99=BB=E5=BD=95=E5=AF=86=E7=A0=81=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=A7=84=E5=88=99=E4=B8=8E=E4=B8=89=E7=B1=BB=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E8=A1=A5=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 七个设置登录密码入口统一 8~12 位、四类字符至少三类(1001/1016),修改密码与建店铺返回字段级提示 - 订单导出末列“操作人”、IoT 卡导出末列“套餐到期时间”、设备导出流量标 MB 并新增“设备合计(GB)” - 历史导出任务按冻结表头识别新旧尾列,行宽不变 - OpenSpec Change fix-august-iteration-export-and-password-hints:10/12,5.3 前端同步与 5.4 测试部署导出验证待完成 --- internal/application/shop/create.go | 4 ++ internal/domain/distribution/distribution.go | 5 +- internal/exporter/device_scene.go | 51 +++++++++++---- internal/exporter/iot_card_scene.go | 50 +++++++++++++-- internal/exporter/order_scene.go | 27 ++++++-- internal/handler/admin/auth.go | 9 ++- internal/handler/admin/shop.go | 3 +- internal/handler/auth/handler.go | 9 ++- internal/handler/validation/password.go | 21 +++++++ internal/model/dto/account_dto.go | 6 +- internal/model/dto/agent_distribution_dto.go | 2 +- internal/model/dto/auth_dto.go | 4 +- internal/model/dto/enterprise_dto.go | 4 +- internal/model/dto/shop_dto.go | 2 +- internal/service/account/service.go | 12 ++++ internal/service/auth/service.go | 5 ++ internal/service/enterprise/service.go | 9 +++ .../.openspec.yaml | 2 + .../design.md | 47 ++++++++++++++ .../proposal.md | 30 +++++++++ .../specs/export-task/spec.md | 57 +++++++++++++++++ .../specs/identity-access/spec.md | 62 +++++++++++++++++++ .../tasks.md | 26 ++++++++ pkg/validator/password.go | 52 ++++++++++++++++ 24 files changed, 462 insertions(+), 37 deletions(-) create mode 100644 internal/handler/validation/password.go create mode 100644 openspec/changes/fix-august-iteration-export-and-password-hints/.openspec.yaml create mode 100644 openspec/changes/fix-august-iteration-export-and-password-hints/design.md create mode 100644 openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md create mode 100644 openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md create mode 100644 openspec/changes/fix-august-iteration-export-and-password-hints/specs/identity-access/spec.md create mode 100644 openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md create mode 100644 pkg/validator/password.go diff --git a/internal/application/shop/create.go b/internal/application/shop/create.go index aaf522ed..61900732 100644 --- a/internal/application/shop/create.go +++ b/internal/application/shop/create.go @@ -16,6 +16,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/validator" ) // CreateService 收口平台与代理创建店铺的完整事务。 @@ -53,6 +54,9 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque default: return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")) } + if err := validator.ValidateLoginPassword("初始账号密码", request.InitPassword); err != nil { + return s.fail(ctx, request, err) + } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost) if err != nil { return s.fail(ctx, request, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")) diff --git a/internal/domain/distribution/distribution.go b/internal/domain/distribution/distribution.go index c721b699..6d893b3c 100644 --- a/internal/domain/distribution/distribution.go +++ b/internal/domain/distribution/distribution.go @@ -10,6 +10,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/validator" ) const ( @@ -64,8 +65,8 @@ func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, erro if len(input.Username) < 3 || len(input.Username) > 50 { return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名长度必须为 3 至 50 个字符") } - if len(input.Password) < 6 || len(input.Password) > 64 { - return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "密码长度必须为 6 至 64 个字符") + if err := validator.ValidateLoginPassword("密码", input.Password); err != nil { + return RegistrationInput{}, err } return input, nil } diff --git a/internal/exporter/device_scene.go b/internal/exporter/device_scene.go index 3b9d97bd..b9bf6bda 100644 --- a/internal/exporter/device_scene.go +++ b/internal/exporter/device_scene.go @@ -14,13 +14,19 @@ import ( const ( deviceExportBaseHeaderCount = 6 deviceExportCardGroupSize = 5 - // deviceExportTailHeaderCount 是当前尾部固定列数(含新增的「关联手机号」列)。 - deviceExportTailHeaderCount = 6 - // deviceExportLegacyTailHeaderCount 是新增尾部列之前的固定列数,仅供历史任务表头反解回退使用。 + // deviceExportTailHeaderCount 是当前尾部固定列数(含「关联手机号」「设备合计(GB)」列)。 + deviceExportTailHeaderCount = 7 + // deviceExportPhoneTailHeaderCount 是新增「设备合计(GB)」之前、带「关联手机号」的尾部列数,供历史任务表头反解。 + deviceExportPhoneTailHeaderCount = 6 + // deviceExportLegacyTailHeaderCount 是新增「关联手机号」之前的尾部列数,供历史任务表头反解。 deviceExportLegacyTailHeaderCount = 5 deviceExportMinCardGroups = 1 // deviceExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。 deviceExportAssociatedPhoneHeader = "关联手机号" + // deviceExportTotalGBHeader 是「设备合计(GB)」列的表头,固定位于导出最后一列。 + deviceExportTotalGBHeader = "设备合计(GB)" + // deviceExportMBPerGB MB 换算 GB 的进制。 + deviceExportMBPerGB = 1024 ) // DeviceDataSource 设备导出数据源。 @@ -64,6 +70,7 @@ func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offse } cardGroups := cardGroupCountFromHeaders(params.ResolvedHeaders) + tailCount := deviceExportTailCount(params.ResolvedHeaders) if cardGroups <= 0 { resolved, err := s.resolveCardGroupCount(ctx, params) if err != nil { @@ -101,7 +108,7 @@ func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offse rows := make([][]string, 0, len(devices)) for _, item := range devices { - rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], associatedPhones[item.ID], cardGroups)) + rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], associatedPhones[item.ID], cardGroups, tailCount)) } return rows, nil } @@ -375,22 +382,24 @@ func buildDeviceExportHeaders(cardGroups int) []string { fmt.Sprintf("ICCID%d", i), fmt.Sprintf("接入号%d", i), fmt.Sprintf("ICCID%d对应的运营商", i), - fmt.Sprintf("ICCID%d使用的流量", i), + fmt.Sprintf("ICCID%d使用的流量(MB)", i), fmt.Sprintf("卡状态%d", i), ) } headers = append(headers, - "设备合计", + "设备合计(MB)", "套餐的生效时间", "套餐的到期时间", "当前套餐", "钱包余额", deviceExportAssociatedPhoneHeader, + deviceExportTotalGBHeader, ) return headers } -func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, associatedPhone string, cardGroups int) []string { +// buildDeviceExportRow 构造一行设备导出数据;tailCount 为冻结表头的尾部列数,历史表头不输出其后新增的列。 +func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, associatedPhone string, cardGroups int, tailCount int) []string { if cardGroups < deviceExportMinCardGroups { cardGroups = deviceExportMinCardGroups } @@ -431,19 +440,35 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg formatOptionalTime(pkg.ExpiresAt), pkg.PackageName, formatMoneyYuan(item.WalletBalance), - associatedPhone, ) + if tailCount >= deviceExportPhoneTailHeaderCount { + row = append(row, associatedPhone) + } + if tailCount >= deviceExportTailHeaderCount { + row = append(row, formatMB(totalUsage/deviceExportMBPerGB)) + } return row } // cardGroupCountFromHeaders 从已持久化的表头反解卡组列数。 -// 先按当前尾列数判定;不整除时回退到新增尾部列之前的尾列数再判定, -// 否则历史任务凭 ResolvedHeaders 重导出时列组数会被算成 0,缺失全部卡列。 +// 先按末列识别新旧表头的尾部列数再反解,否则历史任务凭 ResolvedHeaders 重导出时卡列组数会算错或为 0。 func cardGroupCountFromHeaders(headers []string) int { - if count := cardGroupCountWithTail(headers, deviceExportTailHeaderCount); count > 0 { - return count + return cardGroupCountWithTail(headers, deviceExportTailCount(headers)) +} + +// deviceExportTailCount 按末列表头识别尾部固定列数:「设备合计(GB)」为当前表头,「关联手机号」为上一版,其余为最早版本。 +func deviceExportTailCount(headers []string) int { + if len(headers) == 0 { + return deviceExportTailHeaderCount + } + switch headers[len(headers)-1] { + case deviceExportTotalGBHeader: + return deviceExportTailHeaderCount + case deviceExportAssociatedPhoneHeader: + return deviceExportPhoneTailHeaderCount + default: + return deviceExportLegacyTailHeaderCount } - return cardGroupCountWithTail(headers, deviceExportLegacyTailHeaderCount) } // cardGroupCountWithTail 按指定尾部固定列数反解卡组列数,不整除即无法确定列组。 diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index 0da7f459..e2269bcd 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -7,17 +7,19 @@ import ( "gorm.io/gorm" + packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" "github.com/break/junhong_cmp_fiber/pkg/constants" ) // IotCardDataSource IoT 卡导出数据源。 type IotCardDataSource struct { - db *gorm.DB + db *gorm.DB + expiryQ *packageexpiryquery.Query } // NewIotCardDataSource 创建 IoT 卡导出数据源。 func NewIotCardDataSource(db *gorm.DB) *IotCardDataSource { - return &IotCardDataSource{db: db} + return &IotCardDataSource{db: db, expiryQ: packageexpiryquery.NewQuery(db)} } // Scene 返回导出场景编码。 @@ -37,8 +39,8 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { - // 「关联手机号」固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 - return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader}, nil + // 「关联手机号」「套餐到期时间」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 + return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 @@ -79,10 +81,18 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if err != nil { return nil, err } + includeExpiry := len(params.ResolvedHeaders) == 0 || iotCardHeadersIncludeFinalExpiry(params.ResolvedHeaders) + var finalExpiries map[uint]string + if includeExpiry { + finalExpiries, err = s.loadFinalExpiries(ctx, cardIDs) + if err != nil { + return nil, err + } + } rows := make([][]string, 0, len(items)) for _, item := range items { - rows = append(rows, []string{ + row := []string{ item.ICCID, item.MSISDN, item.DeviceVirtualNo, @@ -96,11 +106,36 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs strconv.FormatInt(item.DataUsageMB, 10), strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10), associatedPhones[item.ID], - }) + } + if includeExpiry { + row = append(row, finalExpiries[item.ID]) + } + rows = append(rows, row) } return rows, nil } +// loadFinalExpiries 按分页批量推算卡的最终套餐到期时间,只有可精确推算时输出。 +func (s *IotCardDataSource) loadFinalExpiries(ctx context.Context, cardIDs []uint) (map[uint]string, error) { + estimates, err := s.expiryQ.ResolveBatch(ctx, constants.AssetTypeIotCard, cardIDs) + if err != nil { + return nil, err + } + result := make(map[uint]string, len(estimates)) + for cardID, estimate := range estimates { + if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact { + continue + } + result[cardID] = formatOptionalTime(estimate.EstimatedFinalExpiresAt) + } + return result, nil +} + +// iotCardHeadersIncludeFinalExpiry 判断冻结表头是否为带“套餐到期时间”末列的新表头。 +func iotCardHeadersIncludeFinalExpiry(headers []string) bool { + return len(headers) > 0 && headers[len(headers)-1] == iotCardExportFinalExpiryHeader +} + // baseQuery 构造卡导出的基础查询。 // 绑定设备名称从当前有效绑定中取一条,避免多绑定历史记录放大导出行数。 func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB { @@ -234,6 +269,9 @@ func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, pa // iotCardExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。 const iotCardExportAssociatedPhoneHeader = "关联手机号" +// iotCardExportFinalExpiryHeader 是「套餐到期时间」列的表头,固定位于导出最后一列。 +const iotCardExportFinalExpiryHeader = "套餐到期时间" + type iotCardExportRow struct { ID uint `gorm:"column:id"` ICCID string `gorm:"column:iccid"` diff --git a/internal/exporter/order_scene.go b/internal/exporter/order_scene.go index ac351a01..de293d53 100644 --- a/internal/exporter/order_scene.go +++ b/internal/exporter/order_scene.go @@ -16,6 +16,8 @@ const ( orderExportBaseHeaderCount = 11 orderExportCardGroupSize = 2 orderExportMinCardGroups = 1 + // orderExportOperatorHeader 操作人列固定追加在最后,历史任务的冻结表头没有该列。 + orderExportOperatorHeader = "操作人" ) // OrderDataSource 订单导出数据源。 @@ -63,6 +65,7 @@ func (s *OrderDataSource) Fetch(ctx context.Context, params ExportParams, offset } cardGroups := orderCardGroupCountFromHeaders(params.ResolvedHeaders) + includeOperator := len(params.ResolvedHeaders) == 0 || orderHeadersIncludeOperator(params.ResolvedHeaders) if cardGroups <= 0 { resolved, err := s.resolveCardGroupCount(ctx, params) if err != nil { @@ -107,7 +110,11 @@ func (s *OrderDataSource) Fetch(ctx context.Context, params ExportParams, offset if item.DeviceID != nil { cards = cardMap[*item.DeviceID] } - rows = append(rows, buildOrderExportRow(item, itemMap[item.ID], paymentMap[item.ID], cards, cardGroups)) + row := buildOrderExportRow(item, itemMap[item.ID], paymentMap[item.ID], cards, cardGroups) + if includeOperator { + row = append(row, item.OperatorAccountName) + } + rows = append(rows, row) } return rows, nil } @@ -243,6 +250,7 @@ func (s *OrderDataSource) fetchOrders(ctx context.Context, params ExportParams, o.payment_method, o.created_at, o.device_id, + o.operator_account_name, COALESCE(sh.shop_name, '') AS shop_name `). Order("o.id DESC"). @@ -461,6 +469,8 @@ type orderExportRow struct { PaymentMethod string `gorm:"column:payment_method"` CreatedAt time.Time `gorm:"column:created_at"` DeviceID *uint `gorm:"column:device_id"` + // OperatorAccountName 订单创建时冻结的操作者名称,缺失时导出为空,不回查当前账号。 + OperatorAccountName string `gorm:"column:operator_account_name"` } type orderExportItemRow struct { @@ -511,7 +521,7 @@ func buildOrderExportHeaders(cardGroups int) []string { fmt.Sprintf("ICCID%d", i), ) } - return headers + return append(headers, orderExportOperatorHeader) } func buildOrderExportRow(item orderExportRow, items []orderExportItemRow, payment orderExportPaymentRow, cards []orderExportCardRow, cardGroups int) []string { @@ -543,16 +553,25 @@ func buildOrderExportRow(item orderExportRow, items []orderExportItemRow, paymen } func orderCardGroupCountFromHeaders(headers []string) int { - if len(headers) < orderExportBaseHeaderCount { + fixedCount := orderExportBaseHeaderCount + if orderHeadersIncludeOperator(headers) { + fixedCount++ + } + if len(headers) < fixedCount { return 0 } - cardColumnCount := len(headers) - orderExportBaseHeaderCount + cardColumnCount := len(headers) - fixedCount if cardColumnCount <= 0 || cardColumnCount%orderExportCardGroupSize != 0 { return 0 } return cardColumnCount / orderExportCardGroupSize } +// orderHeadersIncludeOperator 判断冻结表头是否为带“操作人”末列的新表头。 +func orderHeadersIncludeOperator(headers []string) bool { + return len(headers) > 0 && headers[len(headers)-1] == orderExportOperatorHeader +} + func joinOrderPackageNames(items []orderExportItemRow) string { if len(items) == 0 { return "" diff --git a/internal/handler/admin/auth.go b/internal/handler/admin/auth.go index acaecf4d..54251fa5 100644 --- a/internal/handler/admin/auth.go +++ b/internal/handler/admin/auth.go @@ -1,6 +1,7 @@ package admin import ( + "github.com/break/junhong_cmp_fiber/internal/handler/validation" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/service/auth" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -12,6 +13,9 @@ import ( "go.uber.org/zap" ) +// changePasswordInvalidPrefix 修改密码参数校验失败提示的统一前缀。 +const changePasswordInvalidPrefix = "修改密码参数不合法" + // AuthHandler 后台认证处理器 type AuthHandler struct { authService *auth.Service @@ -147,7 +151,10 @@ func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error { zap.String("method", c.Method()), zap.Error(err), ) - return errors.New(errors.CodeInvalidParam) + return errors.New(errors.CodeInvalidParam, validation.Message(changePasswordInvalidPrefix, &req, err)) + } + if err := validation.LoginPassword(changePasswordInvalidPrefix, "新密码", req.NewPassword); err != nil { + return err } ctx := c.UserContext() diff --git a/internal/handler/admin/shop.go b/internal/handler/admin/shop.go index 59565beb..2a503442 100644 --- a/internal/handler/admin/shop.go +++ b/internal/handler/admin/shop.go @@ -10,6 +10,7 @@ import ( shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + "github.com/break/junhong_cmp_fiber/internal/handler/validation" "github.com/break/junhong_cmp_fiber/internal/model/dto" shopquery "github.com/break/junhong_cmp_fiber/internal/query/shop" shopService "github.com/break/junhong_cmp_fiber/internal/service/shop" @@ -200,7 +201,7 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error { if err := h.validator.Struct(&req); err != nil { logger.GetAppLogger().Warn("店铺创建参数验证失败", zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err)) - return errors.New(errors.CodeInvalidParam) + return errors.New(errors.CodeInvalidParam, validation.Message("店铺参数不合法", &req, err)) } createService := h.createService diff --git a/internal/handler/auth/handler.go b/internal/handler/auth/handler.go index 73732e64..69ce2705 100644 --- a/internal/handler/auth/handler.go +++ b/internal/handler/auth/handler.go @@ -1,6 +1,7 @@ package auth import ( + "github.com/break/junhong_cmp_fiber/internal/handler/validation" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/service/auth" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -12,6 +13,9 @@ import ( "go.uber.org/zap" ) +// changePasswordInvalidPrefix 修改密码参数校验失败提示的统一前缀。 +const changePasswordInvalidPrefix = "修改密码参数不合法" + // Handler 统一认证处理器 // 合并后台和 H5 认证接口 type Handler struct { @@ -154,7 +158,10 @@ func (h *Handler) ChangePassword(c *fiber.Ctx) error { zap.String("method", c.Method()), zap.Error(err), ) - return errors.New(errors.CodeInvalidParam) + return errors.New(errors.CodeInvalidParam, validation.Message(changePasswordInvalidPrefix, &req, err)) + } + if err := validation.LoginPassword(changePasswordInvalidPrefix, "新密码", req.NewPassword); err != nil { + return err } ctx := c.UserContext() diff --git a/internal/handler/validation/password.go b/internal/handler/validation/password.go new file mode 100644 index 00000000..f248c075 --- /dev/null +++ b/internal/handler/validation/password.go @@ -0,0 +1,21 @@ +package validation + +import ( + stderrors "errors" + + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/validator" +) + +// LoginPassword 按统一登录密码规则校验,失败时在规则提示前加上 prefix,错误码保持规则给出的值。 +func LoginPassword(prefix, label, password string) error { + err := validator.ValidateLoginPassword(label, password) + if err == nil { + return nil + } + var appErr *errors.AppError + if !stderrors.As(err, &appErr) { + return errors.New(errors.CodeInvalidParam, prefix) + } + return errors.New(appErr.Code, prefix+":"+appErr.Message) +} diff --git a/internal/model/dto/account_dto.go b/internal/model/dto/account_dto.go index 3863ef5d..73a4474a 100644 --- a/internal/model/dto/account_dto.go +++ b/internal/model/dto/account_dto.go @@ -4,7 +4,7 @@ package dto type CreateAccountRequest struct { Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"用户名"` Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"` - Password string `json:"password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"密码"` + Password string `json:"password" validate:"required,min=8,max=12" required:"true" minLength:"8" maxLength:"12" description:"密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` UserType int `json:"user_type" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"` ShopID *uint `json:"shop_id" description:"关联店铺ID(代理账号必填)"` EnterpriseID *uint `json:"enterprise_id" description:"关联企业ID(企业账号必填)"` @@ -14,7 +14,7 @@ type CreateAccountRequest struct { type UpdateAccountRequest struct { Username *string `json:"username" validate:"omitempty,min=3,max=50" minLength:"3" maxLength:"50" description:"用户名"` Phone *string `json:"phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"手机号"` - Password *string `json:"password" validate:"omitempty,min=8,max=32" minLength:"8" maxLength:"32" description:"密码"` + Password *string `json:"password" validate:"omitempty,min=8,max=12" minLength:"8" maxLength:"12" description:"密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` Status *int `json:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"状态 (0:禁用, 1:启用)"` } @@ -71,7 +71,7 @@ type AccountPageResult struct { // UpdatePasswordRequest 修改密码请求 // 用于管理员重置密码场景,无需验证旧密码 type UpdatePasswordRequest struct { - NewPassword string `json:"new_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"新密码(8-32位)"` + NewPassword string `json:"new_password" validate:"required,min=8,max=12" required:"true" minLength:"8" maxLength:"12" description:"新密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` } // UpdateStatusRequest 状态切换请求 diff --git a/internal/model/dto/agent_distribution_dto.go b/internal/model/dto/agent_distribution_dto.go index 7419cfff..86797633 100644 --- a/internal/model/dto/agent_distribution_dto.go +++ b/internal/model/dto/agent_distribution_dto.go @@ -5,7 +5,7 @@ type CreateAgentDistributionRegistrationReq struct { DistributionCode string `json:"distribution_code" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"上级代理店铺的分销码"` Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"注册手机号,必须已完成短信验证码校验"` Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"短信验证码,校验成功即消费"` - Password string `json:"password" validate:"required,min=6,max=64" required:"true" minLength:"6" maximum:"64" description:"代理主账号登录密码"` + Password string `json:"password" validate:"required,min=8,max=12" required:"true" minLength:"8" maxLength:"12" description:"代理主账号登录密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"代理主账号用户名"` ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"申请店铺名称"` ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"申请店铺编号"` diff --git a/internal/model/dto/auth_dto.go b/internal/model/dto/auth_dto.go index 9c2dbc96..876a1627 100644 --- a/internal/model/dto/auth_dto.go +++ b/internal/model/dto/auth_dto.go @@ -48,6 +48,6 @@ type RefreshTokenResponse struct { } type ChangePasswordRequest struct { - OldPassword string `json:"old_password" validate:"required"` - NewPassword string `json:"new_password" validate:"required,min=8,max=32"` + OldPassword string `json:"old_password" validate:"required" required:"true" description:"旧密码"` + NewPassword string `json:"new_password" validate:"required,min=8,max=12" required:"true" minLength:"8" maxLength:"12" description:"新密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` } diff --git a/internal/model/dto/enterprise_dto.go b/internal/model/dto/enterprise_dto.go index b08399bb..91261e3f 100644 --- a/internal/model/dto/enterprise_dto.go +++ b/internal/model/dto/enterprise_dto.go @@ -8,7 +8,7 @@ type CreateEnterpriseReq struct { ContactName string `json:"contact_name" validate:"required,max=50" required:"true" maximum:"50" description:"联系人姓名"` ContactPhone string `json:"contact_phone" validate:"required,max=20" required:"true" maximum:"20" description:"联系人电话"` LoginPhone string `json:"login_phone" validate:"required,len=11" required:"true" description:"登录手机号(作为企业账号)"` - Password string `json:"password" validate:"required,min=6,max=20" required:"true" minimum:"6" maximum:"20" description:"登录密码"` + Password string `json:"password" validate:"required,min=8,max=12" required:"true" minLength:"8" maxLength:"12" description:"登录密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` BusinessLicense string `json:"business_license" validate:"max=100" maximum:"100" description:"营业执照号"` Province string `json:"province" validate:"max=50" maximum:"50" description:"省份"` City string `json:"city" validate:"max=50" maximum:"50" description:"城市"` @@ -82,7 +82,7 @@ type UpdateEnterpriseStatusReq struct { } type UpdateEnterprisePasswordBody struct { - Password string `json:"password" validate:"required,min=6,max=20" required:"true" minimum:"6" maximum:"20" description:"新密码"` + Password string `json:"password" validate:"required,min=8,max=12" required:"true" minLength:"8" maxLength:"12" description:"新密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` } type UpdateEnterprisePasswordReq struct { diff --git a/internal/model/dto/shop_dto.go b/internal/model/dto/shop_dto.go index 00f94952..415b80cc 100644 --- a/internal/model/dto/shop_dto.go +++ b/internal/model/dto/shop_dto.go @@ -30,7 +30,7 @@ type CreateShopRequest struct { District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"` Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"` DefaultRoleID uint `json:"default_role_id" validate:"required,min=1" required:"true" minimum:"1" description:"店铺默认角色ID(必须是客户角色)"` - InitPassword string `json:"init_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"初始账号密码"` + InitPassword string `json:"init_password" validate:"required,min=8,max=12" required:"true" minLength:"8" maxLength:"12" description:"初始账号密码(8~12 位,大写字母、小写字母、数字、特殊符号至少三类)"` InitUsername string `json:"init_username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"初始账号用户名"` InitPhone string `json:"init_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"初始账号手机号"` } diff --git a/internal/service/account/service.go b/internal/service/account/service.go index 89a1cdce..4ba9ffbe 100644 --- a/internal/service/account/service.go +++ b/internal/service/account/service.go @@ -22,6 +22,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/validator" "github.com/jackc/pgx/v5/pgconn" "github.com/redis/go-redis/v9" "go.uber.org/zap" @@ -145,6 +146,10 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*m return nil, errors.New(errors.CodePhoneExists, "手机号已存在") } + if err := validator.ValidateLoginPassword("密码", req.Password); err != nil { + return nil, err + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败") @@ -329,6 +334,9 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountReq } if req.Password != nil { + if err := validator.ValidateLoginPassword("密码", *req.Password); err != nil { + return nil, err + } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { s.recordSecurityFailure(ctx, constants.AuditActionAccountPasswordReset, "更新账号凭据处理失败", constants.AuditResultFailed, account, passwordAuthentication(account, "failed"), beforeData, nil, err) @@ -851,6 +859,10 @@ func (s *Service) UpdatePassword(ctx context.Context, accountID uint, newPasswor return errors.Wrap(errors.CodeInternalError, err, "获取账号失败") } + if err := validator.ValidateLoginPassword("新密码", newPassword); err != nil { + return err + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { s.recordSecurityFailure(ctx, constants.AuditActionAccountPasswordReset, "重置账号密码失败", constants.AuditResultFailed, account, passwordAuthentication(account, "failed"), nil, nil, err) diff --git a/internal/service/auth/service.go b/internal/service/auth/service.go index e975e31e..8046ff5a 100644 --- a/internal/service/auth/service.go +++ b/internal/service/auth/service.go @@ -14,6 +14,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/validator" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -224,6 +225,10 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword, return errors.Wrap(errors.CodeInternalError, err, "查询账号失败") } + if err := validator.ValidateLoginPassword("新密码", newPassword); err != nil { + return err + } + if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil { appErr := errors.New(errors.CodeInvalidOldPassword, "旧密码错误") s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "拒绝修改账号密码", constants.AuditResultDenied, "", appErr) diff --git a/internal/service/enterprise/service.go b/internal/service/enterprise/service.go index 94ad53f6..fa0ea89a 100644 --- a/internal/service/enterprise/service.go +++ b/internal/service/enterprise/service.go @@ -12,6 +12,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/validator" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -102,6 +103,10 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt } } + if err := validator.ValidateLoginPassword("登录密码", req.Password); err != nil { + return nil, err + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败") @@ -329,6 +334,10 @@ func (s *Service) UpdatePassword(ctx context.Context, id uint, password string) ownerShop, _ = s.shopStore.GetByID(ctx, *enterprise.OwnerShopID) } + if err := validator.ValidateLoginPassword("新密码", password); err != nil { + return err + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败") diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/.openspec.yaml b/openspec/changes/fix-august-iteration-export-and-password-hints/.openspec.yaml new file mode 100644 index 00000000..a3692196 --- /dev/null +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/design.md b/openspec/changes/fix-august-iteration-export-and-password-hints/design.md new file mode 100644 index 00000000..3a42ab14 --- /dev/null +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/design.md @@ -0,0 +1,47 @@ +## Context + +M1、M4~M6 原写在 `fix-august-iteration-follow-up-gaps`,改写该 Change 为第 1 批缺陷时移出;2026-10-11 确定并入第 1 批,单独成本 Change。现状已核对: + +- `ChangePasswordRequest`(`internal/model/dto/auth_dto.go:50`)校验为 `required` 与 `min=8,max=32`,Handler 校验失败只返回“参数验证失败”,字段与规则被丢弃。 +- 设置登录密码的入口规则不一且无强度要求:本人改密码、后台账号创建/修改/改密码、建店铺初始密码为 8~32;企业客户新增与改密码为 6~20;代理注册为 6~64(`internal/domain/distribution/distribution.go:67`)。错误码 `1016`(`CodePasswordTooWeak`)已定义但未使用。 +- `customer_account_dto.go`、`shop_account_dto.go` 中的建账号与改密码 DTO 没有任何调用方,不在本次范围。 +- 订单导出表头有“店铺”无“操作人”(`internal/exporter/order_scene.go`),订单列表已有冻结的 `operator_name`。 +- IoT 卡导出无到期列;卡列表最终到期由 `packageexpiry.Query.ResolveBatch`(`internal/query/packageexpiry/query.go:41`)推算。 +- 设备导出表头为 `ICCIDn使用的流量`、“设备合计”(`internal/exporter/device_scene.go:378`),数值实为 MB,未标单位。 +- 导出任务创建时冻结 `ResolvedHeaders`,Worker 按冻结表头生成行。 + +## Goals / Non-Goals + +**Goals:** 设置登录密码的入口统一长度与强度规则;修改密码失败消息直接说明字段与规则;三类导出补齐核对字段与单位;历史任务列不错位。 + +**Non-Goals:** 不强制存量弱密码修改,不改登录校验;不改超级管理员操作密码与 WiFi 密码;不改订单与支付写入、到期推算口径;不改前端;无迁移。 + +## Decisions + +1. **修改密码复用 `internal/handler/validation.Message`。** 为请求字段补中文 `description`,Handler 返回“修改密码参数不合法:”加字段级消息;错误码、HTTP 状态和校验顺序不变。与项目已有中文校验提示约定一致,不在 Handler 内硬编码 validator 分支。 +2. **订单“操作人”取冻结的 `operator_name`,追加在最后一列。** 不回查当前账号名称;是否输出以任务 `ResolvedHeaders` 是否含“操作人”为准。 +3. **卡“套餐到期时间”按分页批量推算,追加在最后一列。** 每页调用一次 `ResolveBatch`,禁止逐卡查询;只有 `exact` 输出 `estimated_final_expires_at`,其余留空。 +4. **设备流量数值不变,只改表头并加 GB 列。** 新任务表头改为 `ICCIDn使用的流量(MB)`、“设备合计(MB)”,在最后一列(“关联手机号”之后)加“设备合计(GB)”,值为 MB ÷ 1024 保留两位小数;历史任务按冻结表头输出旧列名与旧列数。 + +5. **密码规则集中在一个函数,在服务层哈希前调用。** `pkg/validator` 新增登录密码规则函数,依次判断:长度 8~12(`1001`)→ 只含 ASCII 可见字符且无空格(`1016`)→ 大写、小写、数字、特殊符号四类至少三类(`1016`)。各入口在 `bcrypt.GenerateFromPassword` 前调用;DTO 标签同步改为 `min=8,max=12` 供文档与早期拦截。不只靠 DTO 标签,因为项目存在多个独立 `validator.New()` 实例,且代理注册在领域层自行校验,集中函数能保证口径一致。 +6. **启动时的系统管理员不校验。** `CreateSystemAccount`(`internal/bootstrap/admin.go:51`)按配置创建,校验会让配置弱密码的环境无法启动;该账号上线后改密码走本人改密码入口,受新规则约束。 +7. **长度上限 8~12 位为业务确认值。** 现有 8~32 位的密码超过 12 位的可继续登录,只在下次修改时受限。 + +5. **新列一律追加在最后(2026-10-11 确认)。** 与项目既有做法一致(IoT 卡导出注释:“「关联手机号」固定追加在尾部”)。原因:分片按位置把行截齐到冻结表头(`internal/task/export_common.go` 的 `alignRowsToHeaders`),新代码上线前派发、上线后执行的任务只会少最后一列,插在中间则后面各列整体错位。订单与设备导出的卡列是动态的,组数靠表头反推:订单为(总列数 − 固定列数)÷ 2(`orderCardGroupCountFromHeaders`),设备为按尾部固定列数(现为 6,旧为 5)反推(`cardGroupCountFromHeaders`)。追加末列后须先按末列表头是否为“操作人”/“设备合计(GB)”识别新旧表头再反推,否则卡列组数算错或为 0。 + +## Risks / Trade-offs + +- [前端提示与后端规则不一致] → 任务中单列前端同步,测试环境联调确认。 +- [12 位上限压低可用强度] → 业务已确认;规则集中在一个函数,后续放宽只改一处。 + +- [批量推算增加导出耗时] → 每页一次批量查询,无 N+1。 +- [新列导致历史任务列错位] → 行构造一律以冻结 `ResolvedHeaders` 判定是否输出新列。 +- [调用方按旧消息“参数验证失败”匹配] → 错误码 `1001` 不变,调用方应按错误码分支。 + +## Migration Plan + +无迁移。测试环境部署 API 与 Worker 后,新建导出任务使用新表头,已有任务沿用冻结表头。 + +## Open Questions + +无。 diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md b/openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md new file mode 100644 index 00000000..e2eb8177 --- /dev/null +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/proposal.md @@ -0,0 +1,30 @@ +## Why + +8-9 月迭代中的 4 个优化项原排在第 2 批,2026-10-11 确定并入第 1 批一起交付:修改密码失败只提示“参数验证失败”(M1),且各入口密码规则不一(6~20、6~50、6~64、8~32 位)、只限长度无强度要求;订单导出缺“操作人”(M4,运营清单 B-1);IoT 卡导出缺“套餐到期时间”(M5,运营清单 21);设备导出流量未标单位、无 GB 合计(M6,运营清单 22)。内容沿用原 `fix-august-iteration-follow-up-gaps` 中已核实的方案,依据为《8-9月迭代排期安排》。 + +## What Changes + +- **BREAKING** 所有设置登录密码的入口统一为:8~12 位、仅 ASCII 可见字符、大写/小写/数字/特殊符号四类至少三类。长度不符返回 `1001`,强度不足返回 `1016`。存量密码照常登录,不强制修改。 +- `PUT /api/auth/password` 校验失败消息改为“修改密码参数不合法:”加字段与规则,例如“新密码长度不能小于 8”;不回显密码。 +- 订单导出新任务在最后一列增加“操作人”,取订单冻结的 `operator_name`。 +- IoT 卡导出新任务在最后一列增加“套餐到期时间”,复用卡列表最终到期推算,只有精确推算时输出。 +- 设备导出新任务表头改为 `ICCIDn使用的流量(MB)`、“设备合计(MB)”,在最后一列新增“设备合计(GB)”(MB ÷ 1024,两位小数)。 +- 历史导出任务按冻结表头输出,不插入新列。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `identity-access`:登录密码统一长度与强度规则;修改密码接口的字段级中文校验提示。 +- `export-task`:订单、IoT 卡、设备三类导出的新列、来源、单位与历史任务表头兼容。 + +## Impact + +- 代码:`pkg/validator`(新增登录密码规则函数);设置密码的服务入口 `internal/service/auth/service.go`、`internal/service/account/service.go`、`internal/application/shop/create.go`、`internal/service/enterprise/service.go`、`internal/domain/distribution/distribution.go`;相关 DTO 校验标签(`auth_dto.go`、`account_dto.go`、`shop_dto.go`、`enterprise_dto.go`、`agent_distribution_dto.go`);`internal/handler/auth/handler.go`、`internal/exporter/order_scene.go`、`internal/exporter/iot_card_scene.go`(批量调用 `packageexpiry.Query.ResolveBatch`)、`internal/exporter/device_scene.go`。 +- 接口:七个设置密码入口的长度上限与强度规则变化,修改密码错误消息变化;三类导出新任务表头变化;重新生成接口文档。 +- 前端:后台建账号、改密码、新增企业、建店铺与 H5 代理注册页面的密码提示与前端校验需同步改为新规则;导出无需改动。 +- 无数据库迁移,不改 Redis 与第三方调用。 diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md b/openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md new file mode 100644 index 00000000..e7713cb7 --- /dev/null +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/specs/export-task/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: 订单导出包含操作人 + +系统 SHALL 在新建订单导出任务的最后一列增加“操作人”,取订单创建时冻结的 `operator_name`;字段缺失时输出空值,MUST NOT 以当前账号名称回填历史订单。 + +#### Scenario: 新任务导出订单操作人 + +- **WHEN** 用户新建订单导出任务 +- **THEN** 表头最后一列为“操作人” +- **AND** 每行值与订单列表同一订单的 `operator_name` 口径一致 + +#### Scenario: 历史订单没有操作人快照 + +- **WHEN** 新任务导出一条 `operator_name` 为空的历史订单 +- **THEN** “操作人”列为空且该订单仍被导出 + +### Requirement: IoT 卡导出包含最终套餐到期时间 + +系统 SHALL 在新建 IoT 卡导出任务的最后一列增加“套餐到期时间”,并复用卡列表的全部有效主套餐连续使用后的最终到期推算口径。只有推算状态为 `exact` 时输出时间,其他状态输出空值且 MUST NOT 阻断整项导出。 + +#### Scenario: 最终到期时间可精确推算 + +- **WHEN** 新任务导出的 IoT 卡存在可精确推算的最终套餐到期时间 +- **THEN** 表头最后一列为“套餐到期时间” +- **AND** 该列输出与卡列表 `estimated_final_expires_at` 相同的时间事实 + +#### Scenario: 最终到期时间不可精确推算 + +- **WHEN** 新任务导出的 IoT 卡到期推算状态为待激活、无参与套餐或数据异常 +- **THEN** “套餐到期时间”列为空且该卡仍被导出 + +### Requirement: 设备导出明确流量单位并提供 GB 合计 + +系统 SHALL 对新建设备导出任务将每张绑定卡的当前月使用流量和设备合计明确标注为 MB,并在最后一列额外返回“设备合计(GB)”。GB 合计 MUST 等于全部当前绑定卡 MB 使用量之和除以 1024,并保留两位小数。 + +#### Scenario: 新任务导出设备流量 + +- **WHEN** 用户新建设备导出任务 +- **THEN** 动态卡列名为 `ICCIDn使用的流量(MB)`,MB 合计列名为“设备合计(MB)” +- **AND** 表头最后一列为“设备合计(GB)” +- **AND** GB 合计按 MB 合计除以 1024 后保留两位小数 + +#### Scenario: 无绑定卡的设备 + +- **WHEN** 新任务导出没有当前绑定卡的设备 +- **THEN** 每张卡的动态列为空,MB 与 GB 合计均输出格式化的零值 + +### Requirement: 历史导出任务保持冻结表头 + +系统 MUST 继续使用导出任务创建时冻结的表头决定输出列;本次新增的列一律追加在最后一列,新增或重命名的列只适用于新任务;订单与设备导出按冻结表头反推卡列组数时 MUST 同时识别带与不带新增末列的表头,卡列不得缺失或错位;历史任务重新执行或下载时 MUST 保持原列名、原列数与行宽对齐。 + +#### Scenario: 重跑变更前创建的导出任务 + +- **WHEN** Worker 处理一个在本 Change 部署前已冻结表头的订单、IoT 卡或设备导出任务 +- **THEN** 输出仍使用该任务冻结的旧表头和对应旧行宽 +- **AND** 不插入本 Change 新增的列 diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/specs/identity-access/spec.md b/openspec/changes/fix-august-iteration-export-and-password-hints/specs/identity-access/spec.md new file mode 100644 index 00000000..9ca54020 --- /dev/null +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/specs/identity-access/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: 登录密码统一长度与强度规则 + +系统设置或修改登录密码时 SHALL 统一要求:长度 8~12 位;只能由 ASCII 可见字符组成(不含空格、中文等);在大写字母、小写字母、数字、特殊符号四类中至少包含三类。规则 MUST 适用于以下全部入口:本人修改密码(`PUT /api/auth/password`)、后台创建账号与修改账号(含密码时)、后台修改账号密码、创建店铺的初始账号密码、新增企业客户与修改企业账号密码、代理扫码注册。长度或必填不满足时返回错误码 `1001`;字符种类不足或含不允许字符时返回错误码 `1016`(密码强度不足)。错误消息 MUST 指明规则,MUST NOT 回显密码。规则只在设置密码时校验,MUST NOT 影响登录:存量不符合新规则的密码照常登录,不强制修改。超级管理员操作密码、WiFi 密码与启动时按配置创建的系统管理员账号不适用本规则。 + +#### Scenario: 符合规则的密码 + +- **WHEN** 调用方在任一适用入口提交 `Abc12345`(大写、小写、数字三类,8 位) +- **THEN** 系统接受该密码 + +#### Scenario: 只有两类字符 + +- **WHEN** 调用方提交 `abcd1234`(小写、数字两类) +- **THEN** 系统返回错误码 `1016`,消息为“密码须包含大写字母、小写字母、数字、特殊符号中的至少三类” +- **AND** 不设置或修改密码 + +#### Scenario: 长度超出范围 + +- **WHEN** 调用方提交 7 位或 13 位的密码 +- **THEN** 系统返回错误码 `1001`,消息指明密码长度须为 8~12 位 +- **AND** 不设置或修改密码 + +#### Scenario: 含不允许的字符 + +- **WHEN** 调用方提交含空格或中文的密码 +- **THEN** 系统返回错误码 `1016`,消息指明密码只能包含字母、数字和特殊符号 +- **AND** 不设置或修改密码 + +#### Scenario: 存量弱密码登录 + +- **GIVEN** 账号现有密码为规则上线前设置的 6 位纯数字 +- **WHEN** 该账号用原密码登录 +- **THEN** 登录照常成功,不要求修改密码 + +### Requirement: 修改密码参数校验提示 + +系统 SHALL 在已认证调用方请求 `PUT /api/auth/password` 且请求字段未通过校验时,在响应消息中以“修改密码参数不合法:”开头,明确指出首个失败字段及其规则;长度与必填失败返回错误码 `1001`,强度不足返回 `1016`。响应消息 MUST NOT 回显旧密码、新密码或其他请求字段值。 + +#### Scenario: 新密码短于最小长度 + +- **WHEN** 已认证调用方提交非空旧密码和少于 8 位的新密码 +- **THEN** 系统返回错误码 `1001`,消息为“修改密码参数不合法:新密码长度不能小于 8” +- **AND** 系统不校验旧密码是否正确且不修改密码 + +#### Scenario: 新密码超过最大长度 + +- **WHEN** 已认证调用方提交非空旧密码和超过 12 位的新密码 +- **THEN** 系统返回错误码 `1001`,消息为“修改密码参数不合法:新密码长度不能超过 12” +- **AND** 系统不修改密码 + +#### Scenario: 新密码强度不足 + +- **WHEN** 已认证调用方提交非空旧密码和 8 位纯小写字母的新密码 +- **THEN** 系统返回错误码 `1016`,消息为“修改密码参数不合法:新密码须包含大写字母、小写字母、数字、特殊符号中的至少三类” +- **AND** 系统不修改密码 + +#### Scenario: 必填密码字段缺失 + +- **WHEN** 已认证调用方提交的请求缺少旧密码或新密码 +- **THEN** 系统返回错误码 `1001`,消息明确指出对应密码字段不能为空 +- **AND** 系统不修改密码 diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md b/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md new file mode 100644 index 00000000..84447631 --- /dev/null +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md @@ -0,0 +1,26 @@ +## 1. M1 登录密码规则与修改密码提示 + +- [x] 1.1 `pkg/validator` 新增登录密码规则函数(8~12 位 → `1001`;仅 ASCII 可见字符、四类至少三类 → `1016`),消息不含密码;在本人改密码、后台账号创建/修改/改密码、建店铺初始密码、企业新增/改密码、代理注册(替换 `distribution.go:67` 的 6~64 判断)共七处哈希前调用;对应 DTO 标签改为 `min=8,max=12` 并更新字段说明。不改登录校验与 `CreateSystemAccount`。 +- [x] 1.2 为 `ChangePasswordRequest` 的旧密码、新密码字段补中文描述,统一修改密码 Handler 用 `validation.Message` 返回“修改密码参数不合法:”加字段级提示;结构校验通过后在 Handler 内做强度判断(同样带该前缀),先于旧密码比对;不回显密码。 +- [x] 1.3 核对短于 8 位、超过 12 位、强度不足、必填缺失四种消息符合 delta spec;JSON 解析失败与旧密码错误行为不变。 + +## 2. M4 订单导出操作人 + +- [x] 2.1 订单导出查询带出 `operator_name`,新任务在最后一列输出“操作人”;`orderCardGroupCountFromHeaders` 先识别末列是否为“操作人”再反推卡列组数;按 `ResolvedHeaders` 判定是否输出,历史任务保持旧列序与行宽。 + +## 3. M5 IoT 卡导出套餐到期时间 + +- [x] 3.1 IoT 卡导出新任务在最后一列增加“套餐到期时间”;每个分页调用一次 `packageexpiry.Query.ResolveBatch`,只有 `exact` 输出时间,其余留空;历史任务不加列。 + +## 4. M6 设备导出流量单位 + +- [x] 4.1 设备导出新任务表头改为 `ICCIDn使用的流量(MB)`、“设备合计(MB)”,在最后一列增加“设备合计(GB)”(MB 合计 ÷ 1024,两位小数),`cardGroupCountFromHeaders` 增加对新尾部列数(7)的识别;无绑定卡设备 MB、GB 合计输出格式化零值;历史任务按冻结表头输出。 +- [x] 4.2 核对三类导出新旧表头与每行列数严格一致,空操作人、到期不可精确推算、无绑定卡设备均不丢行。 + +## 5. 文档与测试环境验证 + +- [x] 5.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 +- [x] 5.2 按 ENG-TEST-001(测试库 `junhong_cmp_test`、Redis DB 6):修改密码接口验证四种提示且失败不改密码;七个入口各用 `Abc12345` 成功、`abcd1234` 返回 `1016`、7 位与 13 位返回 `1001`、含空格返回 `1016`;用规则上线前的 6 位旧密码账号登录照常成功。只使用本 Change 自建的测试账号、企业与店铺,验证后清理。 +- [ ] 5.3 通知测试环境前端同步七个入口的密码提示与前端校验(8~12 位、四类至少三类)。 +- [ ] 5.4 测试环境为订单、IoT 卡、设备各建一个导出任务,下载核对新表头、值来源、MB/GB 换算与列数;再用变更前创建的任务验证旧表头与行宽不变;只清理本 Change 自建的任务与对象。 +- [ ] 5.5 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/pkg/validator/password.go b/pkg/validator/password.go new file mode 100644 index 00000000..aef7c90a --- /dev/null +++ b/pkg/validator/password.go @@ -0,0 +1,52 @@ +package validator + +import ( + "unicode/utf8" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +const ( + // LoginPasswordMinLength 登录密码最小长度 + LoginPasswordMinLength = 8 + // LoginPasswordMaxLength 登录密码最大长度 + LoginPasswordMaxLength = 12 + // loginPasswordMinCategories 大写、小写、数字、特殊符号四类中至少需要的类数 + loginPasswordMinCategories = 3 +) + +// ValidateLoginPassword 校验设置或修改登录密码时的统一规则。 +// label 为提示中的字段名(如“密码”“新密码”);消息只描述规则,不回显密码。 +// 长度不符返回 CodeInvalidParam,含不允许字符或类别不足返回 CodePasswordTooWeak。 +func ValidateLoginPassword(label, password string) error { + length := utf8.RuneCountInString(password) + if length < LoginPasswordMinLength || length > LoginPasswordMaxLength { + return errors.New(errors.CodeInvalidParam, label+"长度须为 8~12 位") + } + var hasUpper, hasLower, hasDigit, hasSymbol bool + for i := 0; i < len(password); i++ { + ch := password[i] + switch { + case ch <= ' ' || ch > '~': + return errors.New(errors.CodePasswordTooWeak, label+"只能包含字母、数字和特殊符号") + case ch >= 'A' && ch <= 'Z': + hasUpper = true + case ch >= 'a' && ch <= 'z': + hasLower = true + case ch >= '0' && ch <= '9': + hasDigit = true + default: + hasSymbol = true + } + } + categories := 0 + for _, present := range []bool{hasUpper, hasLower, hasDigit, hasSymbol} { + if present { + categories++ + } + } + if categories < loginPasswordMinCategories { + return errors.New(errors.CodePasswordTooWeak, label+"须包含大写字母、小写字母、数字、特殊符号中的至少三类") + } + return nil +}