This commit is contained in:
@@ -169,6 +169,72 @@ func GetOpsCostSummaryHandler(service *billing.Service) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func GetFamilyUsageOverviewHandler(service *billing.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := webPrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
from, to, timezone, ok := usageOverviewQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := service.FamilyUsageOverview(
|
||||
r.Context(), principal, r.PathValue("id"), r.URL.Query().Get("device_id"), timezone, from, to,
|
||||
)
|
||||
if err != nil {
|
||||
writeBillingError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func GetOpsUsageOverviewHandler(service *billing.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := webPrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tenantID, ok := opsTenantHeader(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
from, to, timezone, ok := usageOverviewQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := service.OpsUsageOverview(
|
||||
r.Context(), principal, tenantID, r.URL.Query().Get("household_id"), r.URL.Query().Get("device_id"), timezone, from, to,
|
||||
)
|
||||
if err != nil {
|
||||
writeBillingError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func usageOverviewQuery(w http.ResponseWriter, r *http.Request) (time.Time, time.Time, string, bool) {
|
||||
from, fromErr := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
|
||||
to, toErr := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
|
||||
timezone := r.URL.Query().Get("timezone")
|
||||
if timezone == "" {
|
||||
timezone = "Asia/Shanghai"
|
||||
}
|
||||
if fromErr != nil || toErr != nil {
|
||||
writeValidation(w, r, "通话用量时间范围不正确", FieldError{Field: "from/to", Message: "必须是RFC3339时间且范围不超过366天"})
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
writeValidation(w, r, "通话用量时区不正确", FieldError{Field: "timezone", Message: "必须是有效的IANA时区"})
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
return from, to, timezone, true
|
||||
}
|
||||
|
||||
func writeBillingError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrInvalid):
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/opsconfig"
|
||||
)
|
||||
|
||||
func GetOpsContentQualityHandler(service *opsconfig.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := webPrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
|
||||
if !validUUID(tenantID) {
|
||||
writeValidation(w, r, "内容质量查询参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
|
||||
return
|
||||
}
|
||||
items, err := service.ContentQuality(r.Context(), principal, tenantID, r.URL.Query().Get("model_ref"), r.URL.Query().Get("prompt_version"))
|
||||
if err != nil {
|
||||
writeOpsConfigError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/memorytool"
|
||||
)
|
||||
|
||||
func MemoryToolSearchHandler(service *memorytool.Service, signer *memorytool.TokenSigner, limiter RateLimiter, logger *slog.Logger) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
authorization := strings.TrimSpace(request.Header.Get("Authorization"))
|
||||
if !strings.HasPrefix(authorization, "Bearer ") {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "MEMORY_TOOL_AUTHENTICATION_FAILED", "记忆工具身份验证失败")
|
||||
return
|
||||
}
|
||||
claims, err := signer.Verify(strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")))
|
||||
if err != nil {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "MEMORY_TOOL_AUTHENTICATION_FAILED", "记忆工具身份验证失败")
|
||||
return
|
||||
}
|
||||
if limiter != nil && !allowScopes(writer, request, logger, limiter, [][2]string{{"memory_tool", claims.ConversationID}}) {
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var input struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "MEMORY_TOOL_INVALID_REQUEST", "记忆检索参数不正确")
|
||||
return
|
||||
}
|
||||
input.Query = strings.TrimSpace(input.Query)
|
||||
if input.Limit == 0 {
|
||||
input.Limit = 3
|
||||
}
|
||||
if input.Query == "" || len([]rune(input.Query)) > 500 || input.Limit < 1 || input.Limit > 5 {
|
||||
WriteError(writer, request, http.StatusBadRequest, "MEMORY_TOOL_INVALID_REQUEST", "记忆检索参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := service.Search(request.Context(), claims, input.Query, input.Limit, RequestID(request.Context()))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, memorytool.ErrConversationUnavailable):
|
||||
WriteError(writer, request, http.StatusConflict, "MEMORY_TOOL_CONVERSATION_UNAVAILABLE", "本次通话当前不能使用记忆检索")
|
||||
case errors.Is(err, memorytool.ErrEngineModeUnsupported):
|
||||
WriteError(writer, request, http.StatusConflict, "MEMORY_TOOL_ENGINE_MODE_UNSUPPORTED", "本次语音处理方式未启用通话内记忆工具")
|
||||
default:
|
||||
slog.ErrorContext(request.Context(), "memory tool search failed", "conversation_id", claims.ConversationID, "error", err)
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "MEMORY_TOOL_UNAVAILABLE", Message: "记忆检索暂时不可用,请继续普通交流", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/opsdevice"
|
||||
)
|
||||
|
||||
func ListOpsDevicesHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.List(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func ListOpsDeviceOptionsHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.Options(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
HardwareRevision string `json:"hardware_revision"`
|
||||
MinimumSecurityVersion string `json:"minimum_security_version"`
|
||||
MinimumFirmwareVersion string `json:"minimum_firmware_version"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "设备登记参数不正确") {
|
||||
return
|
||||
}
|
||||
if (body.HouseholdID != "" && !validUUID(body.HouseholdID)) || (body.SubjectID != "" && !validUUID(body.SubjectID)) {
|
||||
writeValidation(w, r, "设备登记参数不正确", FieldError{Field: "assignment", Message: "家庭和长者必须是有效UUID"})
|
||||
return
|
||||
}
|
||||
result, err := service.Create(r.Context(), principal, tenantID, RequestID(r.Context()), opsdevice.CreateInput{
|
||||
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
|
||||
MinimumFirmwareVersion: body.MinimumFirmwareVersion, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(result.Device.Version))
|
||||
WriteJSON(w, r, http.StatusCreated, result)
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
expected, err := concurrency.ParseIfMatch(r)
|
||||
if err != nil {
|
||||
writeETagError(w, r, err)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Model *string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision"`
|
||||
MinimumSecurityVersion *string `json:"minimum_security_version"`
|
||||
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
|
||||
Status *string `json:"status"`
|
||||
HouseholdID *string `json:"household_id"`
|
||||
SubjectID *string `json:"subject_id"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "设备修改参数不正确") {
|
||||
return
|
||||
}
|
||||
if (body.HouseholdID != nil && *body.HouseholdID != "" && !validUUID(*body.HouseholdID)) || (body.SubjectID != nil && *body.SubjectID != "" && !validUUID(*body.SubjectID)) {
|
||||
writeValidation(w, r, "设备修改参数不正确", FieldError{Field: "assignment", Message: "家庭和长者必须是有效UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.Update(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected, opsdevice.UpdateInput{
|
||||
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
|
||||
MinimumFirmwareVersion: body.MinimumFirmwareVersion, Status: body.Status, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
expected, err := concurrency.ParseIfMatch(r)
|
||||
if err != nil {
|
||||
writeETagError(w, r, err)
|
||||
return
|
||||
}
|
||||
item, err := service.Delete(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected)
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func RotateOpsDeviceCredentialHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.RotateCredential(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusCreated, item)
|
||||
})
|
||||
}
|
||||
|
||||
func IssueOpsDeviceClaimHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.IssueDeviceClaim(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusCreated, item)
|
||||
})
|
||||
}
|
||||
|
||||
func opsDevicePrincipal(w http.ResponseWriter, r *http.Request) (principal identity.Principal, tenantID string, ok bool) {
|
||||
principal, ok = webPrincipal(w, r)
|
||||
if !ok {
|
||||
return principal, "", false
|
||||
}
|
||||
tenantID = strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
|
||||
if !validUUID(tenantID) {
|
||||
writeValidation(w, r, "运维设备请求参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须提供有效的客户组织UUID"})
|
||||
return principal, "", false
|
||||
}
|
||||
return principal, tenantID, true
|
||||
}
|
||||
|
||||
func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, opsdevice.ErrInvalid):
|
||||
writeValidation(w, r, "设备管理参数不正确", FieldError{Field: "device", Message: "设备字段或家庭/长者归属不正确"})
|
||||
case errors.Is(err, opsdevice.ErrAccessDenied):
|
||||
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "仅独立运维账号可以管理设备")
|
||||
case errors.Is(err, opsdevice.ErrNotFound):
|
||||
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "设备不存在")
|
||||
case errors.Is(err, opsdevice.ErrVersionConflict):
|
||||
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "设备信息已变化,请刷新后重试")
|
||||
case errors.Is(err, opsdevice.ErrClaimUnavailable):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_CLAIM_UNAVAILABLE", "只有已登记且尚未绑定的设备可以生成绑定码")
|
||||
default:
|
||||
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "设备管理操作失败")
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"data-terminal-cloud/internal/health"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/media"
|
||||
"data-terminal-cloud/internal/memorytool"
|
||||
"data-terminal-cloud/internal/opsaccess"
|
||||
"data-terminal-cloud/internal/opsconfig"
|
||||
"data-terminal-cloud/internal/opsdevice"
|
||||
"data-terminal-cloud/internal/saas"
|
||||
"data-terminal-cloud/internal/sharing"
|
||||
"data-terminal-cloud/internal/webevents"
|
||||
@@ -39,6 +41,7 @@ type RouterOptions struct {
|
||||
AlertService *alerts.Service
|
||||
DiagnosticService *diagnostics.Service
|
||||
OpsAccessService *opsaccess.Service
|
||||
OpsDeviceService *opsdevice.Service
|
||||
ConversationCreator ConversationCreator
|
||||
ConversationActivator ConversationActivator
|
||||
DeviceConversationStateReader DeviceConversationStateReader
|
||||
@@ -51,6 +54,8 @@ type RouterOptions struct {
|
||||
PlatformHealthCallbackSecret []byte
|
||||
PlatformHealthCallbackSkew time.Duration
|
||||
PlatformHealthProcessor PlatformHealthProcessor
|
||||
MemoryToolService *memorytool.Service
|
||||
MemoryToolSigner *memorytool.TokenSigner
|
||||
}
|
||||
|
||||
func NewRouter(options RouterOptions) http.Handler {
|
||||
@@ -77,6 +82,9 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/version", func(writer http.ResponseWriter, request *http.Request) {
|
||||
WriteJSON(writer, request, http.StatusOK, buildinfo.Current())
|
||||
})
|
||||
if options.ServiceName == "api" && options.MemoryToolService != nil && options.MemoryToolSigner != nil {
|
||||
mux.Handle("POST /internal/v1/tools/memory-search", MemoryToolSearchHandler(options.MemoryToolService, options.MemoryToolSigner, options.RateLimiter, logger))
|
||||
}
|
||||
if options.ServiceName == "api" && options.TokenVerifier != nil && options.IdentityRepository != nil {
|
||||
mux.Handle("GET /api/v1/me", BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))))
|
||||
}
|
||||
@@ -95,6 +103,7 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("PATCH /api/v1/sharing-grants/{id}/resource", authenticated(EditSharedStoryHandler(options.SharingService)))
|
||||
}
|
||||
if options.OpsConfigService != nil {
|
||||
mux.Handle("GET /api/v1/ops/content-quality", authenticated(GetOpsContentQualityHandler(options.OpsConfigService)))
|
||||
mux.Handle("GET /api/v1/ops/resource-references", authenticated(ListOpsResourceReferencesHandler(options.OpsConfigService)))
|
||||
mux.Handle("POST /api/v1/ops/resource-references", authenticated(CreateOpsResourceReferenceHandler(options.OpsConfigService)))
|
||||
mux.Handle("PATCH /api/v1/ops/resource-references/{id}/revoke", authenticated(RevokeOpsResourceReferenceHandler(options.OpsConfigService)))
|
||||
@@ -112,12 +121,14 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("POST /api/v1/ops/config-versions/{id}/rollback", authenticated(RollbackOpsConfigVersionHandler(options.OpsConfigService)))
|
||||
}
|
||||
if options.BillingService != nil {
|
||||
mux.Handle("GET /api/v1/households/{id}/usage-overview", authenticated(GetFamilyUsageOverviewHandler(options.BillingService)))
|
||||
mux.Handle("GET /api/v1/ops/price-books", authenticated(ListOpsPriceBooksHandler(options.BillingService)))
|
||||
mux.Handle("POST /api/v1/ops/price-books", authenticated(CreateOpsPriceBookHandler(options.BillingService)))
|
||||
mux.Handle("PATCH /api/v1/ops/price-books/{id}/publish", authenticated(PublishOpsPriceBookHandler(options.BillingService)))
|
||||
mux.Handle("GET /api/v1/ops/quota-policies", authenticated(ListOpsQuotaPoliciesHandler(options.BillingService)))
|
||||
mux.Handle("POST /api/v1/ops/quota-policies", authenticated(CreateOpsQuotaPolicyHandler(options.BillingService)))
|
||||
mux.Handle("GET /api/v1/ops/cost-summary", authenticated(GetOpsCostSummaryHandler(options.BillingService)))
|
||||
mux.Handle("GET /api/v1/ops/usage-overview", authenticated(GetOpsUsageOverviewHandler(options.BillingService)))
|
||||
}
|
||||
if options.DiagnosticService != nil {
|
||||
mux.Handle("GET /api/v1/ops/diagnostics/platform-health", authenticated(GetOpsPlatformHealthHandler(options.DiagnosticService)))
|
||||
@@ -144,6 +155,15 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("GET /api/v1/ops/sensitive-conversations/{id}", authenticated(GetOpsSensitiveConversationHandler(options.OpsAccessService)))
|
||||
mux.Handle("POST /api/v1/ops/sensitive-media/{id}/play", authenticated(CreateOpsSensitiveRecordingAccessHandler(options.OpsAccessService)))
|
||||
}
|
||||
if options.OpsDeviceService != nil {
|
||||
mux.Handle("GET /api/v1/ops/devices", authenticated(ListOpsDevicesHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices", authenticated(CreateOpsDeviceHandler(options.OpsDeviceService)))
|
||||
mux.Handle("GET /api/v1/ops/device-assignment-options", authenticated(ListOpsDeviceOptionsHandler(options.OpsDeviceService)))
|
||||
mux.Handle("PATCH /api/v1/ops/devices/{id}", authenticated(UpdateOpsDeviceHandler(options.OpsDeviceService)))
|
||||
mux.Handle("DELETE /api/v1/ops/devices/{id}", authenticated(DeleteOpsDeviceHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/credentials/rotate", authenticated(RotateOpsDeviceCredentialHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/claims", authenticated(IssueOpsDeviceClaimHandler(options.OpsDeviceService)))
|
||||
}
|
||||
mux.Handle("GET /api/v1/households", authenticated(ListHouseholdsHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/households", authenticated(CreateHouseholdHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/households/{id}", authenticated(GetHouseholdHandler(options.SaaSService)))
|
||||
@@ -171,6 +191,8 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("POST /api/v1/device-claims", authenticated(ClaimDeviceHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/devices", authenticated(ListManagedDevicesHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/devices/{id}", authenticated(GetManagedDeviceHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/devices/{id}/binding-impact", authenticated(GetDeviceBindingImpactHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/devices/{id}/binding-change", authenticated(ChangeDeviceBindingHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/devices/{id}/presence", authenticated(GetDevicePresenceHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/devices/{id}/twin", authenticated(GetDeviceTwinHandler(options.SaaSService)))
|
||||
mux.Handle("PATCH /api/v1/devices/{id}/twin", authenticated(PatchDeviceTwinHandler(options.SaaSService)))
|
||||
@@ -207,6 +229,7 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("GET /api/v1/subjects/{id}/stories", authenticated(ListSubjectStoriesHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/stories/{id}", authenticated(GetStoryHandler(options.SaaSService)))
|
||||
mux.Handle("PATCH /api/v1/stories/{id}", authenticated(PatchStoryHandler(options.SaaSService)))
|
||||
mux.Handle("PATCH /api/v1/stories/{id}/facts/{fact_id}", authenticated(UpdateFactCandidateHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/stories/{id}/confirm", authenticated(ConfirmStoryHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/stories/{id}/reject", authenticated(RejectStoryHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/subjects/{id}/memories", authenticated(ListSubjectMemoriesHandler(options.SaaSService)))
|
||||
|
||||
@@ -729,6 +729,9 @@ func ClaimDeviceHandler(service *saas.Service) http.Handler {
|
||||
if len(input.ClaimCode) < 16 || len(input.ClaimCode) > 128 {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "claim_code", Message: "长度必须为16至128字符"})
|
||||
}
|
||||
if validUUID(input.ClaimCode) {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "claim_code", Message: "不能填写设备编号,请输入设备屏幕或维护端生成的一次性绑定码"})
|
||||
}
|
||||
if !validUUID(input.HouseholdID) {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "household_id", Message: "必须是UUID"})
|
||||
}
|
||||
@@ -766,6 +769,86 @@ func GetManagedDeviceHandler(service *saas.Service) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeviceBindingImpactHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := resourcePrincipal(writer, request, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
action := strings.TrimSpace(request.URL.Query().Get("action"))
|
||||
targetSubjectID := strings.TrimSpace(request.URL.Query().Get("target_subject_id"))
|
||||
fieldErrors := make([]FieldError, 0)
|
||||
if action != saas.DeviceBindingActionUnbind && action != saas.DeviceBindingActionTransfer {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "action", Message: "必须是unbind或transfer"})
|
||||
}
|
||||
if action == saas.DeviceBindingActionTransfer && !validUUID(targetSubjectID) {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "target_subject_id", Message: "转移时必须提供目标长者UUID"})
|
||||
}
|
||||
if len(fieldErrors) > 0 {
|
||||
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "设备绑定影响检查参数不正确", FieldErrors: fieldErrors})
|
||||
return
|
||||
}
|
||||
result, err := service.GetDeviceBindingImpact(request.Context(), principal, request.PathValue("id"), action, targetSubjectID)
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(writer, request, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
func ChangeDeviceBindingHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := resourcePrincipal(writer, request, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Action string `json:"action"`
|
||||
ExpectedBindingID string `json:"expected_binding_id"`
|
||||
TargetSubjectID string `json:"target_subject_id"`
|
||||
Reason string `json:"reason"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
}
|
||||
if !decodeWebJSON(writer, request, &input, "设备绑定变更参数不正确") {
|
||||
return
|
||||
}
|
||||
input.Action = strings.TrimSpace(input.Action)
|
||||
input.ExpectedBindingID = strings.TrimSpace(input.ExpectedBindingID)
|
||||
input.TargetSubjectID = strings.TrimSpace(input.TargetSubjectID)
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
fieldErrors := make([]FieldError, 0)
|
||||
if input.Action != saas.DeviceBindingActionUnbind && input.Action != saas.DeviceBindingActionTransfer {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "action", Message: "必须是unbind或transfer"})
|
||||
}
|
||||
if !validUUID(input.ExpectedBindingID) {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "expected_binding_id", Message: "必须是影响检查返回的当前绑定UUID"})
|
||||
}
|
||||
if input.Action == saas.DeviceBindingActionTransfer && !validUUID(input.TargetSubjectID) {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "target_subject_id", Message: "转移时必须提供目标长者UUID"})
|
||||
}
|
||||
if len(input.Reason) < 3 || len(input.Reason) > 300 {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "reason", Message: "长度必须为3至300字符"})
|
||||
}
|
||||
if !input.Acknowledged {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "acknowledged", Message: "必须确认已核对影响范围"})
|
||||
}
|
||||
if len(fieldErrors) > 0 {
|
||||
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "设备绑定变更参数不正确", FieldErrors: fieldErrors})
|
||||
return
|
||||
}
|
||||
result, err := service.ChangeDeviceBinding(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), saas.ChangeDeviceBindingInput{
|
||||
Action: input.Action, ExpectedBindingID: input.ExpectedBindingID, TargetSubjectID: input.TargetSubjectID, Reason: input.Reason, Acknowledged: input.Acknowledged,
|
||||
})
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
func GetDevicePresenceHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := resourcePrincipal(writer, request, "id")
|
||||
@@ -1009,21 +1092,104 @@ func PatchStoryHandler(service *saas.Service) http.Handler {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Content string `json:"content"`
|
||||
Reason string `json:"reason"`
|
||||
Content string `json:"content"`
|
||||
Reason string `json:"reason"`
|
||||
ErrorType string `json:"error_type"`
|
||||
}
|
||||
if !decodeWebJSON(writer, request, &input, "故事修订参数不正确") {
|
||||
return
|
||||
}
|
||||
input.Content = strings.TrimSpace(input.Content)
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
input.ErrorType = strings.TrimSpace(input.ErrorType)
|
||||
if input.Content == "" || len([]rune(input.Content)) > 50000 || input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
||||
writeValidation(writer, request, "故事修订参数不正确",
|
||||
FieldError{Field: "content", Message: "不能为空且最长50000字符"},
|
||||
FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
||||
return
|
||||
}
|
||||
result, err := service.ReviseStory(request.Context(), principal, request.PathValue("id"), input.Content, input.Reason, RequestID(request.Context()), expected)
|
||||
if input.ErrorType != "" && !saas.IsContentErrorType(input.ErrorType) {
|
||||
writeValidation(writer, request, "故事修订参数不正确", FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
||||
return
|
||||
}
|
||||
result, err := service.ReviseStory(request.Context(), principal, request.PathValue("id"), input.Content, input.Reason, RequestID(request.Context()), expected, input.ErrorType)
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("ETag", concurrency.FormatETag(result.Version))
|
||||
WriteJSON(writer, request, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateFactCandidateHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := resourcePrincipal(writer, request, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(request.PathValue("fact_id")) {
|
||||
writeValidation(writer, request, "事实候选编号不正确", FieldError{Field: "fact_id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
expected, err := concurrency.ParseIfMatch(request)
|
||||
if err != nil {
|
||||
writeETagError(writer, request, err)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
FactType *string `json:"fact_type"`
|
||||
Content *string `json:"content"`
|
||||
Sensitivity *string `json:"sensitivity"`
|
||||
Status *string `json:"status"`
|
||||
ErrorType *string `json:"error_type"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !decodeWebJSON(writer, request, &input, "事实候选审核参数不正确") {
|
||||
return
|
||||
}
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
fieldErrors := make([]FieldError, 0)
|
||||
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
||||
}
|
||||
if input.FactType != nil {
|
||||
value := strings.TrimSpace(*input.FactType)
|
||||
input.FactType = &value
|
||||
if value == "" || len(value) > 64 {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "fact_type", Message: "不能为空且最长64字符"})
|
||||
}
|
||||
}
|
||||
if input.Content != nil {
|
||||
value := strings.TrimSpace(*input.Content)
|
||||
input.Content = &value
|
||||
if value == "" || len([]rune(value)) > 4000 {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "content", Message: "不能为空且最长4000字符"})
|
||||
}
|
||||
}
|
||||
if input.Sensitivity != nil && *input.Sensitivity != "normal" && *input.Sensitivity != "sensitive" && *input.Sensitivity != "high" {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "sensitivity", Message: "必须为normal、sensitive或high"})
|
||||
}
|
||||
if input.Status != nil && *input.Status != "CANDIDATE" && *input.Status != "CONFIRMED" && *input.Status != "REJECTED" && *input.Status != "SUPERSEDED" {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "status", Message: "事实候选状态不正确"})
|
||||
}
|
||||
if input.ErrorType != nil {
|
||||
value := strings.TrimSpace(*input.ErrorType)
|
||||
input.ErrorType = &value
|
||||
if value == "" || !saas.IsContentErrorType(value) {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
||||
}
|
||||
}
|
||||
if input.FactType == nil && input.Content == nil && input.Sensitivity == nil && input.Status == nil {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "body", Message: "至少提供一个需要修改或审核的字段"})
|
||||
}
|
||||
if len(fieldErrors) > 0 {
|
||||
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "事实候选审核参数不正确", FieldErrors: fieldErrors})
|
||||
return
|
||||
}
|
||||
result, err := service.UpdateFactCandidate(request.Context(), principal, request.PathValue("id"), request.PathValue("fact_id"), RequestID(request.Context()), expected, saas.UpdateFactCandidateInput{
|
||||
FactType: input.FactType, Content: input.Content, Sensitivity: input.Sensitivity, Status: input.Status, ErrorType: input.ErrorType, Reason: input.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
return
|
||||
@@ -1053,16 +1219,22 @@ func storyDecisionHandler(service *saas.Service, confirm bool) http.Handler {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Reason string `json:"reason"`
|
||||
Reason string `json:"reason"`
|
||||
ErrorType string `json:"error_type"`
|
||||
}
|
||||
if !decodeWebJSON(writer, request, &input, "故事审核参数不正确") {
|
||||
return
|
||||
}
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
input.ErrorType = strings.TrimSpace(input.ErrorType)
|
||||
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
||||
writeValidation(writer, request, "故事审核参数不正确", FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
||||
return
|
||||
}
|
||||
if input.ErrorType != "" && !saas.IsContentErrorType(input.ErrorType) {
|
||||
writeValidation(writer, request, "故事审核参数不正确", FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
||||
return
|
||||
}
|
||||
if confirm {
|
||||
story, memory, err := service.ConfirmStory(request.Context(), principal, request.PathValue("id"), input.Reason, RequestID(request.Context()), expected)
|
||||
if err != nil {
|
||||
@@ -1073,7 +1245,7 @@ func storyDecisionHandler(service *saas.Service, confirm bool) http.Handler {
|
||||
WriteJSON(writer, request, http.StatusOK, map[string]any{"story": story, "memory": memory})
|
||||
return
|
||||
}
|
||||
story, err := service.RejectStory(request.Context(), principal, request.PathValue("id"), input.Reason, RequestID(request.Context()), expected)
|
||||
story, err := service.RejectStory(request.Context(), principal, request.PathValue("id"), input.Reason, RequestID(request.Context()), expected, input.ErrorType)
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
return
|
||||
@@ -1131,9 +1303,10 @@ func PatchMemoryHandler(service *saas.Service) http.Handler {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Content *string `json:"content"`
|
||||
Status *string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Content *string `json:"content"`
|
||||
Status *string `json:"status"`
|
||||
ErrorType *string `json:"error_type"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !decodeWebJSON(writer, request, &input, "记忆修改参数不正确") {
|
||||
return
|
||||
@@ -1153,6 +1326,13 @@ func PatchMemoryHandler(service *saas.Service) http.Handler {
|
||||
if input.Status != nil && !oneOf(*input.Status, "ACTIVE", "DISABLED") {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "status", Message: "只允许ACTIVE或DISABLED"})
|
||||
}
|
||||
if input.ErrorType != nil {
|
||||
value := strings.TrimSpace(*input.ErrorType)
|
||||
input.ErrorType = &value
|
||||
if value == "" || !saas.IsContentErrorType(value) {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "error_type", Message: "质量问题类型不正确"})
|
||||
}
|
||||
}
|
||||
if input.Reason == "" || len([]rune(input.Reason)) > 500 {
|
||||
fieldErrors = append(fieldErrors, FieldError{Field: "reason", Message: "不能为空且最长500字符"})
|
||||
}
|
||||
@@ -1160,7 +1340,7 @@ func PatchMemoryHandler(service *saas.Service) http.Handler {
|
||||
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "记忆修改参数不正确", FieldErrors: fieldErrors})
|
||||
return
|
||||
}
|
||||
result, err := service.UpdateMemory(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), expected, saas.UpdateMemoryInput{Content: input.Content, Status: input.Status, Reason: input.Reason})
|
||||
result, err := service.UpdateMemory(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()), expected, saas.UpdateMemoryInput{Content: input.Content, Status: input.Status, ErrorType: input.ErrorType, Reason: input.Reason})
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
return
|
||||
@@ -1233,15 +1413,23 @@ func CreatePrivacyRequestHandler(service *saas.Service) http.Handler {
|
||||
Reason string `json:"reason"`
|
||||
Confirmation string `json:"confirmation"`
|
||||
Scope struct {
|
||||
Categories []string `json:"categories"`
|
||||
Categories []string `json:"categories"`
|
||||
ConsentTypes []string `json:"consent_types"`
|
||||
Corrections struct {
|
||||
PreferredName *string `json:"preferred_name"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
ProfileData *map[string]any `json:"profile_data"`
|
||||
ConversationPreferences *map[string]any `json:"conversation_preferences"`
|
||||
} `json:"corrections"`
|
||||
} `json:"scope"`
|
||||
}
|
||||
if !decodeWebJSON(writer, request, &input, "隐私请求参数不正确") {
|
||||
return
|
||||
}
|
||||
input.RequestType = strings.ToUpper(strings.TrimSpace(input.RequestType))
|
||||
if input.RequestType != "ACCESS" && input.RequestType != "DELETE" {
|
||||
writeValidation(writer, request, "隐私请求参数不正确", FieldError{Field: "request_type", Message: "必须为ACCESS或DELETE"})
|
||||
if input.RequestType != "ACCESS" && input.RequestType != "CORRECTION" && input.RequestType != "CONSENT_WITHDRAWAL" && input.RequestType != "DELETE" {
|
||||
writeValidation(writer, request, "隐私请求参数不正确", FieldError{Field: "request_type", Message: "必须为ACCESS、CORRECTION、CONSENT_WITHDRAWAL或DELETE"})
|
||||
return
|
||||
}
|
||||
if input.RequestType == "DELETE" {
|
||||
@@ -1274,6 +1462,68 @@ func CreatePrivacyRequestHandler(service *saas.Service) http.Handler {
|
||||
if len(categories) > 0 {
|
||||
scope["categories"] = categories
|
||||
}
|
||||
if input.RequestType == "CORRECTION" {
|
||||
corrections := map[string]any{}
|
||||
if input.Scope.Corrections.PreferredName != nil {
|
||||
value := strings.TrimSpace(*input.Scope.Corrections.PreferredName)
|
||||
if len([]rune(value)) < 1 || len([]rune(value)) > 120 {
|
||||
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections.preferred_name", Message: "长度必须为1至120字符"})
|
||||
return
|
||||
}
|
||||
corrections["preferred_name"] = value
|
||||
}
|
||||
if input.Scope.Corrections.Language != nil {
|
||||
value := strings.TrimSpace(*input.Scope.Corrections.Language)
|
||||
if len(value) < 2 || len(value) > 20 {
|
||||
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections.language", Message: "长度必须为2至20字符"})
|
||||
return
|
||||
}
|
||||
corrections["language"] = value
|
||||
}
|
||||
if input.Scope.Corrections.Timezone != nil {
|
||||
value := strings.TrimSpace(*input.Scope.Corrections.Timezone)
|
||||
if _, err := time.LoadLocation(value); err != nil {
|
||||
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections.timezone", Message: "必须是有效IANA时区"})
|
||||
return
|
||||
}
|
||||
corrections["timezone"] = value
|
||||
}
|
||||
if input.Scope.Corrections.ProfileData != nil {
|
||||
corrections["profile_data"] = *input.Scope.Corrections.ProfileData
|
||||
}
|
||||
if input.Scope.Corrections.ConversationPreferences != nil {
|
||||
corrections["conversation_preferences"] = *input.Scope.Corrections.ConversationPreferences
|
||||
}
|
||||
if len(corrections) == 0 {
|
||||
writeValidation(writer, request, "更正申请参数不正确", FieldError{Field: "scope.corrections", Message: "至少提供一个需要更正的字段"})
|
||||
return
|
||||
}
|
||||
scope = map[string]any{"corrections": corrections}
|
||||
}
|
||||
if input.RequestType == "CONSENT_WITHDRAWAL" {
|
||||
allowedConsentTypes := map[string]bool{
|
||||
saas.ConsentTranscriptStorage: true, saas.ConsentRawAudioStorage: true, saas.ConsentStoryExtraction: true,
|
||||
saas.ConsentPersonalMemory: true, saas.ConsentFamilySharing: true,
|
||||
}
|
||||
seenConsentTypes := map[string]bool{}
|
||||
consentTypes := make([]string, 0, len(input.Scope.ConsentTypes))
|
||||
for index, raw := range input.Scope.ConsentTypes {
|
||||
consentType := strings.TrimSpace(raw)
|
||||
if !allowedConsentTypes[consentType] {
|
||||
writeValidation(writer, request, "授权撤回申请参数不正确", FieldError{Field: fmt.Sprintf("scope.consent_types[%d]", index), Message: "授权类型不正确"})
|
||||
return
|
||||
}
|
||||
if !seenConsentTypes[consentType] {
|
||||
seenConsentTypes[consentType] = true
|
||||
consentTypes = append(consentTypes, consentType)
|
||||
}
|
||||
}
|
||||
if len(consentTypes) == 0 {
|
||||
writeValidation(writer, request, "授权撤回申请参数不正确", FieldError{Field: "scope.consent_types", Message: "至少选择一种授权"})
|
||||
return
|
||||
}
|
||||
scope = map[string]any{"consent_types": consentTypes}
|
||||
}
|
||||
result, created, err := service.CreatePrivacyRequest(request.Context(), principal, request.PathValue("id"), idempotencyKey, RequestID(request.Context()), saas.CreatePrivacyRequestInput{RequestType: input.RequestType, Scope: scope, Reason: input.Reason, Confirmation: input.Confirmation})
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
@@ -1633,6 +1883,10 @@ func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_CLAIM_ALREADY_USED", "设备绑定码已经用于其他绑定")
|
||||
case errors.Is(err, saas.ErrDeviceAlreadyBound):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_ALREADY_BOUND", "设备已经存在有效绑定")
|
||||
case errors.Is(err, saas.ErrDeviceBindingBlocked):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_BINDING_BLOCKED", "设备仍有活动通话、未完成命令或固件任务,请先处理阻断项")
|
||||
case errors.Is(err, saas.ErrDeviceBindingStale):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_BINDING_STALE", "设备绑定已发生变化,请重新执行影响检查")
|
||||
case errors.Is(err, saas.ErrIdempotencyConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已用于不同的请求内容")
|
||||
case errors.Is(err, saas.ErrSubjectDeleteConflict):
|
||||
@@ -1665,6 +1919,8 @@ func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error
|
||||
WriteError(writer, request, http.StatusConflict, "PERSONALIZED_MEMORY_CONSENT_REQUIRED", "生成个性化记忆前必须取得当前有效授权")
|
||||
case errors.Is(err, saas.ErrReviewStateConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "STORY_REVIEW_STATE_CONFLICT", "当前故事审核状态不允许此操作")
|
||||
case errors.Is(err, saas.ErrFactReviewStateConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "FACT_REVIEW_STATE_CONFLICT", "当前事实候选状态不允许此操作")
|
||||
case errors.Is(err, pagination.ErrInvalidCursor):
|
||||
writeValidation(writer, request, "分页游标无效", FieldError{Field: "cursor", Message: "游标无效、已损坏或不是本服务签发"})
|
||||
default:
|
||||
|
||||
@@ -103,6 +103,15 @@ func TestDecodeAndResourcePrincipal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimDeviceRejectsDeviceIdentifierAsClaimCode(t *testing.T) {
|
||||
request := authenticatedRequest(http.MethodPost, "/api/v1/device-claims", `{"claim_code":"10000000-0000-0000-0000-000000000512","household_id":"10000000-0000-0000-0000-000000000211","subject_id":"10000000-0000-0000-0000-000000000411"}`, identity.Principal{Issuer: "test", Subject: "owner"})
|
||||
recorder := httptest.NewRecorder()
|
||||
ClaimDeviceHandler(nil).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "不能填写设备编号") {
|
||||
t.Fatalf("device identifier claim status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaaSErrorAndETagMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
|
||||
Reference in New Issue
Block a user