All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。 端点改造(13 个入口):IoT 卡导入任务、设备导入任务、导出任务列表、订单列表参数名不变仅收紧解析;换货、分配记录、代理充值、临期列表改名 start_time/end_time(旧参数名显式拒绝);提现记录两处删除解析失败静默跳过,非法参数一律 1001;授权记录由起始闭结束开改为闭区间含两端;临期列表改按当前生效主套餐最终到期时刻比较,保留剩余天数上下限与既有粗放窗口。 临期导出新建:新场景 expiring_asset 与受控入口 POST /api/admin/expiring-assets/export,复用列表候选预筛与最终到期推算,一行一资产、加油包不单独成行,列序与 111 §18.1 逐列一致,店铺/业务员/用户组按执行时当前归属补充且不超出创建时冻结范围。 佣金明细导出新增按创建时间闭区间筛选(原佣金与回溯两条分支各自创建时间列),记录粒度、列定义与余额口径不变。 冻结与遗留任务:创建期把筛选与时间边界规范化为 UTC RFC3339 秒级串写入既有 query_json,无新列无迁移;执行期只按冻结值严格解析,非法冻结值在任何分片与文件动作前落任务失败并写安全摘要,不放行全量;重试沿用原快照。达量预警导出执行期同样纳入严格解析(其入口契约、列定义与触发快照口径不变)。 归档 add-export-time-filter-standards 并新建主 Spec openspec/specs/export-time-filter/spec.md,同步 requirement-evidence.json 与入口能力矩阵,README 导出场景清单更新为 11 个场景。 验证:junhong_cmp_test + 本地隔离 Redis(DB7,测试部署共享队列 DB6 未被占用)实跑 85 PASS / 0 FAIL(接受/拒绝集合、区间与顺序语义、列表与导出同筛选行集一致、代理 HTTP 全链路与范围冻结、遗留旧格式任务安全失败、列与余额口径回归、表头逐字),门禁 gofmt/go build/gendocs 两次一致/openspec validate/doctor/context-health 全绿;无 Schema 变更、无迁移、无运行时开关。
514 lines
15 KiB
Go
514 lines
15 KiB
Go
package enterprise_card
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type AuthorizationService struct {
|
|
db *gorm.DB
|
|
enterpriseStore *postgres.EnterpriseStore
|
|
iotCardStore *postgres.IotCardStore
|
|
authorizationStore *postgres.EnterpriseCardAuthorizationStore
|
|
logger *zap.Logger
|
|
accessAudit accessauditapp.Writer
|
|
}
|
|
|
|
func NewAuthorizationService(
|
|
db *gorm.DB,
|
|
enterpriseStore *postgres.EnterpriseStore,
|
|
iotCardStore *postgres.IotCardStore,
|
|
authorizationStore *postgres.EnterpriseCardAuthorizationStore,
|
|
logger *zap.Logger,
|
|
accessAudit accessauditapp.Writer,
|
|
) *AuthorizationService {
|
|
return &AuthorizationService{
|
|
db: db,
|
|
enterpriseStore: enterpriseStore,
|
|
iotCardStore: iotCardStore,
|
|
authorizationStore: authorizationStore,
|
|
logger: logger,
|
|
accessAudit: accessAudit,
|
|
}
|
|
}
|
|
|
|
type BatchAuthorizeRequest struct {
|
|
EnterpriseID uint
|
|
CardIDs []uint
|
|
AuthorizerID uint
|
|
AuthorizerType int
|
|
Remark string
|
|
}
|
|
|
|
func (s *AuthorizationService) BatchAuthorize(ctx context.Context, req BatchAuthorizeRequest) error {
|
|
if len(req.CardIDs) == 0 {
|
|
return errors.New(errors.CodeInvalidParam, "卡ID列表不能为空")
|
|
}
|
|
|
|
userID := middleware.GetUserIDFromContext(ctx)
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
shopID := middleware.GetShopIDFromContext(ctx)
|
|
|
|
if userID == 0 {
|
|
return errors.New(errors.CodeUnauthorized, "用户信息无效")
|
|
}
|
|
|
|
enterprise, err := s.enterpriseStore.GetByID(ctx, req.EnterpriseID)
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
|
}
|
|
return err
|
|
}
|
|
|
|
if userType == constants.UserTypeAgent {
|
|
if enterprise.OwnerShopID == nil || *enterprise.OwnerShopID != shopID {
|
|
return errors.New(errors.CodeCannotAuthorizeToOthersEnterprise, "只能授权给自己的企业")
|
|
}
|
|
}
|
|
|
|
cards, err := s.iotCardStore.GetByIDs(ctx, req.CardIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(cards) != len(req.CardIDs) {
|
|
return errors.New(errors.CodeIotCardNotFound, "部分卡不存在")
|
|
}
|
|
|
|
cardMap := make(map[uint]*model.IotCard)
|
|
for _, card := range cards {
|
|
cardMap[card.ID] = card
|
|
}
|
|
|
|
for _, cardID := range req.CardIDs {
|
|
card := cardMap[cardID]
|
|
|
|
if card.ShopID == nil {
|
|
return errors.New(errors.CodeIotCardStatusNotAllowed, fmt.Sprintf("卡 %s 未分销,不能授权", card.ICCID))
|
|
}
|
|
|
|
if userType == constants.UserTypeAgent && *card.ShopID != shopID {
|
|
return errors.New(errors.CodeCannotAuthorizeOthersCard, fmt.Sprintf("卡 %s 不属于您的店铺", card.ICCID))
|
|
}
|
|
}
|
|
|
|
boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, req.CardIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(boundCardIDs) > 0 {
|
|
return errors.New(errors.CodeCannotAuthorizeBoundCard, "部分卡已绑定设备,不能授权")
|
|
}
|
|
|
|
existingAuths, err := s.authorizationStore.ListByCards(ctx, req.CardIDs, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
existingMap := make(map[uint]bool)
|
|
for _, auth := range existingAuths {
|
|
if auth.EnterpriseID == req.EnterpriseID {
|
|
existingMap[auth.CardID] = true
|
|
}
|
|
}
|
|
|
|
var newAuths []*model.EnterpriseCardAuthorization
|
|
for _, cardID := range req.CardIDs {
|
|
if existingMap[cardID] {
|
|
continue
|
|
}
|
|
newAuths = append(newAuths, &model.EnterpriseCardAuthorization{
|
|
EnterpriseID: req.EnterpriseID,
|
|
CardID: cardID,
|
|
AuthorizedBy: req.AuthorizerID,
|
|
AuthorizerType: req.AuthorizerType,
|
|
Remark: req.Remark,
|
|
})
|
|
}
|
|
|
|
if len(newAuths) == 0 {
|
|
return errors.New(errors.CodeCardAlreadyAuthorized, "所有卡已授权给该企业")
|
|
}
|
|
|
|
return s.authorizationStore.BatchCreate(ctx, newAuths)
|
|
}
|
|
|
|
type RevokeAuthorizationsRequest struct {
|
|
EnterpriseID uint
|
|
CardIDs []uint
|
|
RevokedBy uint
|
|
}
|
|
|
|
func (s *AuthorizationService) RevokeAuthorizations(ctx context.Context, req RevokeAuthorizationsRequest) error {
|
|
if len(req.CardIDs) == 0 {
|
|
return errors.New(errors.CodeInvalidParam, "卡ID列表不能为空")
|
|
}
|
|
|
|
userID := middleware.GetUserIDFromContext(ctx)
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
|
|
if userID == 0 {
|
|
return errors.New(errors.CodeUnauthorized, "用户信息无效")
|
|
}
|
|
|
|
existingAuths, err := s.authorizationStore.ListByCards(ctx, req.CardIDs, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
authMap := make(map[uint]*model.EnterpriseCardAuthorization)
|
|
for _, auth := range existingAuths {
|
|
if auth.EnterpriseID == req.EnterpriseID {
|
|
authMap[auth.CardID] = auth
|
|
}
|
|
}
|
|
|
|
if len(authMap) == 0 {
|
|
return errors.New(errors.CodeCardNotAuthorized, "卡未授权给该企业")
|
|
}
|
|
|
|
if userType == constants.UserTypeAgent {
|
|
for _, auth := range authMap {
|
|
if auth.AuthorizedBy != userID {
|
|
return errors.New(errors.CodeCannotRevokeOthersAuthorization, "只能回收自己创建的授权")
|
|
}
|
|
}
|
|
}
|
|
|
|
var cardIDsToRevoke []uint
|
|
for cardID := range authMap {
|
|
cardIDsToRevoke = append(cardIDsToRevoke, cardID)
|
|
}
|
|
|
|
return s.authorizationStore.RevokeAuthorizations(ctx, req.EnterpriseID, cardIDsToRevoke, req.RevokedBy)
|
|
}
|
|
|
|
type ListAuthorizationsRequest struct {
|
|
EnterpriseID *uint
|
|
AuthorizedBy *uint
|
|
IncludeRevoked bool
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
type ListAuthorizationsResponse struct {
|
|
Authorizations []*model.EnterpriseCardAuthorization
|
|
Total int64
|
|
}
|
|
|
|
func (s *AuthorizationService) ListAuthorizations(ctx context.Context, req ListAuthorizationsRequest) (*ListAuthorizationsResponse, error) {
|
|
if req.Page <= 0 {
|
|
req.Page = 1
|
|
}
|
|
if req.PageSize <= 0 {
|
|
req.PageSize = constants.DefaultPageSize
|
|
}
|
|
if req.PageSize > constants.MaxPageSize {
|
|
req.PageSize = constants.MaxPageSize
|
|
}
|
|
|
|
opts := postgres.AuthorizationListOptions{
|
|
EnterpriseID: req.EnterpriseID,
|
|
AuthorizedBy: req.AuthorizedBy,
|
|
IncludeRevoked: req.IncludeRevoked,
|
|
Offset: (req.Page - 1) * req.PageSize,
|
|
Limit: req.PageSize,
|
|
}
|
|
|
|
auths, total, err := s.authorizationStore.ListWithOptions(ctx, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ListAuthorizationsResponse{
|
|
Authorizations: auths,
|
|
Total: total,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthorizationService) GetAuthorizedCardIDs(ctx context.Context, enterpriseID uint) ([]uint, error) {
|
|
return s.authorizationStore.GetActiveAuthorizedCardIDs(ctx, enterpriseID)
|
|
}
|
|
|
|
type ListRecordsRequest struct {
|
|
EnterpriseID *uint
|
|
ICCID string
|
|
AuthorizerType *int
|
|
Status *int
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
type AuthorizationRecord struct {
|
|
ID uint
|
|
EnterpriseID uint
|
|
EnterpriseName string
|
|
CardID uint
|
|
ICCID string
|
|
MSISDN string
|
|
AuthorizedBy uint
|
|
AuthorizerName string
|
|
AuthorizerType int
|
|
AuthorizedAt string
|
|
RevokedBy *uint
|
|
RevokerName string
|
|
RevokedAt *string
|
|
Status int
|
|
Remark string
|
|
}
|
|
|
|
type ListRecordsResponse struct {
|
|
Items []AuthorizationRecord
|
|
Total int64
|
|
Page int
|
|
Size int
|
|
}
|
|
|
|
func (s *AuthorizationService) ListRecords(ctx context.Context, req ListRecordsRequest) (*ListRecordsResponse, error) {
|
|
if req.Page <= 0 {
|
|
req.Page = 1
|
|
}
|
|
if req.PageSize <= 0 {
|
|
req.PageSize = constants.DefaultPageSize
|
|
}
|
|
if req.PageSize > constants.MaxPageSize {
|
|
req.PageSize = constants.MaxPageSize
|
|
}
|
|
|
|
opts := postgres.AuthorizationWithJoinListOptions{
|
|
EnterpriseID: req.EnterpriseID,
|
|
ICCID: req.ICCID,
|
|
AuthorizerType: req.AuthorizerType,
|
|
Status: req.Status,
|
|
StartTime: req.StartTime,
|
|
EndTime: req.EndTime,
|
|
Offset: (req.Page - 1) * req.PageSize,
|
|
Limit: req.PageSize,
|
|
}
|
|
|
|
results, total, err := s.authorizationStore.ListWithJoin(ctx, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]AuthorizationRecord, len(results))
|
|
for i, r := range results {
|
|
status := 1
|
|
if r.RevokedAt != nil {
|
|
status = 0
|
|
}
|
|
|
|
var revokedAt *string
|
|
if r.RevokedAt != nil {
|
|
t := r.RevokedAt.Format("2006-01-02 15:04:05")
|
|
revokedAt = &t
|
|
}
|
|
|
|
revokerName := ""
|
|
if r.RevokerName != nil {
|
|
revokerName = *r.RevokerName
|
|
}
|
|
|
|
items[i] = AuthorizationRecord{
|
|
ID: r.ID,
|
|
EnterpriseID: r.EnterpriseID,
|
|
EnterpriseName: r.EnterpriseName,
|
|
CardID: r.CardID,
|
|
ICCID: r.ICCID,
|
|
MSISDN: r.MSISDN,
|
|
AuthorizedBy: r.AuthorizedBy,
|
|
AuthorizerName: r.AuthorizerName,
|
|
AuthorizerType: r.AuthorizerType,
|
|
AuthorizedAt: r.AuthorizedAt.Format("2006-01-02 15:04:05"),
|
|
RevokedBy: r.RevokedBy,
|
|
RevokerName: revokerName,
|
|
RevokedAt: revokedAt,
|
|
Status: status,
|
|
Remark: r.Remark,
|
|
}
|
|
}
|
|
|
|
return &ListRecordsResponse{
|
|
Items: items,
|
|
Total: total,
|
|
Page: req.Page,
|
|
Size: req.PageSize,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthorizationService) GetRecordDetail(ctx context.Context, id uint) (*AuthorizationRecord, error) {
|
|
r, err := s.authorizationStore.GetByIDWithJoin(ctx, id)
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
status := 1
|
|
if r.RevokedAt != nil {
|
|
status = 0
|
|
}
|
|
|
|
var revokedAt *string
|
|
if r.RevokedAt != nil {
|
|
t := r.RevokedAt.Format("2006-01-02 15:04:05")
|
|
revokedAt = &t
|
|
}
|
|
|
|
revokerName := ""
|
|
if r.RevokerName != nil {
|
|
revokerName = *r.RevokerName
|
|
}
|
|
|
|
return &AuthorizationRecord{
|
|
ID: r.ID,
|
|
EnterpriseID: r.EnterpriseID,
|
|
EnterpriseName: r.EnterpriseName,
|
|
CardID: r.CardID,
|
|
ICCID: r.ICCID,
|
|
MSISDN: r.MSISDN,
|
|
AuthorizedBy: r.AuthorizedBy,
|
|
AuthorizerName: r.AuthorizerName,
|
|
AuthorizerType: r.AuthorizerType,
|
|
AuthorizedAt: r.AuthorizedAt.Format("2006-01-02 15:04:05"),
|
|
RevokedBy: r.RevokedBy,
|
|
RevokerName: revokerName,
|
|
RevokedAt: revokedAt,
|
|
Status: status,
|
|
Remark: r.Remark,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint, remark string) (*AuthorizationRecord, error) {
|
|
userID := middleware.GetUserIDFromContext(ctx)
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
|
|
if userID == 0 {
|
|
return nil, errors.New(errors.CodeUnauthorized, "用户信息无效")
|
|
}
|
|
if s.db == nil || s.accessAudit == nil {
|
|
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
|
|
}
|
|
|
|
record, err := s.authorizationStore.GetByIDWithJoin(ctx, id)
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
switch userType {
|
|
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
|
// 超级管理员和平台用户: 允许修改任意授权记录备注
|
|
case constants.UserTypeAgent:
|
|
// 代理用户: 只能修改自己创建的授权记录
|
|
if record.AuthorizedBy != userID {
|
|
err := errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
|
|
s.recordRemarkFailure(ctx, record, err)
|
|
return nil, err
|
|
}
|
|
case constants.UserTypeEnterprise:
|
|
// 企业用户: 禁止修改授权记录备注
|
|
err := errors.New(errors.CodeForbidden, "企业用户不允许修改授权记录备注")
|
|
s.recordRemarkFailure(ctx, record, err)
|
|
return nil, err
|
|
default:
|
|
err := errors.New(errors.CodeForbidden, "无权限修改授权记录备注")
|
|
s.recordRemarkFailure(ctx, record, err)
|
|
return nil, err
|
|
}
|
|
|
|
var enterprise model.Enterprise
|
|
var card model.IotCard
|
|
var auth model.EnterpriseCardAuthorization
|
|
var ownerShop *model.Shop
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&auth, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return errors.New(errors.CodeNotFound, "授权记录不存在")
|
|
}
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
|
|
}
|
|
if userType == constants.UserTypeAgent && auth.AuthorizedBy != userID {
|
|
return errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
|
|
}
|
|
if err := tx.First(&enterprise, auth.EnterpriseID).Error; err != nil {
|
|
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
|
}
|
|
if err := tx.First(&card, auth.CardID).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权卡失败")
|
|
}
|
|
if enterprise.OwnerShopID != nil {
|
|
var shop model.Shop
|
|
if err := tx.Unscoped().First(&shop, *enterprise.OwnerShopID).Error; err == nil {
|
|
ownerShop = &shop
|
|
}
|
|
}
|
|
beforeRemark := auth.Remark
|
|
if beforeRemark == remark {
|
|
return nil
|
|
}
|
|
if err := tx.Model(&model.EnterpriseCardAuthorization{}).Where("id = ?", auth.ID).Update("remark", remark).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新授权备注失败")
|
|
}
|
|
auth.Remark = remark
|
|
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
|
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注",
|
|
OperatorID: userID, Enterprise: &enterprise, Shop: ownerShop,
|
|
Cards: []accessauditapp.IotCardChange{{
|
|
Card: &card, Relation: constants.AuditResourceRelationReference,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
|
}},
|
|
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{
|
|
Authorization: &auth,
|
|
BeforeData: map[string]any{"remark": beforeRemark}, AfterData: map[string]any{"remark": remark},
|
|
}},
|
|
})
|
|
})
|
|
if err != nil {
|
|
s.recordRemarkFailure(ctx, record, err)
|
|
return nil, err
|
|
}
|
|
|
|
result, err := s.GetRecordDetail(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AuthorizationService) recordRemarkFailure(ctx context.Context, record *postgres.AuthorizationWithJoin, originalErr error) {
|
|
if record == nil {
|
|
return
|
|
}
|
|
enterprise, _ := s.enterpriseStore.GetByID(ctx, record.EnterpriseID)
|
|
card, _ := s.iotCardStore.GetByID(ctx, record.CardID)
|
|
auth := &model.EnterpriseCardAuthorization{
|
|
ID: record.ID, EnterpriseID: record.EnterpriseID, CardID: record.CardID,
|
|
AuthorizedBy: record.AuthorizedBy, AuthorizerType: record.AuthorizerType,
|
|
AuthorizedAt: record.AuthorizedAt, RevokedBy: record.RevokedBy, RevokedAt: record.RevokedAt, Remark: record.Remark,
|
|
}
|
|
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
|
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注失败",
|
|
Result: enterpriseCardFailureResult(originalErr), OperatorID: middleware.GetUserIDFromContext(ctx),
|
|
Enterprise: enterprise, Cards: []accessauditapp.IotCardChange{{Card: card}},
|
|
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{Authorization: auth}},
|
|
}, originalErr)
|
|
}
|