From afe67d704ef562b668b6806fcedcb96d207eb4d7 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 27 Apr 2026 17:22:56 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E7=BD=91=E7=BB=9C=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 57 + README.md | 306 + backend/Dockerfile | 47 + backend/cmd/check_db/main.go | 61 + backend/cmd/main.go | 164 + backend/cmd/scripts/init_extension_docs.go | 936 ++ backend/cmd/scripts/init_extension_docs.sql | 852 ++ backend/cmd/scripts/migrate_to_balance.go | 87 + backend/config.yaml | 26 + backend/docs/API.md | 800 ++ backend/docs/EXTENSION_API.md | 630 ++ backend/go.mod | 72 + backend/internal/config/config.go | 76 + backend/internal/database/database.go | 713 ++ backend/internal/middleware/app_crypto.go | 47 + backend/internal/middleware/crypto.go | 156 + backend/internal/middleware/middleware.go | 234 + backend/internal/middleware/usage_limit.go | 156 + backend/internal/model/models.go | 928 ++ backend/internal/router/app/account.go | 185 + backend/internal/router/app/app.go | 15 + .../internal/router/app/app_crypto_helper.go | 100 + backend/internal/router/app/auth.go | 905 ++ backend/internal/router/app/cloud.go | 918 ++ backend/internal/router/app/devices.go | 390 + backend/internal/router/app/dynamic.go | 427 + backend/internal/router/app/info.go | 146 + backend/internal/router/app/payment.go | 217 + .../router/app/response_encryption.go | 73 + .../internal/router/developer/agent_apps.go | 776 ++ backend/internal/router/developer/agents.go | 585 ++ .../router/developer/announcements.go | 190 + .../internal/router/developer/applications.go | 1057 +++ backend/internal/router/developer/cards.go | 969 ++ backend/internal/router/developer/cloud.go | 827 ++ .../internal/router/developer/dashboard.go | 169 + .../internal/router/developer/developer.go | 58 + backend/internal/router/developer/devices.go | 506 + backend/internal/router/developer/dynamic.go | 805 ++ backend/internal/router/developer/email.go | 481 + .../internal/router/developer/extension.go | 650 ++ backend/internal/router/developer/finance.go | 526 ++ backend/internal/router/developer/logs.go | 84 + backend/internal/router/developer/orders.go | 282 + backend/internal/router/developer/profile.go | 383 + backend/internal/router/developer/tickets.go | 413 + backend/internal/router/developer/usage.go | 296 + backend/internal/router/developer/users.go | 673 ++ backend/internal/router/developer/versions.go | 565 ++ .../internal/router/extension/extension.go | 749 ++ backend/internal/router/frontend/frontend.go | 805 ++ .../router/frontend/payment_callback.go | 115 + backend/internal/router/router.go | 71 + backend/internal/service/auth.go | 342 + backend/internal/service/email.go | 154 + backend/internal/service/log_service.go | 151 + backend/internal/service/package_limit.go | 192 + backend/internal/service/payment/bepusdt.go | 211 + backend/internal/service/payment/payment.go | 105 + backend/internal/service/setting.go | 127 + backend/internal/service/sms.go | 133 + backend/internal/service/usage_alert.go | 280 + backend/internal/utils/webhook/trigger.go | 77 + .../add_card_type_recharge_fields.sql | 16 + .../migrations/add_file_fields_to_cloud.sql | 11 + .../add_file_fields_to_user_variables.sql | 5 + backend/migrations/migrate_to_balance.sql | 72 + backend/pkg/crypto/crypto.go | 229 + backend/pkg/jwt/jwt.go | 75 + backend/pkg/logger/logger.go | 15 + backend/pkg/response/response.go | 103 + backend/pkg/utils/utils.go | 42 + backend/scripts/check_all_deleted.go | 43 + .../scripts/check_deleted_dynamic_codes.go | 39 + backend/scripts/check_dynamic_codes.go | 36 + backend/scripts/check_i18n_data.go | 44 + backend/scripts/check_packages.go | 33 + backend/scripts/create_cloud_function_doc.go | 977 ++ backend/scripts/create_manual_deduct_doc.go | 349 + backend/scripts/fix_package_data.go | 63 + backend/scripts/init_packages.go | 113 + backend/scripts/list_docs.go | 23 + backend/scripts/migrate_docs_i18n.go | 56 + backend/scripts/translate_docs_i18n.go | 178 + backend/scripts/translate_packages_i18n.go | 93 + backend/scripts/update_api_docs.go | 794 ++ backend/scripts/update_doc_translations.go | 187 + backend/scripts/update_dynamic_code_doc.go | 302 + backend/scripts/update_dynamic_code_docs.go | 1000 ++ backend/scripts/update_to_cloud_function.go | 30 + database/schema.sql | 283 + database/seeds.sql | 111 + docker-compose.yml | 75 + frontend/.env.example | 5 + frontend/.gitignore | 35 + frontend/components.json | 20 + frontend/eslint.config.mjs | 39 + frontend/index.html | 31 + frontend/package.json | 105 + frontend/pnpm-lock.yaml | 8185 +++++++++++++++++ frontend/public/_redirects | 1 + frontend/public/logo-black.svg | 1 + frontend/public/logo.svg | 1 + frontend/src/App.vue | 25 + frontend/src/assets/chart-theme.css | 118 + frontend/src/assets/icons/arrow-dark.svg | 4 + frontend/src/assets/icons/arrow-light.svg | 5 + frontend/src/assets/index.css | 154 + frontend/src/assets/nprogress.css | 61 + frontend/src/assets/scrollbar.css | 25 + frontend/src/assets/themes.css | 559 ++ .../app-sidebar/data/sidebar-data.ts | 41 + frontend/src/components/app-sidebar/index.vue | 24 + .../src/components/app-sidebar/nav-footer.vue | 108 + .../components/app-sidebar/nav-team-add.vue | 91 + .../src/components/app-sidebar/nav-team.vue | 136 + .../components/app-sidebar/team-switcher.vue | 31 + frontend/src/components/app-sidebar/types.ts | 43 + .../app-sidebar/validators/team.validator.ts | 17 + .../command-change-theme.vue | 26 + .../command-item-has-icon.vue | 18 + .../command-menu-panel/command-to-page.vue | 52 + .../components/command-menu-panel/index.vue | 66 + frontend/src/components/confirm-dialog.vue | 71 + frontend/src/components/country-select.vue | 138 + frontend/src/components/custom-error.vue | 34 + .../custom-theme/content-layout.vue | 30 + .../components/custom-theme/custom-color.vue | 40 + .../components/custom-theme/custom-radius.vue | 33 + .../custom-theme/custom-theme-title.vue | 14 + .../components/custom-theme/theme-popover.vue | 34 + .../custom-theme/toggle-color-mode.vue | 42 + .../components/data-table/bulk-actions.vue | 90 + .../components/data-table/column-header.vue | 93 + .../src/components/data-table/data-table.vue | 116 + .../data-table/date-range-filter.vue | 122 + .../components/data-table/faceted-filter.vue | 121 + .../src/components/data-table/radio-cell.vue | 35 + .../components/data-table/single-filter.vue | 97 + .../components/data-table/table-columns.ts | 49 + .../components/data-table/table-loading.vue | 8 + .../data-table/table-pagination.vue | 222 + frontend/src/components/data-table/types.ts | 22 + .../data-table/use-generate-vue-table.ts | 119 + .../components/data-table/view-options.vue | 72 + .../components/developer-sidebar/index.vue | 155 + .../developer-sidebar/nav-footer.vue | 162 + .../components/global-layout/basic-header.vue | 27 + .../components/global-layout/basic-page.vue | 20 + .../src/components/global-layout/index.ts | 6 + .../global-layout/two-col-aside.vue | 48 + .../src/components/global-layout/two-col.vue | 19 + .../src/components/global-layout/types.ts | 19 + .../components/inspira-ui/flickering-grid.vue | 184 + .../components/inspira-ui/glowing-effect.vue | 197 + .../components/inspira-ui/marquee/index.vue | 76 + .../inspira-ui/marquee/review-card.vue | 31 + .../components/inspira-ui/ripple/circle.vue | 46 + .../inspira-ui/ripple/container.vue | 36 + .../components/inspira-ui/ripple/index.vue | 36 + frontend/src/components/language-change.vue | 60 + frontend/src/components/loading.vue | 3 + .../marketing-layout/the-footer.vue | 100 + .../marketing-layout/the-header.vue | 643 ++ frontend/src/components/no-result-found.vue | 22 + .../src/components/prop-ui/modal/Modal.vue | 28 + .../components/prop-ui/modal/ModalClose.vue | 21 + .../components/prop-ui/modal/ModalContent.vue | 29 + .../prop-ui/modal/ModalDescription.vue | 30 + .../components/prop-ui/modal/ModalFooter.vue | 22 + .../components/prop-ui/modal/ModalHeader.vue | 22 + .../components/prop-ui/modal/ModalTitle.vue | 30 + .../components/prop-ui/modal/ModalTrigger.vue | 21 + .../src/components/prop-ui/modal/index.ts | 10 + .../src/components/prop-ui/modal/use-modal.ts | 31 + frontend/src/components/sign-in-button.vue | 17 + frontend/src/components/sign-up-button.vue | 17 + frontend/src/components/sort-select/index.vue | 38 + frontend/src/components/sort-select/types.ts | 1 + frontend/src/components/sva-ui/copy/Copy.vue | 72 + frontend/src/components/sva-ui/copy/index.ts | 22 + .../sva-ui/inline-tip/InlineTip.vue | 39 + .../src/components/sva-ui/inline-tip/index.ts | 24 + .../components/sva-ui/status-badge/Status.vue | 33 + .../sva-ui/status-badge/StatusBadge.vue | 26 + .../components/sva-ui/status-badge/index.ts | 35 + frontend/src/components/toggle-theme.vue | 25 + .../src/components/ui/accordion/Accordion.vue | 18 + .../ui/accordion/AccordionContent.vue | 23 + .../components/ui/accordion/AccordionItem.vue | 24 + .../ui/accordion/AccordionTrigger.vue | 37 + frontend/src/components/ui/accordion/index.ts | 4 + .../ui/alert-dialog/AlertDialog.vue | 15 + .../ui/alert-dialog/AlertDialogAction.vue | 18 + .../ui/alert-dialog/AlertDialogCancel.vue | 25 + .../ui/alert-dialog/AlertDialogContent.vue | 44 + .../alert-dialog/AlertDialogDescription.vue | 23 + .../ui/alert-dialog/AlertDialogFooter.vue | 22 + .../ui/alert-dialog/AlertDialogHeader.vue | 17 + .../ui/alert-dialog/AlertDialogTitle.vue | 21 + .../ui/alert-dialog/AlertDialogTrigger.vue | 12 + .../src/components/ui/alert-dialog/index.ts | 9 + frontend/src/components/ui/alert/Alert.vue | 21 + .../components/ui/alert/AlertDescription.vue | 17 + .../src/components/ui/alert/AlertTitle.vue | 17 + frontend/src/components/ui/alert/index.ts | 24 + .../ui/aspect-ratio/AspectRatio.vue | 16 + .../src/components/ui/aspect-ratio/index.ts | 1 + frontend/src/components/ui/avatar/Avatar.vue | 18 + .../components/ui/avatar/AvatarFallback.vue | 21 + .../src/components/ui/avatar/AvatarImage.vue | 16 + frontend/src/components/ui/avatar/index.ts | 3 + frontend/src/components/ui/badge/Badge.vue | 26 + frontend/src/components/ui/badge/index.ts | 26 + .../components/ui/breadcrumb/Breadcrumb.vue | 17 + .../ui/breadcrumb/BreadcrumbEllipsis.vue | 23 + .../ui/breadcrumb/BreadcrumbItem.vue | 17 + .../ui/breadcrumb/BreadcrumbLink.vue | 21 + .../ui/breadcrumb/BreadcrumbList.vue | 17 + .../ui/breadcrumb/BreadcrumbPage.vue | 20 + .../ui/breadcrumb/BreadcrumbSeparator.vue | 22 + .../src/components/ui/breadcrumb/index.ts | 7 + .../ui/button-group/ButtonGroup.vue | 22 + .../ui/button-group/ButtonGroupSeparator.vue | 24 + .../ui/button-group/ButtonGroupText.vue | 29 + .../src/components/ui/button-group/index.ts | 25 + frontend/src/components/ui/button/Button.vue | 29 + frontend/src/components/ui/button/index.ts | 38 + .../src/components/ui/calendar/Calendar.vue | 160 + .../components/ui/calendar/CalendarCell.vue | 23 + .../ui/calendar/CalendarCellTrigger.vue | 39 + .../components/ui/calendar/CalendarGrid.vue | 23 + .../ui/calendar/CalendarGridBody.vue | 15 + .../ui/calendar/CalendarGridHead.vue | 16 + .../ui/calendar/CalendarGridRow.vue | 22 + .../ui/calendar/CalendarHeadCell.vue | 23 + .../components/ui/calendar/CalendarHeader.vue | 23 + .../ui/calendar/CalendarHeading.vue | 30 + .../ui/calendar/CalendarNextButton.vue | 31 + .../ui/calendar/CalendarPrevButton.vue | 31 + frontend/src/components/ui/calendar/index.ts | 14 + frontend/src/components/ui/card/Card.vue | 22 + .../src/components/ui/card/CardAction.vue | 17 + .../src/components/ui/card/CardContent.vue | 17 + .../components/ui/card/CardDescription.vue | 17 + .../src/components/ui/card/CardFooter.vue | 17 + .../src/components/ui/card/CardHeader.vue | 17 + frontend/src/components/ui/card/CardTitle.vue | 17 + frontend/src/components/ui/card/index.ts | 7 + .../src/components/ui/carousel/Carousel.vue | 53 + .../ui/carousel/CarouselContent.vue | 33 + .../components/ui/carousel/CarouselItem.vue | 24 + .../components/ui/carousel/CarouselNext.vue | 41 + .../ui/carousel/CarouselPrevious.vue | 41 + frontend/src/components/ui/carousel/index.ts | 10 + .../src/components/ui/carousel/interface.ts | 26 + .../src/components/ui/carousel/useCarousel.ts | 56 + .../components/ui/chart/ChartContainer.vue | 59 + .../ui/chart/ChartLegendContent.vue | 60 + .../src/components/ui/chart/ChartStyle.vue | 42 + .../ui/chart/ChartTooltipContent.vue | 105 + frontend/src/components/ui/chart/index.ts | 29 + frontend/src/components/ui/chart/utils.ts | 43 + .../src/components/ui/checkbox/Checkbox.vue | 35 + frontend/src/components/ui/checkbox/index.ts | 1 + .../components/ui/collapsible/Collapsible.vue | 19 + .../ui/collapsible/CollapsibleContent.vue | 15 + .../ui/collapsible/CollapsibleTrigger.vue | 15 + .../src/components/ui/collapsible/index.ts | 3 + .../src/components/ui/combobox/Combobox.vue | 19 + .../components/ui/combobox/ComboboxAnchor.vue | 23 + .../components/ui/combobox/ComboboxEmpty.vue | 21 + .../components/ui/combobox/ComboboxGroup.vue | 27 + .../components/ui/combobox/ComboboxInput.vue | 42 + .../components/ui/combobox/ComboboxItem.vue | 24 + .../ui/combobox/ComboboxItemIndicator.vue | 23 + .../components/ui/combobox/ComboboxList.vue | 33 + .../ui/combobox/ComboboxSeparator.vue | 21 + .../ui/combobox/ComboboxTrigger.vue | 24 + .../ui/combobox/ComboboxViewport.vue | 23 + frontend/src/components/ui/combobox/index.ts | 12 + .../src/components/ui/command/Command.vue | 87 + .../components/ui/command/CommandDialog.vue | 31 + .../components/ui/command/CommandEmpty.vue | 27 + .../components/ui/command/CommandGroup.vue | 45 + .../components/ui/command/CommandInput.vue | 39 + .../src/components/ui/command/CommandItem.vue | 76 + .../src/components/ui/command/CommandList.vue | 25 + .../ui/command/CommandSeparator.vue | 21 + .../components/ui/command/CommandShortcut.vue | 17 + frontend/src/components/ui/command/index.ts | 25 + .../ui/context-menu/ContextMenu.vue | 18 + .../context-menu/ContextMenuCheckboxItem.vue | 39 + .../ui/context-menu/ContextMenuContent.vue | 37 + .../ui/context-menu/ContextMenuGroup.vue | 15 + .../ui/context-menu/ContextMenuItem.vue | 38 + .../ui/context-menu/ContextMenuLabel.vue | 22 + .../ui/context-menu/ContextMenuPortal.vue | 15 + .../ui/context-menu/ContextMenuRadioGroup.vue | 21 + .../ui/context-menu/ContextMenuRadioItem.vue | 39 + .../ui/context-menu/ContextMenuSeparator.vue | 21 + .../ui/context-menu/ContextMenuShortcut.vue | 17 + .../ui/context-menu/ContextMenuSub.vue | 21 + .../ui/context-menu/ContextMenuSubContent.vue | 32 + .../ui/context-menu/ContextMenuSubTrigger.vue | 32 + .../ui/context-menu/ContextMenuTrigger.vue | 17 + .../src/components/ui/context-menu/index.ts | 14 + .../components/ui/date-picker/DatePicker.vue | 87 + .../ui/date-picker/DateTimePicker.vue | 141 + .../components/ui/date-picker/TimePicker.vue | 72 + .../src/components/ui/date-picker/index.ts | 3 + frontend/src/components/ui/dialog/Dialog.vue | 19 + .../src/components/ui/dialog/DialogClose.vue | 15 + .../components/ui/dialog/DialogContent.vue | 53 + .../ui/dialog/DialogDescription.vue | 23 + .../src/components/ui/dialog/DialogFooter.vue | 15 + .../src/components/ui/dialog/DialogHeader.vue | 17 + .../components/ui/dialog/DialogOverlay.vue | 21 + .../ui/dialog/DialogScrollContent.vue | 59 + .../src/components/ui/dialog/DialogTitle.vue | 23 + .../components/ui/dialog/DialogTrigger.vue | 15 + frontend/src/components/ui/dialog/index.ts | 10 + frontend/src/components/ui/drawer/Drawer.vue | 23 + .../src/components/ui/drawer/DrawerClose.vue | 15 + .../components/ui/drawer/DrawerContent.vue | 38 + .../ui/drawer/DrawerDescription.vue | 21 + .../src/components/ui/drawer/DrawerFooter.vue | 17 + .../src/components/ui/drawer/DrawerHeader.vue | 17 + .../components/ui/drawer/DrawerOverlay.vue | 19 + .../src/components/ui/drawer/DrawerTitle.vue | 21 + .../components/ui/drawer/DrawerTrigger.vue | 15 + frontend/src/components/ui/drawer/index.ts | 9 + .../ui/dropdown-menu/DropdownMenu.vue | 19 + .../DropdownMenuCheckboxItem.vue | 39 + .../ui/dropdown-menu/DropdownMenuContent.vue | 39 + .../ui/dropdown-menu/DropdownMenuGroup.vue | 15 + .../ui/dropdown-menu/DropdownMenuItem.vue | 31 + .../ui/dropdown-menu/DropdownMenuLabel.vue | 23 + .../dropdown-menu/DropdownMenuRadioGroup.vue | 21 + .../dropdown-menu/DropdownMenuRadioItem.vue | 40 + .../dropdown-menu/DropdownMenuSeparator.vue | 23 + .../ui/dropdown-menu/DropdownMenuShortcut.vue | 17 + .../ui/dropdown-menu/DropdownMenuSub.vue | 18 + .../dropdown-menu/DropdownMenuSubContent.vue | 27 + .../dropdown-menu/DropdownMenuSubTrigger.vue | 30 + .../ui/dropdown-menu/DropdownMenuTrigger.vue | 17 + .../src/components/ui/dropdown-menu/index.ts | 16 + frontend/src/components/ui/empty/Empty.vue | 20 + .../src/components/ui/empty/EmptyContent.vue | 20 + .../components/ui/empty/EmptyDescription.vue | 20 + .../src/components/ui/empty/EmptyHeader.vue | 20 + .../src/components/ui/empty/EmptyMedia.vue | 21 + .../src/components/ui/empty/EmptyTitle.vue | 17 + frontend/src/components/ui/empty/index.ts | 26 + frontend/src/components/ui/field/Field.vue | 25 + .../src/components/ui/field/FieldContent.vue | 20 + .../components/ui/field/FieldDescription.vue | 22 + .../src/components/ui/field/FieldError.vue | 53 + .../src/components/ui/field/FieldGroup.vue | 20 + .../src/components/ui/field/FieldLabel.vue | 23 + .../src/components/ui/field/FieldLegend.vue | 24 + .../components/ui/field/FieldSeparator.vue | 29 + frontend/src/components/ui/field/FieldSet.vue | 21 + .../src/components/ui/field/FieldTitle.vue | 20 + frontend/src/components/ui/field/index.ts | 39 + .../src/components/ui/form/FormControl.vue | 17 + .../components/ui/form/FormDescription.vue | 21 + frontend/src/components/ui/form/FormItem.vue | 23 + frontend/src/components/ui/form/FormLabel.vue | 25 + .../src/components/ui/form/FormMessage.vue | 23 + frontend/src/components/ui/form/index.ts | 7 + .../src/components/ui/form/injectionKeys.ts | 4 + .../src/components/ui/form/useFormField.ts | 30 + .../components/ui/hover-card/HoverCard.vue | 19 + .../ui/hover-card/HoverCardContent.vue | 43 + .../ui/hover-card/HoverCardTrigger.vue | 15 + .../src/components/ui/hover-card/index.ts | 3 + .../components/ui/input-group/InputGroup.vue | 35 + .../ui/input-group/InputGroupAddon.vue | 36 + .../ui/input-group/InputGroupButton.vue | 21 + .../ui/input-group/InputGroupInput.vue | 19 + .../ui/input-group/InputGroupText.vue | 19 + .../ui/input-group/InputGroupTextarea.vue | 19 + .../src/components/ui/input-group/index.ts | 59 + .../src/components/ui/input-otp/InputOTP.vue | 28 + .../components/ui/input-otp/InputOTPGroup.vue | 22 + .../ui/input-otp/InputOTPSeparator.vue | 21 + .../components/ui/input-otp/InputOTPSlot.vue | 32 + frontend/src/components/ui/input-otp/index.ts | 4 + frontend/src/components/ui/input/Input.vue | 33 + frontend/src/components/ui/input/index.ts | 1 + frontend/src/components/ui/item/Item.vue | 27 + .../src/components/ui/item/ItemActions.vue | 17 + .../src/components/ui/item/ItemContent.vue | 17 + .../components/ui/item/ItemDescription.vue | 21 + .../src/components/ui/item/ItemFooter.vue | 17 + frontend/src/components/ui/item/ItemGroup.vue | 18 + .../src/components/ui/item/ItemHeader.vue | 17 + frontend/src/components/ui/item/ItemMedia.vue | 21 + .../src/components/ui/item/ItemSeparator.vue | 18 + frontend/src/components/ui/item/ItemTitle.vue | 17 + frontend/src/components/ui/item/index.ts | 54 + frontend/src/components/ui/kbd/Kbd.vue | 21 + frontend/src/components/ui/kbd/KbdGroup.vue | 17 + frontend/src/components/ui/kbd/index.ts | 2 + frontend/src/components/ui/label/Label.vue | 26 + frontend/src/components/ui/label/index.ts | 1 + .../src/components/ui/menubar/Menubar.vue | 33 + .../ui/menubar/MenubarCheckboxItem.vue | 39 + .../components/ui/menubar/MenubarContent.vue | 45 + .../components/ui/menubar/MenubarGroup.vue | 15 + .../src/components/ui/menubar/MenubarItem.vue | 36 + .../components/ui/menubar/MenubarLabel.vue | 20 + .../src/components/ui/menubar/MenubarMenu.vue | 15 + .../ui/menubar/MenubarRadioGroup.vue | 21 + .../ui/menubar/MenubarRadioItem.vue | 39 + .../ui/menubar/MenubarSeparator.vue | 21 + .../components/ui/menubar/MenubarShortcut.vue | 17 + .../src/components/ui/menubar/MenubarSub.vue | 24 + .../ui/menubar/MenubarSubContent.vue | 39 + .../ui/menubar/MenubarSubTrigger.vue | 28 + .../components/ui/menubar/MenubarTrigger.vue | 28 + frontend/src/components/ui/menubar/index.ts | 15 + .../ui/native-select/NativeSelect.vue | 50 + .../ui/native-select/NativeSelectOptGroup.vue | 12 + .../ui/native-select/NativeSelectOption.vue | 15 + .../src/components/ui/native-select/index.ts | 3 + .../ui/navigation-menu/NavigationMenu.vue | 35 + .../navigation-menu/NavigationMenuContent.vue | 31 + .../NavigationMenuIndicator.vue | 23 + .../ui/navigation-menu/NavigationMenuItem.vue | 21 + .../ui/navigation-menu/NavigationMenuLink.vue | 26 + .../ui/navigation-menu/NavigationMenuList.vue | 28 + .../navigation-menu/NavigationMenuTrigger.vue | 32 + .../NavigationMenuViewport.vue | 31 + .../components/ui/navigation-menu/index.ts | 14 + .../ui/number-field/NumberField.vue | 20 + .../ui/number-field/NumberFieldContent.vue | 14 + .../ui/number-field/NumberFieldDecrement.vue | 22 + .../ui/number-field/NumberFieldIncrement.vue | 22 + .../ui/number-field/NumberFieldInput.vue | 16 + .../src/components/ui/number-field/index.ts | 5 + .../components/ui/pagination/Pagination.vue | 26 + .../ui/pagination/PaginationContent.vue | 22 + .../ui/pagination/PaginationEllipsis.vue | 25 + .../ui/pagination/PaginationFirst.vue | 33 + .../ui/pagination/PaginationItem.vue | 34 + .../ui/pagination/PaginationLast.vue | 33 + .../ui/pagination/PaginationNext.vue | 33 + .../ui/pagination/PaginationPrevious.vue | 33 + .../src/components/ui/pagination/index.ts | 8 + .../src/components/ui/pin-input/PinInput.vue | 26 + .../components/ui/pin-input/PinInputGroup.vue | 21 + .../components/ui/pin-input/PinInputInput.vue | 20 + .../ui/pin-input/PinInputSeparator.vue | 19 + .../components/ui/pin-input/PinInputSlot.vue | 21 + frontend/src/components/ui/pin-input/index.ts | 4 + .../src/components/ui/popover/Popover.vue | 19 + .../components/ui/popover/PopoverAnchor.vue | 15 + .../components/ui/popover/PopoverContent.vue | 45 + .../components/ui/popover/PopoverTrigger.vue | 15 + frontend/src/components/ui/popover/index.ts | 4 + .../src/components/ui/progress/Progress.vue | 38 + frontend/src/components/ui/progress/index.ts | 1 + .../components/ui/radio-group/RadioGroup.vue | 25 + .../ui/radio-group/RadioGroupItem.vue | 40 + .../src/components/ui/radio-group/index.ts | 2 + .../ui/range-calendar/RangeCalendar.vue | 62 + .../ui/range-calendar/RangeCalendarCell.vue | 23 + .../RangeCalendarCellTrigger.vue | 41 + .../ui/range-calendar/RangeCalendarGrid.vue | 23 + .../range-calendar/RangeCalendarGridBody.vue | 15 + .../range-calendar/RangeCalendarGridHead.vue | 15 + .../range-calendar/RangeCalendarGridRow.vue | 22 + .../range-calendar/RangeCalendarHeadCell.vue | 23 + .../ui/range-calendar/RangeCalendarHeader.vue | 23 + .../range-calendar/RangeCalendarHeading.vue | 30 + .../RangeCalendarNextButton.vue | 32 + .../RangeCalendarPrevButton.vue | 32 + .../src/components/ui/range-calendar/index.ts | 12 + .../ui/resizable/ResizableHandle.vue | 30 + .../ui/resizable/ResizablePanel.vue | 20 + .../ui/resizable/ResizablePanelGroup.vue | 25 + frontend/src/components/ui/resizable/index.ts | 3 + .../components/ui/scroll-area/ScrollArea.vue | 33 + .../components/ui/scroll-area/ScrollBar.vue | 32 + .../src/components/ui/scroll-area/index.ts | 2 + frontend/src/components/ui/select/Select.vue | 19 + .../components/ui/select/SelectContent.vue | 51 + .../src/components/ui/select/SelectGroup.vue | 15 + .../src/components/ui/select/SelectItem.vue | 44 + .../components/ui/select/SelectItemText.vue | 15 + .../src/components/ui/select/SelectLabel.vue | 17 + .../ui/select/SelectScrollDownButton.vue | 26 + .../ui/select/SelectScrollUpButton.vue | 26 + .../components/ui/select/SelectSeparator.vue | 19 + .../components/ui/select/SelectTrigger.vue | 33 + .../src/components/ui/select/SelectValue.vue | 15 + frontend/src/components/ui/select/index.ts | 11 + .../src/components/ui/separator/Separator.vue | 29 + frontend/src/components/ui/separator/index.ts | 1 + frontend/src/components/ui/sheet/Sheet.vue | 19 + .../src/components/ui/sheet/SheetClose.vue | 15 + .../src/components/ui/sheet/SheetContent.vue | 62 + .../components/ui/sheet/SheetDescription.vue | 21 + .../src/components/ui/sheet/SheetFooter.vue | 16 + .../src/components/ui/sheet/SheetHeader.vue | 15 + .../src/components/ui/sheet/SheetOverlay.vue | 21 + .../src/components/ui/sheet/SheetTitle.vue | 21 + .../src/components/ui/sheet/SheetTrigger.vue | 15 + frontend/src/components/ui/sheet/index.ts | 8 + .../src/components/ui/sidebar/Sidebar.vue | 96 + .../components/ui/sidebar/SidebarContent.vue | 18 + .../components/ui/sidebar/SidebarFooter.vue | 18 + .../components/ui/sidebar/SidebarGroup.vue | 18 + .../ui/sidebar/SidebarGroupAction.vue | 27 + .../ui/sidebar/SidebarGroupContent.vue | 18 + .../ui/sidebar/SidebarGroupLabel.vue | 25 + .../components/ui/sidebar/SidebarHeader.vue | 18 + .../components/ui/sidebar/SidebarInput.vue | 22 + .../components/ui/sidebar/SidebarInset.vue | 21 + .../src/components/ui/sidebar/SidebarMenu.vue | 18 + .../ui/sidebar/SidebarMenuAction.vue | 35 + .../ui/sidebar/SidebarMenuBadge.vue | 26 + .../ui/sidebar/SidebarMenuButton.vue | 48 + .../ui/sidebar/SidebarMenuButtonChild.vue | 36 + .../components/ui/sidebar/SidebarMenuItem.vue | 18 + .../ui/sidebar/SidebarMenuSkeleton.vue | 35 + .../components/ui/sidebar/SidebarMenuSub.vue | 22 + .../ui/sidebar/SidebarMenuSubButton.vue | 37 + .../ui/sidebar/SidebarMenuSubItem.vue | 18 + .../components/ui/sidebar/SidebarProvider.vue | 82 + .../src/components/ui/sidebar/SidebarRail.vue | 33 + .../ui/sidebar/SidebarSeparator.vue | 19 + .../components/ui/sidebar/SidebarTrigger.vue | 27 + frontend/src/components/ui/sidebar/index.ts | 60 + frontend/src/components/ui/sidebar/utils.ts | 19 + .../src/components/ui/skeleton/Skeleton.vue | 17 + frontend/src/components/ui/skeleton/index.ts | 1 + frontend/src/components/ui/slider/Slider.vue | 43 + frontend/src/components/ui/slider/index.ts | 1 + frontend/src/components/ui/sonner/Sonner.vue | 42 + frontend/src/components/ui/sonner/index.ts | 1 + .../src/components/ui/spinner/Spinner.vue | 17 + frontend/src/components/ui/spinner/index.ts | 1 + .../src/components/ui/stepper/Stepper.vue | 27 + .../ui/stepper/StepperDescription.vue | 19 + .../ui/stepper/StepperIndicator.vue | 32 + .../src/components/ui/stepper/StepperItem.vue | 23 + .../ui/stepper/StepperSeparator.vue | 27 + .../components/ui/stepper/StepperTitle.vue | 19 + .../components/ui/stepper/StepperTrigger.vue | 22 + frontend/src/components/ui/stepper/index.ts | 7 + frontend/src/components/ui/switch/Switch.vue | 47 + frontend/src/components/ui/switch/index.ts | 1 + frontend/src/components/ui/table/Table.vue | 16 + .../src/components/ui/table/TableBody.vue | 17 + .../src/components/ui/table/TableCaption.vue | 17 + .../src/components/ui/table/TableCell.vue | 22 + .../src/components/ui/table/TableEmpty.vue | 34 + .../src/components/ui/table/TableFooter.vue | 17 + .../src/components/ui/table/TableHead.vue | 17 + .../src/components/ui/table/TableHeader.vue | 17 + frontend/src/components/ui/table/TableRow.vue | 17 + frontend/src/components/ui/table/index.ts | 9 + frontend/src/components/ui/table/utils.ts | 10 + frontend/src/components/ui/tabs/Tabs.vue | 24 + .../src/components/ui/tabs/TabsContent.vue | 21 + frontend/src/components/ui/tabs/TabsList.vue | 24 + .../src/components/ui/tabs/TabsTrigger.vue | 26 + frontend/src/components/ui/tabs/index.ts | 4 + .../components/ui/tags-input/TagsInput.vue | 20 + .../ui/tags-input/TagsInputInput.vue | 17 + .../ui/tags-input/TagsInputItem.vue | 20 + .../ui/tags-input/TagsInputItemDelete.vue | 22 + .../ui/tags-input/TagsInputItemText.vue | 17 + .../src/components/ui/tags-input/index.ts | 5 + .../src/components/ui/textarea/Textarea.vue | 28 + frontend/src/components/ui/textarea/index.ts | 1 + .../ui/toggle-group/ToggleGroup.vue | 49 + .../ui/toggle-group/ToggleGroupItem.vue | 46 + .../src/components/ui/toggle-group/index.ts | 2 + frontend/src/components/ui/toggle/Toggle.vue | 35 + frontend/src/components/ui/toggle/index.ts | 28 + .../src/components/ui/tooltip/Tooltip.vue | 19 + .../components/ui/tooltip/TooltipContent.vue | 34 + .../components/ui/tooltip/TooltipProvider.vue | 14 + .../components/ui/tooltip/TooltipTrigger.vue | 15 + frontend/src/components/ui/tooltip/index.ts | 4 + frontend/src/composables/use-auth.ts | 43 + frontend/src/composables/use-axios.ts | 30 + frontend/src/composables/use-sidebar.ts | 82 + frontend/src/composables/use-system-theme.ts | 29 + frontend/src/constants/pagination.ts | 2 + frontend/src/constants/route-path.ts | 6 + frontend/src/constants/themes.ts | 24 + frontend/src/layouts/admin.vue | 22 + frontend/src/layouts/agent.vue | 231 + frontend/src/layouts/blank.vue | 3 + frontend/src/layouts/default.vue | 46 + frontend/src/layouts/developer.vue | 103 + frontend/src/lib/utils.ts | 17 + frontend/src/main.ts | 22 + frontend/src/pages/[...path].vue | 18 + frontend/src/pages/agent/apps/[id].vue | 99 + frontend/src/pages/agent/apps/index.vue | 100 + frontend/src/pages/agent/cards/create.vue | 175 + frontend/src/pages/agent/cards/index.vue | 138 + frontend/src/pages/agent/finance/index.vue | 136 + frontend/src/pages/agent/index.vue | 184 + frontend/src/pages/agent/users/index.vue | 111 + frontend/src/pages/auth/login.vue | 179 + frontend/src/pages/auth/register.vue | 215 + .../developer/agent-apps/[id]/card-types.vue | 236 + .../pages/developer/agent-apps/[id]/edit.vue | 222 + .../developer/agent-apps/[id]/recharge.vue | 185 + .../agent-apps/components/columns.ts | 239 + .../components/data-table-toolbar.vue | 40 + .../agent-apps/components/data-table.vue | 69 + .../pages/developer/agent-apps/data/data.ts | 12 + .../pages/developer/agent-apps/data/schema.ts | 56 + .../src/pages/developer/agent-apps/index.vue | 391 + .../src/pages/developer/agent-apps/invite.vue | 170 + .../pages/developer/agent-apps/request.vue | 170 + frontend/src/pages/developer/agents/[id].vue | 370 + .../developer/agents/components/columns.ts | 194 + .../agents/components/data-table.vue | 96 + .../src/pages/developer/agents/create.vue | 326 + .../src/pages/developer/agents/data/schema.ts | 18 + frontend/src/pages/developer/agents/index.vue | 307 + .../pages/developer/announcements/[id].vue | 315 + .../announcements/components/columns.ts | 117 + .../components/data-table-toolbar.vue | 52 + .../announcements/components/data-table.vue | 96 + .../pages/developer/announcements/create.vue | 292 + .../developer/announcements/data/schema.ts | 19 + .../pages/developer/announcements/index.vue | 354 + frontend/src/pages/developer/applications.vue | 248 + .../developer/applications/[id]/email.vue | 484 + .../developer/applications/[id]/security.vue | 557 ++ .../developer/applications/[id]/settings.vue | 881 ++ .../applications/components/columns.ts | 206 + .../applications/components/data-table.vue | 81 + .../pages/developer/applications/create.vue | 652 ++ .../developer/applications/data/schema.ts | 54 + .../src/pages/developer/card-types/[id].vue | 324 + .../card-types/components/columns.ts | 176 + .../components/data-table-toolbar.vue | 43 + .../card-types/components/data-table.vue | 94 + .../src/pages/developer/card-types/create.vue | 372 + .../pages/developer/card-types/data/data.ts | 25 + .../pages/developer/card-types/data/schema.ts | 29 + .../src/pages/developer/card-types/index.vue | 409 + .../developer/cards/components/columns.ts | 202 + .../cards/components/data-table-toolbar.vue | 52 + .../developer/cards/components/data-table.vue | 109 + frontend/src/pages/developer/cards/create.vue | 279 + .../src/pages/developer/cards/data/data.ts | 32 + .../src/pages/developer/cards/data/schema.ts | 36 + frontend/src/pages/developer/cards/index.vue | 423 + .../pages/developer/cloud-constants/[id].vue | 273 + .../cloud-constants/components/columns.ts | 185 + .../components/data-table-row-actions.vue | 44 + .../components/data-table-toolbar.vue | 43 + .../cloud-constants/components/data-table.vue | 98 + .../developer/cloud-constants/create.vue | 354 + .../developer/cloud-constants/data/data.ts | 9 + .../developer/cloud-constants/data/schema.ts | 21 + .../pages/developer/cloud-constants/index.vue | 337 + .../pages/developer/cloud-variables/[id].vue | 364 + .../cloud-variables/[id]/records.vue | 274 + .../cloud-variables/components/columns.ts | 232 + .../components/data-table-row-actions.vue | 54 + .../components/data-table-toolbar.vue | 43 + .../cloud-variables/components/data-table.vue | 102 + .../developer/cloud-variables/create.vue | 516 ++ .../developer/cloud-variables/data/data.ts | 9 + .../developer/cloud-variables/data/schema.ts | 25 + .../pages/developer/cloud-variables/index.vue | 361 + .../developer/devices/components/columns.ts | 179 + .../devices/components/data-table-toolbar.vue | 93 + .../devices/components/data-table.vue | 99 + .../src/pages/developer/devices/data/data.ts | 19 + .../pages/developer/devices/data/schema.ts | 27 + .../src/pages/developer/devices/index.vue | 386 + frontend/src/pages/developer/dynamic-code.vue | 195 + .../src/pages/developer/dynamic-code/[id].vue | 307 + .../dynamic-code/components/columns.ts | 157 + .../components/data-table-toolbar.vue | 43 + .../dynamic-code/components/data-table.vue | 91 + .../pages/developer/dynamic-code/create.vue | 260 + .../developer/dynamic-code/data/schema.ts | 20 + .../pages/developer/dynamic-code/index.vue | 356 + .../developer/extension/api-keys/create.vue | 318 + .../extension/components/apikey-columns.ts | 156 + .../components/apikey-data-table-toolbar.vue | 52 + .../components/apikey-data-table.vue | 97 + .../extension/components/webhook-columns.ts | 165 + .../components/webhook-data-table-toolbar.vue | 52 + .../components/webhook-data-table.vue | 98 + .../pages/developer/extension/data/schema.ts | 40 + .../src/pages/developer/extension/index.vue | 560 ++ .../developer/extension/webhooks/create.vue | 315 + .../src/pages/developer/finance/index.vue | 580 ++ frontend/src/pages/developer/index.vue | 695 ++ frontend/src/pages/developer/logs.vue | 214 + .../src/pages/developer/risk-control/[id].vue | 320 + .../risk-control/components/columns.ts | 227 + .../components/data-table-toolbar.vue | 24 + .../risk-control/components/data-table.vue | 84 + .../pages/developer/risk-control/create.vue | 417 + .../developer/risk-control/data/schema.ts | 17 + .../pages/developer/risk-control/index.vue | 289 + .../developer/sessions/components/columns.ts | 154 + .../components/data-table-toolbar.vue | 59 + .../sessions/components/data-table.vue | 71 + .../pages/developer/sessions/data/schema.ts | 19 + .../src/pages/developer/sessions/index.vue | 247 + frontend/src/pages/developer/tickets.vue | 334 + frontend/src/pages/developer/tickets/[id].vue | 285 + .../developer/tickets/components/columns.ts | 195 + .../tickets/components/data-table-toolbar.vue | 43 + .../tickets/components/data-table.vue | 85 + .../src/pages/developer/tickets/create.vue | 360 + .../pages/developer/tickets/data/schema.ts | 23 + frontend/src/pages/developer/users/[id].vue | 405 + .../developer/users/components/columns.ts | 210 + .../users/components/data-table-toolbar.vue | 48 + .../developer/users/components/data-table.vue | 93 + frontend/src/pages/developer/users/create.vue | 208 + .../src/pages/developer/users/data/data.ts | 58 + .../src/pages/developer/users/data/schema.ts | 35 + frontend/src/pages/developer/users/index.vue | 385 + .../src/pages/developer/versions/[id].vue | 391 + .../developer/versions/components/columns.ts | 130 + .../components/data-table-toolbar.vue | 52 + .../versions/components/data-table.vue | 97 + .../src/pages/developer/versions/create.vue | 530 ++ .../pages/developer/versions/data/schema.ts | 22 + .../src/pages/developer/versions/index.vue | 359 + frontend/src/pages/errors.vue | 8 + frontend/src/pages/errors/401.vue | 18 + frontend/src/pages/errors/403.vue | 18 + frontend/src/pages/errors/404.vue | 18 + frontend/src/pages/errors/500.vue | 18 + frontend/src/pages/errors/503.vue | 18 + frontend/src/pages/errors/index.vue | 4 + frontend/src/pages/profile/index.vue | 756 ++ frontend/src/plugins/auto-animate/setup.ts | 7 + frontend/src/plugins/dayjs/setup.ts | 8 + frontend/src/plugins/i18n/en.json | 2333 +++++ frontend/src/plugins/i18n/index.ts | 18 + frontend/src/plugins/i18n/setup.ts | 22 + frontend/src/plugins/i18n/zh.json | 2309 +++++ frontend/src/plugins/index.ts | 19 + frontend/src/plugins/nprogress/setup.ts | 12 + frontend/src/plugins/pinia/setup.ts | 17 + frontend/src/plugins/router/setup.ts | 7 + .../src/plugins/tanstack-vue-query/setup.ts | 18 + frontend/src/router/guard/auth-guard.ts | 21 + frontend/src/router/guard/index.ts | 44 + frontend/src/router/index.ts | 18 + frontend/src/router/routes.ts | 406 + frontend/src/services/api.ts | 86 + .../src/services/api/example-tasks.api.ts | 83 + frontend/src/services/types/response.type.ts | 19 + frontend/src/stores/auth.ts | 42 + frontend/src/stores/theme.ts | 31 + .../src/types/auto-import-components.d.ts | 828 ++ frontend/src/types/auto-import.d.ts | 99 + frontend/src/types/markdown-it.d.ts | 47 + frontend/src/types/route-map.d.ts | 1021 ++ frontend/src/types/vite-env.d.ts | 19 + frontend/src/types/vue-router-meta.d.ts | 10 + frontend/src/utils/env.ts | 41 + frontend/tsconfig.app.json | 35 + frontend/tsconfig.json | 16 + frontend/tsconfig.node.json | 24 + frontend/vercel.json | 3 + frontend/vite.config.ts | 122 + 780 files changed, 88960 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/cmd/check_db/main.go create mode 100644 backend/cmd/main.go create mode 100644 backend/cmd/scripts/init_extension_docs.go create mode 100644 backend/cmd/scripts/init_extension_docs.sql create mode 100644 backend/cmd/scripts/migrate_to_balance.go create mode 100644 backend/config.yaml create mode 100644 backend/docs/API.md create mode 100644 backend/docs/EXTENSION_API.md create mode 100644 backend/go.mod create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/database/database.go create mode 100644 backend/internal/middleware/app_crypto.go create mode 100644 backend/internal/middleware/crypto.go create mode 100644 backend/internal/middleware/middleware.go create mode 100644 backend/internal/middleware/usage_limit.go create mode 100644 backend/internal/model/models.go create mode 100644 backend/internal/router/app/account.go create mode 100644 backend/internal/router/app/app.go create mode 100644 backend/internal/router/app/app_crypto_helper.go create mode 100644 backend/internal/router/app/auth.go create mode 100644 backend/internal/router/app/cloud.go create mode 100644 backend/internal/router/app/devices.go create mode 100644 backend/internal/router/app/dynamic.go create mode 100644 backend/internal/router/app/info.go create mode 100644 backend/internal/router/app/payment.go create mode 100644 backend/internal/router/app/response_encryption.go create mode 100644 backend/internal/router/developer/agent_apps.go create mode 100644 backend/internal/router/developer/agents.go create mode 100644 backend/internal/router/developer/announcements.go create mode 100644 backend/internal/router/developer/applications.go create mode 100644 backend/internal/router/developer/cards.go create mode 100644 backend/internal/router/developer/cloud.go create mode 100644 backend/internal/router/developer/dashboard.go create mode 100644 backend/internal/router/developer/developer.go create mode 100644 backend/internal/router/developer/devices.go create mode 100644 backend/internal/router/developer/dynamic.go create mode 100644 backend/internal/router/developer/email.go create mode 100644 backend/internal/router/developer/extension.go create mode 100644 backend/internal/router/developer/finance.go create mode 100644 backend/internal/router/developer/logs.go create mode 100644 backend/internal/router/developer/orders.go create mode 100644 backend/internal/router/developer/profile.go create mode 100644 backend/internal/router/developer/tickets.go create mode 100644 backend/internal/router/developer/usage.go create mode 100644 backend/internal/router/developer/users.go create mode 100644 backend/internal/router/developer/versions.go create mode 100644 backend/internal/router/extension/extension.go create mode 100644 backend/internal/router/frontend/frontend.go create mode 100644 backend/internal/router/frontend/payment_callback.go create mode 100644 backend/internal/router/router.go create mode 100644 backend/internal/service/auth.go create mode 100644 backend/internal/service/email.go create mode 100644 backend/internal/service/log_service.go create mode 100644 backend/internal/service/package_limit.go create mode 100644 backend/internal/service/payment/bepusdt.go create mode 100644 backend/internal/service/payment/payment.go create mode 100644 backend/internal/service/setting.go create mode 100644 backend/internal/service/sms.go create mode 100644 backend/internal/service/usage_alert.go create mode 100644 backend/internal/utils/webhook/trigger.go create mode 100644 backend/migrations/add_card_type_recharge_fields.sql create mode 100644 backend/migrations/add_file_fields_to_cloud.sql create mode 100644 backend/migrations/add_file_fields_to_user_variables.sql create mode 100644 backend/migrations/migrate_to_balance.sql create mode 100644 backend/pkg/crypto/crypto.go create mode 100644 backend/pkg/jwt/jwt.go create mode 100644 backend/pkg/logger/logger.go create mode 100644 backend/pkg/response/response.go create mode 100644 backend/pkg/utils/utils.go create mode 100644 backend/scripts/check_all_deleted.go create mode 100644 backend/scripts/check_deleted_dynamic_codes.go create mode 100644 backend/scripts/check_dynamic_codes.go create mode 100644 backend/scripts/check_i18n_data.go create mode 100644 backend/scripts/check_packages.go create mode 100644 backend/scripts/create_cloud_function_doc.go create mode 100644 backend/scripts/create_manual_deduct_doc.go create mode 100644 backend/scripts/fix_package_data.go create mode 100644 backend/scripts/init_packages.go create mode 100644 backend/scripts/list_docs.go create mode 100644 backend/scripts/migrate_docs_i18n.go create mode 100644 backend/scripts/translate_docs_i18n.go create mode 100644 backend/scripts/translate_packages_i18n.go create mode 100644 backend/scripts/update_api_docs.go create mode 100644 backend/scripts/update_doc_translations.go create mode 100644 backend/scripts/update_dynamic_code_doc.go create mode 100644 backend/scripts/update_dynamic_code_docs.go create mode 100644 backend/scripts/update_to_cloud_function.go create mode 100644 database/schema.sql create mode 100644 database/seeds.sql create mode 100644 docker-compose.yml create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/public/_redirects create mode 100644 frontend/public/logo-black.svg create mode 100644 frontend/public/logo.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/chart-theme.css create mode 100644 frontend/src/assets/icons/arrow-dark.svg create mode 100644 frontend/src/assets/icons/arrow-light.svg create mode 100644 frontend/src/assets/index.css create mode 100644 frontend/src/assets/nprogress.css create mode 100644 frontend/src/assets/scrollbar.css create mode 100644 frontend/src/assets/themes.css create mode 100644 frontend/src/components/app-sidebar/data/sidebar-data.ts create mode 100644 frontend/src/components/app-sidebar/index.vue create mode 100644 frontend/src/components/app-sidebar/nav-footer.vue create mode 100644 frontend/src/components/app-sidebar/nav-team-add.vue create mode 100644 frontend/src/components/app-sidebar/nav-team.vue create mode 100644 frontend/src/components/app-sidebar/team-switcher.vue create mode 100644 frontend/src/components/app-sidebar/types.ts create mode 100644 frontend/src/components/app-sidebar/validators/team.validator.ts create mode 100644 frontend/src/components/command-menu-panel/command-change-theme.vue create mode 100644 frontend/src/components/command-menu-panel/command-item-has-icon.vue create mode 100644 frontend/src/components/command-menu-panel/command-to-page.vue create mode 100644 frontend/src/components/command-menu-panel/index.vue create mode 100644 frontend/src/components/confirm-dialog.vue create mode 100644 frontend/src/components/country-select.vue create mode 100644 frontend/src/components/custom-error.vue create mode 100644 frontend/src/components/custom-theme/content-layout.vue create mode 100644 frontend/src/components/custom-theme/custom-color.vue create mode 100644 frontend/src/components/custom-theme/custom-radius.vue create mode 100644 frontend/src/components/custom-theme/custom-theme-title.vue create mode 100644 frontend/src/components/custom-theme/theme-popover.vue create mode 100644 frontend/src/components/custom-theme/toggle-color-mode.vue create mode 100644 frontend/src/components/data-table/bulk-actions.vue create mode 100644 frontend/src/components/data-table/column-header.vue create mode 100644 frontend/src/components/data-table/data-table.vue create mode 100644 frontend/src/components/data-table/date-range-filter.vue create mode 100644 frontend/src/components/data-table/faceted-filter.vue create mode 100644 frontend/src/components/data-table/radio-cell.vue create mode 100644 frontend/src/components/data-table/single-filter.vue create mode 100644 frontend/src/components/data-table/table-columns.ts create mode 100644 frontend/src/components/data-table/table-loading.vue create mode 100644 frontend/src/components/data-table/table-pagination.vue create mode 100644 frontend/src/components/data-table/types.ts create mode 100644 frontend/src/components/data-table/use-generate-vue-table.ts create mode 100644 frontend/src/components/data-table/view-options.vue create mode 100644 frontend/src/components/developer-sidebar/index.vue create mode 100644 frontend/src/components/developer-sidebar/nav-footer.vue create mode 100644 frontend/src/components/global-layout/basic-header.vue create mode 100644 frontend/src/components/global-layout/basic-page.vue create mode 100644 frontend/src/components/global-layout/index.ts create mode 100644 frontend/src/components/global-layout/two-col-aside.vue create mode 100644 frontend/src/components/global-layout/two-col.vue create mode 100644 frontend/src/components/global-layout/types.ts create mode 100644 frontend/src/components/inspira-ui/flickering-grid.vue create mode 100644 frontend/src/components/inspira-ui/glowing-effect.vue create mode 100644 frontend/src/components/inspira-ui/marquee/index.vue create mode 100644 frontend/src/components/inspira-ui/marquee/review-card.vue create mode 100644 frontend/src/components/inspira-ui/ripple/circle.vue create mode 100644 frontend/src/components/inspira-ui/ripple/container.vue create mode 100644 frontend/src/components/inspira-ui/ripple/index.vue create mode 100644 frontend/src/components/language-change.vue create mode 100644 frontend/src/components/loading.vue create mode 100644 frontend/src/components/marketing-layout/the-footer.vue create mode 100644 frontend/src/components/marketing-layout/the-header.vue create mode 100644 frontend/src/components/no-result-found.vue create mode 100644 frontend/src/components/prop-ui/modal/Modal.vue create mode 100644 frontend/src/components/prop-ui/modal/ModalClose.vue create mode 100644 frontend/src/components/prop-ui/modal/ModalContent.vue create mode 100644 frontend/src/components/prop-ui/modal/ModalDescription.vue create mode 100644 frontend/src/components/prop-ui/modal/ModalFooter.vue create mode 100644 frontend/src/components/prop-ui/modal/ModalHeader.vue create mode 100644 frontend/src/components/prop-ui/modal/ModalTitle.vue create mode 100644 frontend/src/components/prop-ui/modal/ModalTrigger.vue create mode 100644 frontend/src/components/prop-ui/modal/index.ts create mode 100644 frontend/src/components/prop-ui/modal/use-modal.ts create mode 100644 frontend/src/components/sign-in-button.vue create mode 100644 frontend/src/components/sign-up-button.vue create mode 100644 frontend/src/components/sort-select/index.vue create mode 100644 frontend/src/components/sort-select/types.ts create mode 100644 frontend/src/components/sva-ui/copy/Copy.vue create mode 100644 frontend/src/components/sva-ui/copy/index.ts create mode 100644 frontend/src/components/sva-ui/inline-tip/InlineTip.vue create mode 100644 frontend/src/components/sva-ui/inline-tip/index.ts create mode 100644 frontend/src/components/sva-ui/status-badge/Status.vue create mode 100644 frontend/src/components/sva-ui/status-badge/StatusBadge.vue create mode 100644 frontend/src/components/sva-ui/status-badge/index.ts create mode 100644 frontend/src/components/toggle-theme.vue create mode 100644 frontend/src/components/ui/accordion/Accordion.vue create mode 100644 frontend/src/components/ui/accordion/AccordionContent.vue create mode 100644 frontend/src/components/ui/accordion/AccordionItem.vue create mode 100644 frontend/src/components/ui/accordion/AccordionTrigger.vue create mode 100644 frontend/src/components/ui/accordion/index.ts create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialog.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogAction.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogCancel.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogContent.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogDescription.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogFooter.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogHeader.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue create mode 100644 frontend/src/components/ui/alert-dialog/AlertDialogTrigger.vue create mode 100644 frontend/src/components/ui/alert-dialog/index.ts create mode 100644 frontend/src/components/ui/alert/Alert.vue create mode 100644 frontend/src/components/ui/alert/AlertDescription.vue create mode 100644 frontend/src/components/ui/alert/AlertTitle.vue create mode 100644 frontend/src/components/ui/alert/index.ts create mode 100644 frontend/src/components/ui/aspect-ratio/AspectRatio.vue create mode 100644 frontend/src/components/ui/aspect-ratio/index.ts create mode 100644 frontend/src/components/ui/avatar/Avatar.vue create mode 100644 frontend/src/components/ui/avatar/AvatarFallback.vue create mode 100644 frontend/src/components/ui/avatar/AvatarImage.vue create mode 100644 frontend/src/components/ui/avatar/index.ts create mode 100644 frontend/src/components/ui/badge/Badge.vue create mode 100644 frontend/src/components/ui/badge/index.ts create mode 100644 frontend/src/components/ui/breadcrumb/Breadcrumb.vue create mode 100644 frontend/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue create mode 100644 frontend/src/components/ui/breadcrumb/BreadcrumbItem.vue create mode 100644 frontend/src/components/ui/breadcrumb/BreadcrumbLink.vue create mode 100644 frontend/src/components/ui/breadcrumb/BreadcrumbList.vue create mode 100644 frontend/src/components/ui/breadcrumb/BreadcrumbPage.vue create mode 100644 frontend/src/components/ui/breadcrumb/BreadcrumbSeparator.vue create mode 100644 frontend/src/components/ui/breadcrumb/index.ts create mode 100644 frontend/src/components/ui/button-group/ButtonGroup.vue create mode 100644 frontend/src/components/ui/button-group/ButtonGroupSeparator.vue create mode 100644 frontend/src/components/ui/button-group/ButtonGroupText.vue create mode 100644 frontend/src/components/ui/button-group/index.ts create mode 100644 frontend/src/components/ui/button/Button.vue create mode 100644 frontend/src/components/ui/button/index.ts create mode 100644 frontend/src/components/ui/calendar/Calendar.vue create mode 100644 frontend/src/components/ui/calendar/CalendarCell.vue create mode 100644 frontend/src/components/ui/calendar/CalendarCellTrigger.vue create mode 100644 frontend/src/components/ui/calendar/CalendarGrid.vue create mode 100644 frontend/src/components/ui/calendar/CalendarGridBody.vue create mode 100644 frontend/src/components/ui/calendar/CalendarGridHead.vue create mode 100644 frontend/src/components/ui/calendar/CalendarGridRow.vue create mode 100644 frontend/src/components/ui/calendar/CalendarHeadCell.vue create mode 100644 frontend/src/components/ui/calendar/CalendarHeader.vue create mode 100644 frontend/src/components/ui/calendar/CalendarHeading.vue create mode 100644 frontend/src/components/ui/calendar/CalendarNextButton.vue create mode 100644 frontend/src/components/ui/calendar/CalendarPrevButton.vue create mode 100644 frontend/src/components/ui/calendar/index.ts create mode 100644 frontend/src/components/ui/card/Card.vue create mode 100644 frontend/src/components/ui/card/CardAction.vue create mode 100644 frontend/src/components/ui/card/CardContent.vue create mode 100644 frontend/src/components/ui/card/CardDescription.vue create mode 100644 frontend/src/components/ui/card/CardFooter.vue create mode 100644 frontend/src/components/ui/card/CardHeader.vue create mode 100644 frontend/src/components/ui/card/CardTitle.vue create mode 100644 frontend/src/components/ui/card/index.ts create mode 100644 frontend/src/components/ui/carousel/Carousel.vue create mode 100644 frontend/src/components/ui/carousel/CarouselContent.vue create mode 100644 frontend/src/components/ui/carousel/CarouselItem.vue create mode 100644 frontend/src/components/ui/carousel/CarouselNext.vue create mode 100644 frontend/src/components/ui/carousel/CarouselPrevious.vue create mode 100644 frontend/src/components/ui/carousel/index.ts create mode 100644 frontend/src/components/ui/carousel/interface.ts create mode 100644 frontend/src/components/ui/carousel/useCarousel.ts create mode 100644 frontend/src/components/ui/chart/ChartContainer.vue create mode 100644 frontend/src/components/ui/chart/ChartLegendContent.vue create mode 100644 frontend/src/components/ui/chart/ChartStyle.vue create mode 100644 frontend/src/components/ui/chart/ChartTooltipContent.vue create mode 100644 frontend/src/components/ui/chart/index.ts create mode 100644 frontend/src/components/ui/chart/utils.ts create mode 100644 frontend/src/components/ui/checkbox/Checkbox.vue create mode 100644 frontend/src/components/ui/checkbox/index.ts create mode 100644 frontend/src/components/ui/collapsible/Collapsible.vue create mode 100644 frontend/src/components/ui/collapsible/CollapsibleContent.vue create mode 100644 frontend/src/components/ui/collapsible/CollapsibleTrigger.vue create mode 100644 frontend/src/components/ui/collapsible/index.ts create mode 100644 frontend/src/components/ui/combobox/Combobox.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxAnchor.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxEmpty.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxGroup.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxInput.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxItem.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxItemIndicator.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxList.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxSeparator.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxTrigger.vue create mode 100644 frontend/src/components/ui/combobox/ComboboxViewport.vue create mode 100644 frontend/src/components/ui/combobox/index.ts create mode 100644 frontend/src/components/ui/command/Command.vue create mode 100644 frontend/src/components/ui/command/CommandDialog.vue create mode 100644 frontend/src/components/ui/command/CommandEmpty.vue create mode 100644 frontend/src/components/ui/command/CommandGroup.vue create mode 100644 frontend/src/components/ui/command/CommandInput.vue create mode 100644 frontend/src/components/ui/command/CommandItem.vue create mode 100644 frontend/src/components/ui/command/CommandList.vue create mode 100644 frontend/src/components/ui/command/CommandSeparator.vue create mode 100644 frontend/src/components/ui/command/CommandShortcut.vue create mode 100644 frontend/src/components/ui/command/index.ts create mode 100644 frontend/src/components/ui/context-menu/ContextMenu.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuCheckboxItem.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuContent.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuGroup.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuItem.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuLabel.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuPortal.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuRadioGroup.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuRadioItem.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuSeparator.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuShortcut.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuSub.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuSubContent.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuSubTrigger.vue create mode 100644 frontend/src/components/ui/context-menu/ContextMenuTrigger.vue create mode 100644 frontend/src/components/ui/context-menu/index.ts create mode 100644 frontend/src/components/ui/date-picker/DatePicker.vue create mode 100644 frontend/src/components/ui/date-picker/DateTimePicker.vue create mode 100644 frontend/src/components/ui/date-picker/TimePicker.vue create mode 100644 frontend/src/components/ui/date-picker/index.ts create mode 100644 frontend/src/components/ui/dialog/Dialog.vue create mode 100644 frontend/src/components/ui/dialog/DialogClose.vue create mode 100644 frontend/src/components/ui/dialog/DialogContent.vue create mode 100644 frontend/src/components/ui/dialog/DialogDescription.vue create mode 100644 frontend/src/components/ui/dialog/DialogFooter.vue create mode 100644 frontend/src/components/ui/dialog/DialogHeader.vue create mode 100644 frontend/src/components/ui/dialog/DialogOverlay.vue create mode 100644 frontend/src/components/ui/dialog/DialogScrollContent.vue create mode 100644 frontend/src/components/ui/dialog/DialogTitle.vue create mode 100644 frontend/src/components/ui/dialog/DialogTrigger.vue create mode 100644 frontend/src/components/ui/dialog/index.ts create mode 100644 frontend/src/components/ui/drawer/Drawer.vue create mode 100644 frontend/src/components/ui/drawer/DrawerClose.vue create mode 100644 frontend/src/components/ui/drawer/DrawerContent.vue create mode 100644 frontend/src/components/ui/drawer/DrawerDescription.vue create mode 100644 frontend/src/components/ui/drawer/DrawerFooter.vue create mode 100644 frontend/src/components/ui/drawer/DrawerHeader.vue create mode 100644 frontend/src/components/ui/drawer/DrawerOverlay.vue create mode 100644 frontend/src/components/ui/drawer/DrawerTitle.vue create mode 100644 frontend/src/components/ui/drawer/DrawerTrigger.vue create mode 100644 frontend/src/components/ui/drawer/index.ts create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenu.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuContent.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuGroup.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuItem.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuLabel.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue create mode 100644 frontend/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue create mode 100644 frontend/src/components/ui/dropdown-menu/index.ts create mode 100644 frontend/src/components/ui/empty/Empty.vue create mode 100644 frontend/src/components/ui/empty/EmptyContent.vue create mode 100644 frontend/src/components/ui/empty/EmptyDescription.vue create mode 100644 frontend/src/components/ui/empty/EmptyHeader.vue create mode 100644 frontend/src/components/ui/empty/EmptyMedia.vue create mode 100644 frontend/src/components/ui/empty/EmptyTitle.vue create mode 100644 frontend/src/components/ui/empty/index.ts create mode 100644 frontend/src/components/ui/field/Field.vue create mode 100644 frontend/src/components/ui/field/FieldContent.vue create mode 100644 frontend/src/components/ui/field/FieldDescription.vue create mode 100644 frontend/src/components/ui/field/FieldError.vue create mode 100644 frontend/src/components/ui/field/FieldGroup.vue create mode 100644 frontend/src/components/ui/field/FieldLabel.vue create mode 100644 frontend/src/components/ui/field/FieldLegend.vue create mode 100644 frontend/src/components/ui/field/FieldSeparator.vue create mode 100644 frontend/src/components/ui/field/FieldSet.vue create mode 100644 frontend/src/components/ui/field/FieldTitle.vue create mode 100644 frontend/src/components/ui/field/index.ts create mode 100644 frontend/src/components/ui/form/FormControl.vue create mode 100644 frontend/src/components/ui/form/FormDescription.vue create mode 100644 frontend/src/components/ui/form/FormItem.vue create mode 100644 frontend/src/components/ui/form/FormLabel.vue create mode 100644 frontend/src/components/ui/form/FormMessage.vue create mode 100644 frontend/src/components/ui/form/index.ts create mode 100644 frontend/src/components/ui/form/injectionKeys.ts create mode 100644 frontend/src/components/ui/form/useFormField.ts create mode 100644 frontend/src/components/ui/hover-card/HoverCard.vue create mode 100644 frontend/src/components/ui/hover-card/HoverCardContent.vue create mode 100644 frontend/src/components/ui/hover-card/HoverCardTrigger.vue create mode 100644 frontend/src/components/ui/hover-card/index.ts create mode 100644 frontend/src/components/ui/input-group/InputGroup.vue create mode 100644 frontend/src/components/ui/input-group/InputGroupAddon.vue create mode 100644 frontend/src/components/ui/input-group/InputGroupButton.vue create mode 100644 frontend/src/components/ui/input-group/InputGroupInput.vue create mode 100644 frontend/src/components/ui/input-group/InputGroupText.vue create mode 100644 frontend/src/components/ui/input-group/InputGroupTextarea.vue create mode 100644 frontend/src/components/ui/input-group/index.ts create mode 100644 frontend/src/components/ui/input-otp/InputOTP.vue create mode 100644 frontend/src/components/ui/input-otp/InputOTPGroup.vue create mode 100644 frontend/src/components/ui/input-otp/InputOTPSeparator.vue create mode 100644 frontend/src/components/ui/input-otp/InputOTPSlot.vue create mode 100644 frontend/src/components/ui/input-otp/index.ts create mode 100644 frontend/src/components/ui/input/Input.vue create mode 100644 frontend/src/components/ui/input/index.ts create mode 100644 frontend/src/components/ui/item/Item.vue create mode 100644 frontend/src/components/ui/item/ItemActions.vue create mode 100644 frontend/src/components/ui/item/ItemContent.vue create mode 100644 frontend/src/components/ui/item/ItemDescription.vue create mode 100644 frontend/src/components/ui/item/ItemFooter.vue create mode 100644 frontend/src/components/ui/item/ItemGroup.vue create mode 100644 frontend/src/components/ui/item/ItemHeader.vue create mode 100644 frontend/src/components/ui/item/ItemMedia.vue create mode 100644 frontend/src/components/ui/item/ItemSeparator.vue create mode 100644 frontend/src/components/ui/item/ItemTitle.vue create mode 100644 frontend/src/components/ui/item/index.ts create mode 100644 frontend/src/components/ui/kbd/Kbd.vue create mode 100644 frontend/src/components/ui/kbd/KbdGroup.vue create mode 100644 frontend/src/components/ui/kbd/index.ts create mode 100644 frontend/src/components/ui/label/Label.vue create mode 100644 frontend/src/components/ui/label/index.ts create mode 100644 frontend/src/components/ui/menubar/Menubar.vue create mode 100644 frontend/src/components/ui/menubar/MenubarCheckboxItem.vue create mode 100644 frontend/src/components/ui/menubar/MenubarContent.vue create mode 100644 frontend/src/components/ui/menubar/MenubarGroup.vue create mode 100644 frontend/src/components/ui/menubar/MenubarItem.vue create mode 100644 frontend/src/components/ui/menubar/MenubarLabel.vue create mode 100644 frontend/src/components/ui/menubar/MenubarMenu.vue create mode 100644 frontend/src/components/ui/menubar/MenubarRadioGroup.vue create mode 100644 frontend/src/components/ui/menubar/MenubarRadioItem.vue create mode 100644 frontend/src/components/ui/menubar/MenubarSeparator.vue create mode 100644 frontend/src/components/ui/menubar/MenubarShortcut.vue create mode 100644 frontend/src/components/ui/menubar/MenubarSub.vue create mode 100644 frontend/src/components/ui/menubar/MenubarSubContent.vue create mode 100644 frontend/src/components/ui/menubar/MenubarSubTrigger.vue create mode 100644 frontend/src/components/ui/menubar/MenubarTrigger.vue create mode 100644 frontend/src/components/ui/menubar/index.ts create mode 100644 frontend/src/components/ui/native-select/NativeSelect.vue create mode 100644 frontend/src/components/ui/native-select/NativeSelectOptGroup.vue create mode 100644 frontend/src/components/ui/native-select/NativeSelectOption.vue create mode 100644 frontend/src/components/ui/native-select/index.ts create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenu.vue create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenuContent.vue create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenuIndicator.vue create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenuItem.vue create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenuLink.vue create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenuList.vue create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenuTrigger.vue create mode 100644 frontend/src/components/ui/navigation-menu/NavigationMenuViewport.vue create mode 100644 frontend/src/components/ui/navigation-menu/index.ts create mode 100644 frontend/src/components/ui/number-field/NumberField.vue create mode 100644 frontend/src/components/ui/number-field/NumberFieldContent.vue create mode 100644 frontend/src/components/ui/number-field/NumberFieldDecrement.vue create mode 100644 frontend/src/components/ui/number-field/NumberFieldIncrement.vue create mode 100644 frontend/src/components/ui/number-field/NumberFieldInput.vue create mode 100644 frontend/src/components/ui/number-field/index.ts create mode 100644 frontend/src/components/ui/pagination/Pagination.vue create mode 100644 frontend/src/components/ui/pagination/PaginationContent.vue create mode 100644 frontend/src/components/ui/pagination/PaginationEllipsis.vue create mode 100644 frontend/src/components/ui/pagination/PaginationFirst.vue create mode 100644 frontend/src/components/ui/pagination/PaginationItem.vue create mode 100644 frontend/src/components/ui/pagination/PaginationLast.vue create mode 100644 frontend/src/components/ui/pagination/PaginationNext.vue create mode 100644 frontend/src/components/ui/pagination/PaginationPrevious.vue create mode 100644 frontend/src/components/ui/pagination/index.ts create mode 100644 frontend/src/components/ui/pin-input/PinInput.vue create mode 100644 frontend/src/components/ui/pin-input/PinInputGroup.vue create mode 100644 frontend/src/components/ui/pin-input/PinInputInput.vue create mode 100644 frontend/src/components/ui/pin-input/PinInputSeparator.vue create mode 100644 frontend/src/components/ui/pin-input/PinInputSlot.vue create mode 100644 frontend/src/components/ui/pin-input/index.ts create mode 100644 frontend/src/components/ui/popover/Popover.vue create mode 100644 frontend/src/components/ui/popover/PopoverAnchor.vue create mode 100644 frontend/src/components/ui/popover/PopoverContent.vue create mode 100644 frontend/src/components/ui/popover/PopoverTrigger.vue create mode 100644 frontend/src/components/ui/popover/index.ts create mode 100644 frontend/src/components/ui/progress/Progress.vue create mode 100644 frontend/src/components/ui/progress/index.ts create mode 100644 frontend/src/components/ui/radio-group/RadioGroup.vue create mode 100644 frontend/src/components/ui/radio-group/RadioGroupItem.vue create mode 100644 frontend/src/components/ui/radio-group/index.ts create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendar.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarCell.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarGrid.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarGridBody.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarGridHead.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarGridRow.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarHeadCell.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarHeader.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarHeading.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarNextButton.vue create mode 100644 frontend/src/components/ui/range-calendar/RangeCalendarPrevButton.vue create mode 100644 frontend/src/components/ui/range-calendar/index.ts create mode 100644 frontend/src/components/ui/resizable/ResizableHandle.vue create mode 100644 frontend/src/components/ui/resizable/ResizablePanel.vue create mode 100644 frontend/src/components/ui/resizable/ResizablePanelGroup.vue create mode 100644 frontend/src/components/ui/resizable/index.ts create mode 100644 frontend/src/components/ui/scroll-area/ScrollArea.vue create mode 100644 frontend/src/components/ui/scroll-area/ScrollBar.vue create mode 100644 frontend/src/components/ui/scroll-area/index.ts create mode 100644 frontend/src/components/ui/select/Select.vue create mode 100644 frontend/src/components/ui/select/SelectContent.vue create mode 100644 frontend/src/components/ui/select/SelectGroup.vue create mode 100644 frontend/src/components/ui/select/SelectItem.vue create mode 100644 frontend/src/components/ui/select/SelectItemText.vue create mode 100644 frontend/src/components/ui/select/SelectLabel.vue create mode 100644 frontend/src/components/ui/select/SelectScrollDownButton.vue create mode 100644 frontend/src/components/ui/select/SelectScrollUpButton.vue create mode 100644 frontend/src/components/ui/select/SelectSeparator.vue create mode 100644 frontend/src/components/ui/select/SelectTrigger.vue create mode 100644 frontend/src/components/ui/select/SelectValue.vue create mode 100644 frontend/src/components/ui/select/index.ts create mode 100644 frontend/src/components/ui/separator/Separator.vue create mode 100644 frontend/src/components/ui/separator/index.ts create mode 100644 frontend/src/components/ui/sheet/Sheet.vue create mode 100644 frontend/src/components/ui/sheet/SheetClose.vue create mode 100644 frontend/src/components/ui/sheet/SheetContent.vue create mode 100644 frontend/src/components/ui/sheet/SheetDescription.vue create mode 100644 frontend/src/components/ui/sheet/SheetFooter.vue create mode 100644 frontend/src/components/ui/sheet/SheetHeader.vue create mode 100644 frontend/src/components/ui/sheet/SheetOverlay.vue create mode 100644 frontend/src/components/ui/sheet/SheetTitle.vue create mode 100644 frontend/src/components/ui/sheet/SheetTrigger.vue create mode 100644 frontend/src/components/ui/sheet/index.ts create mode 100644 frontend/src/components/ui/sidebar/Sidebar.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarContent.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarFooter.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarGroup.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarGroupAction.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarGroupContent.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarGroupLabel.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarHeader.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarInput.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarInset.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenu.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuAction.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuBadge.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuButton.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuButtonChild.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuItem.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuSkeleton.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuSub.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuSubButton.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarMenuSubItem.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarProvider.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarRail.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarSeparator.vue create mode 100644 frontend/src/components/ui/sidebar/SidebarTrigger.vue create mode 100644 frontend/src/components/ui/sidebar/index.ts create mode 100644 frontend/src/components/ui/sidebar/utils.ts create mode 100644 frontend/src/components/ui/skeleton/Skeleton.vue create mode 100644 frontend/src/components/ui/skeleton/index.ts create mode 100644 frontend/src/components/ui/slider/Slider.vue create mode 100644 frontend/src/components/ui/slider/index.ts create mode 100644 frontend/src/components/ui/sonner/Sonner.vue create mode 100644 frontend/src/components/ui/sonner/index.ts create mode 100644 frontend/src/components/ui/spinner/Spinner.vue create mode 100644 frontend/src/components/ui/spinner/index.ts create mode 100644 frontend/src/components/ui/stepper/Stepper.vue create mode 100644 frontend/src/components/ui/stepper/StepperDescription.vue create mode 100644 frontend/src/components/ui/stepper/StepperIndicator.vue create mode 100644 frontend/src/components/ui/stepper/StepperItem.vue create mode 100644 frontend/src/components/ui/stepper/StepperSeparator.vue create mode 100644 frontend/src/components/ui/stepper/StepperTitle.vue create mode 100644 frontend/src/components/ui/stepper/StepperTrigger.vue create mode 100644 frontend/src/components/ui/stepper/index.ts create mode 100644 frontend/src/components/ui/switch/Switch.vue create mode 100644 frontend/src/components/ui/switch/index.ts create mode 100644 frontend/src/components/ui/table/Table.vue create mode 100644 frontend/src/components/ui/table/TableBody.vue create mode 100644 frontend/src/components/ui/table/TableCaption.vue create mode 100644 frontend/src/components/ui/table/TableCell.vue create mode 100644 frontend/src/components/ui/table/TableEmpty.vue create mode 100644 frontend/src/components/ui/table/TableFooter.vue create mode 100644 frontend/src/components/ui/table/TableHead.vue create mode 100644 frontend/src/components/ui/table/TableHeader.vue create mode 100644 frontend/src/components/ui/table/TableRow.vue create mode 100644 frontend/src/components/ui/table/index.ts create mode 100644 frontend/src/components/ui/table/utils.ts create mode 100644 frontend/src/components/ui/tabs/Tabs.vue create mode 100644 frontend/src/components/ui/tabs/TabsContent.vue create mode 100644 frontend/src/components/ui/tabs/TabsList.vue create mode 100644 frontend/src/components/ui/tabs/TabsTrigger.vue create mode 100644 frontend/src/components/ui/tabs/index.ts create mode 100644 frontend/src/components/ui/tags-input/TagsInput.vue create mode 100644 frontend/src/components/ui/tags-input/TagsInputInput.vue create mode 100644 frontend/src/components/ui/tags-input/TagsInputItem.vue create mode 100644 frontend/src/components/ui/tags-input/TagsInputItemDelete.vue create mode 100644 frontend/src/components/ui/tags-input/TagsInputItemText.vue create mode 100644 frontend/src/components/ui/tags-input/index.ts create mode 100644 frontend/src/components/ui/textarea/Textarea.vue create mode 100644 frontend/src/components/ui/textarea/index.ts create mode 100644 frontend/src/components/ui/toggle-group/ToggleGroup.vue create mode 100644 frontend/src/components/ui/toggle-group/ToggleGroupItem.vue create mode 100644 frontend/src/components/ui/toggle-group/index.ts create mode 100644 frontend/src/components/ui/toggle/Toggle.vue create mode 100644 frontend/src/components/ui/toggle/index.ts create mode 100644 frontend/src/components/ui/tooltip/Tooltip.vue create mode 100644 frontend/src/components/ui/tooltip/TooltipContent.vue create mode 100644 frontend/src/components/ui/tooltip/TooltipProvider.vue create mode 100644 frontend/src/components/ui/tooltip/TooltipTrigger.vue create mode 100644 frontend/src/components/ui/tooltip/index.ts create mode 100644 frontend/src/composables/use-auth.ts create mode 100644 frontend/src/composables/use-axios.ts create mode 100644 frontend/src/composables/use-sidebar.ts create mode 100644 frontend/src/composables/use-system-theme.ts create mode 100644 frontend/src/constants/pagination.ts create mode 100644 frontend/src/constants/route-path.ts create mode 100644 frontend/src/constants/themes.ts create mode 100644 frontend/src/layouts/admin.vue create mode 100644 frontend/src/layouts/agent.vue create mode 100644 frontend/src/layouts/blank.vue create mode 100644 frontend/src/layouts/default.vue create mode 100644 frontend/src/layouts/developer.vue create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pages/[...path].vue create mode 100644 frontend/src/pages/agent/apps/[id].vue create mode 100644 frontend/src/pages/agent/apps/index.vue create mode 100644 frontend/src/pages/agent/cards/create.vue create mode 100644 frontend/src/pages/agent/cards/index.vue create mode 100644 frontend/src/pages/agent/finance/index.vue create mode 100644 frontend/src/pages/agent/index.vue create mode 100644 frontend/src/pages/agent/users/index.vue create mode 100644 frontend/src/pages/auth/login.vue create mode 100644 frontend/src/pages/auth/register.vue create mode 100644 frontend/src/pages/developer/agent-apps/[id]/card-types.vue create mode 100644 frontend/src/pages/developer/agent-apps/[id]/edit.vue create mode 100644 frontend/src/pages/developer/agent-apps/[id]/recharge.vue create mode 100644 frontend/src/pages/developer/agent-apps/components/columns.ts create mode 100644 frontend/src/pages/developer/agent-apps/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/agent-apps/components/data-table.vue create mode 100644 frontend/src/pages/developer/agent-apps/data/data.ts create mode 100644 frontend/src/pages/developer/agent-apps/data/schema.ts create mode 100644 frontend/src/pages/developer/agent-apps/index.vue create mode 100644 frontend/src/pages/developer/agent-apps/invite.vue create mode 100644 frontend/src/pages/developer/agent-apps/request.vue create mode 100644 frontend/src/pages/developer/agents/[id].vue create mode 100644 frontend/src/pages/developer/agents/components/columns.ts create mode 100644 frontend/src/pages/developer/agents/components/data-table.vue create mode 100644 frontend/src/pages/developer/agents/create.vue create mode 100644 frontend/src/pages/developer/agents/data/schema.ts create mode 100644 frontend/src/pages/developer/agents/index.vue create mode 100644 frontend/src/pages/developer/announcements/[id].vue create mode 100644 frontend/src/pages/developer/announcements/components/columns.ts create mode 100644 frontend/src/pages/developer/announcements/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/announcements/components/data-table.vue create mode 100644 frontend/src/pages/developer/announcements/create.vue create mode 100644 frontend/src/pages/developer/announcements/data/schema.ts create mode 100644 frontend/src/pages/developer/announcements/index.vue create mode 100644 frontend/src/pages/developer/applications.vue create mode 100644 frontend/src/pages/developer/applications/[id]/email.vue create mode 100644 frontend/src/pages/developer/applications/[id]/security.vue create mode 100644 frontend/src/pages/developer/applications/[id]/settings.vue create mode 100644 frontend/src/pages/developer/applications/components/columns.ts create mode 100644 frontend/src/pages/developer/applications/components/data-table.vue create mode 100644 frontend/src/pages/developer/applications/create.vue create mode 100644 frontend/src/pages/developer/applications/data/schema.ts create mode 100644 frontend/src/pages/developer/card-types/[id].vue create mode 100644 frontend/src/pages/developer/card-types/components/columns.ts create mode 100644 frontend/src/pages/developer/card-types/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/card-types/components/data-table.vue create mode 100644 frontend/src/pages/developer/card-types/create.vue create mode 100644 frontend/src/pages/developer/card-types/data/data.ts create mode 100644 frontend/src/pages/developer/card-types/data/schema.ts create mode 100644 frontend/src/pages/developer/card-types/index.vue create mode 100644 frontend/src/pages/developer/cards/components/columns.ts create mode 100644 frontend/src/pages/developer/cards/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/cards/components/data-table.vue create mode 100644 frontend/src/pages/developer/cards/create.vue create mode 100644 frontend/src/pages/developer/cards/data/data.ts create mode 100644 frontend/src/pages/developer/cards/data/schema.ts create mode 100644 frontend/src/pages/developer/cards/index.vue create mode 100644 frontend/src/pages/developer/cloud-constants/[id].vue create mode 100644 frontend/src/pages/developer/cloud-constants/components/columns.ts create mode 100644 frontend/src/pages/developer/cloud-constants/components/data-table-row-actions.vue create mode 100644 frontend/src/pages/developer/cloud-constants/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/cloud-constants/components/data-table.vue create mode 100644 frontend/src/pages/developer/cloud-constants/create.vue create mode 100644 frontend/src/pages/developer/cloud-constants/data/data.ts create mode 100644 frontend/src/pages/developer/cloud-constants/data/schema.ts create mode 100644 frontend/src/pages/developer/cloud-constants/index.vue create mode 100644 frontend/src/pages/developer/cloud-variables/[id].vue create mode 100644 frontend/src/pages/developer/cloud-variables/[id]/records.vue create mode 100644 frontend/src/pages/developer/cloud-variables/components/columns.ts create mode 100644 frontend/src/pages/developer/cloud-variables/components/data-table-row-actions.vue create mode 100644 frontend/src/pages/developer/cloud-variables/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/cloud-variables/components/data-table.vue create mode 100644 frontend/src/pages/developer/cloud-variables/create.vue create mode 100644 frontend/src/pages/developer/cloud-variables/data/data.ts create mode 100644 frontend/src/pages/developer/cloud-variables/data/schema.ts create mode 100644 frontend/src/pages/developer/cloud-variables/index.vue create mode 100644 frontend/src/pages/developer/devices/components/columns.ts create mode 100644 frontend/src/pages/developer/devices/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/devices/components/data-table.vue create mode 100644 frontend/src/pages/developer/devices/data/data.ts create mode 100644 frontend/src/pages/developer/devices/data/schema.ts create mode 100644 frontend/src/pages/developer/devices/index.vue create mode 100644 frontend/src/pages/developer/dynamic-code.vue create mode 100644 frontend/src/pages/developer/dynamic-code/[id].vue create mode 100644 frontend/src/pages/developer/dynamic-code/components/columns.ts create mode 100644 frontend/src/pages/developer/dynamic-code/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/dynamic-code/components/data-table.vue create mode 100644 frontend/src/pages/developer/dynamic-code/create.vue create mode 100644 frontend/src/pages/developer/dynamic-code/data/schema.ts create mode 100644 frontend/src/pages/developer/dynamic-code/index.vue create mode 100644 frontend/src/pages/developer/extension/api-keys/create.vue create mode 100644 frontend/src/pages/developer/extension/components/apikey-columns.ts create mode 100644 frontend/src/pages/developer/extension/components/apikey-data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/extension/components/apikey-data-table.vue create mode 100644 frontend/src/pages/developer/extension/components/webhook-columns.ts create mode 100644 frontend/src/pages/developer/extension/components/webhook-data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/extension/components/webhook-data-table.vue create mode 100644 frontend/src/pages/developer/extension/data/schema.ts create mode 100644 frontend/src/pages/developer/extension/index.vue create mode 100644 frontend/src/pages/developer/extension/webhooks/create.vue create mode 100644 frontend/src/pages/developer/finance/index.vue create mode 100644 frontend/src/pages/developer/index.vue create mode 100644 frontend/src/pages/developer/logs.vue create mode 100644 frontend/src/pages/developer/risk-control/[id].vue create mode 100644 frontend/src/pages/developer/risk-control/components/columns.ts create mode 100644 frontend/src/pages/developer/risk-control/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/risk-control/components/data-table.vue create mode 100644 frontend/src/pages/developer/risk-control/create.vue create mode 100644 frontend/src/pages/developer/risk-control/data/schema.ts create mode 100644 frontend/src/pages/developer/risk-control/index.vue create mode 100644 frontend/src/pages/developer/sessions/components/columns.ts create mode 100644 frontend/src/pages/developer/sessions/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/sessions/components/data-table.vue create mode 100644 frontend/src/pages/developer/sessions/data/schema.ts create mode 100644 frontend/src/pages/developer/sessions/index.vue create mode 100644 frontend/src/pages/developer/tickets.vue create mode 100644 frontend/src/pages/developer/tickets/[id].vue create mode 100644 frontend/src/pages/developer/tickets/components/columns.ts create mode 100644 frontend/src/pages/developer/tickets/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/tickets/components/data-table.vue create mode 100644 frontend/src/pages/developer/tickets/create.vue create mode 100644 frontend/src/pages/developer/tickets/data/schema.ts create mode 100644 frontend/src/pages/developer/users/[id].vue create mode 100644 frontend/src/pages/developer/users/components/columns.ts create mode 100644 frontend/src/pages/developer/users/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/users/components/data-table.vue create mode 100644 frontend/src/pages/developer/users/create.vue create mode 100644 frontend/src/pages/developer/users/data/data.ts create mode 100644 frontend/src/pages/developer/users/data/schema.ts create mode 100644 frontend/src/pages/developer/users/index.vue create mode 100644 frontend/src/pages/developer/versions/[id].vue create mode 100644 frontend/src/pages/developer/versions/components/columns.ts create mode 100644 frontend/src/pages/developer/versions/components/data-table-toolbar.vue create mode 100644 frontend/src/pages/developer/versions/components/data-table.vue create mode 100644 frontend/src/pages/developer/versions/create.vue create mode 100644 frontend/src/pages/developer/versions/data/schema.ts create mode 100644 frontend/src/pages/developer/versions/index.vue create mode 100644 frontend/src/pages/errors.vue create mode 100644 frontend/src/pages/errors/401.vue create mode 100644 frontend/src/pages/errors/403.vue create mode 100644 frontend/src/pages/errors/404.vue create mode 100644 frontend/src/pages/errors/500.vue create mode 100644 frontend/src/pages/errors/503.vue create mode 100644 frontend/src/pages/errors/index.vue create mode 100644 frontend/src/pages/profile/index.vue create mode 100644 frontend/src/plugins/auto-animate/setup.ts create mode 100644 frontend/src/plugins/dayjs/setup.ts create mode 100644 frontend/src/plugins/i18n/en.json create mode 100644 frontend/src/plugins/i18n/index.ts create mode 100644 frontend/src/plugins/i18n/setup.ts create mode 100644 frontend/src/plugins/i18n/zh.json create mode 100644 frontend/src/plugins/index.ts create mode 100644 frontend/src/plugins/nprogress/setup.ts create mode 100644 frontend/src/plugins/pinia/setup.ts create mode 100644 frontend/src/plugins/router/setup.ts create mode 100644 frontend/src/plugins/tanstack-vue-query/setup.ts create mode 100644 frontend/src/router/guard/auth-guard.ts create mode 100644 frontend/src/router/guard/index.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/router/routes.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/api/example-tasks.api.ts create mode 100644 frontend/src/services/types/response.type.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/theme.ts create mode 100644 frontend/src/types/auto-import-components.d.ts create mode 100644 frontend/src/types/auto-import.d.ts create mode 100644 frontend/src/types/markdown-it.d.ts create mode 100644 frontend/src/types/route-map.d.ts create mode 100644 frontend/src/types/vite-env.d.ts create mode 100644 frontend/src/types/vue-router-meta.d.ts create mode 100644 frontend/src/utils/env.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vercel.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..608eb35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Dependencies +node_modules/ +vendor/ + +# Build outputs +dist/ +build/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test outputs +coverage/ +*.test +*.out + +# Go +*.sum + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log + +# Uploads +uploads/ +backend/uploads/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Temp +tmp/ +temp/ +*.tmp + +# Trae +.trae/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8930afd --- /dev/null +++ b/README.md @@ -0,0 +1,306 @@ +# 开发者部署平台 (Developer Platform) + +## 项目简介 + +开发者部署平台是一个面向开发者的软件授权和验证管理平台,提供开发者官网展示、产品功能介绍、产品定价方案、开发者后台管理系统以及代理商后台管理系统。 + +## 功能模块 + +### 1. 开发者官网展示 +- 平台介绍页面 +- 功能特性展示 +- 快速入门指南 +- 开发者社区入口 + +### 2. 产品功能介绍 +- 授权验证功能 +- 用户管理功能 +- 数据统计功能 +- API 接口说明 + +### 3. 产品定价方案 +- 套餐价格展示 +- 功能对比表 +- 免费试用入口 +- 在线购买流程 + +### 4. 开发者后台管理系统 +- 应用管理 +- 卡密管理 +- 用户管理 +- 设备管理 +- 版本管理 +- 云端数据管理 +- 风控管理 +- 财务管理 +- 工单系统 +- 日志记录 + +### 5. 代理商后台管理系统 +- 代理授权管理 +- 卡密权限配置 +- 余额充值 +- 佣金管理 +- 数据统计 + +## 技术架构 + +### 前端 +- **框架**: Vue 3 + TypeScript +- **构建工具**: Vite +- **UI 组件库**: shadcn-vue +- **样式**: Tailwind CSS +- **状态管理**: Pinia +- **路由**: Vue Router + +### 后端 +- **语言**: Go 1.21+ +- **Web 框架**: Gin +- **数据库**: MySQL 8.0 +- **缓存**: Redis +- **ORM**: GORM + +## 项目结构 + +``` +developer-platform/ +├── frontend/ # 前端项目 +│ ├── src/ +│ │ ├── pages/ # 页面组件 +│ │ │ ├── developer/ # 开发者后台页面 +│ │ │ │ ├── applications/ # 应用管理 +│ │ │ │ ├── cards/ # 卡密管理 +│ │ │ │ ├── users/ # 用户管理 +│ │ │ │ ├── devices/ # 设备管理 +│ │ │ │ ├── versions/ # 版本管理 +│ │ │ │ ├── cloud-constants/ # 云端常量 +│ │ │ │ ├── cloud-variables/ # 云端变量 +│ │ │ │ ├── dynamic-code/ # 云端函数 +│ │ │ │ ├── risk-control/ # 风控管理 +│ │ │ │ ├── agent-apps/ # 代理授权 +│ │ │ │ ├── finance/ # 财务管理 +│ │ │ │ ├── tickets/ # 工单系统 +│ │ │ │ └── logs/ # 日志记录 +│ │ │ └── index.vue # 官网首页 +│ │ ├── components/ # 公共组件 +│ │ ├── router/ # 路由配置 +│ │ ├── stores/ # 状态管理 +│ │ └── services/ # API 服务 +│ └── package.json +├── backend/ # 后端项目 +│ ├── cmd/ # 应用入口 +│ ├── internal/ +│ │ ├── router/ # 路由配置 +│ │ │ ├── developer/ # 开发者路由 +│ │ │ ├── app/ # 应用 API 路由 +│ │ │ └── extension/ # 扩展路由 +│ │ ├── middleware/ # 中间件 +│ │ ├── model/ # 数据模型 +│ │ ├── service/ # 业务逻辑 +│ │ └── config/ # 配置管理 +│ └── go.mod +├── database/ # 数据库文件 +│ ├── schema.sql # 数据库结构 +│ └── seeds.sql # 初始数据 +└── docker-compose.yml # Docker 编排文件 +``` + +## 快速开始 + +### 环境要求 + +- Node.js >= 22.15 +- pnpm >= 10 +- Go >= 1.21 +- MySQL >= 8.0 +- Redis >= 7.0 + +### 本地开发 + +#### 1. 克隆项目 + +```bash +git clone +cd developer-platform +``` + +#### 2. 启动后端服务 + +```bash +cd backend +cp config.yaml.example config.yaml +# 修改 config.yaml 中的数据库和 Redis 配置 +go mod download +go run cmd/main.go +``` + +#### 3. 启动前端服务 + +```bash +cd frontend +pnpm install +pnpm dev +``` + +#### 4. 访问应用 + +- 前端地址: http://localhost:5173 +- 后端 API: http://localhost:8080 + +### Docker 部署 + +```bash +# 构建并启动所有服务 +docker-compose up -d + +# 查看服务状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f + +# 停止服务 +docker-compose down +``` + +## 数据库配置 + +### 创建数据库 + +```sql +CREATE DATABASE developer_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### 导入数据结构 + +```bash +mysql -u root -p developer_platform < database/schema.sql +mysql -u root -p developer_platform < database/seeds.sql +``` + +## 环境变量 + +### 后端环境变量 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| APP_ENV | 运行环境 | development | +| DATABASE_HOST | 数据库地址 | localhost | +| DATABASE_PORT | 数据库端口 | 3306 | +| DATABASE_NAME | 数据库名称 | developer_platform | +| DATABASE_USERNAME | 数据库用户名 | root | +| DATABASE_PASSWORD | 数据库密码 | - | +| REDIS_HOST | Redis 地址 | localhost | +| REDIS_PORT | Redis 端口 | 6379 | + +## 部署文档 + +### 传统部署 + +1. **准备服务器** + - 操作系统: Ubuntu 20.04+ / CentOS 7+ + - 内存: 最低 2GB,推荐 4GB+ + - CPU: 最低 2 核,推荐 4 核+ + - 存储: 最低 20GB,推荐 50GB+ + +2. **安装依赖** + ```bash + # 安装 Node.js + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + + # 安装 pnpm + npm install -g pnpm + + # 安装 Go + wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz + sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz + export PATH=$PATH:/usr/local/go/bin + + # 安装 MySQL + sudo apt-get install mysql-server + + # 安装 Redis + sudo apt-get install redis-server + ``` + +3. **部署后端** + ```bash + cd backend + go build -o developer-platform cmd/main.go + ./developer-platform + ``` + +4. **部署前端** + ```bash + cd frontend + pnpm install + pnpm build + # 使用 Nginx 托管 dist 目录 + ``` + +### Docker 部署 + +```bash +# 构建镜像 +docker build -t developer-platform-frontend ./frontend +docker build -t developer-platform-backend ./backend + +# 运行容器 +docker-compose up -d +``` + +### Kubernetes 部署 + +提供完整的 Kubernetes 部署配置文件,支持: +- Deployment 配置 +- Service 配置 +- Ingress 配置 +- ConfigMap 和 Secret 配置 +- 持久化存储配置 + +## 数据隔离说明 + +本平台与企业官网平台完全独立: +- 独立的数据库实例 +- 独立的 Redis 缓存 +- 独立的后端服务 +- 独立的前端应用 +- 无数据共享和依赖 + +## 安全性保障 + +1. **数据加密**: 敏感数据采用 AES 加密存储 +2. **身份认证**: JWT Token 认证机制 +3. **权限控制**: 基于角色的访问控制 (RBAC) +4. **SQL 注入防护**: 使用 ORM 框架防止 SQL 注入 +5. **XSS 防护**: 前端输入输出过滤 +6. **CSRF 防护**: Token 验证机制 +7. **API 密钥管理**: 安全的 API 密钥生成和管理 + +## 开发者工具 + +### SDK 支持 + +提供多语言 SDK: +- JavaScript/TypeScript SDK +- Python SDK +- Go SDK +- Java SDK + +### API 文档 + +完整的 RESTful API 文档,支持: +- OpenAPI 3.0 规范 +- Swagger UI 在线文档 +- Postman 集合 + +## 许可证 + +MIT License + +## 联系方式 + +- 作者: viaeon +- 邮箱: support@example.com +- 文档: https://docs.example.com diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c7dc11f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,47 @@ +# 后端Dockerfile +FROM golang:1.21-alpine AS builder + +# 设置工作目录 +WORKDIR /app + +# 安装必要的包 +RUN apk add --no-cache git + +# 复制go mod文件 +COPY go.mod go.sum ./ + +# 下载依赖 +RUN go mod download + +# 复制源代码 +COPY . . + +# 构建应用 +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/main.go + +# 最终镜像 +FROM alpine:latest + +# 安装ca证书 +RUN apk --no-cache add ca-certificates + +# 创建非root用户 +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /root/ + +# 从builder阶段复制二进制文件 +COPY --from=builder /app/main . + +# 更改文件所有者 +RUN chown appuser:appgroup main + +# 切换到非root用户 +USER appuser + +# 暴露端口 +EXPOSE 8080 + +# 启动应用 +CMD ["./main"] \ No newline at end of file diff --git a/backend/cmd/check_db/main.go b/backend/cmd/check_db/main.go new file mode 100644 index 0000000..76fadca --- /dev/null +++ b/backend/cmd/check_db/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/model" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type User = model.User +type Application = model.Application +type Package = model.Package +type PackagePermission = model.PackagePermission +type UserPackage = model.UserPackage + +func main() { + dbPath := "verification_platform.db" + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + log.Fatal("Failed to connect database:", err) + } + + var users []User + db.Find(&users) + fmt.Println("=== 用户列表 ===") + for _, u := range users { + fmt.Printf("ID: %d, 用户名: %s, 当前套餐ID: %v\n", u.ID, u.Username, u.CurrentPackageID) + } + + var packages []Package + db.Find(&packages) + fmt.Println("\n=== 套餐列表 ===") + for _, p := range packages { + fmt.Printf("ID: %d, 名称: %s\n", p.ID, p.Name) + } + + var permissions []PackagePermission + db.Find(&permissions) + fmt.Println("\n=== 套餐权限 ===") + for _, p := range permissions { + fmt.Printf("套餐ID: %d, 最大应用数: %d\n", p.PackageID, p.MaxApplications) + } + + var userPackages []UserPackage + db.Find(&userPackages) + fmt.Println("\n=== 用户套餐 ===") + for _, up := range userPackages { + fmt.Printf("用户ID: %d, 套餐ID: %d\n", up.UserID, up.PackageID) + } + + var apps []Application + db.Order("created_at ASC").Find(&apps) + fmt.Println("\n=== 应用列表 ===") + for i, a := range apps { + fmt.Printf("索引: %d, ID: %d, 用户ID: %d, 名称: %s, 创建时间: %v\n", i, a.ID, a.UserID, a.Name, a.CreatedAt) + } +} diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..5473c0f --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,164 @@ +package main + +// 重要提示:请始终从 backend/ 目录运行此服务 +// 正确的运行方式: +// cd d:\Code\verify\verification-platform\backend +// go run cmd/main.go +// +// 如果从其他目录运行,可能会导致数据库文件路径错误,造成数据丢失。 +// +// 嵌入前端静态文件: +// 1. 编译前端:cd frontend-new && npm run build +// 2. 将 dist 目录内容复制到 backend/embedded/dist +// 3. 取消下方 embed 相关代码的注释 +// 4. 重新编译后端 + +import ( + "embed" + "fmt" + "io/fs" + "log" + "net/http" + "strings" + "verification-platform-backend/internal/config" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/middleware" + "verification-platform-backend/internal/router" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/logger" + + "github.com/gin-gonic/gin" +) + +//go:embed embedded/dist +var embeddedFiles embed.FS + +// @title 网络验证平台API +// @version 1.0 +// @description 网络验证平台后端API接口文档 +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @host localhost:8080 +// @BasePath /api/v1 + +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization +func main() { + // 初始化配置 + config.Init() + + // 初始化日志 + logger.Init() + + // 初始化数据库 + database.Init() + + // 设置Gin模式 + if config.GetString("app.env") == "production" { + gin.SetMode(gin.ReleaseMode) + } + + // 创建Gin引擎 + r := gin.Default() + + // 设置请求体大小限制 (32MB) + r.MaxMultipartMemory = 32 << 20 + + // 添加中间件 + r.Use(middleware.Logger()) + r.Use(middleware.Recovery()) + r.Use(middleware.Cors()) + + r.Use(func(c *gin.Context) { + fmt.Printf("全局中间件: %s %s\n", c.Request.Method, c.Request.URL.Path) + c.Next() + }) + + // 静态文件服务 - 用于访问上传的图标 + r.Static("/uploads", "uploads") + + // 设置路由 + router.SetupRoutes(r) + + // 嵌入前端静态文件服务 + setupEmbeddedFrontend(r) + + // 启动服务器 + port := config.GetString("app.port") + if port == "" { + port = "8080" + } + + log.Printf("Server starting on port %s", port) + if err := r.Run(":" + port); err != nil { + log.Fatal("Failed to start server: ", err) + } +} + +func setupEmbeddedFrontend(r *gin.Engine) { + distFS, err := fs.Sub(embeddedFiles, "embedded/dist") + if err != nil { + log.Printf("Warning: embedded dist not found, frontend will not be served") + return + } + + indexHTML, err := fs.ReadFile(distFS, "index.html") + if err != nil { + log.Printf("Warning: index.html not found in embedded dist") + return + } + + r.NoRoute(func(c *gin.Context) { + if c.Request.Method == "GET" && !strings.HasPrefix(c.Request.URL.Path, "/api/") { + serveIndexHTML(c, string(indexHTML)) + return + } + c.JSON(404, gin.H{"error": "not found"}) + }) + + r.GET("/assets/*filepath", func(c *gin.Context) { + c.FileFromFS(c.Request.URL.Path, http.FS(distFS)) + }) +} + +func serveIndexHTML(c *gin.Context, indexHTML string) { + settingService := service.NewSettingService() + settings, err := settingService.GetSettingsByCategory("basic") + if err != nil { + settings = make(map[string]interface{}) + } + + siteName := getSettingString(settings, "siteName", "微授权") + siteDescription := getSettingString(settings, "siteDescription", "专业的应用验证平台") + siteKeywords := getSettingString(settings, "siteKeywords", "验证平台,软件授权,卡密验证") + logoLight := getSettingString(settings, "logoLight", "") + logoDark := getSettingString(settings, "logoDark", "") + favicon := getSettingString(settings, "favicon", "") + + html := indexHTML + html = strings.ReplaceAll(html, "{{.SiteName}}", siteName) + html = strings.ReplaceAll(html, "{{.SiteDescription}}", siteDescription) + html = strings.ReplaceAll(html, "{{.SiteKeywords}}", siteKeywords) + html = strings.ReplaceAll(html, "{{.LogoLight}}", logoLight) + html = strings.ReplaceAll(html, "{{.LogoDark}}", logoDark) + html = strings.ReplaceAll(html, "{{.Favicon}}", favicon) + + c.Data(200, "text/html; charset=utf-8", []byte(html)) +} + +func getSettingString(settings map[string]interface{}, key, defaultValue string) string { + if val, ok := settings[key]; ok { + if str, ok := val.(string); ok && str != "" { + return str + } + } + return defaultValue +} diff --git a/backend/cmd/scripts/init_extension_docs.go b/backend/cmd/scripts/init_extension_docs.go new file mode 100644 index 0000000..8582095 --- /dev/null +++ b/backend/cmd/scripts/init_extension_docs.go @@ -0,0 +1,936 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + + "gorm.io/gorm" +) + +func main() { + database.Init() + db := database.DB + + log.Println("开始初始化扩展API文档...") + + var category model.DocCategory + result := db.Where("slug = ?", "extension-api").First(&category) + if result.Error == gorm.ErrRecordNotFound { + category = model.DocCategory{ + Name: "扩展API", + Slug: "extension-api", + Description: "开放API文档,供开发者在自己的服务器集成平台功能", + Icon: "code", + Status: "active", + Sort: 100, + } + if err := db.Create(&category).Error; err != nil { + log.Fatalf("创建文档分类失败: %v", err) + } + log.Println("创建文档分类成功") + } else { + log.Println("文档分类已存在") + } + + docs := []struct { + Title string + Slug string + Content string + Sort int + }{ + { + Title: "扩展API概述", + Slug: "extension-api-overview", + Content: `# 扩展API概述 + +扩展API允许开发者在自己的服务器上集成平台功能,实现自定义的业务逻辑。 + +## 功能特点 + +- **用户管理**: 查询用户信息、充值、扣费等操作 +- **卡密管理**: 查询卡密、生成卡密 +- **消息通知**: 向用户发送通知消息 +- **应用管理**: 获取应用信息和统计数据 +- **云端变量**: 读写用户和应用级别的云端变量 + +## 认证方式 + +所有API请求需要进行签名认证,请求头需要包含以下字段: + +| 字段 | 说明 | +|------|------| +| X-Access-Key | API密钥的Access Key | +| X-Signature | 请求签名 | +| X-Timestamp | 请求时间戳(秒级) | + +## 签名算法 + +签名生成步骤: + +1. 拼接签名字符串: ` + "`METHOD + PATH + TIMESTAMP + BODY`" + ` +2. 使用Secret Key对签名字符串进行HMAC-SHA256签名 +3. 将签名结果转换为十六进制字符串 + +` + "```javascript" + ` +// JavaScript示例 +const crypto = require('crypto'); + +function generateSignature(method, path, timestamp, body, secretKey) { + const stringToSign = method + path + timestamp + body; + const hmac = crypto.createHmac('sha256', secretKey); + hmac.update(stringToSign); + return hmac.digest('hex'); +} +` + "```" + ` + +## 请求限制 + +- 时间戳有效期:5分钟 +- 请求频率:根据套餐限制 + +## 创建API密钥 + +1. 进入「扩展配置」页面 +2. 切换到「API密钥」标签 +3. 点击「创建密钥」按钮 +4. 选择应用并配置权限 +5. 保存Access Key和Secret Key + +> ⚠️ **注意**: Secret Key只在创建时显示一次,请妥善保存! +`, + Sort: 1, + }, + { + Title: "用户管理", + Slug: "extension-api-user", + Content: `# 用户管理 + +## 获取用户信息 + +获取指定用户的详细信息。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/user/:userId +` + "```" + ` + +### 请求头 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| X-Access-Key | string | 是 | Access Key | +| X-Signature | string | 是 | 请求签名 | +| X-Timestamp | string | 是 | 时间戳 | + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "id": 1, + "username": "testuser", + "email": "test@example.com", + "status": "active", + "expiry_at": "2025-12-31T23:59:59Z", + "last_login_at": "2024-01-15T10:30:00Z", + "is_trial_user": false, + "created_at": "2024-01-01T00:00:00Z" + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`user.read`" + ` + +--- + +## 获取用户列表 + +获取应用下的用户列表。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/users +` + "```" + ` + +### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认1 | +| page_size | int | 否 | 每页数量,默认20 | +| status | string | 否 | 状态筛选 | +| search | string | 否 | 搜索关键词 | + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "users": [...], + "total": 100, + "page": 1, + "page_size": 20, + "total_page": 5 + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`user.list`" + ` + +--- + +## 用户充值 + +为用户充值时长。 + +### 请求 + +` + "```" + ` +POST /api/v1/ext/user/:userId/recharge +` + "```" + ` + +### 请求体 + +` + "```json" + ` +{ + "amount": 30, + "type": "days", + "description": "在线充值" +} +` + "```" + ` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| amount | int | 是 | 充值数量 | +| type | string | 是 | 类型:days(天数) | +| description | string | 否 | 描述说明 | + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "message": "充值成功", + "user": { + "id": 1, + "expiry_at": "2025-12-31T23:59:59Z" + } + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`user.recharge`" + ` + +--- + +## 用户扣费 + +扣除用户时长。 + +### 请求 + +` + "```" + ` +POST /api/v1/ext/user/:userId/deduct +` + "```" + ` + +### 请求体 + +` + "```json" + ` +{ + "amount": 10, + "type": "days", + "description": "消费扣费" +} +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "message": "扣除成功", + "user": { + "id": 1, + "expiry_at": "2025-12-21T23:59:59Z" + } + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`user.deduct`" + ` + +--- + +## 获取用户变量 + +获取用户的云端变量。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/user/:userId/variables +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "vip_level": { + "value": "3", + "scope": "user" + }, + "game_score": { + "value": "1000", + "scope": "user" + } + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`user.variables`" + ` + +--- + +## 更新用户变量 + +更新用户的云端变量。 + +### 请求 + +` + "```" + ` +POST /api/v1/ext/user/:userId/variables +` + "```" + ` + +### 请求体 + +` + "```json" + ` +{ + "variables": { + "vip_level": "5", + "game_score": "2000" + } +} +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "message": "更新成功" + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`user.variables`" + ` +`, + Sort: 2, + }, + { + Title: "卡密管理", + Slug: "extension-api-card", + Content: `# 卡密管理 + +## 获取卡密列表 + +获取应用下的卡密列表。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/cards +` + "```" + ` + +### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认1 | +| page_size | int | 否 | 每页数量,默认20 | +| status | string | 否 | 状态筛选:unused/used/disabled | +| card_type_id | int | 否 | 卡密类型ID | + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "cards": [ + { + "id": 1, + "card_key": "ABCD1234EFGH5678", + "card_type_id": 1, + "status": "unused", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "total": 100, + "page": 1, + "page_size": 20, + "total_page": 5 + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`card.read`" + ` + +--- + +## 生成卡密 + +批量生成卡密。 + +### 请求 + +` + "```" + ` +POST /api/v1/ext/cards/generate +` + "```" + ` + +### 请求体 + +` + "```json" + ` +{ + "card_type_id": 1, + "count": 10 +} +` + "```" + ` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| card_type_id | int | 是 | 卡密类型ID | +| count | int | 是 | 生成数量 | + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "message": "生成成功", + "count": 10, + "cards": [ + { + "id": 1, + "card_key": "ABCD1234EFGH5678", + "status": "unused" + } + ] + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`card.generate`" + ` + +--- + +## 获取卡密详情 + +获取单个卡密的详细信息。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/card/:cardId +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "id": 1, + "card_key": "ABCD1234EFGH5678", + "card_type_id": 1, + "status": "unused", + "used_at": null, + "app_user_id": null, + "created_at": "2024-01-01T00:00:00Z" + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`card.read`" + ` +`, + Sort: 3, + }, + { + Title: "消息通知", + Slug: "extension-api-notification", + Content: `# 消息通知 + +## 发送单个通知 + +向指定用户发送通知消息。 + +### 请求 + +` + "```" + ` +POST /api/v1/ext/notification +` + "```" + ` + +### 请求体 + +` + "```json" + ` +{ + "user_id": 1, + "title": "系统通知", + "content": "您的账户已充值成功!", + "type": "info" +} +` + "```" + ` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| user_id | int | 是 | 用户ID | +| title | string | 是 | 通知标题 | +| content | string | 是 | 通知内容 | +| type | string | 否 | 类型:info/warning/error | + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "message": "发送成功", + "notification": { + "id": 1, + "title": "系统通知", + "content": "您的账户已充值成功!" + } + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`notification.send`" + ` + +--- + +## 批量发送通知 + +向所有用户发送通知消息。 + +### 请求 + +` + "```" + ` +POST /api/v1/ext/notification/batch +` + "```" + ` + +### 请求体 + +` + "```json" + ` +{ + "title": "系统公告", + "content": "系统将于今晚进行维护...", + "type": "warning" +} +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "message": "发送成功", + "notification": { + "id": 1, + "title": "系统公告", + "content": "系统将于今晚进行维护..." + } + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`notification.send`" + ` +`, + Sort: 4, + }, + { + Title: "应用管理", + Slug: "extension-api-app", + Content: `# 应用管理 + +## 获取应用信息 + +获取当前应用的基本信息。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/app/info +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "id": 1, + "name": "我的应用", + "description": "应用描述", + "billing_type": "subscription", + "encrypt_type": "aes", + "bind_type": "device", + "max_devices": 3, + "multi_open": false, + "enable_trial": true, + "trial_duration": 7, + "status": "active", + "created_at": "2024-01-01T00:00:00Z" + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`app.info`" + ` + +--- + +## 获取应用统计 + +获取应用的统计数据。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/app/stats +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "user_count": 1000, + "active_user_count": 500, + "card_count": 200, + "used_card_count": 150 + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`app.stats`" + ` + +--- + +## 获取应用变量 + +获取应用级别的云端变量。 + +### 请求 + +` + "```" + ` +GET /api/v1/ext/app/variables +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "app_version": "1.0.0", + "maintenance_mode": "false", + "announcement": "欢迎使用本应用" + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`app.variables`" + ` + +--- + +## 更新应用变量 + +更新应用级别的云端变量。 + +### 请求 + +` + "```" + ` +POST /api/v1/ext/app/variables +` + "```" + ` + +### 请求体 + +` + "```json" + ` +{ + "variables": { + "app_version": "1.0.1", + "maintenance_mode": "true", + "announcement": "系统维护中,请稍后..." + } +} +` + "```" + ` + +### 响应示例 + +` + "```json" + ` +{ + "code": 0, + "message": "success", + "data": { + "message": "更新成功" + } +} +` + "```" + ` + +### 所需权限 + +- ` + "`app.variables`" + ` +`, + Sort: 5, + }, + { + Title: "代码示例", + Slug: "extension-api-examples", + Content: `# 代码示例 + +## JavaScript/Node.js + +` + "```javascript" + ` +const crypto = require('crypto'); +const axios = require('axios'); + +const ACCESS_KEY = 'your_access_key'; +const SECRET_KEY = 'your_secret_key'; +const BASE_URL = 'https://your-domain.com/api/v1/ext'; + +// 生成签名 +function generateSignature(method, path, timestamp, body) { + const bodyStr = body ? JSON.stringify(body) : ''; + const stringToSign = method + path + timestamp + bodyStr; + const hmac = crypto.createHmac('sha256', SECRET_KEY); + hmac.update(stringToSign); + return hmac.digest('hex'); +} + +// 发送请求 +async function request(method, path, body = null) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateSignature(method, path, timestamp, body); + + const headers = { + 'X-Access-Key': ACCESS_KEY, + 'X-Signature': signature, + 'X-Timestamp': timestamp, + 'Content-Type': 'application/json' + }; + + const config = { + method, + url: BASE_URL + path, + headers + }; + + if (body) { + config.data = body; + } + + const response = await axios(config); + return response.data; +} + +// 使用示例 +async function main() { + // 获取用户信息 + const user = await request('GET', '/user/1'); + console.log('用户信息:', user); + + // 用户充值 + const result = await request('POST', '/user/1/recharge', { + amount: 30, + type: 'days', + description: '在线充值' + }); + console.log('充值结果:', result); +} + +main().catch(console.error); +` + "```" + ` + +## Python + +` + "```python" + ` +import hmac +import hashlib +import time +import requests +import json + +ACCESS_KEY = 'your_access_key' +SECRET_KEY = 'your_secret_key' +BASE_URL = 'https://your-domain.com/api/v1/ext' + +def generate_signature(method, path, timestamp, body=''): + string_to_sign = method + path + timestamp + body + signature = hmac.new( + SECRET_KEY.encode('utf-8'), + string_to_sign.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + return signature + +def request(method, path, body=None): + timestamp = str(int(time.time())) + body_str = json.dumps(body) if body else '' + signature = generate_signature(method, path, timestamp, body_str) + + headers = { + 'X-Access-Key': ACCESS_KEY, + 'X-Signature': signature, + 'X-Timestamp': timestamp, + 'Content-Type': 'application/json' + } + + url = BASE_URL + path + response = requests.request(method, url, headers=headers, json=body) + return response.json() + +# 使用示例 +if __name__ == '__main__': + # 获取用户信息 + user = request('GET', '/user/1') + print('用户信息:', user) + + # 用户充值 + result = request('POST', '/user/1/recharge', { + 'amount': 30, + 'type': 'days', + 'description': '在线充值' + }) + print('充值结果:', result) +` + "```" + ` + +## 在线充值集成示例 + +以下是一个完整的在线充值集成示例,展示如何实现用户在线支付后自动充值: + +` + "```javascript" + ` +// 支付回调处理 +app.post('/payment/callback', async (req, res) => { + const { order_id, user_id, amount, status } = req.body; + + // 验证支付状态 + if (status !== 'success') { + return res.json({ code: -1, message: '支付失败' }); + } + + try { + // 调用扩展API为用户充值 + const result = await request('POST', '/user/' + user_id + '/recharge', { + amount: Math.floor(amount), // 充值天数 + type: 'days', + description: '在线支付充值 - 订单号: ' + order_id + }); + + if (result.code === 0) { + // 更新订单状态 + await updateOrderStatus(order_id, 'completed'); + res.json({ code: 0, message: '充值成功' }); + } else { + res.json({ code: -1, message: result.message }); + } + } catch (error) { + console.error('充值失败:', error); + res.json({ code: -1, message: '充值失败' }); + } +}); +` + "```" + ` +`, + Sort: 6, + }, + } + + for _, docData := range docs { + var existingDoc model.Doc + result := db.Where("slug = ?", docData.Slug).First(&existingDoc) + if result.Error == gorm.ErrRecordNotFound { + doc := model.Doc{ + CategoryID: &category.ID, + Title: docData.Title, + Slug: docData.Slug, + Content: docData.Content, + Status: "published", + Sort: docData.Sort, + } + if err := db.Create(&doc).Error; err != nil { + log.Printf("创建文档 %s 失败: %v", docData.Title, err) + } else { + log.Printf("创建文档 %s 成功", docData.Title) + } + } else { + existingDoc.Content = docData.Content + existingDoc.Title = docData.Title + db.Save(&existingDoc) + log.Printf("文档 %s 已存在,已更新", docData.Title) + } + } + + log.Println("扩展API文档初始化完成!") + fmt.Println("\n文档列表:") + fmt.Println("1. 扩展API概述") + fmt.Println("2. 用户管理") + fmt.Println("3. 卡密管理") + fmt.Println("4. 消息通知") + fmt.Println("5. 应用管理") + fmt.Println("6. 代码示例") + fmt.Println("\n请访问管理后台的文档管理页面查看和编辑这些文档。") +} diff --git a/backend/cmd/scripts/init_extension_docs.sql b/backend/cmd/scripts/init_extension_docs.sql new file mode 100644 index 0000000..d97dd3a --- /dev/null +++ b/backend/cmd/scripts/init_extension_docs.sql @@ -0,0 +1,852 @@ +-- 扩展API文档初始化脚本 +-- 运行方式: 在项目根目录执行 go run cmd/scripts/init_extension_docs.go + +-- 首先创建扩展API文档分类 +INSERT INTO doc_categories (name, slug, description, icon, status, sort, created_at, updated_at) +VALUES ('扩展API', 'extension-api', '开放API文档,供开发者在自己的服务器集成平台功能', 'code', 'active', 100, datetime('now'), datetime('now')); + +-- 概述文档 +INSERT INTO docs (category_id, title, slug, content, status, sort, created_at, updated_at) +VALUES ( + (SELECT id FROM doc_categories WHERE slug = 'extension-api'), + '扩展API概述', + 'extension-api-overview', + '# 扩展API概述 + +扩展API允许开发者在自己的服务器上集成平台功能,实现自定义的业务逻辑。 + +## 功能特点 + +- **用户管理**: 查询用户信息、充值、扣费等操作 +- **卡密管理**: 查询卡密、生成卡密 +- **消息通知**: 向用户发送通知消息 +- **应用管理**: 获取应用信息和统计数据 +- **云端变量**: 读写用户和应用级别的云端变量 + +## 认证方式 + +所有API请求需要进行签名认证,请求头需要包含以下字段: + +| 字段 | 说明 | +|------|------| +| X-Access-Key | API密钥的Access Key | +| X-Signature | 请求签名 | +| X-Timestamp | 请求时间戳(秒级) | + +## 签名算法 + +签名生成步骤: + +1. 拼接签名字符串: `METHOD + PATH + TIMESTAMP + BODY` +2. 使用Secret Key对签名字符串进行HMAC-SHA256签名 +3. 将签名结果转换为十六进制字符串 + +```javascript +// JavaScript示例 +const crypto = require(''crypto''); + +function generateSignature(method, path, timestamp, body, secretKey) { + const stringToSign = method + path + timestamp + body; + const hmac = crypto.createHmac(''sha256'', secretKey); + hmac.update(stringToSign); + return hmac.digest(''hex''); +} +``` + +## 请求限制 + +- 时间戳有效期:5分钟 +- 请求频率:根据套餐限制 + +## 创建API密钥 + +1. 进入「扩展配置」页面 +2. 切换到「API密钥」标签 +3. 点击「创建密钥」按钮 +4. 选择应用并配置权限 +5. 保存Access Key和Secret Key + +> ⚠️ **注意**: Secret Key只在创建时显示一次,请妥善保存! +', + 'published', + 1, + datetime('now'), + datetime('now') +); + +-- 用户管理API文档 +INSERT INTO docs (category_id, title, slug, content, status, sort, created_at, updated_at) +VALUES ( + (SELECT id FROM doc_categories WHERE slug = 'extension-api'), + '用户管理', + 'extension-api-user', + '# 用户管理 + +## 获取用户信息 + +获取指定用户的详细信息。 + +### 请求 + +``` +GET /api/v1/ext/user/:userId +``` + +### 请求头 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| X-Access-Key | string | 是 | Access Key | +| X-Signature | string | 是 | 请求签名 | +| X-Timestamp | string | 是 | 时间戳 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "username": "testuser", + "email": "test@example.com", + "status": "active", + "balance": 100.00, + "expiry_at": "2024-12-31T23:59:59Z", + "last_login_at": "2024-01-15T10:30:00Z", + "is_trial_user": false, + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +--- + +## 获取用户列表 + +获取应用下的用户列表。 + +### 请求 + +``` +GET /api/v1/ext/users +``` + +### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认1 | +| page_size | int | 否 | 每页数量,默认20 | +| status | string | 否 | 状态筛选 | +| search | string | 否 | 搜索关键词 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "users": [...], + "total": 100, + "page": 1, + "page_size": 20, + "total_page": 5 + } +} +``` + +--- + +## 用户充值 + +为用户充值时长或余额。 + +### 请求 + +``` +POST /api/v1/ext/user/:userId/recharge +``` + +### 请求体 + +```json +{ + "amount": 100, + "type": "days", + "description": "在线充值" +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| amount | int | 是 | 充值数量 | +| type | string | 是 | 类型:days(天数)或 balance(余额) | +| description | string | 否 | 描述说明 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "充值成功", + "user": { + "id": 1, + "balance": 100.00, + "expiry_at": "2024-12-31T23:59:59Z" + } + } +} +``` + +--- + +## 用户扣费 + +扣除用户余额或时长。 + +### 请求 + +``` +POST /api/v1/ext/user/:userId/deduct +``` + +### 请求体 + +```json +{ + "amount": 50, + "type": "balance", + "description": "消费扣费" +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| amount | int | 是 | 扣除数量 | +| type | string | 是 | 类型:days(天数)或 balance(余额) | +| description | string | 否 | 描述说明 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "扣除成功", + "user": { + "id": 1, + "balance": 50.00 + } + } +} +``` + +--- + +## 获取用户变量 + +获取用户的云端变量。 + +### 请求 + +``` +GET /api/v1/ext/user/:userId/variables +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "vip_level": { + "value": "3", + "scope": "user" + }, + "game_score": { + "value": "1000", + "scope": "user" + } + } +} +``` + +--- + +## 更新用户变量 + +更新用户的云端变量。 + +### 请求 + +``` +POST /api/v1/ext/user/:userId/variables +``` + +### 请求体 + +```json +{ + "variables": { + "vip_level": "5", + "game_score": "2000" + } +} +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "更新成功" + } +} +``` +', + 'published', + 2, + datetime('now'), + datetime('now') +); + +-- 卡密管理API文档 +INSERT INTO docs (category_id, title, slug, content, status, sort, created_at, updated_at) +VALUES ( + (SELECT id FROM doc_categories WHERE slug = 'extension-api'), + '卡密管理', + 'extension-api-card', + '# 卡密管理 + +## 获取卡密列表 + +获取应用下的卡密列表。 + +### 请求 + +``` +GET /api/v1/ext/cards +``` + +### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认1 | +| page_size | int | 否 | 每页数量,默认20 | +| status | string | 否 | 状态筛选:unused/used/disabled | +| card_type_id | int | 否 | 卡密类型ID | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "cards": [ + { + "id": 1, + "card_key": "ABCD1234EFGH5678", + "card_type_id": 1, + "status": "unused", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "total": 100, + "page": 1, + "page_size": 20, + "total_page": 5 + } +} +``` + +--- + +## 生成卡密 + +批量生成卡密。 + +### 请求 + +``` +POST /api/v1/ext/cards/generate +``` + +### 请求体 + +```json +{ + "card_type_id": 1, + "count": 10 +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| card_type_id | int | 是 | 卡密类型ID | +| count | int | 是 | 生成数量 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "生成成功", + "count": 10, + "cards": [ + { + "id": 1, + "card_key": "ABCD1234EFGH5678", + "status": "unused" + } + ] + } +} +``` + +--- + +## 获取卡密详情 + +获取单个卡密的详细信息。 + +### 请求 + +``` +GET /api/v1/ext/card/:cardId +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "card_key": "ABCD1234EFGH5678", + "card_type_id": 1, + "status": "unused", + "used_at": null, + "app_user_id": null, + "created_at": "2024-01-01T00:00:00Z" + } +} +``` +', + 'published', + 3, + datetime('now'), + datetime('now') +); + +-- 消息通知API文档 +INSERT INTO docs (category_id, title, slug, content, status, sort, created_at, updated_at) +VALUES ( + (SELECT id FROM doc_categories WHERE slug = 'extension-api'), + '消息通知', + 'extension-api-notification', + '# 消息通知 + +## 发送单个通知 + +向指定用户发送通知消息。 + +### 请求 + +``` +POST /api/v1/ext/notification +``` + +### 请求体 + +```json +{ + "user_id": 1, + "title": "系统通知", + "content": "您的账户已充值成功!", + "type": "info" +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| user_id | int | 是 | 用户ID | +| title | string | 是 | 通知标题 | +| content | string | 是 | 通知内容 | +| type | string | 否 | 类型:info/warning/error | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "发送成功", + "notification": { + "id": 1, + "title": "系统通知", + "content": "您的账户已充值成功!" + } + } +} +``` + +--- + +## 批量发送通知 + +向所有用户发送通知消息。 + +### 请求 + +``` +POST /api/v1/ext/notification/batch +``` + +### 请求体 + +```json +{ + "title": "系统公告", + "content": "系统将于今晚进行维护...", + "type": "warning" +} +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "发送成功", + "notification": { + "id": 1, + "title": "系统公告", + "content": "系统将于今晚进行维护..." + } + } +} +``` +', + 'published', + 4, + datetime('now'), + datetime('now') +); + +-- 应用管理API文档 +INSERT INTO docs (category_id, title, slug, content, status, sort, created_at, updated_at) +VALUES ( + (SELECT id FROM doc_categories WHERE slug = 'extension-api'), + '应用管理', + 'extension-api-app', + '# 应用管理 + +## 获取应用信息 + +获取当前应用的基本信息。 + +### 请求 + +``` +GET /api/v1/ext/app/info +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "name": "我的应用", + "description": "应用描述", + "billing_type": "subscription", + "encrypt_type": "aes", + "bind_type": "device", + "max_devices": 3, + "multi_open": false, + "enable_trial": true, + "trial_balance": 7, + "status": "active", + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +--- + +## 获取应用统计 + +获取应用的统计数据。 + +### 请求 + +``` +GET /api/v1/ext/app/stats +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "user_count": 1000, + "active_user_count": 500, + "card_count": 200, + "used_card_count": 150 + } +} +``` + +--- + +## 获取应用变量 + +获取应用级别的云端变量。 + +### 请求 + +``` +GET /api/v1/ext/app/variables +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "app_version": "1.0.0", + "maintenance_mode": "false", + "announcement": "欢迎使用本应用" + } +} +``` + +--- + +## 更新应用变量 + +更新应用级别的云端变量。 + +### 请求 + +``` +POST /api/v1/ext/app/variables +``` + +### 请求体 + +```json +{ + "variables": { + "app_version": "1.0.1", + "maintenance_mode": "true", + "announcement": "系统维护中,请稍后..." + } +} +``` + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "更新成功" + } +} +``` +', + 'published', + 5, + datetime('now'), + datetime('now') +); + +-- 代码示例文档 +INSERT INTO docs (category_id, title, slug, content, status, sort, created_at, updated_at) +VALUES ( + (SELECT id FROM doc_categories WHERE slug = 'extension-api'), + '代码示例', + 'extension-api-examples', + '# 代码示例 + +## JavaScript/Node.js + +```javascript +const crypto = require(''crypto''); +const axios = require(''axios''); + +const ACCESS_KEY = ''your_access_key''; +const SECRET_KEY = ''your_secret_key''; +const BASE_URL = ''https://your-domain.com/api/v1/ext''; + +// 生成签名 +function generateSignature(method, path, timestamp, body) { + const bodyStr = body ? JSON.stringify(body) : ''''; + const stringToSign = method + path + timestamp + bodyStr; + const hmac = crypto.createHmac(''sha256'', SECRET_KEY); + hmac.update(stringToSign); + return hmac.digest(''hex''); +} + +// 发送请求 +async function request(method, path, body = null) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateSignature(method, path, timestamp, body); + + const headers = { + ''X-Access-Key'': ACCESS_KEY, + ''X-Signature'': signature, + ''X-Timestamp'': timestamp, + ''Content-Type'': ''application/json'' + }; + + const config = { + method, + url: BASE_URL + path, + headers + }; + + if (body) { + config.data = body; + } + + const response = await axios(config); + return response.data; +} + +// 使用示例 +async function main() { + // 获取用户信息 + const user = await request(''GET'', ''/user/1''); + console.log(''用户信息:'', user); + + // 用户充值 + const result = await request(''POST'', ''/user/1/recharge'', { + amount: 100, + type: ''days'', + description: ''在线充值'' + }); + console.log(''充值结果:'', result); +} + +main().catch(console.error); +``` + +## Python + +```python +import hmac +import hashlib +import time +import requests +import json + +ACCESS_KEY = ''your_access_key'' +SECRET_KEY = ''your_secret_key'' +BASE_URL = ''https://your-domain.com/api/v1/ext'' + +def generate_signature(method, path, timestamp, body=''''): + string_to_sign = method + path + timestamp + body + signature = hmac.new( + SECRET_KEY.encode(''utf-8''), + string_to_sign.encode(''utf-8''), + hashlib.sha256 + ).hexdigest() + return signature + +def request(method, path, body=None): + timestamp = str(int(time.time())) + body_str = json.dumps(body) if body else '''' + signature = generate_signature(method, path, timestamp, body_str) + + headers = { + ''X-Access-Key'': ACCESS_KEY, + ''X-Signature'': signature, + ''X-Timestamp'': timestamp, + ''Content-Type'': ''application/json'' + } + + url = BASE_URL + path + response = requests.request(method, url, headers=headers, json=body) + return response.json() + +# 使用示例 +if __name__ == ''__main__'': + # 获取用户信息 + user = request(''GET'', ''/user/1'') + print(''用户信息:'', user) + + # 用户充值 + result = request(''POST'', ''/user/1/recharge'', { + ''amount'': 100, + ''type'': ''days'', + ''description'': ''在线充值'' + }) + print(''充值结果:'', result) +``` + +## 在线充值集成示例 + +以下是一个完整的在线充值集成示例,展示如何实现用户在线支付后自动充值: + +```javascript +// 支付回调处理 +app.post(''/payment/callback'', async (req, res) => { + const { order_id, user_id, amount, status } = req.body; + + // 验证支付状态 + if (status !== ''success'') { + return res.json({ code: -1, message: ''支付失败'' }); + } + + try { + // 调用扩展API为用户充值 + const result = await request(''POST'', ''/user/'' + user_id + ''/recharge'', { + amount: parseFloat(amount), + type: ''days'', + description: ''在线支付充值 - 订单号: '' + order_id + }); + + if (result.code === 200) { + // 更新订单状态 + await updateOrderStatus(order_id, ''completed''); + res.json({ code: 0, message: ''充值成功'' }); + } else { + res.json({ code: -1, message: result.message }); + } + } catch (error) { + console.error(''充值失败:'', error); + res.json({ code: -1, message: ''充值失败'' }); + } +}); +``` +', + 'published', + 6, + datetime('now'), + datetime('now') +); diff --git a/backend/cmd/scripts/migrate_to_balance.go b/backend/cmd/scripts/migrate_to_balance.go new file mode 100644 index 0000000..a1a9404 --- /dev/null +++ b/backend/cmd/scripts/migrate_to_balance.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + + "gorm.io/gorm" +) + +func main() { + database.Init() + db := database.DB + + log.Println("开始迁移:统一收费模式为余额") + + if err := addColumnsSQLite(db); err != nil { + log.Fatalf("添加列失败: %v", err) + } + + if err := updateBillingType(db); err != nil { + log.Fatalf("更新billing_type失败: %v", err) + } + + log.Println("迁移完成!") +} + +func columnExists(db *gorm.DB, table, column string) bool { + var count int64 + db.Raw("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&count) + return count > 0 +} + +func addColumnsSQLite(db *gorm.DB) error { + log.Println("添加新列...") + + if !columnExists(db, "app_users", "balance") { + if err := db.Exec("ALTER TABLE app_users ADD COLUMN balance DECIMAL(10,2) DEFAULT 0").Error; err != nil { + return fmt.Errorf("添加app_users.balance列失败: %w", err) + } + log.Println("添加app_users.balance列成功") + } else { + log.Println("app_users.balance列已存在") + } + + if !columnExists(db, "applications", "deduction_cycle") { + if err := db.Exec("ALTER TABLE applications ADD COLUMN deduction_cycle VARCHAR(20) DEFAULT 'per_use'").Error; err != nil { + return fmt.Errorf("添加applications.deduction_cycle列失败: %w", err) + } + log.Println("添加applications.deduction_cycle列成功") + } else { + log.Println("applications.deduction_cycle列已存在") + } + + if !columnExists(db, "applications", "deduction_amount") { + if err := db.Exec("ALTER TABLE applications ADD COLUMN deduction_amount DECIMAL(10,2) DEFAULT 1").Error; err != nil { + return fmt.Errorf("添加applications.deduction_amount列失败: %w", err) + } + log.Println("添加applications.deduction_amount列成功") + } else { + log.Println("applications.deduction_amount列已存在") + } + + if !columnExists(db, "applications", "trial_balance") { + if err := db.Exec("ALTER TABLE applications ADD COLUMN trial_balance DECIMAL(10,2) DEFAULT 0").Error; err != nil { + return fmt.Errorf("添加applications.trial_balance列失败: %w", err) + } + log.Println("添加applications.trial_balance列成功") + } else { + log.Println("applications.trial_balance列已存在") + } + + return nil +} + +func updateBillingType(db *gorm.DB) error { + log.Println("更新billing_type...") + + result := db.Exec("UPDATE applications SET billing_type = 'count' WHERE billing_type = 'point'") + if result.Error != nil { + return fmt.Errorf("更新billing_type失败: %w", result.Error) + } + log.Printf("更新了 %d 个应用的billing_type从point到count", result.RowsAffected) + + return nil +} diff --git a/backend/config.yaml b/backend/config.yaml new file mode 100644 index 0000000..0c4c8f3 --- /dev/null +++ b/backend/config.yaml @@ -0,0 +1,26 @@ +app: + name: "verification-platform" + env: "development" + port: "8080" + jwt_secret: "your-secret-key-change-in-production" + jwt_expire_hours: "24" + +database: + host: "localhost" + port: "3306" + name: "verification_platform" + username: "root" + password: "root123456" + charset: "utf8mb4" + max_idle_conns: "10" + max_open_conns: "100" + +redis: + host: "localhost" + port: "6379" + password: "" + db: "0" + +log: + level: "info" + format: "json" \ No newline at end of file diff --git a/backend/docs/API.md b/backend/docs/API.md new file mode 100644 index 0000000..8ca5f06 --- /dev/null +++ b/backend/docs/API.md @@ -0,0 +1,800 @@ +# 应用对接 API 文档 + +## 概述 + +本文档描述了开发者应用程序对接网络验证平台的 API 接口。所有 API 请求的基础路径为: + +``` +http://your-domain:8080/api/v1/app/{appKey} +``` + +其中 `{appKey}` 为开发者在后台创建应用时获取的应用密钥。 + +## 通用说明 + +### 请求格式 + +- 所有请求使用 JSON 格式 +- Content-Type: `application/json` + +### 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": {} +} +``` + +### 错误码 + +| 错误码 | 说明 | +|--------|------| +| 200 | 成功 | +| 400 | 参数错误 | +| 404 | 资源不存在 | +| 500 | 服务器错误 | + +--- + +## 应用信息接口 + +### 获取应用信息 + +获取应用的基本信息。 + +**请求** + +``` +GET /api/v1/app/{appKey}/info +``` + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "name": "应用名称", + "description": "应用描述", + "status": "active" + } +} +``` + +### 检查更新 + +检查应用是否有新版本。 + +**请求** + +``` +GET /api/v1/app/{appKey}/check-update?version=1.0.0 +``` + +**参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| version | string | 否 | 当前客户端版本号 | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "has_update": true, + "latest_version": "1.0.1", + "download_url": "https://example.com/download/1.0.1", + "update_notes": "修复若干bug", + "update_strategy": "optional", + "update_method": "manual" + } +} +``` + +**响应字段说明** + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| has_update | boolean | 是否有更新 | +| latest_version | string | 最新版本号 | +| download_url | string | 下载地址 | +| update_notes | string | 更新说明 | +| update_strategy | string | 更新策略:`optional`(可选更新) 或 `force`(强制更新) | +| update_method | string | 更新方式:`auto`(自动更新) 或 `manual`(手动更新) | + +### 获取公告列表 + +获取应用的公告列表。 + +**请求** + +``` +GET /api/v1/app/{appKey}/announcements +``` + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 1, + "title": "公告标题", + "content": "公告内容", + "type": "info", + "priority": "normal", + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +--- + +## 用户认证接口 + +### 用户注册 + +注册新用户。 + +**请求** + +``` +POST /api/v1/app/{appKey}/register +``` + +**请求体** + +```json +{ + "username": "testuser", + "password": "123456", + "device_id": "DEVICE_001" +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| username | string | 是 | 用户名 | +| password | string | 是 | 密码 | +| device_id | string | 否 | 设备ID | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "token": "" + } +} +``` + +### 用户登录 + +用户登录验证。 + +**请求** + +``` +POST /api/v1/app/{appKey}/login +``` + +**请求体** + +```json +{ + "username": "testuser", + "password": "123456", + "device_id": "DEVICE_001" +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| username | string | 是 | 用户名 | +| password | string | 是 | 密码 | +| device_id | string | 否 | 设备ID | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "token": "" + } +} +``` + +--- + +## 账户管理接口 + +### 获取账户信息 + +获取用户账户详细信息。 + +**请求** + +``` +GET /api/v1/app/{appKey}/account +``` + +**请求体** + +```json +{ + "user_id": 1 +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | uint | 是 | 用户ID | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "username": "testuser", + "expire_time": "2024-12-31T23:59:59Z", + "status": "active" + } +} +``` + +### 心跳检测 + +客户端心跳检测,保持在线状态。 + +**请求** + +``` +POST /api/v1/app/{appKey}/heartbeat +``` + +**请求体** + +```json +{ + "user_id": 1 +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | uint | 是 | 用户ID | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "心跳成功" + } +} +``` + +--- + +## 充值与试用接口 + +### 卡密充值 + +使用卡密为账户充值。 + +**请求** + +``` +POST /api/v1/app/{appKey}/recharge +``` + +**请求体** + +```json +{ + "user_id": 1, + "card_key": "CARD_KEY_123456" +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | uint | 是 | 用户ID | +| card_key | string | 是 | 卡密 | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "充值成功", + "value": 30 + } +} +``` + +### 试用申请 + +申请试用功能。 + +**请求** + +``` +POST /api/v1/app/{appKey}/trial +``` + +**请求体** + +```json +{ + "user_id": 1 +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | uint | 是 | 用户ID | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "试用成功", + "duration": 7 + } +} +``` + +--- + +## 设备管理接口 + +### 获取设备列表 + +获取用户绑定的设备列表。 + +**请求** + +``` +GET /api/v1/app/{appKey}/devices +``` + +**请求体** + +```json +{ + "user_id": 1 +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | uint | 是 | 用户ID | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "device_id": "DEVICE_001", + "user_id": 1 + } + ] +} +``` + +### 解绑设备 + +解绑指定设备。 + +**请求** + +``` +POST /api/v1/app/{appKey}/unbind-device +``` + +**请求体** + +```json +{ + "user_id": 1, + "device_id": "DEVICE_001" +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| user_id | uint | 是 | 用户ID | +| device_id | string | 是 | 设备ID | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "解绑成功" + } +} +``` + +--- + +## 云端数据接口 + +**注意**:云端数据接口需要用户登录认证,需要在请求头中携带JWT token: +``` +Authorization: Bearer {token} +``` + +### 获取云端常量 + +获取应用设置的云端常量。 + +**请求** + +``` +GET /api/v1/app/{appKey}/constants +``` + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 1, + "name": "常量名称", + "key": "CONSTANT_KEY", + "value": "常量值", + "var_type": "string", + "description": "常量描述" + } + ] +} +``` + +### 获取指定云端常量 + +根据key获取指定的云端常量。 + +**请求** + +``` +GET /api/v1/app/{appKey}/constants/{key} +``` + +**路径参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| key | string | 是 | 常量key | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "name": "常量名称", + "key": "CONSTANT_KEY", + "value": "常量值", + "var_type": "string", + "description": "常量描述" + } +} +``` + +### 获取云端变量 + +获取用户的云端变量。 + +**请求** + +``` +GET /api/v1/app/{appKey}/variables +``` + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 1, + "name": "变量名称", + "key": "VARIABLE_KEY", + "default_value": "默认值", + "var_type": "string", + "scope": "user", + "description": "变量描述" + } + ] +} +``` + +### 获取指定云端变量 + +根据key获取指定的云端变量。 + +**请求** + +``` +GET /api/v1/app/{appKey}/variables/{key} +``` + +**路径参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| key | string | 是 | 变量key | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "name": "变量名称", + "key": "VARIABLE_KEY", + "default_value": "默认值", + "var_type": "string", + "scope": "user", + "description": "变量描述" + } +} +``` + +### 更新云端变量 + +更新用户的云端变量。 + +**请求** + +``` +POST /api/v1/app/{appKey}/variables +``` + +**请求体** + +```json +{ + "variables": { + "key1": "value1", + "key2": "value2" + } +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| variables | object | 是 | 变量键值对 | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "更新成功" + } +} +``` + +### 调用云端函数 + +调用云端自定义函数。 + +**请求** + +``` +POST /api/v1/app/{appKey}/call-function +``` + +**请求体** + +```json +{ + "name": "function_name", + "params": { + "param1": "value1", + "param2": "value2" + } +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| name | string | 是 | 函数名称 | +| params | object | 否 | 函数参数 | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "result": null + } +} +``` + +--- + +## 动态代码接口 + +**注意**:动态代码接口需要用户登录认证,需要在请求头中携带JWT token: +``` +Authorization: Bearer {token} +``` + +### 获取动态代码 + +获取应用的动态代码。 + +**请求** + +``` +GET /api/v1/app/{appKey}/dynamic-code +``` + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "code": "function hello() {\n return 'Hello World';\n}" + } +} +``` + +### 更新动态代码 + +更新应用的动态代码。 + +**请求** + +``` +POST /api/v1/app/{appKey}/dynamic-code +``` + +**请求体** + +```json +{ + "code": "function hello() {\n return 'Hello World';\n}" +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| code | string | 是 | Goja代码(JavaScript语法) | + +**响应** + +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "更新成功" + } +} +``` + +**说明** + +- 动态代码使用Goja引擎执行(Go语言的JavaScript实现) +- 支持完整的JavaScript语法 +- 代码执行环境受限,无法访问系统资源 +- 建议代码简洁高效,避免复杂逻辑 + +--- + +## 错误响应示例 + +### 应用不存在 + +```json +{ + "code": 404, + "message": "应用不存在", + "data": null +} +``` + +### 参数错误 + +```json +{ + "code": 400, + "message": "参数错误", + "data": null +} +``` + +### 用户不存在 + +```json +{ + "code": 404, + "message": "用户不存在", + "data": null +} +``` + +### 卡密已使用 + +```json +{ + "code": 400, + "message": "卡密已使用", + "data": null +} +``` + +--- + +## 接入流程 + +1. **创建应用**:在开发者后台创建应用,获取 `appKey` 和 `secretKey` +2. **配置应用**:设置应用的绑定类型、最大设备数、试用功能等 +3. **创建卡密类型**:根据业务需求创建卡密类型(订阅/计时/点卡) +4. **生成卡密**:生成卡密供用户充值使用 +5. **客户端对接**:按照本文档进行客户端开发 + +## 安全建议 + +1. **HTTPS**:生产环境建议使用 HTTPS 加密传输 +2. **签名验证**:敏感操作建议添加签名验证 +3. **频率限制**:建议对 API 调用频率进行限制 +4. **数据加密**:敏感数据建议加密存储和传输 + +## 版本历史 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.1.0 | 2024-03-10 | 新增动态代码接口(使用Goja引擎) | +| 1.0.0 | 2024-01-01 | 初始版本 | diff --git a/backend/docs/EXTENSION_API.md b/backend/docs/EXTENSION_API.md new file mode 100644 index 0000000..a07196b --- /dev/null +++ b/backend/docs/EXTENSION_API.md @@ -0,0 +1,630 @@ +# 扩展功能API文档 + +## 概述 + +扩展功能允许开发者通过Webhook和开放API将平台功能与自己的业务系统对接,实现自动化业务流程。 + +## 功能特性 + +- **Webhook回调**: 实时接收平台事件通知 +- **开放API**: 通过API密钥访问平台功能 +- **签名验证**: 确保请求安全可靠 +- **权限控制**: 细粒度的API访问权限管理 + +--- + +## 一、Webhook配置 + +### 1.1 创建Webhook + +**接口**: `POST /api/v1/dev/extension/webhooks` + +**请求参数**: +```json +{ + "application_id": 1, + "name": "商城系统", + "url": "https://your-server.com/webhook", + "secret_key": "your_secret_key", + "events": ["user.registered", "user.recharged"], + "retry_count": 3, + "timeout": 10 +} +``` + +**参数说明**: +- `application_id` (必填): 应用ID +- `name` (必填): Webhook名称 +- `url` (必填): 回调URL +- `secret_key` (可选): 密钥,用于验证回调请求 +- `events` (必填): 订阅的事件类型数组 +- `retry_count` (可选): 重试次数,默认3次 +- `timeout` (可选): 超时时间(秒),默认10秒 + +### 1.2 查询Webhook列表 + +**接口**: `GET /api/v1/dev/extension/webhooks?application_id=1` + +**响应示例**: +```json +{ + "code": 200, + "data": [ + { + "id": 1, + "application_id": 1, + "name": "商城系统", + "url": "https://your-server.com/webhook", + "secret_key": "your_secret_key", + "events": "[\"user.registered\",\"user.recharged\"]", + "status": "active", + "retry_count": 3, + "timeout": 10, + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +### 1.3 更新Webhook + +**接口**: `PUT /api/v1/dev/extension/webhooks/:id` + +**请求参数**: 同创建Webhook + +### 1.4 删除Webhook + +**接口**: `DELETE /api/v1/dev/extension/webhooks/:id` + +### 1.5 测试Webhook + +**接口**: `POST /api/v1/dev/extension/webhooks/:id/test` + +### 1.6 查看Webhook日志 + +**接口**: `GET /api/v1/dev/extension/webhooks/logs?webhook_id=1` + +--- + +## 二、Webhook事件 + +### 2.1 支持的事件类型 + +| 事件代码 | 事件名称 | 触发时机 | +|---------|---------|---------| +| `user.registered` | 用户注册 | 新用户注册成功时 | +| `user.login` | 用户登录 | 用户登录成功时 | +| `user.recharged` | 用户充值 | 用户充值成功时 | +| `user.expired` | 用户到期 | 用户会员到期时 | +| `card.used` | 卡密使用 | 卡密被使用时 | +| `card.expired` | 卡密过期 | 卡密过期时 | +| `abnormal.detected` | 异常检测 | 检测到异常行为时 | + +### 2.2 回调数据格式 + +当事件触发时,平台会向配置的URL发送POST请求: + +```json +{ + "event": "user.registered", + "timestamp": 1234567890, + "data": { + "user_id": 123, + "username": "testuser", + "email": "test@example.com", + "application_id": 1 + } +} +``` + +### 2.3 验证回调请求 + +如果配置了`secret_key`,平台会在请求头中添加: + +``` +X-Webhook-Secret: your_secret_key +``` + +您的服务器应验证此头部以确保请求来自平台。 + +--- + +## 三、API密钥管理 + +### 3.1 创建API密钥 + +**接口**: `POST /api/v1/dev/extension/api-keys` + +**请求参数**: +```json +{ + "application_id": 1, + "name": "商城对接", + "permissions": ["user.read", "user.recharge"], + "expires_at": "2025-12-31T23:59:59Z" +} +``` + +**参数说明**: +- `application_id` (必填): 应用ID +- `name` (必填): 密钥名称 +- `permissions` (可选): 权限列表 +- `expires_at` (可选): 过期时间 + +**响应示例**: +```json +{ + "code": 200, + "data": { + "id": 1, + "application_id": 1, + "name": "商城对接", + "access_key": "abc123...", + "secret_key": "xyz789...", + "permissions": "[\"user.read\",\"user.recharge\"]", + "status": "active", + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +**重要**: `secret_key`只在创建时返回一次,请妥善保存! + +### 3.2 查询API密钥列表 + +**接口**: `GET /api/v1/dev/extension/api-keys?application_id=1` + +### 3.3 更新API密钥 + +**接口**: `PUT /api/v1/dev/extension/api-keys/:id` + +### 3.4 删除API密钥 + +**接口**: `DELETE /api/v1/dev/extension/api-keys/:id` + +### 3.5 重新生成密钥 + +**接口**: `POST /api/v1/dev/extension/api-keys/:id/regenerate` + +**注意**: 重新生成后,旧密钥立即失效! + +--- + +## 四、开放API接口 + +### 4.1 认证方式 + +所有开放API请求都需要进行签名认证: + +**请求头**: +``` +X-Access-Key: your_access_key +X-Signature: calculated_signature +X-Timestamp: 1234567890 +``` + +**签名算法**: +```javascript +// 1. 构造签名字符串 +const stringToSign = METHOD + PATH + TIMESTAMP + BODY + +// 示例 +const method = "POST" +const path = "/api/v1/ext/user/123/recharge" +const timestamp = "1234567890" +const body = JSON.stringify({ + "amount": 100, + "type": "points", + "description": "商城充值" +}) + +const stringToSign = method + path + timestamp + body +// 结果: "POST/api/v1/ext/user/123/recharge1234567890{\"amount\":100,\"type\":\"points\",\"description\":\"商城充值\"}" + +// 2. 计算签名 +const signature = HMAC-SHA256(secretKey, stringToSign) +``` + +**Python示例**: +```python +import hmac +import hashlib +import time +import json + +def calculate_signature(secret_key, method, path, timestamp, body): + string_to_sign = method + path + str(timestamp) + body + signature = hmac.new( + secret_key.encode('utf-8'), + string_to_sign.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + return signature + +# 使用示例 +access_key = "your_access_key" +secret_key = "your_secret_key" +method = "POST" +path = "/api/v1/ext/user/123/recharge" +timestamp = int(time.time()) +body = json.dumps({ + "amount": 100, + "type": "points", + "description": "商城充值" +}) + +signature = calculate_signature(secret_key, method, path, timestamp, body) + +headers = { + "X-Access-Key": access_key, + "X-Signature": signature, + "X-Timestamp": str(timestamp), + "Content-Type": "application/json" +} +``` + +### 4.2 用户相关接口 + +#### 4.2.1 查询用户信息 + +**接口**: `GET /api/v1/ext/user/:userId` + +**响应示例**: +```json +{ + "code": 200, + "data": { + "id": 123, + "username": "testuser", + "email": "test@example.com", + "points": 1000, + "level": 1, + "status": "active", + "expiry_at": "2024-12-31T23:59:59Z", + "last_login_at": "2024-01-01T12:00:00Z", + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +#### 4.2.2 查询用户列表 + +**接口**: `GET /api/v1/ext/users?page=1&page_size=20&status=active&search=test` + +**参数说明**: +- `page`: 页码,默认1 +- `page_size`: 每页数量,默认20 +- `status`: 用户状态筛选 +- `search`: 搜索关键词(用户名或邮箱) + +#### 4.2.3 为用户充值 + +**接口**: `POST /api/v1/ext/user/:userId/recharge` + +**请求参数**: +```json +{ + "amount": 100, + "type": "points", + "description": "商城充值" +} +``` + +**参数说明**: +- `amount` (必填): 充值数量 +- `type` (必填): 充值类型 + - `points`: 充值积分 + - `days`: 充值天数 +- `description` (可选): 充值说明 + +#### 4.2.4 扣除用户余额 + +**接口**: `POST /api/v1/ext/user/:userId/deduct` + +**请求参数**: +```json +{ + "amount": 50, + "type": "points", + "description": "购买商品" +} +``` + +#### 4.2.5 更新用户积分 + +**接口**: `POST /api/v1/ext/user/:userId/points` + +**请求参数**: +```json +{ + "points": 500 +} +``` + +### 4.3 卡密相关接口 + +#### 4.3.1 查询卡密列表 + +**接口**: `GET /api/v1/ext/cards?page=1&page_size=20&status=unused&card_type_id=1` + +#### 4.3.2 生成卡密 + +**接口**: `POST /api/v1/ext/cards/generate` + +**请求参数**: +```json +{ + "card_type_id": 1, + "count": 10 +} +``` + +#### 4.3.3 查询卡密详情 + +**接口**: `GET /api/v1/ext/card/:cardId` + +### 4.4 通知相关接口 + +#### 4.4.1 发送通知 + +**接口**: `POST /api/v1/ext/notification` + +**请求参数**: +```json +{ + "user_id": 123, + "title": "系统通知", + "content": "您的充值已到账", + "type": "system" +} +``` + +#### 4.4.2 批量发送通知 + +**接口**: `POST /api/v1/ext/notification/batch` + +**请求参数**: +```json +{ + "title": "系统公告", + "content": "系统将于今晚维护", + "type": "announcement" +} +``` + +### 4.5 应用信息接口 + +#### 4.5.1 获取应用信息 + +**接口**: `GET /api/v1/ext/app/info` + +#### 4.5.2 获取应用统计 + +**接口**: `GET /api/v1/ext/app/stats` + +**响应示例**: +```json +{ + "code": 200, + "data": { + "user_count": 1000, + "active_user_count": 800, + "card_count": 500, + "used_card_count": 300 + } +} +``` + +--- + +## 五、权限说明 + +### 5.1 可用权限列表 + +| 权限代码 | 权限名称 | 说明 | +|---------|---------|------| +| `user.read` | 查看用户 | 查询用户信息和列表 | +| `user.recharge` | 用户充值 | 为用户充值积分或天数 | +| `user.deduct` | 用户扣费 | 扣除用户积分或天数 | +| `card.read` | 查看卡密 | 查询卡密信息和列表 | +| `card.generate` | 生成卡密 | 生成新的卡密 | +| `notification.send` | 发送通知 | 发送用户通知 | + +--- + +## 六、错误码说明 + +| 错误码 | 说明 | +|-------|------| +| 200 | 成功 | +| 400 | 请求参数错误 | +| 401 | 认证失败(缺少认证信息、签名错误、密钥无效等) | +| 403 | 权限不足 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +--- + +## 七、最佳实践 + +### 7.1 安全建议 + +1. **妥善保管密钥**: Secret Key只在创建时显示一次,请立即保存 +2. **定期更换密钥**: 建议定期重新生成API密钥 +3. **最小权限原则**: 只授予必要的权限 +4. **验证Webhook**: 验证回调请求的来源和完整性 +5. **使用HTTPS**: 确保所有通信都使用HTTPS + +### 7.2 性能优化 + +1. **批量操作**: 使用批量接口减少请求次数 +2. **缓存数据**: 合理缓存用户信息等数据 +3. **异步处理**: Webhook回调建议异步处理业务逻辑 + +### 7.3 错误处理 + +1. **重试机制**: 实现请求重试逻辑,处理网络异常 +2. **日志记录**: 记录所有API请求和响应,便于排查问题 +3. **超时设置**: 合理设置请求超时时间 + +--- + +## 八、完整示例 + +### 8.1 Python示例 + +```python +import requests +import hmac +import hashlib +import time +import json + +class ExtensionAPI: + def __init__(self, access_key, secret_key, base_url="http://localhost:8080"): + self.access_key = access_key + self.secret_key = secret_key + self.base_url = base_url + + def _calculate_signature(self, method, path, timestamp, body=""): + string_to_sign = method + path + str(timestamp) + body + signature = hmac.new( + self.secret_key.encode('utf-8'), + string_to_sign.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + return signature + + def _get_headers(self, method, path, body=""): + timestamp = int(time.time()) + signature = self._calculate_signature(method, path, timestamp, body) + return { + "X-Access-Key": self.access_key, + "X-Signature": signature, + "X-Timestamp": str(timestamp), + "Content-Type": "application/json" + } + + def get_user(self, user_id): + path = f"/api/v1/ext/user/{user_id}" + headers = self._get_headers("GET", path) + response = requests.get(f"{self.base_url}{path}", headers=headers) + return response.json() + + def recharge_user(self, user_id, amount, recharge_type, description=""): + path = f"/api/v1/ext/user/{user_id}/recharge" + body = json.dumps({ + "amount": amount, + "type": recharge_type, + "description": description + }) + headers = self._get_headers("POST", path, body) + response = requests.post(f"{self.base_url}{path}", headers=headers, data=body) + return response.json() + +# 使用示例 +api = ExtensionAPI("your_access_key", "your_secret_key") + +# 查询用户 +user = api.get_user(123) +print(user) + +# 为用户充值100积分 +result = api.recharge_user(123, 100, "points", "商城充值") +print(result) +``` + +### 8.2 Node.js示例 + +```javascript +const crypto = require('crypto'); +const axios = require('axios'); + +class ExtensionAPI { + constructor(accessKey, secretKey, baseUrl = 'http://localhost:8080') { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.baseUrl = baseUrl; + } + + calculateSignature(method, path, timestamp, body = '') { + const stringToSign = method + path + timestamp + body; + return crypto + .createHmac('sha256', this.secretKey) + .update(stringToSign) + .digest('hex'); + } + + getHeaders(method, path, body = '') { + const timestamp = Math.floor(Date.now() / 1000); + const signature = this.calculateSignature(method, path, timestamp, body); + return { + 'X-Access-Key': this.accessKey, + 'X-Signature': signature, + 'X-Timestamp': timestamp.toString(), + 'Content-Type': 'application/json' + }; + } + + async getUser(userId) { + const path = `/api/v1/ext/user/${userId}`; + const headers = this.getHeaders('GET', path); + const response = await axios.get(`${this.baseUrl}${path}`, { headers }); + return response.data; + } + + async rechargeUser(userId, amount, type, description = '') { + const path = `/api/v1/ext/user/${userId}/recharge`; + const body = JSON.stringify({ amount, type, description }); + const headers = this.getHeaders('POST', path, body); + const response = await axios.post(`${this.baseUrl}${path}`, body, { headers }); + return response.data; + } +} + +// 使用示例 +const api = new ExtensionAPI('your_access_key', 'your_secret_key'); + +// 查询用户 +api.getUser(123).then(user => console.log(user)); + +// 为用户充值100积分 +api.rechargeUser(123, 100, 'points', '商城充值').then(result => console.log(result)); +``` + +--- + +## 九、常见问题 + +### Q1: 签名验证失败怎么办? + +**A**: 请检查以下几点: +1. 确保使用正确的Secret Key +2. 确保时间戳是当前Unix时间戳(秒级) +3. 确保签名字符串的拼接顺序正确:METHOD + PATH + TIMESTAMP + BODY +4. 确保Body是原始JSON字符串,不要格式化或添加空格 + +### Q2: Webhook回调失败怎么办? + +**A**: +1. 检查回调URL是否可访问 +2. 查看Webhook日志了解失败原因 +3. 确保服务器能正确处理POST请求 +4. 检查是否设置了正确的超时时间 + +### Q3: API密钥泄露了怎么办? + +**A**: 立即删除旧密钥并创建新密钥,或者使用"重新生成"功能。 + +### Q4: 如何测试API接口? + +**A**: 可以使用Postman、curl等工具,参考本文档中的签名算法构造请求。 + +--- + +## 十、技术支持 + +如有问题,请通过以下方式获取帮助: +- 查看平台文档 +- 提交工单 +- 联系技术支持 diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a3db06e --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,72 @@ +module verification-platform-backend + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/glebarez/sqlite v1.11.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/golang-jwt/jwt/v5 v5.0.0 + github.com/spf13/viper v1.17.0 + golang.org/x/crypto v0.14.0 + gorm.io/driver/mysql v1.5.1 + gorm.io/gorm v1.30.0 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/dop251/goja v0.0.0-20260305124333-6a7976c22267 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.3.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.10.0 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.20.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect +) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..7184f2e --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "log" + + "github.com/spf13/viper" +) + +// Init 初始化配置 +func Init() { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + + // 设置默认值 + setDefaults() + + // 读取配置文件 + if err := viper.ReadInConfig(); err != nil { + log.Printf("Warning: Config file not found, using defaults: %v", err) + } + + // 环境变量覆盖 + viper.AutomaticEnv() +} + +// setDefaults 设置默认配置值 +func setDefaults() { + // 应用配置 + viper.SetDefault("app.name", "verification-platform") + viper.SetDefault("app.env", "development") + viper.SetDefault("app.port", "8080") + viper.SetDefault("app.jwt_secret", "your-secret-key") + viper.SetDefault("app.jwt_expire", "24h") + + // 数据库配置 + viper.SetDefault("database.host", "localhost") + viper.SetDefault("database.port", "3306") + viper.SetDefault("database.name", "verification_platform") + viper.SetDefault("database.username", "root") + viper.SetDefault("database.password", "") + viper.SetDefault("database.charset", "utf8mb4") + viper.SetDefault("database.max_idle_conns", "10") + viper.SetDefault("database.max_open_conns", "100") + + // Redis配置 + viper.SetDefault("redis.host", "localhost") + viper.SetDefault("redis.port", "6379") + viper.SetDefault("redis.password", "") + viper.SetDefault("redis.db", "0") + + // 日志配置 + viper.SetDefault("log.level", "info") + viper.SetDefault("log.format", "json") +} + +// GetString 获取字符串配置 +func GetString(key string) string { + return viper.GetString(key) +} + +// GetInt 获取整数配置 +func GetInt(key string) int { + return viper.GetInt(key) +} + +// GetBool 获取布尔配置 +func GetBool(key string) bool { + return viper.GetBool(key) +} + +// GetStringSlice 获取字符串切片配置 +func GetStringSlice(key string) []string { + return viper.GetStringSlice(key) +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..96a0f8f --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,713 @@ +package database + +// 重要提示:请始终从 backend/ 目录运行服务 +// 正确的运行方式: +// cd d:\Code\verify\verification-platform\backend +// go run cmd/main.go +// +// 数据库文件路径基于可执行文件所在目录计算,确保无论从哪个目录运行都使用同一个数据库文件。 + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + "verification-platform-backend/internal/config" + "verification-platform-backend/internal/model" + + "github.com/glebarez/sqlite" + "github.com/go-redis/redis/v8" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var ( + DB *gorm.DB + RDB *redis.Client +) + +// Init 初始化数据库连接 +func Init() { + initMySQL() + initRedis() +} + +// initMySQL 初始化MySQL连接 +func initMySQL() { + // 尝试连接MySQL + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local", + config.GetString("database.username"), + config.GetString("database.password"), + config.GetString("database.host"), + config.GetString("database.port"), + config.GetString("database.name"), + config.GetString("database.charset"), + ) + + var err error + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + DisableForeignKeyConstraintWhenMigrating: true, + }) + + // 如果MySQL连接失败,创建一个SQLite数据库用于开发 + if err != nil { + log.Println("Failed to connect to MySQL, using SQLite database for development:", err.Error()) + + log.Println("Using file database for development") + + dbPath := "verification_platform.db" + + absPath, err := filepath.Abs(dbPath) + if err != nil { + log.Fatal("Failed to get absolute path:", err.Error()) + } + + wd, err := os.Getwd() + if err != nil { + log.Fatal("Failed to get working directory:", err.Error()) + } + log.Printf("Working directory: %s, basename: %s\n", wd, filepath.Base(wd)) + + if filepath.Base(wd) == "cmd" { + dbPath = filepath.Join(filepath.Dir(wd), "verification_platform.db") + absPath = dbPath + } + + DB, err = gorm.Open(sqlite.Open(absPath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + log.Fatal("Failed to create SQLite database:", err.Error()) + } + log.Printf("Database file path: %s\n", absPath) + } + + // 设置连接池(仅对非内存数据库有效) + if sqlDB, err := DB.DB(); err == nil { + sqlDB.SetMaxIdleConns(config.GetInt("database.max_idle_conns")) + sqlDB.SetMaxOpenConns(config.GetInt("database.max_open_conns")) + sqlDB.SetConnMaxLifetime(time.Hour) + } + + // 检查applications表结构 + var columns []struct { + CID int + Name string + Type string + NotNull int + DefaultVal interface{} + PK int + } + DB.Raw("PRAGMA table_info(applications)").Scan(&columns) + log.Println("Applications table columns:") + for _, col := range columns { + log.Printf(" - %s (%s)\n", col.Name, col.Type) + } + + // 检查app_users表结构 + var appUserColumns []struct { + CID int + Name string + Type string + NotNull int + DefaultVal interface{} + PK int + } + DB.Raw("PRAGMA table_info(app_users)").Scan(&appUserColumns) + log.Println("AppUsers table columns:") + for _, col := range appUserColumns { + log.Printf(" - %s (%s)\n", col.Name, col.Type) + } + + // 自动迁移数据库表 + err = DB.AutoMigrate( + &model.User{}, + &model.AppUser{}, + &model.UserLevel{}, + &model.Application{}, + &model.Version{}, + &model.CardType{}, + &model.Card{}, + &model.AgentApplication{}, + &model.AgentApplicationCardType{}, + &model.AgentApplicationRequest{}, + &model.Order{}, + &model.Log{}, + &model.RechargeRecord{}, + &model.ConsumptionRecord{}, + &model.Ticket{}, + &model.TicketReply{}, + &model.UserVariable{}, + &model.CloudConstant{}, + &model.CloudVariable{}, + &model.IPBlacklist{}, + &model.DeviceBlacklist{}, + &model.AbnormalBehavior{}, + &model.DocCategory{}, + &model.Doc{}, + &model.Package{}, + &model.PackagePermission{}, + &model.Setting{}, + &model.Announcement{}, + &model.UserPackage{}, + &model.UserDevice{}, + &model.UserIP{}, + &model.DeviceSession{}, + &model.PackHistory{}, + &model.WebhookConfig{}, + &model.WebhookLog{}, + &model.ExtensionAPIKey{}, + &model.DynamicCode{}, + &model.PaymentChannel{}, + &model.Captcha{}, + &model.ApiUsage{}, + &model.StorageUsage{}, + &model.UsageAlertRecord{}, + &model.Notification{}, + &model.RiskControlRule{}, + &model.EmailVerifyCode{}, + &model.AppSMTPConfig{}, + &model.EmailTemplate{}, + &model.VersionFile{}, + &model.CloudVariableRecord{}, + ) + if err != nil { + log.Println("Failed to migrate database:", err.Error()) + } + + // 手动迁移:检查并添加 risk_control_rules 表的 user_id 列 + var riskControlColumns []struct { + Name string + } + DB.Raw("PRAGMA table_info(risk_control_rules)").Scan(&riskControlColumns) + hasUserID := false + for _, col := range riskControlColumns { + if col.Name == "user_id" { + hasUserID = true + break + } + } + if !hasUserID { + log.Println("Adding user_id column to risk_control_rules table...") + if err := DB.Exec("ALTER TABLE risk_control_rules ADD COLUMN user_id INTEGER").Error; err != nil { + log.Println("Failed to add user_id column:", err.Error()) + } else { + log.Println("user_id column added successfully") + } + } + + // 修复 application_id 列的 NOT NULL 约束(SQLite需要重建表) + var riskControlTableInfo []struct { + CID int + Name string + Type string + NotNull int + DefaultVal interface{} + PK int + } + DB.Raw("PRAGMA table_info(risk_control_rules)").Scan(&riskControlTableInfo) + for _, col := range riskControlTableInfo { + if col.Name == "application_id" && col.NotNull == 1 { + log.Println("Fixing application_id NOT NULL constraint...") + // 创建临时表 + DB.Exec(`CREATE TABLE risk_control_rules_new ( + id INTEGER PRIMARY KEY, + user_id INTEGER, + application_id INTEGER, + type TEXT NOT NULL, + value TEXT NOT NULL, + reason TEXT, + status TEXT DEFAULT 'active', + expires_at DATETIME, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME + )`) + // 复制数据 + DB.Exec(`INSERT INTO risk_control_rules_new SELECT * FROM risk_control_rules`) + // 删除旧表 + DB.Exec(`DROP TABLE risk_control_rules`) + // 重命名新表 + DB.Exec(`ALTER TABLE risk_control_rules_new RENAME TO risk_control_rules`) + // 重建索引 + DB.Exec(`CREATE INDEX IF NOT EXISTS idx_risk_control_rules_user_id ON risk_control_rules(user_id)`) + DB.Exec(`CREATE INDEX IF NOT EXISTS idx_risk_control_rules_application_id ON risk_control_rules(application_id)`) + DB.Exec(`CREATE INDEX IF NOT EXISTS idx_risk_control_rules_deleted_at ON risk_control_rules(deleted_at)`) + log.Println("application_id NOT NULL constraint fixed") + break + } + } + + // 手动迁移:检查并添加 packages 表的 currency 列 + var packageColumns []struct { + Name string + } + DB.Raw("PRAGMA table_info(packages)").Scan(&packageColumns) + hasCurrency := false + for _, col := range packageColumns { + if col.Name == "currency" { + hasCurrency = true + break + } + } + if !hasCurrency { + log.Println("Adding currency column to packages table...") + if err := DB.Exec("ALTER TABLE packages ADD COLUMN currency TEXT DEFAULT 'CNY'").Error; err != nil { + log.Println("Failed to add currency column:", err.Error()) + } else { + log.Println("currency column added successfully") + } + } + + // 初始化数据 + initData() + + // 初始化工单数据(独立于initData,确保总是被调用) + initTicketData() + + // 初始化文档分类和文档 + initDocData() +} + +// initData 初始化数据库中的基础数据 +func initData() { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) + + // 修复现有的授权记录,将 is_received 设置为 true + DB.Model(&model.AgentApplication{}).Where("is_received = ?", false).Update("is_received", true) + + // 确保管理员用户存在 + var adminCount int64 + DB.Model(&model.User{}).Where("username = ?", "admin").Count(&adminCount) + if adminCount == 0 { + log.Println("Creating admin user...") + adminEmail := "admin@example.com" + adminUser := model.User{ + Username: "admin", + Email: &adminEmail, + Password: string(hashedPassword), + Role: "admin", + Status: "active", + } + DB.Create(&adminUser) + } + + // 确保开发者用户存在 + var devCount int64 + DB.Model(&model.User{}).Where("username = ?", "dev").Count(&devCount) + if devCount == 0 { + log.Println("Creating dev user...") + devEmail := "dev@example.com" + devUser := model.User{ + Username: "dev", + Email: &devEmail, + Password: string(hashedPassword), + Role: "developer", + Status: "active", + } + DB.Create(&devUser) + } + + // 确保代理用户存在 + var agentCount int64 + DB.Model(&model.User{}).Where("username = ?", "agent").Count(&agentCount) + if agentCount == 0 { + log.Println("Creating agent user...") + agentEmail := "agent@example.com" + agentUser := model.User{ + Username: "agent", + Email: &agentEmail, + Password: string(hashedPassword), + Role: "developer", + Status: "active", + } + DB.Create(&agentUser) + } + + // 清理没有关联应用的云端常量和变量 + var orphanConstants int64 + DB.Model(&model.CloudConstant{}).Where("app_id IS NULL").Count(&orphanConstants) + if orphanConstants > 0 { + log.Printf("Cleaning up %d orphan cloud constants...", orphanConstants) + DB.Where("app_id IS NULL").Delete(&model.CloudConstant{}) + } + + var orphanVariables int64 + DB.Model(&model.CloudVariable{}).Where("app_id IS NULL").Count(&orphanVariables) + if orphanVariables > 0 { + log.Printf("Cleaning up %d orphan cloud variables...", orphanVariables) + DB.Where("app_id IS NULL").Delete(&model.CloudVariable{}) + } + + // 初始化文档分类和文档 + initDocData() +} + +func initDocData() { + var categoryCount int64 + DB.Model(&model.DocCategory{}).Count(&categoryCount) + + if categoryCount > 0 { + log.Println("Document data already exists, skipping initialization") + return + } + + log.Println("Initializing document data...") + + categories := []model.DocCategory{ + {Name: "快速开始", NameEn: "Quick Start", Slug: "quickstart", Description: "5分钟内完成集成", DescriptionEn: "Complete integration in 5 minutes", Icon: "zap", Sort: 1}, + {Name: "API文档", NameEn: "API Documentation", Slug: "api", Description: "完整的API接口说明", DescriptionEn: "Complete API reference", Icon: "code", Sort: 2}, + {Name: "示例代码", NameEn: "Code Examples", Slug: "examples", Description: "常见使用场景示例", DescriptionEn: "Common use case examples", Icon: "terminal", Sort: 3}, + {Name: "常见问题", NameEn: "FAQ", Slug: "faq", Description: "解答常见疑问", DescriptionEn: "Frequently asked questions", Icon: "help-circle", Sort: 4}, + } + + for i := range categories { + DB.Create(&categories[i]) + } + + var quickstartCat, apiCat, examplesCat, faqCat model.DocCategory + DB.Where("slug = ?", "quickstart").First(&quickstartCat) + DB.Where("slug = ?", "api").First(&apiCat) + DB.Where("slug = ?", "examples").First(&examplesCat) + DB.Where("slug = ?", "faq").First(&faqCat) + + docs := []struct { + CategoryID *uint + Title string + TitleEn string + Slug string + Content string + ContentEn string + Summary string + SummaryEn string + Sort int + }{ + { + CategoryID: &apiCat.ID, + Title: "用户注册", + TitleEn: "User Registration", + Slug: "api-register", + Content: "# 用户注册\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/register\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| username | string | 是 | 用户名 |\n| password | string | 是 | 密码 |\n| device_id | string | 是 | 设备指纹,设备唯一标识 |\n| device_name | string | 否 | 设备名称,如\"我的电脑\",不传则使用device_id |\n| email | string | 条件必填 | 邮箱地址,启用邮箱验证时必填 |\n| email_code | string | 条件必填 | 邮箱验证码,启用强制邮箱验证时必填 |\n| device_type | string | 否 | 设备类型:android/ios/windows/mac/linux/web,不传则自动识别 |\n| instance_id | string | 否 | 实例ID,用于多开识别,不传则使用device_id |\n\n### 请求示例\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"abc123def456\",\n \"device_name\": \"我的电脑\",\n \"email\": \"user@example.com\",\n \"email_code\": \"123456\",\n \"device_type\": \"windows\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"注册成功\",\n \"data\": {\n \"user_id\": 123\n }\n}\n```\n\n### 说明\n- device_id 是设备指纹,用于唯一标识设备\n- device_name 是设备的友好名称,用于显示\n- device_type 不传时,系统会根据 User-Agent 自动识别\n- 若应用启用了强制邮箱验证,则 email 和 email_code 为必填参数\n\n### 错误响应\n```json\n{\n \"code\": 400,\n \"message\": \"用户名已存在\"\n}\n```\n\n### 邮箱验证错误\n```json\n{\n \"code\": 400,\n \"message\": \"验证码错误或已过期\"\n}\n```", + ContentEn: "# User Registration\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/register\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| username | string | Yes | Username |\n| password | string | Yes | Password |\n| device_id | string | Yes | Device fingerprint, unique device identifier |\n| device_name | string | No | Device name, e.g. \"My Computer\", defaults to device_id |\n| email | string | Conditional | Email address, required when email verification is enabled |\n| email_code | string | Conditional | Email verification code, required when mandatory email verification is enabled |\n| device_type | string | No | Device type: android/ios/windows/mac/linux/web, auto-detected if not provided |\n| instance_id | string | No | Instance ID for multi-instance detection, defaults to device_id |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"abc123def456\",\n \"device_name\": \"My Computer\",\n \"email\": \"user@example.com\",\n \"email_code\": \"123456\",\n \"device_type\": \"windows\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Registration successful\",\n \"data\": {\n \"user_id\": 123\n }\n}\n```\n\n### Notes\n- device_id is the device fingerprint used to uniquely identify the device\n- device_name is a friendly name for the device display\n- device_type is auto-detected from User-Agent if not provided\n- If mandatory email verification is enabled, email and email_code are required\n\n### Error Response\n```json\n{\n \"code\": 400,\n \"message\": \"Username already exists\"\n}\n```\n\n### Email Verification Error\n```json\n{\n \"code\": 400,\n \"message\": \"Invalid or expired verification code\"\n}\n```", + Summary: "用户注册说明", + SummaryEn: "User registration instructions", + Sort: 1, + }, + { + CategoryID: &apiCat.ID, + Title: "发送邮箱验证码", + TitleEn: "Send Email Verification Code", + Slug: "api-send-email-code", + Content: "# 发送邮箱验证码\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/send-email-code\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| email | string | 是 | 邮箱地址 |\n| purpose | string | 否 | 用途:register(注册)、reset_password(重置密码)、change_email(更换邮箱),默认register |\n\n### 请求示例\n```json\n{\n \"email\": \"user@example.com\",\n \"purpose\": \"register\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"success\",\n \"data\": {\n \"message\": \"验证码已发送\"\n }\n}\n```\n\n### 说明\n- 验证码有效期为15分钟\n- 若应用未启用邮箱验证,会返回错误\n\n### 错误响应\n```json\n{\n \"code\": 400,\n \"message\": \"该应用未启用邮箱验证\"\n}\n```", + ContentEn: "# Send Email Verification Code\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/send-email-code\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| email | string | Yes | Email address |\n| purpose | string | No | Purpose: register, reset_password, change_email, defaults to register |\n\n### Request Example\n```json\n{\n \"email\": \"user@example.com\",\n \"purpose\": \"register\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"success\",\n \"data\": {\n \"message\": \"Verification code sent\"\n }\n}\n```\n\n### Notes\n- Verification code is valid for 15 minutes\n- Returns error if email verification is not enabled for the application\n\n### Error Response\n```json\n{\n \"code\": 400,\n \"message\": \"Email verification is not enabled for this application\"\n}\n```", + Summary: "发送邮箱验证码说明", + SummaryEn: "Send email verification code instructions", + Sort: 2, + }, + { + CategoryID: &apiCat.ID, + Title: "用户登录", + TitleEn: "User Login", + Slug: "api-login", + Content: "# 用户登录\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/login\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| username | string | 是 | 用户名 |\n| password | string | 是 | 密码 |\n| device_id | string | 是 | 设备指纹,设备唯一标识 |\n| device_name | string | 否 | 设备名称,如\"我的电脑\",不传则使用device_id |\n| device_type | string | 否 | 设备类型:android/ios/windows/mac/linux/web,不传则自动识别 |\n| instance_id | string | 否 | 实例ID,用于多开识别,不传则使用device_id |\n\n### 请求示例\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"abc123def456\",\n \"device_name\": \"我的电脑\",\n \"device_type\": \"windows\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"登录成功\",\n \"data\": {\n \"token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n \"user_id\": 123\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| token | string | JWT令牌,用于后续认证 |\n| user_id | number | 用户ID |\n\n### 说明\n- device_id 是设备指纹,用于唯一标识设备\n- device_name 是设备的友好名称,用于显示\n- device_type 不传时,系统会根据 User-Agent 自动识别\n- 若设备绑定数量已达上限,会返回错误并显示已绑定设备列表\n\n### 错误响应\n```json\n{\n \"code\": 401,\n \"message\": \"用户名或密码错误\"\n}\n```\n\n### 设备上限错误响应\n```json\n{\n \"code\": 403,\n \"message\": \"设备绑定数量已达上限,请解绑后再试\",\n \"data\": {\n \"error_code\": \"DEVICE_LIMIT_EXCEEDED\",\n \"max_devices\": 3,\n \"device_count\": 3,\n \"devices\": [\n {\n \"id\": 1,\n \"device_id\": \"abc123def456\",\n \"device_name\": \"我的电脑\",\n \"device_type\": \"windows\",\n \"online_count\": 1,\n \"created_at\": \"2024-03-04T12:00:00Z\"\n }\n ]\n }\n}\n```", + ContentEn: "# User Login\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/login\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| username | string | Yes | Username |\n| password | string | Yes | Password |\n| device_id | string | Yes | Device fingerprint, unique device identifier |\n| device_name | string | No | Device name, e.g. \"My Computer\", defaults to device_id |\n| device_type | string | No | Device type: android/ios/windows/mac/linux/web, auto-detected if not provided |\n| instance_id | string | No | Instance ID for multi-instance detection, defaults to device_id |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"abc123def456\",\n \"device_name\": \"My Computer\",\n \"device_type\": \"windows\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Login successful\",\n \"data\": {\n \"token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n \"user_id\": 123\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|--------|------|------|\n| token | string | JWT token for subsequent authentication |\n| user_id | number | User ID |\n\n### Notes\n- device_id is the device fingerprint used to uniquely identify the device\n- device_name is a friendly name for the device display\n- device_type is auto-detected from User-Agent if not provided\n- Returns error with bound device list if device limit is exceeded\n\n### Error Response\n```json\n{\n \"code\": 401,\n \"message\": \"Invalid username or password\"\n}\n```\n\n### Device Limit Exceeded Response\n```json\n{\n \"code\": 403,\n \"message\": \"Device limit exceeded, please unbind a device first\",\n \"data\": {\n \"error_code\": \"DEVICE_LIMIT_EXCEEDED\",\n \"max_devices\": 3,\n \"device_count\": 3,\n \"devices\": [\n {\n \"id\": 1,\n \"device_id\": \"abc123def456\",\n \"device_name\": \"My Computer\",\n \"device_type\": \"windows\",\n \"online_count\": 1,\n \"created_at\": \"2024-03-04T12:00:00Z\"\n }\n ]\n }\n}\n```", + Summary: "用户登录说明", + SummaryEn: "User login instructions", + Sort: 3, + }, + { + CategoryID: &apiCat.ID, + Title: "心跳验证", + Slug: "api-heartbeat", + Content: "# 心跳验证\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/heartbeat\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| device_id | string | 是 | 设备ID |\n\n### 请求示例\n```json\n{\n \"device_id\": \"device_001\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"success\"\n}\n```\n\n### 说明\n- 保持用户在线状态\n- 建议每30秒调用一次\n- 超过一定时间未调用心跳,用户将被视为离线\n- 需要在请求头中携带JWT token", + Summary: "心跳验证说明", + Sort: 4, + }, + { + CategoryID: &apiCat.ID, + Title: "卡密充值", + Slug: "api-recharge", + Content: "# 卡密充值\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/recharge\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| username | string | 是 | 用户账号 |\n| card_key | string | 是 | 卡密 |\n\n### 请求示例\n```json\n{\n \"username\": \"user123\",\n \"card_key\": \"VIP123456\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"充值成功\",\n \"data\": {\n \"value\": 30\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| value | number | 充值值(根据卡密类型不同,含义不同)|\n\n### 错误响应\n```json\n{\n \"code\": 400,\n \"message\": \"卡密无效或已过期\"\n}\n```", + Summary: "卡密充值说明", + Sort: 5, + }, + { + CategoryID: &apiCat.ID, + Title: "获取绑定设备列表", + Slug: "api-devices", + Content: "# 获取绑定设备列表\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/devices\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"devices\": [\n {\n \"device_id\": \"device_001\",\n \"device_name\": \"我的电脑\",\n \"bind_time\": \"2024-03-04T12:00:00Z\",\n \"last_active\": \"2024-03-04T13:00:00Z\"\n }\n ]\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| device_id | string | 设备ID |\n| device_name | string | 设备名称 |\n| bind_time | string | 绑定时间 |\n| last_active | string | 最后活跃时间 |", + Summary: "获取绑定设备列表说明", + Sort: 6, + }, + { + CategoryID: &apiCat.ID, + Title: "解绑设备", + Slug: "api-unbind-device", + Content: "# 解绑设备\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/unbind-device\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| device_id | string | 是 | 设备ID |\n\n### 请求示例\n```json\n{\n \"device_id\": \"device_001\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"解绑成功\"\n}\n```\n\n### 错误响应\n```json\n{\n \"code\": 400,\n \"message\": \"设备不存在或未绑定\"\n}\n```", + Summary: "解绑设备说明", + Sort: 7, + }, + { + CategoryID: &apiCat.ID, + Title: "通过认证解绑设备", + Slug: "api-unbind-device-with-auth", + Content: "# 通过认证解绑设备\n\n当用户因设备数量达到上限无法登录时,可以使用此接口通过用户名和密码验证来解绑设备。\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/unbind-device-with-auth\n```\n\n### 说明\n此接口不需要 Authorization header,使用用户名和密码进行身份验证。\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| username | string | 是 | 用户名 |\n| password | string | 是 | 密码 |\n| device_id | string | 是 | 要解绑的设备ID |\n\n### 请求示例\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"device_001\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"message\": \"解绑成功\"\n }\n}\n```\n\n### 错误响应\n\n**参数错误**\n```json\n{\n \"code\": 400,\n \"message\": \"用户名、密码和设备ID不能为空\"\n}\n```\n\n**密码错误**\n```json\n{\n \"code\": 401,\n \"message\": \"密码错误\"\n}\n```\n\n**用户不存在**\n```json\n{\n \"code\": 404,\n \"message\": \"用户不存在\"\n}\n```\n\n**设备不存在**\n```json\n{\n \"code\": 404,\n \"message\": \"设备不存在\"\n}\n```\n\n**解绑失败**\n```json\n{\n \"code\": 500,\n \"message\": \"解绑设备失败\"\n}\n```\n\n### 使用场景\n1. 用户登录时收到\"设备数量已达上限\"的错误\n2. 前端引导用户使用用户名+密码验证方式解绑设备\n3. 用户输入用户名、密码和要解绑的设备ID\n4. 调用此接口完成解绑\n5. 解绑成功后,用户可以正常登录", + Summary: "通过认证解绑设备说明", + Sort: 8, + }, + { + CategoryID: &apiCat.ID, + Title: "重置密码(邮箱验证码)", + Slug: "api-reset-password", + Content: "# 重置密码(邮箱验证码)\n\n当用户忘记密码时,可以通过邮箱验证码重置密码。\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/reset-password\n```\n\n### 说明\n此接口不需要 Authorization header,需要先调用发送邮箱验证码接口获取验证码。\n\n### 前提条件\n- 应用已启用密码重置功能\n- 用户套餐支持密码重置功能\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| email | string | 是 | 邮箱地址 |\n| code | string | 是 | 邮箱验证码 |\n| password | string | 是 | 新密码(至少6位) |\n\n### 请求示例\n```json\n{\n \"email\": \"user@example.com\",\n \"code\": \"123456\",\n \"password\": \"newpassword123\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"message\": \"密码重置成功\"\n }\n}\n```\n\n### 错误响应\n\n**应用未启用密码重置**\n```json\n{\n \"code\": 400,\n \"message\": \"该应用未启用密码重置功能\"\n}\n```\n\n**套餐不支持**\n```json\n{\n \"code\": 403,\n \"message\": \"当前套餐不支持密码重置功能\"\n}\n```\n\n**验证码无效**\n```json\n{\n \"code\": 400,\n \"message\": \"验证码无效或已过期\"\n}\n```\n\n**验证码已过期**\n```json\n{\n \"code\": 400,\n \"message\": \"验证码已过期\"\n}\n```\n\n**用户不存在**\n```json\n{\n \"code\": 404,\n \"message\": \"用户不存在\"\n}\n```\n\n### 使用流程\n1. 调用 `POST /api/v1/app/:appKey/send-email-code` 发送验证码,purpose 设为 `reset_password`\n2. 用户收到验证码后调用此接口重置密码", + Summary: "通过邮箱验证码重置密码", + Sort: 9, + }, + { + CategoryID: &apiCat.ID, + Title: "修改密码", + Slug: "api-change-password", + Content: "# 修改密码\n\n通过用户名和原密码验证来修改密码。\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/change-password\n```\n\n### 说明\n此接口不需要 Authorization header,使用用户名和原密码进行身份验证。\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| username | string | 是 | 用户名 |\n| old_password | string | 是 | 原密码 |\n| new_password | string | 是 | 新密码(至少6位) |\n\n### 请求示例\n```json\n{\n \"username\": \"user123\",\n \"old_password\": \"oldpassword123\",\n \"new_password\": \"newpassword123\"\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"message\": \"密码修改成功\"\n }\n}\n```\n\n### 错误响应\n\n**参数错误**\n```json\n{\n \"code\": 400,\n \"message\": \"参数错误\"\n}\n```\n\n**用户不存在**\n```json\n{\n \"code\": 404,\n \"message\": \"用户不存在\"\n}\n```\n\n**原密码错误**\n```json\n{\n \"code\": 400,\n \"message\": \"原密码错误\"\n}\n```\n\n### 使用场景\n- 用户记得原密码时修改密码\n- 无需邮箱验证的密码修改场景", + Summary: "修改密码", + Sort: 10, + }, + { + CategoryID: &apiCat.ID, + Title: "获取设备数量", + Slug: "api-device-count", + Content: "# 获取设备数量\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/device-count\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"count\": 3,\n \"max_devices\": 5\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| count | number | 当前绑定设备数量 |\n| max_devices | number | 最大允许设备数量 |", + Summary: "获取设备数量说明", + Sort: 11, + }, + { + CategoryID: &apiCat.ID, + Title: "获取应用信息", + Slug: "api-app-info", + Content: "# 获取应用信息\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/info\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"app_id\": 1,\n \"name\": \"我的应用\",\n \"description\": \"游戏验证应用\",\n \"status\": \"active\"\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| app_id | number | 应用ID |\n| name | string | 应用名称 |\n| description | string | 应用描述 |\n| status | string | 应用状态 |", + Summary: "获取应用信息说明", + Sort: 12, + }, + { + CategoryID: &apiCat.ID, + Title: "获取用户账户信息", + Slug: "api-account", + Content: "# 获取用户账户信息\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/account\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"user_id\": 123,\n \"username\": \"user123\",\n \"expire_time\": \"2024-04-04T00:00:00Z\",\n \"remaining_days\": 30\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| user_id | number | 用户ID |\n| username | string | 用户名 |\n| expire_time | string | 过期时间 |\n| remaining_days | number | 剩余天数 |", + Summary: "获取用户账户信息说明", + Sort: 13, + }, + { + CategoryID: &apiCat.ID, + Title: "获取应用常量", + Slug: "api-constants", + Content: "# 获取应用常量\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/constants\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"常量名称\",\n \"key\": \"CONSTANT_KEY\",\n \"value\": \"常量值\",\n \"var_type\": \"string\",\n \"description\": \"常量描述\"\n }\n ]\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| id | number | 常量ID |\n| name | string | 常量名称 |\n| key | string | 常量键 |\n| value | string | 常量值 |\n| var_type | string | 变量类型 |\n| description | string | 描述 |", + Summary: "获取应用常量说明", + Sort: 14, + }, + { + CategoryID: &apiCat.ID, + Title: "获取指定常量", + Slug: "api-constant", + Content: "# 获取指定常量\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/constants/:key\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 路径参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| key | string | 是 | 常量key |\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"id\": 1,\n \"name\": \"常量名称\",\n \"key\": \"CONSTANT_KEY\",\n \"value\": \"常量值\",\n \"var_type\": \"string\",\n \"description\": \"常量描述\"\n }\n}\n```\n\n### 错误响应\n```json\n{\n \"code\": 404,\n \"message\": \"常量不存在\"\n}\n```", + Summary: "获取指定常量说明", + Sort: 15, + }, + { + CategoryID: &apiCat.ID, + Title: "获取应用变量", + Slug: "api-variables", + Content: "# 获取应用变量\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/variables\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"变量名称\",\n \"key\": \"VARIABLE_KEY\",\n \"default_value\": \"默认值\",\n \"var_type\": \"string\",\n \"scope\": \"user\",\n \"description\": \"变量描述\"\n }\n ]\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| id | number | 变量ID |\n| name | string | 变量名称 |\n| key | string | 变量键 |\n| default_value | string | 默认值 |\n| var_type | string | 变量类型 |\n| scope | string | 作用域 |\n| description | string | 描述 |", + Summary: "获取应用变量说明", + Sort: 16, + }, + { + CategoryID: &apiCat.ID, + Title: "获取指定变量", + Slug: "api-variable", + Content: "# 获取指定变量\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/variables/:key\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 路径参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| key | string | 是 | 变量key |\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"id\": 1,\n \"name\": \"变量名称\",\n \"key\": \"VARIABLE_KEY\",\n \"default_value\": \"默认值\",\n \"var_type\": \"string\",\n \"scope\": \"user\",\n \"description\": \"变量描述\"\n }\n}\n```\n\n### 错误响应\n```json\n{\n \"code\": 404,\n \"message\": \"变量不存在\"\n}\n```", + Summary: "获取指定变量说明", + Sort: 17, + }, + { + CategoryID: &apiCat.ID, + Title: "更新用户变量", + Slug: "api-update-variables", + Content: "# 更新用户变量\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/variables\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| variables | object | 是 | 变量键值对 |\n\n### 请求示例\n```json\n{\n \"variables\": {\n \"nickname\": \"新昵称\",\n \"level\": 10\n }\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"更新成功\"\n}\n```\n\n### 说明\n- 只能更新作用域为user的变量\n- 变量值类型需要与定义的类型匹配", + Summary: "更新用户变量说明", + Sort: 18, + }, + { + CategoryID: &apiCat.ID, + Title: "调用云端函数", + Slug: "api-call-function", + Content: "# 调用云端函数\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/call-function\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| function_name | string | 是 | 云端函数名称 |\n| params | object | 否 | 传递给云端函数的参数 |\n\n### 请求示例\n```json\n{\n \"function_name\": \"calculatePrice\",\n \"params\": {\n \"x\": 10,\n \"y\": 20\n }\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"操作成功\",\n \"data\": {\n \"result\": 30,\n \"execution_time\": 0.001234\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| result | any | 云端函数执行结果 |\n| execution_time | float | 执行时间(秒) |\n\n### 错误响应\n```json\n{\n \"code\": 400,\n \"message\": \"代码执行错误: ReferenceError: x is not defined\"\n}\n```\n\n---\n\n## 云端函数编写指南\n\n云端函数使用 JavaScript 语法编写,在服务端安全执行。\n\n### 内置变量\n\n| 变量名 | 类型 | 说明 |\n|--------|------|------|\n| app | object | 当前应用信息 |\n| user | object | 当前登录用户信息(需传入user_id) |\n| params | object | 自定义传入的参数 |\n\n### 示例1:简单计算\n\n```javascript\n// 云端函数代码\nvar x = params.x || 0;\nvar y = params.y || 0;\nreturn x + y;\n```\n\n### 示例2:条件判断\n\n```javascript\n// 根据用户等级返回折扣\nvar level = user.level || 1;\nvar discount = 1.0;\n\nif (level >= 5) {\n discount = 0.7;\n} else if (level >= 3) {\n discount = 0.8;\n} else if (level >= 2) {\n discount = 0.9;\n}\n\nreturn {\n discount: discount,\n message: \"您的等级为\" + level + \",享受\" + (discount * 10) + \"折优惠\"\n};\n```\n\n### 示例3:返回应用信息\n\n```javascript\n// 返回应用配置信息\nreturn {\n app_name: app.name,\n billing_type: app.billing_type,\n max_devices: app.max_devices,\n status: app.status\n};\n```\n\n### 示例4:用户状态检查\n\n```javascript\n// 检查用户状态\nif (!user) {\n return { error: \"用户未登录\" };\n}\n\nreturn {\n username: user.username,\n status: user.status,\n balance: user.balance,\n is_vip: user.balance > 0 || user.balance === -1\n};\n```\n\n### 示例5:复杂业务逻辑\n\n```javascript\n// 积分计算\nvar basePoints = params.basePoints || 100;\nvar multiplier = 1;\n\n// VIP用户双倍积分\nif (user && user.balance > 0) {\n multiplier = 2;\n}\n\n// 节假日加成(示例)\nvar today = new Date();\nvar dayOfWeek = today.getDay();\nif (dayOfWeek === 0 || dayOfWeek === 6) {\n multiplier += 0.5;\n}\n\nvar finalPoints = Math.floor(basePoints * multiplier);\n\nreturn {\n base_points: basePoints,\n multiplier: multiplier,\n final_points: finalPoints,\n reason: multiplier > 2 ? \"VIP周末加成\" : (multiplier > 1 ? \"VIP加成\" : \"基础积分\")\n};\n```\n\n### 注意事项\n\n1. 云端函数必须返回一个值(可以是任意类型)\n2. 函数执行超时时间为5秒\n3. 不支持网络请求和文件系统操作\n4. 请确保代码语法正确,保存前会进行语法检查", + Summary: "调用云端函数说明", + Sort: 19, + }, + { + CategoryID: &apiCat.ID, + Title: "检查更新", + Slug: "api-check-update", + Content: "# 检查更新\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/check-update\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| version | string | 否 | 当前客户端版本号 |\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": {\n \"has_update\": true,\n \"latest_version\": \"2.0.0\",\n \"download_url\": \"https://example.com/download/v2.0.0\",\n \"update_notes\": \"修复了若干bug\",\n \"update_strategy\": \"optional\",\n \"update_method\": \"manual\"\n }\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| has_update | boolean | 是否有更新 |\n| latest_version | string | 最新版本号 |\n| download_url | string | 下载地址 |\n| update_notes | string | 更新说明 |\n| update_strategy | string | 更新策略:`optional`(可选更新) 或 `force`(强制更新) |\n| update_method | string | 更新方式:`auto`(自动更新) 或 `manual`(手动更新) |", + Summary: "检查更新说明", + Sort: 20, + }, + { + CategoryID: &apiCat.ID, + Title: "获取公告", + Slug: "api-announcements", + Content: "# 获取公告\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/announcements\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"title\": \"系统维护通知\",\n \"content\": \"系统将于今晚进行维护...\",\n \"created_at\": \"2024-03-04T10:00:00Z\"\n }\n ]\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| id | number | 公告ID |\n| title | string | 公告标题 |\n| content | string | 公告内容 |\n| created_at | string | 创建时间 |", + Summary: "获取公告说明", + Sort: 21, + }, + { + CategoryID: &apiCat.ID, + Title: "获取在线实例列表", + Slug: "api-instances", + Content: "# 获取在线实例列表\n\n### 接口地址\n```\nGET /api/v1/app/:appKey/instances\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| user_id | number | 是 | 用户ID |\n\n### 请求示例\n```json\n{\n \"user_id\": 123\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"instance_id\": \"instance_001\",\n \"device_id\": \"device_001\",\n \"device_name\": \"我的电脑\",\n \"is_online\": true,\n \"last_heartbeat\": \"2024-03-04T13:00:00Z\",\n \"created_at\": \"2024-03-04T12:00:00Z\"\n }\n ]\n}\n```\n\n### 响应字段说明\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| id | number | 实例ID |\n| instance_id | string | 实例标识 |\n| device_id | string | 设备ID |\n| device_name | string | 设备名称 |\n| is_online | boolean | 是否在线 |\n| last_heartbeat | string | 最后心跳时间 |\n| created_at | string | 创建时间 |", + Summary: "获取在线实例列表说明", + Sort: 22, + }, + { + CategoryID: &apiCat.ID, + Title: "强制离线实例", + Slug: "api-force-offline", + Content: "# 强制离线实例\n\n### 接口地址\n```\nPOST /api/v1/app/:appKey/instances/:instance_id/offline\n```\n\n### 请求头\n```\nAuthorization: Bearer {token}\n```\n\n### 路径参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| instance_id | string | 是 | 实例标识 |\n\n### 请求参数\n| 参数名 | 类型 | 必填 | 说明 |\n|--------|------|------|------|\n| user_id | number | 是 | 用户ID |\n\n### 请求示例\n```json\n{\n \"user_id\": 123\n}\n```\n\n### 响应示例\n```json\n{\n \"code\": 200,\n \"message\": \"已强制离线\"\n}\n```\n\n### 错误响应\n```json\n{\n \"code\": 404,\n \"message\": \"实例不存在\"\n}\n```", + Summary: "强制离线实例说明", + Sort: 23, + }, + { + CategoryID: &examplesCat.ID, + Title: "软件验证示例", + Slug: "example-software", + Content: "# 软件验证示例\n\n## 完整验证流程\n\n### 1. 用户输入用户名和密码\n\n```javascript\nconst username = document.getElementById('username').value;\nconst password = document.getElementById('password').value;\n```\n\n### 2. 获取设备ID\n\n```javascript\nfunction getDeviceId() {\n let deviceId = localStorage.getItem('device_id');\n if (!deviceId) {\n deviceId = 'device_' + Math.random().toString(36).substr(2, 9);\n localStorage.setItem('device_id', deviceId);\n }\n return deviceId;\n}\n\nconst deviceId = getDeviceId();\n```\n\n### 3. 调用登录接口\n\n```javascript\nconst response = await fetch(`/api/v1/app/${appKey}/login`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n username,\n password,\n device_id: deviceId\n })\n});\n\nconst result = await response.json();\n\nif (result.code !== 200) {\n alert('登录失败: ' + result.message);\n return;\n}\n```\n\n### 4. 保存token\n\n```javascript\nlocalStorage.setItem('token', result.data.token);\nlocalStorage.setItem('expire_time', result.data.expire_time);\n```\n\n### 5. 启动心跳\n\n```javascript\nsetInterval(async () => {\n await fetch(`/api/v1/app/${appKey}/heartbeat`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${localStorage.getItem('token')}`\n },\n body: JSON.stringify({\n device_id: deviceId\n })\n });\n}, 30000);\n```\n\n### 6. 进入主程序\n\n```javascript\nstartApplication();\n```\n\n## 完整示例代码\n\n```javascript\n// 配置\nconst appKey = 'your_app_key';\n\n// 获取设备ID\nfunction getDeviceId() {\n let deviceId = localStorage.getItem('device_id');\n if (!deviceId) {\n deviceId = 'device_' + Math.random().toString(36).substr(2, 9);\n localStorage.setItem('device_id', deviceId);\n }\n return deviceId;\n}\n\n// 登录函数\nasync function login() {\n const username = document.getElementById('username').value;\n const password = document.getElementById('password').value;\n const deviceId = getDeviceId();\n\n try {\n const response = await fetch(`/api/v1/app/${appKey}/login`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n username,\n password,\n device_id: deviceId\n })\n });\n\n const result = await response.json();\n\n if (result.code !== 200) {\n alert('登录失败: ' + result.message);\n return false;\n }\n\n // 保存token\n localStorage.setItem('token', result.data.token);\n localStorage.setItem('expire_time', result.data.expire_time);\n localStorage.setItem('user_id', result.data.user_id);\n\n // 启动心跳\n startHeartbeat();\n\n // 进入主程序\n startApplication();\n\n return true;\n } catch (error) {\n console.error('登录错误:', error);\n alert('登录失败,请检查网络连接');\n return false;\n }\n}\n\n// 心跳函数\nfunction startHeartbeat() {\n setInterval(async () => {\n const token = localStorage.getItem('token');\n if (!token) return;\n\n try {\n await fetch(`/api/v1/app/${appKey}/heartbeat`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${token}`\n },\n body: JSON.stringify({\n device_id: getDeviceId()\n })\n });\n } catch (error) {\n console.error('心跳错误:', error);\n }\n }, 30000);\n}\n\n// 检查登录状态\nfunction checkLoginStatus() {\n const token = localStorage.getItem('token');\n const expireTime = localStorage.getItem('expire_time');\n\n if (!token || !expireTime) {\n return false;\n }\n\n const now = new Date();\n const expire = new Date(expireTime);\n\n return now < expire;\n}\n\n// 主程序入口\nfunction startApplication() {\n console.log('启动主程序...');\n // 在这里添加你的主程序逻辑\n}\n\n// 页面加载时检查登录状态\nwindow.onload = function() {\n if (checkLoginStatus()) {\n startHeartbeat();\n startApplication();\n } else {\n // 显示登录界面\n document.getElementById('login-form').style.display = 'block';\n }\n};\n```", + Summary: "软件登录验证完整示例", + Sort: 1, + }, + { + CategoryID: &examplesCat.ID, + Title: "游戏验证示例", + Slug: "example-game", + Content: "# 游戏验证示例\n\n## Unity集成\n\n### 1. 调用登录接口\n\n```csharp\nusing UnityEngine;\nusing UnityEngine.Networking;\n\npublic class AuthManager : MonoBehaviour {\n private string appKey = \"your_app_key\";\n private string token;\n private string deviceId;\n \n void Start() {\n deviceId = SystemInfo.deviceUniqueIdentifier;\n }\n \n public async void Login(string username, string password) {\n string url = $\"https://api.example.com/api/v1/app/{appKey}/login\";\n \n string jsonData = $\"{{\\\"username\\\": \\\"{username}\\\", \\\"password\\\": \\\"{password}\\\", \\\"device_id\\\": \\\"{deviceId}\\\"}}\";\n \n using (UnityWebRequest request = UnityWebRequest.Post(url, jsonData)) {\n request.SetRequestHeader(\"Content-Type\", \"application/json\");\n \n yield return request.SendWebRequest();\n \n if (request.result == UnityWebRequest.Result.Success) {\n string response = request.downloadHandler.text;\n var result = JsonUtility.FromJson(response);\n \n if (result.code == 200) {\n token = result.data.token;\n PlayerPrefs.SetString(\"token\", token);\n \n // 启动心跳\n StartCoroutine(HeartbeatCoroutine());\n \n // 进入游戏\n LoadMainScene();\n } else {\n Debug.LogError(\"登录失败: \" + result.message);\n }\n } else {\n Debug.LogError(\"请求失败: \" + request.error);\n }\n }\n }\n \n IEnumerator HeartbeatCoroutine() {\n while (true) {\n yield return new WaitForSeconds(30f);\n SendHeartbeat();\n }\n }\n \n async void SendHeartbeat() {\n string url = $\"https://api.example.com/api/v1/app/{appKey}/heartbeat\";\n \n string jsonData = $\"{{\\\"device_id\\\": \\\"{deviceId}\\\"}}\";\n \n using (UnityWebRequest request = UnityWebRequest.Post(url, jsonData)) {\n request.SetRequestHeader(\"Content-Type\", \"application/json\");\n request.SetRequestHeader(\"Authorization\", $\"Bearer {token}\");\n \n yield return request.SendWebRequest();\n \n if (request.result != UnityWebRequest.Result.Success) {\n Debug.LogError(\"心跳失败: \" + request.error);\n }\n }\n }\n \n void LoadMainScene() {\n UnityEngine.SceneManagement.SceneManager.LoadScene(\"MainScene\");\n }\n}\n\n[System.Serializable]\npublic class LoginResponse {\n public int code;\n public string message;\n public LoginData data;\n}\n\n[System.Serializable]\npublic class LoginData {\n public string token;\n public string expire_time;\n public int user_id;\n public string username;\n}\n```\n\n### 2. 卡密充值\n\n```csharp\npublic async void Recharge(string username, string cardKey) {\n string url = $\"https://api.example.com/api/v1/app/{appKey}/recharge\";\n \n string jsonData = $\"{{\\\"username\\\": \\\"{username}\\\", \\\"card_key\\\": \\\"{cardKey}\\\"}}\";\n \n using (UnityWebRequest request = UnityWebRequest.Post(url, jsonData)) {\n request.SetRequestHeader(\"Content-Type\", \"application/json\");\n \n yield return request.SendWebRequest();\n \n if (request.result == UnityWebRequest.Result.Success) {\n string response = request.downloadHandler.text;\n var result = JsonUtility.FromJson(response);\n \n if (result.code == 200) {\n Debug.Log(\"充值成功,充值值: \" + result.data.value);\n } else {\n Debug.LogError(\"充值失败: \" + result.message);\n }\n } else {\n Debug.LogError(\"请求失败: \" + request.error);\n }\n }\n}\n\n[System.Serializable]\npublic class RechargeResponse {\n public int code;\n public string message;\n public RechargeData data;\n}\n\n[System.Serializable]\npublic class RechargeData {\n public int value;\n}\n```\n\n### 3. 检查更新\n\n```csharp\npublic async void CheckUpdate(string currentVersion) {\n string url = $\"https://api.example.com/api/v1/app/{appKey}/check-update?version={currentVersion}\";\n \n using (UnityWebRequest request = UnityWebRequest.Get(url)) {\n request.SetRequestHeader(\"Authorization\", $\"Bearer {token}\");\n \n yield return request.SendWebRequest();\n \n if (request.result == UnityWebRequest.Result.Success) {\n string response = request.downloadHandler.text;\n var result = JsonUtility.FromJson(response);\n \n if (result.code == 200 && result.data.has_update) {\n Debug.Log(\"发现新版本: \" + result.data.latest_version);\n Debug.Log(\"更新说明: \" + result.data.update_notes);\n \n if (result.data.update_strategy == \"force\") {\n // 强制更新\n ShowUpdateDialog(true);\n } else {\n // 可选更新\n ShowUpdateDialog(false);\n }\n }\n } else {\n Debug.LogError(\"检查更新失败: \" + request.error);\n }\n }\n}\n\n[System.Serializable]\npublic class UpdateResponse {\n public int code;\n public string message;\n public UpdateData data;\n}\n\n[System.Serializable]\npublic class UpdateData {\n public bool has_update;\n public string latest_version;\n public string download_url;\n public string update_notes;\n public string update_strategy;\n public string update_method;\n}\n```", + Summary: "Unity游戏集成示例", + Sort: 2, + }, + { + CategoryID: &faqCat.ID, + Title: "如何获取API密钥?", + Slug: "faq-api-key", + Content: "# 如何获取API密钥?\n\n## 步骤\n\n1. 登录开发者后台\n2. 进入\"应用管理\"页面\n3. 创建新应用或选择已有应用\n4. 在应用详情页可以看到:\n - **AppID**:应用唯一标识\n - **AppKey**:应用密钥\n - **SecretKey**:加密密钥\n\n## 注意事项\n\n- AppKey只在创建时显示一次,请及时保存\n- 如需重置AppKey,点击\"重置密钥\"按钮\n- 重置后旧密钥立即失效\n- SecretKey用于服务端验证响应,不要在客户端使用", + Summary: "获取API密钥的详细步骤", + Sort: 1, + }, + { + CategoryID: &faqCat.ID, + Title: "卡密验证失败怎么办?", + Slug: "faq-card-fail", + Content: "# 卡密验证失败怎么办?\n\n## 常见原因\n\n1. **卡密已过期**\n - 检查卡密的有效期\n - 过期的卡密无法使用\n\n2. **卡密已被使用**\n - 单次使用的卡密只能验证一次\n - 已使用的卡密无法再次验证\n\n3. **设备绑定错误**\n - 检查设备ID是否正确\n - 确认应用是否启用了设备绑定\n\n4. **应用配置错误**\n - 检查AppID和AppKey是否正确\n - 确认应用状态是否正常\n\n## 解决方法\n\n1. 联系应用客服\n2. 重新获取卡密\n3. 检查设备网络连接\n4. 查看应用日志获取详细错误信息", + Summary: "卡密验证失败的常见原因和解决方法", + Sort: 2, + }, + { + CategoryID: &faqCat.ID, + Title: "如何实现代理授权?", + Slug: "faq-agent", + Content: "# 如何实现代理授权?\n\n## 什么是代理授权?\n\n代理授权允许开发者将自己的应用授权给其他开发者,被授权的开发者可以生成卡密并销售。\n\n## 授权流程\n\n1. **申请授权**\n - 被授权方向授权方提交申请\n - 输入应用ID\n - 等待授权方批准\n\n2. **批准授权**\n - 授权方查看申请\n - 批准申请后设置卡密类型权限\n - 设置代理佣金和折扣\n\n3. **生成卡密**\n - 被授权方选择被授权的应用\n - 选择有权限的卡密类型\n - 生成卡密并销售\n\n4. **结算佣金**\n - 销售收入按比例结算\n - 被授权方获得佣金收入\n\n## 权限管理\n\n- 授权方可以随时修改卡密类型权限\n- 可以暂停或终止授权\n- 可以查看代理的销售数据", + Summary: "代理授权的实现流程和权限管理", + Sort: 3, + }, + } + + for i := range docs { + doc := model.Doc{ + CategoryID: docs[i].CategoryID, + Title: docs[i].Title, + TitleEn: docs[i].TitleEn, + Slug: docs[i].Slug, + Content: docs[i].Content, + ContentEn: docs[i].ContentEn, + Summary: docs[i].Summary, + SummaryEn: docs[i].SummaryEn, + Sort: docs[i].Sort, + Status: "published", + } + DB.Create(&doc) + } + + log.Println("Document data initialized successfully") +} + +// initRedis 初始化Redis连接 +func initRedis() { + RDB = redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", config.GetString("redis.host"), config.GetString("redis.port")), + Password: config.GetString("redis.password"), + DB: config.GetInt("redis.db"), + }) + + // 测试连接 + ctx := context.Background() + _, err := RDB.Ping(ctx).Result() + if err != nil { + log.Println("Failed to connect to Redis:", err.Error()) + // 不再panic,只记录错误 + RDB = nil + } +} + +// initTicketData 初始化工单数据 +func initTicketData() { + var ticketCount int64 + DB.Model(&model.Ticket{}).Count(&ticketCount) + + if ticketCount == 0 { + log.Println("Initializing ticket data...") + + // 确保开发者用户存在 + var devUser model.User + if err := DB.Where("username = ?", "dev").First(&devUser).Error; err != nil { + log.Println("Creating dev user for tickets...") + devEmail := "dev@example.com" + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) + devUser = model.User{ + Username: "dev", + Email: &devEmail, + Password: string(hashedPassword), + Role: "developer", + Status: "active", + } + DB.Create(&devUser) + } + + // 创建示例工单 + tickets := []model.Ticket{ + { + UserID: devUser.ID, + Title: "登录接口返回500错误", + Content: "在使用登录接口时,服务器返回500错误,请帮忙查看。", + Category: "技术问题", + Priority: "high", + Status: "pending", + }, + { + UserID: devUser.ID, + Title: "卡密生成功能咨询", + Content: "请问如何批量生成卡密?是否有相关API接口?", + Category: "功能咨询", + Priority: "medium", + Status: "resolved", + }, + } + + for i := range tickets { + DB.Create(&tickets[i]) + } + + log.Println("Ticket data initialized successfully") + } +} diff --git a/backend/internal/middleware/app_crypto.go b/backend/internal/middleware/app_crypto.go new file mode 100644 index 0000000..2c3d275 --- /dev/null +++ b/backend/internal/middleware/app_crypto.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "fmt" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/crypto" + + "github.com/gin-gonic/gin" +) + +func AppCrypto() gin.HandlerFunc { + return func(c *gin.Context) { + appKey := c.Param("appKey") + if appKey == "" { + c.Next() + return + } + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + c.Next() + return + } + + var encryptType crypto.EncryptType + switch app.EncryptType { + case "aes": + encryptType = crypto.EncryptTypeAES + case "rc4": + encryptType = crypto.EncryptTypeRC4 + default: + encryptType = crypto.EncryptTypeNone + } + + cryptoManager := crypto.NewCryptoManager(encryptType, app.SecretKey) + shouldEncrypt := encryptType != crypto.EncryptTypeNone + + fmt.Printf("[AppCrypto] AppKey: %s, EncryptType: %s, SecretKey: %s, ShouldEncrypt: %v\n", + appKey, app.EncryptType, app.SecretKey, shouldEncrypt) + + c.Set("should_encrypt_response", shouldEncrypt) + c.Set("crypto_manager", cryptoManager) + + c.Next() + } +} diff --git a/backend/internal/middleware/crypto.go b/backend/internal/middleware/crypto.go new file mode 100644 index 0000000..8c093c7 --- /dev/null +++ b/backend/internal/middleware/crypto.go @@ -0,0 +1,156 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/crypto" + + "github.com/gin-gonic/gin" +) + +type CryptoMiddleware struct { + cryptoManager *crypto.CryptoManager + shouldEncrypt bool +} + +func NewCryptoMiddleware(encryptType crypto.EncryptType, secretKey string) *CryptoMiddleware { + shouldEncrypt := encryptType != crypto.EncryptTypeNone + return &CryptoMiddleware{ + cryptoManager: crypto.NewCryptoManager(encryptType, secretKey), + shouldEncrypt: shouldEncrypt, + } +} + +type EncryptedRequest struct { + Data string `json:"data" binding:"required"` +} + +type EncryptedResponse struct { + Data string `json:"data"` +} + +func (cm *CryptoMiddleware) ProcessRequest() gin.HandlerFunc { + return func(c *gin.Context) { + contentType := c.GetHeader("Content-Type") + + if strings.Contains(contentType, "application/json") { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(400, gin.H{"code": 400, "message": "读取请求体失败"}) + c.Abort() + return + } + + var req interface{} + if err := json.Unmarshal(body, &req); err != nil { + c.JSON(400, gin.H{"code": 400, "message": "解析请求体失败"}) + c.Abort() + return + } + + reqMap, ok := req.(map[string]interface{}) + if ok { + if encryptedData, exists := reqMap["data"]; exists { + if dataStr, ok := encryptedData.(string); ok { + decrypted, err := cm.cryptoManager.Decrypt(dataStr) + if err != nil { + c.JSON(400, gin.H{"code": 400, "message": "解密失败: " + err.Error()}) + c.Abort() + return + } + + var decryptedData interface{} + if err := json.Unmarshal([]byte(decrypted), &decryptedData); err != nil { + c.JSON(400, gin.H{"code": 400, "message": "解析解密数据失败"}) + c.Abort() + return + } + + c.Set("decrypted_data", decryptedData) + c.Set("is_encrypted", true) + } + } + } + + c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + } + + c.Set("should_encrypt_response", cm.shouldEncrypt) + c.Set("crypto_manager", cm.cryptoManager) + c.Next() + } +} + +func (cm *CryptoMiddleware) ProcessResponse() gin.HandlerFunc { + return func(c *gin.Context) { + blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer} + c.Writer = blw + + c.Next() + + shouldEncrypt := c.GetBool("should_encrypt_response") + fmt.Printf("[ProcessResponse] ShouldEncrypt: %v\n", shouldEncrypt) + + if shouldEncrypt { + var response map[string]interface{} + if err := json.Unmarshal(blw.body.Bytes(), &response); err == nil { + responseJSON, _ := json.Marshal(response) + fmt.Printf("[ProcessResponse] Response to encrypt: %s\n", string(responseJSON)) + + var cryptoManager *crypto.CryptoManager + if cm, ok := c.Get("crypto_manager"); ok { + cryptoManager = cm.(*crypto.CryptoManager) + } else { + appKey := c.Param("appKey") + if appKey != "" { + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err == nil { + var encryptType crypto.EncryptType + switch app.EncryptType { + case "aes": + encryptType = crypto.EncryptTypeAES + case "rc4": + encryptType = crypto.EncryptTypeRC4 + default: + encryptType = crypto.EncryptTypeNone + } + cryptoManager = crypto.NewCryptoManager(encryptType, app.SecretKey) + } + } + } + + if cryptoManager != nil { + encrypted, err := cryptoManager.Encrypt(string(responseJSON)) + if err == nil { + encryptedResponse := EncryptedResponse{Data: encrypted} + encryptedJSON, _ := json.Marshal(encryptedResponse) + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.Write(encryptedJSON) + fmt.Printf("[ProcessResponse] Encrypted response sent\n") + return + } else { + fmt.Printf("[ProcessResponse] Encryption error: %v\n", err) + } + } else { + fmt.Printf("[ProcessResponse] CryptoManager is nil\n") + } + } else { + fmt.Printf("[ProcessResponse] JSON unmarshal error: %v\n", err) + } + } + } +} + +type bodyLogWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *bodyLogWriter) Write(b []byte) (int, error) { + return w.body.Write(b) +} diff --git a/backend/internal/middleware/middleware.go b/backend/internal/middleware/middleware.go new file mode 100644 index 0000000..85d99e1 --- /dev/null +++ b/backend/internal/middleware/middleware.go @@ -0,0 +1,234 @@ +package middleware + +import ( + "fmt" + "net/http" + "strings" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/jwt" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func CheckAppStatus() gin.HandlerFunc { + return func(c *gin.Context) { + appKey := c.Param("appKey") + if appKey == "" { + c.Next() + return + } + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + c.Abort() + return + } + + if app.Status == "maintenance" { + response.Error(c, 503, "应用维护中,暂时无法访问") + c.Abort() + return + } + if app.Status == "stopped" { + response.Error(c, 403, "应用已停止运营") + c.Abort() + return + } + + c.Set("app", &app) + c.Next() + } +} + +// JWT JWT中间件 +func JWT() gin.HandlerFunc { + return func(c *gin.Context) { + fmt.Printf("JWT中间件被调用: %s %s\n", c.Request.Method, c.Request.URL.Path) + + var token string + + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + token = parts[1] + } + } + + if token == "" { + token = c.Query("token") + } + + if token == "" { + fmt.Printf("JWT中间件: Authorization header is required\n") + response.Error(c, http.StatusUnauthorized, "Authorization header is required") + c.Abort() + return + } + + claims, err := jwt.ParseToken(token) + if err != nil { + fmt.Printf("JWT中间件: Invalid token: %v\n", err) + response.Error(c, http.StatusUnauthorized, "Invalid token") + c.Abort() + return + } + + if time.Now().Unix() > claims.ExpiresAt.Unix() { + fmt.Printf("JWT中间件: Token expired\n") + response.Error(c, http.StatusUnauthorized, "Token expired") + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + + fmt.Printf("JWT中间件: 验证成功, user_id=%d, role=%s\n", claims.UserID, claims.Role) + c.Next() + } +} + +// AdminAuth 管理员权限中间件 +func AdminAuth() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + response.Error(c, http.StatusForbidden, "Access denied") + c.Abort() + return + } + + if role != "admin" { + response.Error(c, http.StatusForbidden, "Admin access required") + c.Abort() + return + } + + c.Next() + } +} + +// DeveloperAuth 开发者权限中间件 +func DeveloperAuth() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + response.Error(c, http.StatusForbidden, "Access denied") + c.Abort() + return + } + + if role != "developer" && role != "admin" { + response.Error(c, http.StatusForbidden, "Developer access required") + c.Abort() + return + } + + c.Next() + } +} + +// PackageAuth 套餐授权中间件 +func PackageAuth() gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get("role") + if role == "admin" { + c.Next() + return + } + + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + c.Abort() + return + } + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + c.Abort() + return + } + + if user.CurrentPackageID == nil { + response.Error(c, 403, "您还没有购买套餐,请先购买套餐后再使用开发者功能") + c.Abort() + return + } + + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND package_id = ? AND status = ?", + userID, user.CurrentPackageID, "active").First(&userPackage).Error; err != nil { + response.Error(c, 403, "套餐授权已失效,请续费或重新购买套餐") + c.Abort() + return + } + + if userPackage.ExpiredAt != nil && userPackage.ExpiredAt.Before(time.Now()) { + response.Error(c, 403, "套餐已过期,请续费后再使用开发者功能") + c.Abort() + return + } + + c.Set("user_package", &userPackage) + c.Next() + } +} + +// Logger 日志中间件 +func Logger() gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n", + param.ClientIP, + param.TimeStamp.Format(time.RFC1123), + param.Method, + param.Path, + param.Request.Proto, + param.StatusCode, + param.Latency, + param.Request.UserAgent(), + param.ErrorMessage, + ) + }) +} + +// Recovery 恢复中间件 +func Recovery() gin.HandlerFunc { + return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { + response.Error(c, http.StatusInternalServerError, "Internal server error") + }) +} + +// Cors 跨域中间件 +func Cors() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "false") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + c.Writer.Header().Set("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusOK) + return + } + + c.Next() + } +} + +// RateLimit 限流中间件 +func RateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + // 这里可以实现基于Redis的限流逻辑 + // 暂时跳过实现 + c.Next() + } +} diff --git a/backend/internal/middleware/usage_limit.go b/backend/internal/middleware/usage_limit.go new file mode 100644 index 0000000..dceb08f --- /dev/null +++ b/backend/internal/middleware/usage_limit.go @@ -0,0 +1,156 @@ +package middleware + +import ( + "fmt" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func CheckApiLimit() gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + c.Abort() + return + } + + var user model.User + if err := database.DB.Preload("CurrentPackage").First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + c.Abort() + return + } + + if user.CurrentPackageID == nil { + response.Error(c, 403, "您还没有购买套餐,请先购买套餐") + c.Abort() + return + } + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err != nil { + response.Error(c, 500, "获取套餐权限失败") + c.Abort() + return + } + + now := time.Now() + if user.ApiCallsResetAt == nil || now.Sub(*user.ApiCallsResetAt) >= 24*time.Hour { + user.ApiCallsUsed = 0 + user.ApiCallsResetAt = &now + database.DB.Save(&user) + } + + if user.ApiCallsUsed >= permission.MaxApiCalls { + response.Error(c, 403, fmt.Sprintf("API调用次数已达上限(%d次/天),请升级套餐", permission.MaxApiCalls)) + c.Abort() + return + } + + c.Next() + + user.ApiCallsUsed++ + database.DB.Save(&user) + } +} + +func CheckStorageLimit(fileSize int64) gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + c.Abort() + return + } + + var user model.User + if err := database.DB.Preload("CurrentPackage").First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + c.Abort() + return + } + + if user.CurrentPackageID == nil { + response.Error(c, 403, "您还没有购买套餐,请先购买套餐") + c.Abort() + return + } + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err != nil { + response.Error(c, 500, "获取套餐权限失败") + c.Abort() + return + } + + maxStorageBytes := int64(permission.MaxStorage) * 1024 * 1024 + if user.StorageUsed+fileSize > maxStorageBytes { + usedMB := float64(user.StorageUsed) / 1024 / 1024 + maxMB := float64(permission.MaxStorage) + response.Error(c, 403, fmt.Sprintf("存储空间不足,已使用 %.2f MB / %.2f MB", usedMB, maxMB)) + c.Abort() + return + } + + c.Next() + } +} + +func RecordApiUsage() gin.HandlerFunc { + return func(c *gin.Context) { + startTime := time.Now() + + c.Next() + + userID, exists := c.Get("user_id") + if !exists { + return + } + + appID, _ := c.Get("app_id") + + duration := time.Since(startTime) + + usage := model.ApiUsage{ + UserID: userID.(uint), + ApplicationID: appID.(uint), + Endpoint: c.Request.URL.Path, + Method: c.Request.Method, + IPAddress: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + ResponseTime: int(duration.Milliseconds()), + StatusCode: c.Writer.Status(), + Success: c.Writer.Status() < 400, + CreatedAt: time.Now(), + } + + if len(c.Errors) > 0 { + usage.ErrorMessage = c.Errors.String() + } + + database.DB.Create(&usage) + } +} + +func UpdateStorageUsed(userID uint, fileSize int64, action string) error { + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + return err + } + + if action == "upload" { + user.StorageUsed += fileSize + } else if action == "delete" { + user.StorageUsed -= fileSize + if user.StorageUsed < 0 { + user.StorageUsed = 0 + } + } + + return database.DB.Save(&user).Error +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go new file mode 100644 index 0000000..54b67de --- /dev/null +++ b/backend/internal/model/models.go @@ -0,0 +1,928 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// User 平台用户模型(开发者、管理员) +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;size:50" json:"username"` + Email *string `gorm:"uniqueIndex;size:100" json:"email"` + Password string `gorm:"size:255" json:"-"` + Avatar string `gorm:"size:255" json:"avatar"` + Signature string `gorm:"size:255" json:"signature"` + Role string `gorm:"size:20;default:developer" json:"role"` + Status string `gorm:"size:20;default:active" json:"status"` + DeviceID string `gorm:"size:100" json:"device_id"` + LastLoginAt *time.Time `json:"last_login_at"` + ResetToken string `gorm:"size:255" json:"-"` + ResetTokenExpiresAt *time.Time `json:"reset_token_expires_at"` + + CurrentPackageID *uint `json:"current_package_id"` + ApiCallsUsed int `gorm:"default:0" json:"api_calls_used"` + StorageUsed int64 `gorm:"default:0" json:"storage_used"` + ApiCallsResetAt *time.Time `json:"api_calls_reset_at"` + ApiToken string `gorm:"size:64" json:"api_token"` + + ParentAgentID *uint `gorm:"index" json:"parent_agent_id"` + Commission float64 `gorm:"default:0" json:"commission"` + CanCreateAgent bool `gorm:"default:false" json:"can_create_agent"` + Balance float64 `gorm:"default:0" json:"balance"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Applications []Application `gorm:"foreignKey:UserID" json:"-"` + AgentApplications []AgentApplication `gorm:"foreignKey:AgentID" json:"-"` + CurrentPackage *Package `gorm:"foreignKey:CurrentPackageID" json:"current_package,omitempty"` + ParentAgent *User `gorm:"foreignKey:ParentAgentID" json:"parent_agent,omitempty"` + ChildAgents []User `gorm:"foreignKey:ParentAgentID" json:"child_agents,omitempty"` +} + +// AppUser 应用用户模型(应用对接的用户) +type AppUser struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex:idx_app_user;size:50" json:"username"` + Email string `gorm:"size:100" json:"email"` + Password string `gorm:"size:255" json:"-"` + DeviceID string `gorm:"size:100" json:"device_id"` + Avatar string `gorm:"size:255" json:"avatar"` + Status string `gorm:"size:20;default:active" json:"status"` + ApplicationID uint `gorm:"index:idx_app_user;not null" json:"application_id"` + Balance float64 `gorm:"default:0" json:"balance"` + ExpiryAt *time.Time `json:"expiry_at"` + LastLoginAt *time.Time `json:"last_login_at"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at"` + IsTrialUser bool `gorm:"default:false" json:"is_trial_user"` + TrialStartAt *time.Time `json:"trial_start_at"` + TrialEndAt *time.Time `json:"trial_end_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +type PaymentChannel struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Type string `gorm:"size:50;not null" json:"type"` + Icon string `gorm:"size:100" json:"icon"` + Config string `gorm:"type:text" json:"config"` + Sort int `gorm:"default:0" json:"sort"` + Status string `gorm:"size:20;default:active" json:"status"` + Remark string `gorm:"size:500" json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// DynamicCode 动态代码模型 +type DynamicCode struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `gorm:"index;not null" json:"application_id"` + Name string `gorm:"size:100;not null" json:"name"` + Key string `gorm:"size:100;uniqueIndex;not null" json:"key"` + Code string `gorm:"type:longtext;not null" json:"code"` + Description string `gorm:"type:text" json:"description"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + Creator *User `gorm:"foreignKey:UserID" json:"creator,omitempty"` + UserID *uint `gorm:"index" json:"user_id"` +} + +// UserLevel 用户等级模型 +type UserLevel struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:50" json:"name"` + Description string `gorm:"size:255" json:"description"` + RequiredPoints int `gorm:"default:0" json:"required_points"` + Discount int `gorm:"default:100" json:"discount"` + IsDefault bool `gorm:"default:false" json:"is_default"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// Application 应用模型 +type Application struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + Name string `gorm:"size:100" json:"name"` + Description string `gorm:"type:text" json:"description"` + IconURL string `gorm:"size:255" json:"icon_url"` + AppKey string `gorm:"uniqueIndex;size:50" json:"app_key"` + BillingType string `gorm:"size:20" json:"billing_type"` + LoginPolicy string `gorm:"size:20;default:loose" json:"login_policy"` + EncryptType string `gorm:"size:20" json:"encrypt_type"` + SecretKey string `gorm:"size:255" json:"secret_key"` + BindType string `gorm:"size:20" json:"bind_type"` + MaxDevices int `gorm:"default:1" json:"max_devices"` + ChangeLimit int `gorm:"default:3" json:"change_limit"` + ChangeInterval int `gorm:"default:7" json:"change_interval"` + ChangeExceedAction string `gorm:"size:20;default:deny" json:"change_exceed_action"` + ChangeDeductAmount float64 `gorm:"default:1" json:"change_deduct_amount"` + MultiOpenMode string `gorm:"size:20;default:forbidden" json:"multi_open_mode"` + MaxInstances int `gorm:"default:1" json:"max_instances"` + MultiOpen bool `gorm:"default:false" json:"multi_open"` + EnableTrial bool `gorm:"default:false" json:"enable_trial"` + TrialBalance float64 `gorm:"default:0" json:"trial_balance"` + TrialDays int `gorm:"default:0" json:"trial_days"` + EnableFreePeriod bool `gorm:"default:false" json:"enable_free_period"` + FreePeriodType string `gorm:"size:20;default:range" json:"free_period_type"` + FreePeriodStart string `gorm:"size:50" json:"free_period_start"` + FreePeriodEnd string `gorm:"size:50" json:"free_period_end"` + FreePeriodWeekdays string `gorm:"size:50" json:"free_period_weekdays"` + FreePeriodStartTime string `gorm:"size:10" json:"free_period_start_time"` + FreePeriodEndTime string `gorm:"size:10" json:"free_period_end_time"` + HeartbeatInterval int `gorm:"default:60" json:"heartbeat_interval"` + HeartbeatTimeout int `gorm:"default:300" json:"heartbeat_timeout"` + MaxAttempts int `gorm:"default:5" json:"max_attempts"` + LockDuration int `gorm:"default:30" json:"lock_duration"` + Status string `gorm:"size:20;default:active" json:"status"` + DeductionMode string `gorm:"size:20;default:auto" json:"deduction_mode"` + DeductionType string `gorm:"size:20;default:login" json:"deduction_type"` + DeductionInterval int `gorm:"default:1" json:"deduction_interval"` + DeductionUnit string `gorm:"size:20;default:minute" json:"deduction_unit"` + DeductionAmount float64 `gorm:"default:1" json:"deduction_amount"` + AllowRegister bool `gorm:"default:true" json:"allow_register"` + RegisterMethods string `gorm:"size:100;default:'[\"username\"]'" json:"register_methods"` + EnableEmailVerify bool `gorm:"default:false" json:"enable_email_verify"` + RequireEmailVerify bool `gorm:"default:false" json:"require_email_verify"` + EnablePasswordReset bool `gorm:"default:false" json:"enable_password_reset"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// Version 版本模型 +type Version struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `json:"application_id"` + Version string `gorm:"size:50" json:"version"` + FilePath string `gorm:"size:255" json:"file_path"` + FileSize int64 `json:"file_size"` + FileHash string `gorm:"size:64" json:"file_hash"` + EntryFile string `gorm:"size:255" json:"entry_file"` + ForceUpdate bool `gorm:"default:false" json:"force_update"` + UpdateStrategy string `gorm:"size:20;default:optional" json:"update_strategy"` + UpdateMethod string `gorm:"size:20;default:manual" json:"update_method"` + MinVersion string `gorm:"size:50" json:"min_version"` + Description string `gorm:"type:text" json:"description"` + Changelog string `gorm:"type:text" json:"changelog"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + Files []VersionFile `gorm:"foreignKey:VersionID" json:"files,omitempty"` +} + +// VersionFile 版本文件模型 +type VersionFile struct { + ID uint `gorm:"primaryKey" json:"id"` + VersionID uint `json:"version_id"` + FilePath string `gorm:"size:500" json:"file_path"` + FileName string `gorm:"size:255" json:"file_name"` + FileSize int64 `json:"file_size"` + FileHash string `gorm:"size:64" json:"file_hash"` + FileType string `gorm:"size:20;default:resource" json:"file_type"` + IsRequired bool `gorm:"default:true" json:"is_required"` + SortOrder int `gorm:"default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Version Version `gorm:"foreignKey:VersionID" json:"version,omitempty"` +} + +// CardType 卡密类型模型 +type CardType struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + ApplicationID uint `json:"application_id"` + Name string `gorm:"size:100" json:"name"` + RechargeType string `gorm:"size:20;default:balance" json:"recharge_type"` // balance(余额充值), subscription(订阅充值) + Value float64 `json:"value"` // 余额值或订阅时长(秒) + ValueUnit string `gorm:"size:20;default:day" json:"value_unit"` // 时长单位: day, month, year (仅订阅充值) + Price float64 `json:"price"` + Description string `gorm:"type:text" json:"description"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application *Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// Card 卡密模型 +type Card struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `json:"application_id"` // 所属应用ID + CardTypeID uint `json:"card_type_id"` + CardKey string `gorm:"uniqueIndex;size:100" json:"card_key"` + CreatorID uint `json:"creator_id"` // 创建人ID + AppUserID *uint `json:"app_user_id"` // 使用者ID + Status string `gorm:"size:20;default:unused" json:"status"` // unused, used, banned + UsedAt *time.Time `json:"used_at"` + ExpireAt *time.Time `json:"expire_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application *Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + CardType CardType `gorm:"foreignKey:CardTypeID" json:"card_type,omitempty"` + Creator User `gorm:"foreignKey:CreatorID" json:"creator,omitempty"` + AppUser *AppUser `gorm:"foreignKey:AppUserID" json:"app_user,omitempty"` +} + +// Announcement 公告模型 +type Announcement struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `json:"application_id"` + Title string `gorm:"size:255" json:"title"` + Content string `gorm:"type:text" json:"content"` + Type string `gorm:"size:20;default:info" json:"type"` // info, warning, urgent + Status string `gorm:"size:20;default:draft" json:"status"` // draft, active + IsTop bool `gorm:"default:false" json:"is_top"` // 是否置顶 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// Order 订单模型 +type Order struct { + ID uint `gorm:"primaryKey" json:"id"` + OrderNo string `gorm:"uniqueIndex;size:100" json:"order_no"` + UserID uint `json:"user_id"` + ApplicationID *uint `json:"application_id"` + PackageID *uint `json:"package_id"` + OrderType string `gorm:"size:50;not null" json:"order_type"` // card_recharge, agent_auth, user_recharge, user_deduct, package + Title string `gorm:"size:200" json:"title"` + Amount float64 `gorm:"type:decimal(10,2)" json:"amount"` + PaymentType string `gorm:"size:50" json:"payment_type"` // alipay, wechat, balance, card, system + PaymentMethod string `gorm:"size:50" json:"payment_method"` + Status string `gorm:"size:20;default:pending" json:"status"` // pending, paid, cancelled, refunded, failed + PaymentAt *time.Time `json:"payment_at"` + RefundAt *time.Time `json:"refund_at"` + RefundReason string `gorm:"type:text" json:"refund_reason"` + Description string `gorm:"type:text" json:"description"` + ExtraData string `gorm:"type:text" json:"extra_data"` // JSON格式存储额外数据 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + Package *Package `gorm:"foreignKey:PackageID" json:"package,omitempty"` +} + +// Log 日志模型 +type Log struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID *uint `json:"user_id"` + ApplicationID *uint `json:"application_id"` + AppUserID *uint `json:"app_user_id"` + LogType string `gorm:"size:20;default:operation" json:"log_type"` // operation, verification, exception + Action string `gorm:"size:100" json:"action"` + Resource string `gorm:"size:100" json:"resource"` + ResourceID *uint `json:"resource_id"` + Details string `gorm:"type:text" json:"details"` + IPAddress string `gorm:"size:50" json:"ip_address"` + UserAgent string `gorm:"size:500" json:"user_agent"` + DeviceID string `gorm:"size:100" json:"device_id"` + Status string `gorm:"size:20;default:success" json:"status"` // success, failed, pending + Level string `gorm:"size:20;default:info" json:"level"` // info, warning, error + ErrorMessage string `gorm:"type:text" json:"error_message"` + StackTrace string `gorm:"type:text" json:"stack_trace"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User *User `gorm:"foreignKey:UserID" json:"user"` + Application *Application `gorm:"foreignKey:ApplicationID" json:"application"` + AppUser *AppUser `gorm:"foreignKey:AppUserID" json:"app_user"` +} + +// RechargeRecord 充值记录模型 +type RechargeRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + OrderNo string `gorm:"size:50;uniqueIndex" json:"order_no"` + CardID *uint `json:"card_id"` + CardCode string `gorm:"size:100" json:"card_code"` + Amount float64 `gorm:"type:decimal(10,2)" json:"amount"` + Status string `gorm:"size:20;default:pending" json:"status"` // pending, success, failed + PaymentType string `gorm:"size:50" json:"payment_type"` // card, alipay, wechat, balance + Remark string `gorm:"type:text" json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + AppUser *AppUser `gorm:"foreignKey:UserID" json:"app_user,omitempty"` + Card *Card `gorm:"foreignKey:CardID" json:"card,omitempty"` +} + +// ConsumptionRecord 消费记录模型 +type ConsumptionRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + ApplicationID uint `json:"application_id"` + OrderNo string `gorm:"size:50;uniqueIndex" json:"order_no"` + Type string `gorm:"size:50" json:"type"` // verification, card_purchase, subscription, feature, manual_deduct + Content string `gorm:"type:text" json:"content"` + Description string `gorm:"type:text" json:"description"` + Amount float64 `gorm:"type:decimal(10,2)" json:"amount"` + BalanceAfter float64 `gorm:"type:decimal(10,2)" json:"balance_after"` + Status string `gorm:"size:20;default:success" json:"status"` // pending, success, failed + PaymentType string `gorm:"size:50" json:"payment_type"` // alipay, wechat, balance + Remark string `gorm:"type:text" json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + AppUser *AppUser `gorm:"foreignKey:UserID" json:"app_user,omitempty"` +} + +// Ticket 工单模型 +type Ticket struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + ApplicationID *uint `json:"application_id"` // 关联的应用ID,如果为空则提交给平台 + Title string `gorm:"size:200" json:"title"` + Content string `gorm:"type:text" json:"content"` + Category string `gorm:"size:50" json:"category"` // account, payment, technical, feature, other + Type string `gorm:"size:50" json:"type"` // user, agent, developer + Status string `gorm:"size:20;default:open" json:"status"` // open, processing, resolved, closed + Priority string `gorm:"size:20;default:normal" json:"priority"` // low, normal, high, urgent + AssignedTo *uint `json:"assigned_to"` // 分配给的应用开发者ID或平台管理员ID + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application *Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + AssignedUser *User `gorm:"foreignKey:AssignedTo" json:"assigned_user,omitempty"` + Replies []TicketReply `gorm:"foreignKey:TicketID" json:"replies,omitempty"` +} + +// TicketReply 工单回复模型 +type TicketReply struct { + ID uint `gorm:"primaryKey" json:"id"` + TicketID uint `json:"ticket_id"` + UserID uint `json:"user_id"` + Content string `gorm:"type:text" json:"content"` + IsAdmin bool `gorm:"default:false" json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Ticket Ticket `gorm:"foreignKey:TicketID" json:"ticket,omitempty"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// UserVariable 用户变量模型 +type UserVariable struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + AppID uint `json:"app_id"` + VarName string `gorm:"size:100" json:"var_name"` + VarValue string `gorm:"type:text" json:"var_value"` + VarType string `gorm:"size:20;default:string" json:"var_type"` + FilePath string `gorm:"size:500" json:"file_path"` + FileSize int64 `json:"file_size"` + MimeType string `gorm:"size:100" json:"mime_type"` + OriginalName string `gorm:"size:255" json:"original_name"` + FileMD5 string `gorm:"size:32" json:"file_md5"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CloudConstant 云端常量模型 +type CloudConstant struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + AppID *uint `json:"app_id"` + Key string `gorm:"size:100;index" json:"key"` + Value string `gorm:"type:text" json:"value"` + VarType string `gorm:"size:20;default:string" json:"var_type"` // string, integer, decimal, binary + FilePath string `gorm:"size:500" json:"file_path"` + FileSize int64 `json:"file_size"` + MimeType string `gorm:"size:100" json:"mime_type"` + OriginalName string `gorm:"size:255" json:"original_name"` + FileMD5 string `gorm:"size:32" json:"file_md5"` + Description string `gorm:"size:255" json:"description"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// RiskControlRule 风控规则模型 +type RiskControlRule struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` // 创建者ID + ApplicationID *uint `gorm:"index" json:"application_id"` // 应用ID,为空表示全局规则 + Type string `gorm:"size:20;not null" json:"type"` // ip, device, user, region + Value string `gorm:"size:255;not null" json:"value"` + Reason string `gorm:"size:500" json:"reason"` + Status string `gorm:"size:20;default:active" json:"status"` // active, inactive + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} +type CloudVariable struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + AppID *uint `json:"app_id"` + Key string `gorm:"size:100;index" json:"key"` + DefaultValue string `gorm:"type:text" json:"default_value"` + VarType string `gorm:"size:20;default:string" json:"var_type"` // string, integer, decimal, binary + DataType string `gorm:"size:20;default:single" json:"data_type"` // single, stream + MaxRecords int `gorm:"default:0" json:"max_records"` // 流水类型最大记录数,0=不限制 + FilePath string `gorm:"size:500" json:"file_path"` + FileSize int64 `json:"file_size"` + MimeType string `gorm:"size:100" json:"mime_type"` + OriginalName string `gorm:"size:255" json:"original_name"` + FileMD5 string `gorm:"size:32" json:"file_md5"` + Scope string `gorm:"size:20;default:app" json:"scope"` + WritePermission string `gorm:"size:20;default:developer" json:"write_permission"` + Description string `gorm:"size:255" json:"description"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +type CloudVariableRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + CloudVariableID uint `gorm:"index;not null" json:"cloud_variable_id"` + AppUserID *uint `json:"app_user_id"` + Data string `gorm:"type:text" json:"data"` + CreatedAt time.Time `json:"created_at"` + + CloudVariable CloudVariable `gorm:"foreignKey:CloudVariableID" json:"cloud_variable,omitempty"` +} + +// IPBlacklist IP黑名单模型 +type IPBlacklist struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + IPAddress string `gorm:"size:50" json:"ip_address"` + Type string `gorm:"size:20;default:single" json:"type"` // single, range + Reason string `gorm:"size:255" json:"reason"` + ExpiresAt *time.Time `json:"expires_at"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// DeviceBlacklist 机器码黑名单模型 +type DeviceBlacklist struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + DeviceID string `gorm:"size:100" json:"device_id"` + Type string `gorm:"size:20;default:temporary" json:"type"` // permanent, temporary + Reason string `gorm:"size:255" json:"reason"` + ExpiresAt *time.Time `json:"expires_at"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// AbnormalBehavior 异常行为模型 +type AbnormalBehavior struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID *uint `json:"user_id"` + DeviceID string `gorm:"size:100" json:"device_id"` + IPAddress string `gorm:"size:50" json:"ip_address"` + Type string `gorm:"size:50" json:"type"` // frequent-login, abnormal-api, data-tampering, suspicious-activity + Level string `gorm:"size:20" json:"level"` // low, medium, high, critical + Description string `gorm:"type:text" json:"description"` + Details string `gorm:"type:text" json:"details"` // JSON格式的详细信息 + Status string `gorm:"size:20;default:pending" json:"status"` // pending, handled, ignored + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// DocCategory 文档分类模型 +type DocCategory struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100" json:"name"` + NameEn string `gorm:"size:100" json:"name_en"` + Slug string `gorm:"size:100;uniqueIndex" json:"slug"` + Description string `gorm:"size:255" json:"description"` + DescriptionEn string `gorm:"size:255" json:"description_en"` + Icon string `gorm:"size:50;default:file-text" json:"icon"` + Status string `gorm:"size:20;default:active" json:"status"` + Sort int `gorm:"default:0" json:"sort"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// AgentApplication 代理应用授权模型 +type AgentApplication struct { + ID uint `gorm:"primaryKey" json:"id"` + AgentID uint `json:"agent_id"` + ApplicationID uint `json:"application_id"` + DeveloperID uint `json:"developer_id"` + Commission float64 `gorm:"default:0.1" json:"commission"` + Discount float64 `gorm:"default:1.0" json:"discount"` + Status string `gorm:"size:20;default:active" json:"status"` + IsReceived bool `gorm:"default:false" json:"is_received"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Agent User `gorm:"foreignKey:AgentID;references:ID" json:"agent,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID;references:ID" json:"application,omitempty"` + Developer User `gorm:"foreignKey:DeveloperID;references:ID" json:"developer,omitempty"` + CardTypes []AgentApplicationCardType `gorm:"foreignKey:AgentApplicationID" json:"card_types,omitempty"` +} + +// AgentApplicationCardType 代理应用卡密类型授权 +type AgentApplicationCardType struct { + ID uint `gorm:"primaryKey" json:"id"` + AgentApplicationID uint `json:"agent_application_id"` + CardTypeID uint `json:"card_type_id"` + CanGenerate bool `gorm:"default:false" json:"can_generate"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + AgentApplication AgentApplication `gorm:"foreignKey:AgentApplicationID" json:"agent_application,omitempty"` + CardType CardType `gorm:"foreignKey:CardTypeID" json:"card_type,omitempty"` +} + +// AgentApplicationRequest 代理应用申请/邀请模型 +type AgentApplicationRequest struct { + ID uint `gorm:"primaryKey" json:"id"` + AgentID uint `json:"agent_id"` + DeveloperID uint `json:"developer_id"` + ApplicationID uint `json:"application_id"` + Type string `gorm:"size:20" json:"type"` + Status string `gorm:"size:20;default:pending" json:"status"` + Message string `gorm:"type:text" json:"message"` + RejectReason string `gorm:"type:text" json:"reject_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Agent User `gorm:"foreignKey:AgentID" json:"agent,omitempty"` + Developer User `gorm:"foreignKey:DeveloperID" json:"developer,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// Package 套餐模型 +type Package struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100" json:"name"` + NameEn string `gorm:"size:100" json:"name_en"` + Price float64 `gorm:"default:0" json:"price"` + Currency string `gorm:"size:10;default:CNY" json:"currency"` // CNY, USD, EUR, GBP, JPY + Period string `gorm:"size:20;default:permanent" json:"period"` // permanent, monthly, yearly + Description string `gorm:"size:255" json:"description"` + DescriptionEn string `gorm:"size:255" json:"description_en"` + Status string `gorm:"size:20;default:active" json:"status"` // active, inactive + Sort int `gorm:"default:0" json:"sort"` + IsRecommended bool `gorm:"default:false" json:"is_recommended"` + AllowUpgrade bool `gorm:"default:true" json:"allow_upgrade"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// PackagePermission 套餐权限模型 +type PackagePermission struct { + ID uint `gorm:"primaryKey" json:"id"` + PackageID uint `json:"package_id"` + MaxApplications int `gorm:"default:1" json:"max_applications"` + MaxStorage int `gorm:"default:100" json:"max_storage"` + MaxApiCalls int `gorm:"default:1000" json:"max_api_calls"` + AllowAgent bool `gorm:"default:false" json:"allow_agent"` + AllowCloudData bool `gorm:"default:false" json:"allow_cloud_data"` + AllowDynamicCode bool `gorm:"default:false" json:"allow_dynamic_code"` + AllowEmail bool `gorm:"default:false" json:"allow_email"` + AllowExtension bool `gorm:"default:false" json:"allow_extension"` + PrioritySupport bool `gorm:"default:false" json:"priority_support"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Doc 文档模型 +type Doc struct { + ID uint `gorm:"primaryKey" json:"id"` + CategoryID *uint `json:"category_id"` + Title string `gorm:"size:200" json:"title"` + TitleEn string `gorm:"size:200" json:"title_en"` + Slug string `gorm:"size:200;uniqueIndex" json:"slug"` + Content string `gorm:"type:longtext" json:"content"` + ContentEn string `gorm:"type:longtext" json:"content_en"` + Summary string `gorm:"size:500" json:"summary"` + SummaryEn string `gorm:"size:500" json:"summary_en"` + Icon string `gorm:"size:50" json:"icon"` + Sort int `gorm:"default:0" json:"sort"` + Status string `gorm:"size:20;default:draft" json:"status"` // draft, published + ViewCount int `gorm:"default:0" json:"view_count"` + AuthorID *uint `json:"author_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Category *DocCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"` +} + +// Setting 系统设置模型 +type Setting struct { + ID uint `gorm:"primaryKey" json:"id"` + Category string `gorm:"size:50;index" json:"category"` // basic, email, sms, payment + Key string `gorm:"size:100;uniqueIndex" json:"key"` + Value string `gorm:"type:text" json:"value"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Captcha 验证码模型 +type Captcha struct { + ID uint `gorm:"primaryKey" json:"id"` + CaptchaID string `gorm:"uniqueIndex;size:50" json:"captcha_id"` + Code string `gorm:"size:10" json:"code"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +type EmailVerifyCode struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `gorm:"index" json:"application_id"` + Email string `gorm:"size:100;index" json:"email"` + Code string `gorm:"size:10" json:"code"` + Purpose string `gorm:"size:20;default:register" json:"purpose"` + ExpiresAt time.Time `json:"expires_at"` + Used bool `gorm:"default:false" json:"used"` + CreatedAt time.Time `json:"created_at"` +} + +type AppSMTPConfig struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `gorm:"uniqueIndex" json:"application_id"` + Host string `gorm:"size:100" json:"host"` + Port int `gorm:"default:465" json:"port"` + User string `gorm:"size:100" json:"user"` + Password string `gorm:"size:255" json:"password"` + FromName string `gorm:"size:100" json:"from_name"` + FromEmail string `gorm:"size:100" json:"from_email"` + UseSSL bool `gorm:"default:true" json:"use_ssl"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +type EmailTemplate struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `gorm:"index" json:"application_id"` + Type string `gorm:"size:50;index" json:"type"` + Name string `gorm:"size:100" json:"name"` + Subject string `gorm:"size:200" json:"subject"` + Content string `gorm:"type:text" json:"content"` + IsDefault bool `gorm:"default:false" json:"is_default"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// UserPackage 用户套餐授权模型 +type UserPackage struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + PackageID uint `json:"package_id"` + Status string `gorm:"size:20;default:active" json:"status"` // active, expired, cancelled + StartedAt time.Time `json:"started_at"` + ExpiredAt *time.Time `json:"expired_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Package Package `gorm:"foreignKey:PackageID" json:"package,omitempty"` +} + +// UserDevice 用户设备模型(设备绑定,持久化) +type UserDevice struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"user_id"` + ApplicationID uint `gorm:"index" json:"application_id"` + DeviceID string `gorm:"size:100;index" json:"device_id"` + DeviceName string `gorm:"size:100" json:"device_name"` + DeviceType string `gorm:"size:50" json:"device_type"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User AppUser `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + Sessions []DeviceSession `gorm:"foreignKey:DeviceID;references:ID" json:"sessions,omitempty"` +} + +// DeviceSession 设备会话模型(多开实例,运行时) +type DeviceSession struct { + ID uint `gorm:"primaryKey" json:"id"` + DeviceID uint `gorm:"index" json:"device_id"` + UserID uint `gorm:"index" json:"user_id"` + ApplicationID uint `gorm:"index" json:"application_id"` + InstanceID string `gorm:"size:100;uniqueIndex:idx_device_instance" json:"instance_id"` + LastHeartbeat *time.Time `json:"last_heartbeat"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Device UserDevice `gorm:"foreignKey:DeviceID" json:"device,omitempty"` + User AppUser `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// UserIP 用户IP绑定模型 +type UserIP struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"user_id"` + ApplicationID uint `gorm:"index" json:"application_id"` + IPAddress string `gorm:"size:50;index" json:"ip_address"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User AppUser `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// PackHistory 加壳历史记录模型 +type PackHistory struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `gorm:"index" json:"application_id"` + UserID uint `gorm:"index" json:"user_id"` + Filename string `gorm:"size:255" json:"filename"` + FileSize int64 `json:"file_size"` + MD5 string `gorm:"size:32" json:"md5"` + DownloadURL string `gorm:"size:500" json:"download_url"` + UploadTime time.Time `json:"upload_time"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` + User AppUser `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// WebhookConfig Webhook配置 +type WebhookConfig struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `gorm:"index;not null" json:"application_id"` + Name string `gorm:"size:100;not null" json:"name"` + URL string `gorm:"size:500;not null" json:"url"` + SecretKey string `gorm:"size:255" json:"secret_key"` + Events string `gorm:"type:text" json:"events"` // JSON数组存储订阅的事件类型 + Status string `gorm:"size:20;default:active" json:"status"` + RetryCount int `gorm:"default:3" json:"retry_count"` + Timeout int `gorm:"default:10" json:"timeout"` // 超时时间(秒) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// WebhookLog Webhook发送日志 +type WebhookLog struct { + ID uint `gorm:"primaryKey" json:"id"` + WebhookID uint `gorm:"index;not null" json:"webhook_id"` + Event string `gorm:"size:50;not null" json:"event"` + RequestData string `gorm:"type:text" json:"request_data"` + ResponseCode int `json:"response_code"` + ResponseData string `gorm:"type:text" json:"response_data"` + Status string `gorm:"size:20" json:"status"` // success, failed, retrying + RetryCount int `json:"retry_count"` + ErrorMessage string `gorm:"type:text" json:"error_message"` + Duration int `json:"duration"` // 请求耗时(毫秒) + CreatedAt time.Time `json:"created_at"` + + WebhookConfig WebhookConfig `gorm:"foreignKey:WebhookID" json:"webhook,omitempty"` +} + +// ExtensionAPIKey 扩展API密钥 +type ExtensionAPIKey struct { + ID uint `gorm:"primaryKey" json:"id"` + ApplicationID uint `gorm:"index;not null" json:"application_id"` + Name string `gorm:"size:100;not null" json:"name"` + AccessKey string `gorm:"uniqueIndex;size:64;not null" json:"access_key"` + SecretKey string `gorm:"size:64;not null" json:"secret_key"` + Permissions string `gorm:"type:text" json:"permissions"` // JSON数组存储权限列表 + Status string `gorm:"size:20;default:active" json:"status"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// ApiUsage API调用统计模型 +type ApiUsage struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + ApplicationID uint `json:"application_id"` + Endpoint string `gorm:"size:255" json:"endpoint"` + Method string `gorm:"size:10" json:"method"` + IPAddress string `gorm:"size:50" json:"ip_address"` + UserAgent string `gorm:"size:500" json:"user_agent"` + ResponseTime int `json:"response_time"` // 响应时间(毫秒) + StatusCode int `json:"status_code"` + Success bool `json:"success"` + ErrorMessage string `gorm:"type:text" json:"error_message"` + CreatedAt time.Time `json:"created_at"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// StorageUsage 存储使用记录模型 +type StorageUsage struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + ApplicationID *uint `json:"application_id"` + ResourceType string `gorm:"size:20" json:"resource_type"` // version, cloud_file + ResourceID uint `json:"resource_id"` + FileName string `gorm:"size:255" json:"file_name"` + FileSize int64 `json:"file_size"` + Action string `gorm:"size:20" json:"action"` // upload, delete + CreatedAt time.Time `json:"created_at"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Application *Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` +} + +// UsageAlertRecord 用量告警记录模型 +type UsageAlertRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + Type string `gorm:"size:20" json:"type"` // api_calls, storage, package_expiry + Level string `gorm:"size:20" json:"level"` // warning, critical + Message string `gorm:"size:255" json:"message"` + Usage float64 `json:"usage"` + Limit float64 `json:"limit"` + Percent float64 `json:"percent"` + Status string `gorm:"size:20;default:sent" json:"status"` // sent, read + CreatedAt time.Time `json:"created_at"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// Notification 站内通知模型 +type Notification struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + Title string `gorm:"size:100" json:"title"` + Content string `gorm:"type:text" json:"content"` + Type string `gorm:"size:20" json:"type"` // usage_alert, system, package + IsRead bool `gorm:"default:false" json:"is_read"` + CreatedAt time.Time `json:"created_at"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} diff --git a/backend/internal/router/app/account.go b/backend/internal/router/app/account.go new file mode 100644 index 0000000..88e33ba --- /dev/null +++ b/backend/internal/router/app/account.go @@ -0,0 +1,185 @@ +package app + +import ( + "log" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupAccountRoutes(r *gin.RouterGroup) { + r.GET("/account", handleAppGetAccount) + r.POST("/heartbeat", handleAppHeartbeat) +} + +func handleAppGetAccount(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + UserID uint `json:"user_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var user model.AppUser + if err := database.DB.First(&user, req.UserID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + response.Success(c, gin.H{ + "user_id": user.ID, + "username": user.Username, + "balance": user.Balance, + "status": user.Status, + }) +} + +func handleAppHeartbeat(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + UserID uint `json:"user_id"` + DeviceID string `json:"device_id"` + InstanceID string `json:"instance_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var user model.AppUser + if err := database.DB.First(&user, req.UserID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + if blocked, reason := checkRiskControl(c, app.ID, req.DeviceID, user.Username); blocked { + log.Printf("[DEBUG] Heartbeat blocked by risk control: %s", reason) + response.Error(c, 403, reason) + return + } + + now := time.Now() + user.LastHeartbeatAt = &now + + if app.BillingType != "free" && app.DeductionMode == "auto" && app.DeductionAmount > 0 { + shouldDeduct := false + + switch app.DeductionType { + case "timer": + if user.LastHeartbeatAt != nil { + elapsed := now.Sub(*user.LastHeartbeatAt) + var interval time.Duration + switch app.DeductionUnit { + case "minute": + interval = time.Duration(app.DeductionInterval) * time.Minute + case "hour": + interval = time.Duration(app.DeductionInterval) * time.Hour + case "day": + interval = time.Duration(app.DeductionInterval) * 24 * time.Hour + default: + interval = time.Duration(app.DeductionInterval) * time.Minute + } + if elapsed >= interval { + shouldDeduct = true + } + } + case "login": + shouldDeduct = true + } + + if shouldDeduct { + if user.Balance >= app.DeductionAmount { + user.Balance -= app.DeductionAmount + log.Printf("[DEBUG] Deducted %.2f from user %d, new balance: %.2f", app.DeductionAmount, user.ID, user.Balance) + } else { + log.Printf("[DEBUG] User %d has insufficient balance: %.2f < %.2f", user.ID, user.Balance, app.DeductionAmount) + response.Error(c, 403, "余额不足") + return + } + } + } + + if err := database.DB.Save(&user).Error; err != nil { + response.Error(c, 500, "更新心跳失败") + return + } + + if req.DeviceID != "" { + var device model.UserDevice + err := database.DB.Where("user_id = ? AND application_id = ? AND device_id = ?", user.ID, app.ID, req.DeviceID).First(&device).Error + + if err != nil { + device = model.UserDevice{ + UserID: user.ID, + ApplicationID: app.ID, + DeviceID: req.DeviceID, + DeviceName: req.DeviceID, + Status: "active", + } + if err := database.DB.Create(&device).Error; err != nil { + log.Printf("[DEBUG] Failed to create device: %v", err) + } + } + + instanceID := req.InstanceID + if instanceID == "" { + instanceID = req.DeviceID + } + + if device.ID > 0 { + var session model.DeviceSession + sessionErr := database.DB.Where("device_id = ? AND instance_id = ?", device.ID, instanceID).First(&session).Error + + if sessionErr != nil { + session = model.DeviceSession{ + DeviceID: device.ID, + UserID: user.ID, + ApplicationID: app.ID, + InstanceID: instanceID, + LastHeartbeat: &now, + } + if err := database.DB.Create(&session).Error; err != nil { + log.Printf("[DEBUG] Failed to create session: %v", err) + } + } else { + session.LastHeartbeat = &now + if err := database.DB.Save(&session).Error; err != nil { + log.Printf("[DEBUG] Failed to update session: %v", err) + } + } + } + } + + response.SuccessWithMessage(c, "心跳成功", gin.H{ + "message": "心跳成功", + "balance": user.Balance, + }) +} diff --git a/backend/internal/router/app/app.go b/backend/internal/router/app/app.go new file mode 100644 index 0000000..58ad70e --- /dev/null +++ b/backend/internal/router/app/app.go @@ -0,0 +1,15 @@ +package app + +import ( + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.RouterGroup) { + SetupPaymentRoutes(r) + SetupAccountRoutes(r) + SetupDynamicRoutes(r) +} + +func SetupAuthRoutes(r *gin.RouterGroup) { + SetupDeviceRoutes(r) +} diff --git a/backend/internal/router/app/app_crypto_helper.go b/backend/internal/router/app/app_crypto_helper.go new file mode 100644 index 0000000..62f4fdd --- /dev/null +++ b/backend/internal/router/app/app_crypto_helper.go @@ -0,0 +1,100 @@ +package app + +import ( + "fmt" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/crypto" + + "github.com/gin-gonic/gin" +) + +func AppCryptoMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + fmt.Printf("[AppCrypto] Middleware called\n") + appKey := c.Param("appKey") + fmt.Printf("[AppCrypto] AppKey from param: %s\n", appKey) + + if appKey == "" { + fmt.Printf("[AppCrypto] AppKey is empty, skipping\n") + c.Next() + return + } + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + fmt.Printf("[AppCrypto] Database error: %v\n", err) + c.Next() + return + } + + fmt.Printf("[AppCrypto] App found: %+v\n", app) + + var encryptType crypto.EncryptType + switch app.EncryptType { + case "aes": + encryptType = crypto.EncryptTypeAES + case "rc4": + encryptType = crypto.EncryptTypeRC4 + default: + encryptType = crypto.EncryptTypeNone + } + + shouldEncrypt := encryptType != crypto.EncryptTypeNone + + fmt.Printf("[AppCrypto] AppKey: %s, EncryptType: %s, SecretKey: %s, ShouldEncrypt: %v\n", + appKey, app.EncryptType, app.SecretKey, shouldEncrypt) + + c.Set("should_encrypt_response", shouldEncrypt) + c.Set("crypto_manager", crypto.NewCryptoManager(encryptType, app.SecretKey)) + + c.Next() + } +} + +func getAppCryptoManager(appKey string) (*crypto.CryptoManager, error) { + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + return nil, err + } + + var encryptType crypto.EncryptType + switch app.EncryptType { + case "aes": + encryptType = crypto.EncryptTypeAES + case "rc4": + encryptType = crypto.EncryptTypeRC4 + default: + encryptType = crypto.EncryptTypeNone + } + + return crypto.NewCryptoManager(encryptType, app.SecretKey), nil +} + +func decryptRequest(c *gin.Context, appKey string) ([]byte, error) { + encryptedData := c.GetHeader("X-Encrypted-Data") + if encryptedData == "" { + return nil, nil + } + + cryptoManager, err := getAppCryptoManager(appKey) + if err != nil { + return nil, err + } + + decrypted, err := cryptoManager.Decrypt(encryptedData) + if err != nil { + return nil, err + } + + return []byte(decrypted), nil +} + +func encryptResponse(c *gin.Context, appKey string, data []byte) (string, error) { + cryptoManager, err := getAppCryptoManager(appKey) + if err != nil { + return "", err + } + + return cryptoManager.Encrypt(string(data)) +} diff --git a/backend/internal/router/app/auth.go b/backend/internal/router/app/auth.go new file mode 100644 index 0000000..0a816c2 --- /dev/null +++ b/backend/internal/router/app/auth.go @@ -0,0 +1,905 @@ +package app + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "strings" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/jwt" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +func getDeviceType(c *gin.Context, clientDeviceType string) string { + validTypes := map[string]bool{ + "android": true, + "ios": true, + "windows": true, + "mac": true, + "linux": true, + "web": true, + } + + if clientDeviceType != "" && validTypes[clientDeviceType] { + return clientDeviceType + } + + userAgent := c.GetHeader("User-Agent") + return parseDeviceTypeFromUserAgent(userAgent) +} + +func parseDeviceTypeFromUserAgent(userAgent string) string { + if userAgent == "" { + return "unknown" + } + + ua := strings.ToLower(userAgent) + + switch { + case strings.Contains(ua, "android"): + return "android" + case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") || strings.Contains(ua, "ipod"): + return "ios" + case strings.Contains(ua, "windows"): + return "windows" + case strings.Contains(ua, "macintosh") || strings.Contains(ua, "mac os x"): + return "mac" + case strings.Contains(ua, "linux"): + return "linux" + case strings.Contains(ua, "mozilla") || strings.Contains(ua, "webkit") || strings.Contains(ua, "chrome") || strings.Contains(ua, "safari"): + return "web" + default: + return "unknown" + } +} + +func SetupAuthUserRoutes(r *gin.RouterGroup) { + r.POST("/register", handleAppRegister) + r.POST("/login", handleAppLogin) + r.POST("/send-email-code", handleAppSendEmailCode) + r.POST("/reset-password", handleAppResetPassword) +} + +func handleAppSendEmailCode(c *gin.Context) { + appKey := c.Param("appKey") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + Email string `json:"email" binding:"required,email"` + Purpose string `json:"purpose"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "请输入有效的邮箱地址") + return + } + + if req.Purpose == "" { + req.Purpose = "register" + } + + if req.Purpose == "register" { + if !app.EnableEmailVerify { + response.Error(c, 400, "该应用未启用邮箱验证") + return + } + } else if req.Purpose == "reset_password" { + if !app.EnablePasswordReset { + response.Error(c, 400, "该应用未启用密码重置功能") + return + } + } + + var smtpConfig model.AppSMTPConfig + if err := database.DB.Where("application_id = ?", app.ID).First(&smtpConfig).Error; err != nil { + response.Error(c, 400, "应用未配置SMTP") + return + } + + var template model.EmailTemplate + database.DB.Where("application_id = ? AND type = ? AND status = ?", app.ID, req.Purpose, "active"). + Order("is_default DESC").First(&template) + + code := generateVerifyCode() + expireAt := time.Now().Add(15 * time.Minute) + + verifyCode := model.EmailVerifyCode{ + ApplicationID: app.ID, + Email: req.Email, + Code: code, + Purpose: req.Purpose, + ExpiresAt: expireAt, + } + database.DB.Create(&verifyCode) + + emailService := service.NewEmailService() + config := service.EmailConfig{ + Host: smtpConfig.Host, + Port: smtpConfig.Port, + User: smtpConfig.User, + Password: smtpConfig.Password, + FromName: smtpConfig.FromName, + FromEmail: smtpConfig.FromEmail, + UseSSL: smtpConfig.UseSSL, + } + + subject := fmt.Sprintf("验证码 - %s", app.Name) + content := fmt.Sprintf(` + + + + +
+

邮箱验证

+

您的验证码是:%s

+

验证码有效期为15分钟,请尽快使用。

+
+

此邮件由 %s 系统自动发送,请勿回复。

+
+ + +`, code, app.Name) + + if template.ID > 0 { + subject = template.Subject + content = template.Content + } + + if err := emailService.SendEmail(config, req.Email, subject, content); err != nil { + response.Error(c, 500, fmt.Sprintf("发送失败: %v", err)) + return + } + + response.Success(c, gin.H{ + "message": "验证码已发送", + }) +} + +func generateVerifyCode() string { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + return fmt.Sprintf("%06d", r.Intn(1000000)) +} + +func handleAppRegister(c *gin.Context) { + appKey := c.Param("appKey") + log.Printf("[DEBUG] Starting registration for appKey: %s", appKey) + + app, exists := c.Get("app") + if !exists { + var appModel model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&appModel).Error; err != nil { + log.Printf("[DEBUG] Application not found for appKey: %s, error: %v", appKey, err) + response.Error(c, 404, "应用不存在") + return + } + app = &appModel + } + appModel := app.(*model.Application) + log.Printf("[DEBUG] Found application: ID=%d, Status=%s", appModel.ID, appModel.Status) + + if service.GetApplicationDisabledStatus(appModel.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + Username string `json:"username"` + Email string `json:"email"` + EmailCode string `json:"email_code"` + Password string `json:"password"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` + InstanceID string `json:"instance_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[DEBUG] Invalid request parameters: %v", err) + response.Error(c, 400, "参数错误") + return + } + log.Printf("[DEBUG] Registration request for username: %s", req.Username) + + if blocked, reason := checkRiskControl(c, appModel.ID, req.DeviceID, req.Username); blocked { + log.Printf("[DEBUG] Registration blocked by risk control: %s", reason) + service.LogVerification(c, &appModel.ID, nil, "register_blocked", "注册被拦截: "+reason, req.DeviceID, fmt.Errorf(reason)) + response.Error(c, 403, reason) + return + } + + if !appModel.AllowRegister { + log.Printf("[DEBUG] Registration disabled for app: %s", appKey) + response.Error(c, 403, "注册功能已关闭") + return + } + + var allowedMethods []string + if appModel.RegisterMethods != "" { + if err := json.Unmarshal([]byte(appModel.RegisterMethods), &allowedMethods); err != nil { + log.Printf("[DEBUG] Failed to parse register methods: %v", err) + allowedMethods = []string{"username"} + } + } else { + allowedMethods = []string{"username"} + } + + isEmailRegister := req.Email != "" && req.Username == "" + isUsernameRegister := req.Username != "" && req.Email == "" + isPhoneRegister := false + + if isEmailRegister && !containsString(allowedMethods, "email") { + response.Error(c, 403, "邮箱注册方式未开放") + return + } + if isUsernameRegister && !containsString(allowedMethods, "username") { + response.Error(c, 403, "用户名注册方式未开放") + return + } + if isPhoneRegister && !containsString(allowedMethods, "phone") { + response.Error(c, 403, "手机号注册方式未开放") + return + } + + if appModel.EnableEmailVerify && appModel.RequireEmailVerify { + if req.Email == "" { + response.Error(c, 400, "请输入邮箱地址") + return + } + if req.EmailCode == "" { + response.Error(c, 400, "请输入邮箱验证码") + return + } + + var verifyCode model.EmailVerifyCode + if err := database.DB.Where( + "application_id = ? AND email = ? AND code = ? AND purpose = ? AND used = ?", + appModel.ID, req.Email, req.EmailCode, "register", false, + ).First(&verifyCode).Error; err != nil { + response.Error(c, 400, "验证码错误或已过期") + return + } + + if verifyCode.ExpiresAt.Before(time.Now()) { + response.Error(c, 400, "验证码已过期") + return + } + + verifyCode.Used = true + database.DB.Save(&verifyCode) + } + + var user model.AppUser + if err := database.DB.Where("username = ? AND application_id = ?", req.Username, appModel.ID).First(&user).Error; err == nil { + log.Printf("[DEBUG] User already exists: %s", req.Username) + service.LogVerification(c, &appModel.ID, nil, "register_failed", "注册失败: 用户已存在 - "+req.Username, req.DeviceID, fmt.Errorf("用户已存在")) + response.Error(c, 400, "用户已存在") + return + } + + user = model.AppUser{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + DeviceID: req.DeviceID, + Avatar: "", + Status: "active", + ApplicationID: appModel.ID, + IsTrialUser: appModel.EnableTrial, + } + + if appModel.EnableTrial { + now := time.Now() + user.TrialStartAt = &now + if appModel.TrialBalance > 0 { + user.Balance = appModel.TrialBalance + } + } + + if err := database.DB.Create(&user).Error; err != nil { + log.Printf("[DEBUG] Failed to create user: %v", err) + response.Error(c, 500, "注册失败") + return + } + log.Printf("[DEBUG] Successfully created user with ID: %d", user.ID) + + if req.DeviceID != "" { + now := time.Now() + deviceType := getDeviceType(c, req.DeviceType) + + deviceName := req.DeviceName + if deviceName == "" { + deviceName = req.DeviceID + } + + var device model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ? AND device_id = ?", user.ID, appModel.ID, req.DeviceID).First(&device).Error; err != nil { + device = model.UserDevice{ + UserID: user.ID, + ApplicationID: appModel.ID, + DeviceID: req.DeviceID, + DeviceName: deviceName, + DeviceType: deviceType, + Status: "active", + } + if err := database.DB.Create(&device).Error; err != nil { + log.Printf("[DEBUG] Failed to create device: %v", err) + } else { + log.Printf("[DEBUG] Successfully created device with ID: %d, type: %s", device.ID, deviceType) + } + } + + instanceID := req.InstanceID + if instanceID == "" { + instanceID = req.DeviceID + } + + if device.ID > 0 { + var session model.DeviceSession + sessionErr := database.DB.Where("device_id = ? AND instance_id = ?", device.ID, instanceID).First(&session).Error + + if sessionErr != nil { + session = model.DeviceSession{ + DeviceID: device.ID, + UserID: user.ID, + ApplicationID: appModel.ID, + InstanceID: instanceID, + LastHeartbeat: &now, + } + if err := database.DB.Create(&session).Error; err != nil { + log.Printf("[DEBUG] Failed to create session: %v", err) + } + } else { + session.LastHeartbeat = &now + if err := database.DB.Save(&session).Error; err != nil { + log.Printf("[DEBUG] Failed to update session: %v", err) + } + } + } + } + + service.LogVerification(c, &appModel.ID, &user.ID, "register", "用户注册: "+req.Username, req.DeviceID, nil) + + response.SuccessWithMessage(c, "注册成功", gin.H{ + "user_id": user.ID, + }) +} + +func handleAppLogin(c *gin.Context) { + appKey := c.Param("appKey") + log.Printf("[DEBUG] Login attempt for appKey: %s", appKey) + + app, exists := c.Get("app") + if !exists { + var appModel model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&appModel).Error; err != nil { + log.Printf("[DEBUG] Application not found for appKey: %s, error: %v", appKey, err) + response.Error(c, 404, "应用不存在") + return + } + app = &appModel + } + appModel := app.(*model.Application) + log.Printf("[DEBUG] Found application: ID=%d, Status=%s", appModel.ID, appModel.Status) + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` + InstanceID string `json:"instance_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[DEBUG] Invalid request parameters: %v", err) + response.Error(c, 400, "参数错误") + return + } + log.Printf("[DEBUG] Login request for username: %s", req.Username) + + if req.DeviceID == "" { + response.Error(c, 400, "设备ID不能为空") + return + } + + var user model.AppUser + if err := database.DB.Where("username = ? AND application_id = ?", req.Username, appModel.ID).First(&user).Error; err != nil { + log.Printf("[DEBUG] User not found: %v", err) + service.LogVerification(c, &appModel.ID, nil, "login_failed", "登录失败: 用户不存在 - "+req.Username, req.DeviceID, fmt.Errorf("用户不存在")) + response.Error(c, 404, "用户不存在") + return + } + + if user.Password != req.Password { + log.Printf("[DEBUG] Password mismatch for user: %s", req.Username) + service.LogVerification(c, &appModel.ID, &user.ID, "login_failed", "登录失败: 密码错误 - "+req.Username, req.DeviceID, fmt.Errorf("密码错误")) + response.Error(c, 400, "密码错误") + return + } + + if blocked, reason := checkRiskControl(c, appModel.ID, req.DeviceID, req.Username); blocked { + log.Printf("[DEBUG] Login blocked by risk control: %s", reason) + service.LogVerification(c, &appModel.ID, &user.ID, "login_blocked", "登录被拦截: "+reason, req.DeviceID, fmt.Errorf(reason)) + response.Error(c, 403, reason) + return + } + + if appModel.LoginPolicy == "strict" { + log.Printf("[DEBUG] LoginPolicy is strict, checking free period and trial") + isInFreePeriod := false + if appModel.EnableFreePeriod { + isInFreePeriod = checkFreePeriod(appModel) + } + log.Printf("[DEBUG] isInFreePeriod=%v", isInFreePeriod) + + if !isInFreePeriod { + log.Printf("[DEBUG] Not in free period, checking trial") + isTrialValid := false + if user.IsTrialUser && user.TrialEndAt != nil && user.TrialEndAt.After(time.Now()) { + isTrialValid = true + } + log.Printf("[DEBUG] isTrialValid=%v, IsTrialUser=%v, TrialEndAt=%v, Balance=%f", isTrialValid, user.IsTrialUser, user.TrialEndAt, user.Balance) + + if !isTrialValid { + if appModel.BillingType != "free" && user.Balance <= 0 { + log.Printf("[DEBUG] User %d has no balance remaining", user.ID) + service.LogVerification(c, &appModel.ID, &user.ID, "login_failed", "登录失败: 余额不足 - "+req.Username, req.DeviceID, fmt.Errorf("余额不足,请充值后继续使用")) + response.Error(c, 403, "余额不足,请充值后继续使用") + return + } + } + } else { + log.Printf("[DEBUG] User is in free period, allowing login") + } + } + + if req.DeviceID != "" { + heartbeatTimeout := time.Duration(appModel.HeartbeatTimeout) * time.Second + timeoutThreshold := time.Now().Add(-heartbeatTimeout) + database.DB.Where("user_id = ? AND application_id = ? AND last_heartbeat < ?", user.ID, appModel.ID, timeoutThreshold).Delete(&model.DeviceSession{}) + + now := time.Now() + deviceType := getDeviceType(c, req.DeviceType) + + deviceName := req.DeviceName + if deviceName == "" { + deviceName = req.DeviceID + } + + clientIP := c.ClientIP() + + if appModel.BindType == "ip" || appModel.BindType == "mixed" { + var userIP model.UserIP + ipErr := database.DB.Where("user_id = ? AND application_id = ? AND ip_address = ?", user.ID, appModel.ID, clientIP).First(&userIP).Error + + if ipErr != nil { + var ipCount int64 + database.DB.Model(&model.UserIP{}). + Where("user_id = ? AND application_id = ?", user.ID, appModel.ID). + Count(&ipCount) + + if appModel.MaxDevices > 0 && ipCount >= int64(appModel.MaxDevices) { + log.Printf("[DEBUG] IP limit exceeded for user %d: %d/%d", user.ID, ipCount, appModel.MaxDevices) + + var userIPs []model.UserIP + database.DB.Where("user_id = ? AND application_id = ?", user.ID, appModel.ID).Order("created_at DESC").Find(&userIPs) + + ipList := make([]gin.H, 0) + for _, ip := range userIPs { + ipList = append(ipList, gin.H{ + "id": ip.ID, + "ip_address": ip.IPAddress, + "status": ip.Status, + "created_at": ip.CreatedAt, + }) + } + + response.ErrorWithData(c, 403, "IP绑定数量已达上限,请解绑后再试", gin.H{ + "error_code": "IP_LIMIT_EXCEEDED", + "max_ips": appModel.MaxDevices, + "ip_count": ipCount, + "ips": ipList, + }) + service.LogVerification(c, &appModel.ID, &user.ID, "login_failed", "登录失败: IP绑定数量已达上限 - "+req.Username, req.DeviceID, fmt.Errorf("IP绑定数量已达上限")) + return + } + + userIP = model.UserIP{ + UserID: user.ID, + ApplicationID: appModel.ID, + IPAddress: clientIP, + Status: "active", + } + if err := database.DB.Create(&userIP).Error; err != nil { + log.Printf("[DEBUG] Failed to create user IP: %v", err) + } else { + log.Printf("[DEBUG] Successfully created user IP with ID: %d, IP: %s", userIP.ID, clientIP) + } + } + } + + var device model.UserDevice + deviceErr := database.DB.Where("user_id = ? AND application_id = ? AND device_id = ?", user.ID, appModel.ID, req.DeviceID).First(&device).Error + + if deviceErr != nil { + if (appModel.BindType == "device" || appModel.BindType == "mixed") && appModel.MaxDevices > 0 { + var deviceCount int64 + database.DB.Model(&model.UserDevice{}). + Where("user_id = ? AND application_id = ?", user.ID, appModel.ID). + Count(&deviceCount) + + if deviceCount >= int64(appModel.MaxDevices) { + log.Printf("[DEBUG] Device limit exceeded for user %d: %d/%d", user.ID, deviceCount, appModel.MaxDevices) + + var devices []model.UserDevice + database.DB.Where("user_id = ? AND application_id = ?", user.ID, appModel.ID).Order("created_at DESC").Find(&devices) + + deviceList := make([]gin.H, 0) + for _, d := range devices { + var onlineSessions []model.DeviceSession + database.DB.Where("device_id = ? AND last_heartbeat > ?", d.ID, timeoutThreshold).Find(&onlineSessions) + + deviceList = append(deviceList, gin.H{ + "id": d.ID, + "device_id": d.DeviceID, + "device_name": d.DeviceName, + "device_type": d.DeviceType, + "online_count": len(onlineSessions), + "created_at": d.CreatedAt, + }) + } + + response.ErrorWithData(c, 403, "设备绑定数量已达上限,请解绑后再试", gin.H{ + "error_code": "DEVICE_LIMIT_EXCEEDED", + "max_devices": appModel.MaxDevices, + "device_count": deviceCount, + "devices": deviceList, + }) + service.LogVerification(c, &appModel.ID, &user.ID, "login_failed", "登录失败: 设备绑定数量已达上限 - "+req.Username, req.DeviceID, fmt.Errorf("设备绑定数量已达上限")) + return + } + } + + device = model.UserDevice{ + UserID: user.ID, + ApplicationID: appModel.ID, + DeviceID: req.DeviceID, + DeviceName: deviceName, + DeviceType: deviceType, + Status: "active", + } + if err := database.DB.Create(&device).Error; err != nil { + log.Printf("[DEBUG] Failed to create device: %v", err) + } else { + log.Printf("[DEBUG] Successfully created device with ID: %d, type: %s", device.ID, deviceType) + } + } + + instanceID := req.InstanceID + if instanceID == "" { + instanceID = req.DeviceID + } + + if device.ID > 0 { + if appModel.MultiOpen && appModel.MaxInstances > 0 { + var sessionCount int64 + database.DB.Model(&model.DeviceSession{}). + Where("device_id = ? AND instance_id != ? AND last_heartbeat > ?", device.ID, instanceID, timeoutThreshold). + Count(&sessionCount) + + if sessionCount >= int64(appModel.MaxInstances) { + if appModel.MultiOpenMode == "forbidden" { + log.Printf("[DEBUG] Multi-instance limit exceeded for user %d device %s: %d/%d", user.ID, req.DeviceID, sessionCount, appModel.MaxInstances) + + var sessions []model.DeviceSession + database.DB.Where("device_id = ? AND instance_id != ?", device.ID, instanceID).Order("last_heartbeat DESC").Find(&sessions) + + sessionList := make([]gin.H, 0) + for _, s := range sessions { + isOnline := s.LastHeartbeat != nil && s.LastHeartbeat.After(timeoutThreshold) + sessionList = append(sessionList, gin.H{ + "id": s.ID, + "instance_id": s.InstanceID, + "is_online": isOnline, + "last_heartbeat": s.LastHeartbeat, + "created_at": s.CreatedAt, + }) + } + + response.ErrorWithData(c, 403, "多开数量已达上限", gin.H{ + "error_code": "MULTI_INSTANCE_LIMIT_EXCEEDED", + "max_instances": appModel.MaxInstances, + "instance_count": sessionCount, + "instances": sessionList, + }) + service.LogVerification(c, &appModel.ID, &user.ID, "login_failed", "登录失败: 多开数量已达上限 - "+req.Username, req.DeviceID, fmt.Errorf("多开数量已达上限")) + return + } + } + } + + var session model.DeviceSession + if err := database.DB.Where("device_id = ? AND instance_id = ?", device.ID, instanceID).First(&session).Error; err != nil { + session = model.DeviceSession{ + DeviceID: device.ID, + UserID: user.ID, + ApplicationID: appModel.ID, + InstanceID: instanceID, + LastHeartbeat: &now, + } + if err := database.DB.Create(&session).Error; err != nil { + log.Printf("[DEBUG] Failed to create session: %v", err) + } else { + log.Printf("[DEBUG] Successfully created session with ID: %d, instance: %s", session.ID, instanceID) + } + } else { + session.LastHeartbeat = &now + if err := database.DB.Save(&session).Error; err != nil { + log.Printf("[DEBUG] Failed to update session: %v", err) + } else { + log.Printf("[DEBUG] Successfully updated session %d last_heartbeat", session.ID) + } + } + } + } + + token, err := jwt.GenerateToken(user.ID, user.Username, "app_user") + if err != nil { + log.Printf("[DEBUG] Failed to generate token: %v", err) + } + + now := time.Now() + database.DB.Model(&user).Updates(map[string]interface{}{ + "last_login_at": now, + "last_heartbeat_at": now, + }) + + service.LogVerification(c, &appModel.ID, &user.ID, "login", "用户登录: "+req.Username, req.DeviceID, nil) + + response.SuccessWithMessage(c, "登录成功", gin.H{ + "user_id": user.ID, + "token": token, + }) +} + +func checkFreePeriod(app *model.Application) bool { + now := time.Now() + log.Printf("[DEBUG] checkFreePeriod: EnableFreePeriod=%v, FreePeriodType=%s, FreePeriodStart=%s, FreePeriodEnd=%s, FreePeriodWeekdays=%s, FreePeriodStartTime=%s, FreePeriodEndTime=%s", + app.EnableFreePeriod, app.FreePeriodType, app.FreePeriodStart, app.FreePeriodEnd, app.FreePeriodWeekdays, app.FreePeriodStartTime, app.FreePeriodEndTime) + + localLocation := time.Local + + if app.FreePeriodType == "range" { + if app.FreePeriodStart == "" || app.FreePeriodEnd == "" { + log.Printf("[DEBUG] checkFreePeriod: range type but missing start or end") + return false + } + var startDate, endDate time.Time + var err error + + if len(app.FreePeriodStart) > 10 { + startDate, err = time.ParseInLocation("2006-01-02T15:04", app.FreePeriodStart, localLocation) + } else { + startDate, err = time.ParseInLocation("2006-01-02", app.FreePeriodStart, localLocation) + } + if err != nil { + log.Printf("[DEBUG] checkFreePeriod: failed to parse start date: %v", err) + return false + } + + if len(app.FreePeriodEnd) > 10 { + endDate, err = time.ParseInLocation("2006-01-02T15:04", app.FreePeriodEnd, localLocation) + } else { + endDate, err = time.ParseInLocation("2006-01-02", app.FreePeriodEnd, localLocation) + if err == nil { + endDate = endDate.Add(24 * time.Hour) + } + } + if err != nil { + log.Printf("[DEBUG] checkFreePeriod: failed to parse end date: %v", err) + return false + } + result := now.After(startDate) && now.Before(endDate) + log.Printf("[DEBUG] checkFreePeriod: range check result=%v, now=%v, start=%v, end=%v", result, now, startDate, endDate) + return result + } else if app.FreePeriodType == "weekdays" { + if app.FreePeriodWeekdays == "" { + log.Printf("[DEBUG] checkFreePeriod: weekdays type but missing weekdays") + return false + } + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + weekdayStr := string(rune('0' + weekday)) + if !contains(app.FreePeriodWeekdays, weekdayStr) { + log.Printf("[DEBUG] checkFreePeriod: weekday %s not in %s", weekdayStr, app.FreePeriodWeekdays) + return false + } + if app.FreePeriodStartTime != "" && app.FreePeriodEndTime != "" { + currentTime := now.Format("15:04") + result := currentTime >= app.FreePeriodStartTime && currentTime <= app.FreePeriodEndTime + log.Printf("[DEBUG] checkFreePeriod: weekday time check result=%v, currentTime=%s, startTime=%s, endTime=%s", result, currentTime, app.FreePeriodStartTime, app.FreePeriodEndTime) + return result + } + log.Printf("[DEBUG] checkFreePeriod: weekday check passed") + return true + } + + log.Printf("[DEBUG] checkFreePeriod: unknown type %s", app.FreePeriodType) + return false +} + +func checkRiskControl(c *gin.Context, appID uint, deviceID string, username string) (bool, string) { + clientIP := c.ClientIP() + + var globalRules []model.RiskControlRule + database.DB.Where("application_id IS NULL AND status = ?", "active").Find(&globalRules) + + var appRules []model.RiskControlRule + database.DB.Where("application_id = ? AND status = ?", appID, "active").Find(&appRules) + + now := time.Now() + + for _, rule := range globalRules { + if rule.ExpiresAt != nil && rule.ExpiresAt.Before(now) { + continue + } + + switch rule.Type { + case "device": + if deviceID != "" && rule.Value == deviceID { + return true, "设备已被全局封禁: " + rule.Reason + } + case "ip": + if rule.Value == clientIP { + return true, "IP已被全局封禁: " + rule.Reason + } + case "region": + } + } + + for _, rule := range appRules { + if rule.ExpiresAt != nil && rule.ExpiresAt.Before(now) { + continue + } + + switch rule.Type { + case "device": + if deviceID != "" && rule.Value == deviceID { + return true, "设备已被封禁: " + rule.Reason + } + case "ip": + if rule.Value == clientIP { + return true, "IP已被封禁: " + rule.Reason + } + case "user": + if username != "" && rule.Value == username { + return true, "账号已被封禁: " + rule.Reason + } + case "region": + } + } + + return false, "" +} + +func contains(s string, substr string) bool { + for i := 0; i < len(s); i++ { + if s[i:i+1] == substr { + return true + } + } + return false +} + +func containsString(slice []string, str string) bool { + for _, v := range slice { + if v == str { + return true + } + } + return false +} + +func checkAppEmailPermission(userID uint, permission *model.PackagePermission) bool { + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND status = ?", userID, "active"). + Preload("Package").First(&userPackage).Error; err != nil { + return false + } + + if userPackage.ExpiredAt != nil && userPackage.ExpiredAt.Before(time.Now()) { + return false + } + + if err := database.DB.Where("package_id = ?", userPackage.PackageID).First(permission).Error; err != nil { + return false + } + + return true +} + +func handleAppResetPassword(c *gin.Context) { + appKey := c.Param("appKey") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + if !app.EnablePasswordReset { + response.Error(c, 400, "该应用未启用密码重置功能") + return + } + + var req struct { + Email string `json:"email" binding:"required,email"` + Code string `json:"code" binding:"required"` + Password string `json:"password" binding:"required,min=6"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var verifyCode model.EmailVerifyCode + if err := database.DB.Where( + "application_id = ? AND email = ? AND code = ? AND purpose = ? AND used = ?", + app.ID, req.Email, req.Code, "reset_password", false, + ).First(&verifyCode).Error; err != nil { + response.Error(c, 400, "验证码无效或已过期") + return + } + + if verifyCode.ExpiresAt.Before(time.Now()) { + response.Error(c, 400, "验证码已过期") + return + } + + var user model.AppUser + if err := database.DB.Where("email = ? AND application_id = ?", req.Email, app.ID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + response.Error(c, 500, "密码加密失败") + return + } + + user.Password = string(hashedPassword) + if err := database.DB.Save(&user).Error; err != nil { + response.Error(c, 500, "密码重置失败") + return + } + + verifyCode.Used = true + database.DB.Save(&verifyCode) + + response.Success(c, gin.H{ + "message": "密码重置成功", + }) +} diff --git a/backend/internal/router/app/cloud.go b/backend/internal/router/app/cloud.go new file mode 100644 index 0000000..eb83da1 --- /dev/null +++ b/backend/internal/router/app/cloud.go @@ -0,0 +1,918 @@ +package app + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/middleware" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupCloudRoutes(r *gin.RouterGroup) { + r.GET("/constants", handleAppGetConstants) + r.GET("/constants/:key", handleAppGetConstantByKey) + r.GET("/constants/:key/download", handleAppDownloadConstant) + r.GET("/variables", handleAppGetVariables) + r.GET("/variables/:key", handleAppGetVariableByKey) + r.GET("/variables/:key/download", handleAppDownloadVariable) + r.POST("/variables/:key/upload", handleAppUploadVariableBinary) + r.POST("/variables", handleAppUpdateVariables) + r.POST("/variables/:key/records", handleAppCreateVariableRecord) + r.GET("/variables/:key/records", handleAppGetVariableRecords) + r.DELETE("/variables/:key/records/:record_id", handleAppDeleteVariableRecord) + r.POST("/call-function", handleAppCallFunction) +} + +func handleAppGetConstants(c *gin.Context) { + appKey := c.Param("appKey") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端常量") + return + } + + var constants []model.CloudConstant + if err := database.DB.Where("user_id = ? AND app_id = ? AND status = ?", app.UserID, app.ID, "active").Find(&constants).Error; err != nil { + response.Error(c, 500, "获取云端常量失败") + return + } + + result := make(map[string]interface{}) + for _, constant := range constants { + if constant.VarType == "binary" { + result[constant.Key] = gin.H{ + "type": "binary", + "value": "/api/v1/app/" + appKey + "/constants/" + constant.Key + "/download", + "file_name": constant.OriginalName, + "file_size": constant.FileSize, + "md5": constant.FileMD5, + "mime_type": constant.MimeType, + } + } else { + result[constant.Key] = gin.H{ + "type": constant.VarType, + "value": constant.Value, + } + } + } + + response.Success(c, result) +} + +func handleAppGetConstantByKey(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端常量") + return + } + + var constant model.CloudConstant + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&constant).Error; err != nil { + response.Error(c, 404, "云端常量不存在") + return + } + + if constant.VarType == "binary" { + response.Success(c, gin.H{ + "key": constant.Key, + "type": "binary", + "value": "/api/v1/app/" + appKey + "/constants/" + constant.Key + "/download", + "file_name": constant.OriginalName, + "file_size": constant.FileSize, + "md5": constant.FileMD5, + "mime_type": constant.MimeType, + }) + } else { + response.Success(c, gin.H{ + "key": constant.Key, + "type": constant.VarType, + "value": constant.Value, + }) + } +} + +func handleAppDownloadConstant(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端常量") + return + } + + var constant model.CloudConstant + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&constant).Error; err != nil { + response.Error(c, 404, "云端常量不存在") + return + } + + if constant.VarType != "binary" || constant.FilePath == "" { + response.Error(c, 400, "该常量不是文件类型") + return + } + + filePath := constant.FilePath + if strings.HasPrefix(filePath, "/") { + filePath = filePath[1:] + } + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + response.Error(c, 404, "文件不存在") + return + } + + encodedFilename := url.QueryEscape(constant.OriginalName) + c.Header("Content-Description", "File Transfer") + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+encodedFilename) + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Expires", "0") + c.Header("Cache-Control", "must-revalidate") + c.Header("Pragma", "public") + c.FileAttachment(filePath, constant.OriginalName) +} + +func handleAppGetVariables(c *gin.Context) { + appKey := c.Param("appKey") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var variables []model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND status = ?", app.UserID, app.ID, "active").Find(&variables).Error; err != nil { + response.Error(c, 500, "获取云端变量失败") + return + } + + var userVariables []model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ?", userID, app.ID).Find(&userVariables).Error; err != nil { + response.Error(c, 500, "获取用户变量失败") + return + } + + userVarMap := make(map[string]model.UserVariable) + for _, uv := range userVariables { + userVarMap[uv.VarName] = uv + } + + result := make(map[string]interface{}) + for _, v := range variables { + if v.VarType == "binary" { + if v.Scope == "user" { + if uv, ok := userVarMap[v.Key]; ok && uv.FilePath != "" { + result[v.Key] = gin.H{ + "type": "binary", + "value": "/api/v1/app/" + appKey + "/variables/" + v.Key + "/download", + "file_name": uv.OriginalName, + "file_size": uv.FileSize, + "md5": uv.FileMD5, + "mime_type": uv.MimeType, + } + } else { + result[v.Key] = gin.H{ + "type": "binary", + "value": "/api/v1/app/" + appKey + "/variables/" + v.Key + "/download", + "file_name": v.OriginalName, + "file_size": v.FileSize, + "md5": v.FileMD5, + "mime_type": v.MimeType, + } + } + } else { + result[v.Key] = gin.H{ + "type": "binary", + "value": "/api/v1/app/" + appKey + "/variables/" + v.Key + "/download", + "file_name": v.OriginalName, + "file_size": v.FileSize, + "md5": v.FileMD5, + "mime_type": v.MimeType, + } + } + } else { + if v.Scope == "app" { + result[v.Key] = gin.H{ + "type": v.VarType, + "value": v.DefaultValue, + } + } else { + value := v.DefaultValue + if uv, ok := userVarMap[v.Key]; ok { + value = uv.VarValue + } + result[v.Key] = gin.H{ + "type": v.VarType, + "value": value, + } + } + } + } + + response.Success(c, result) +} + +func handleAppGetVariableByKey(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var variable model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.VarType == "binary" { + if variable.Scope == "user" { + var userVar model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, app.ID, key).First(&userVar).Error; err == nil && userVar.FilePath != "" { + response.Success(c, gin.H{ + "key": variable.Key, + "type": "binary", + "value": "/api/v1/app/" + appKey + "/variables/" + variable.Key + "/download", + "file_name": userVar.OriginalName, + "file_size": userVar.FileSize, + "md5": userVar.FileMD5, + "mime_type": userVar.MimeType, + }) + return + } + } + response.Success(c, gin.H{ + "key": variable.Key, + "type": "binary", + "value": "/api/v1/app/" + appKey + "/variables/" + variable.Key + "/download", + "file_name": variable.OriginalName, + "file_size": variable.FileSize, + "md5": variable.FileMD5, + "mime_type": variable.MimeType, + }) + return + } + + if variable.Scope == "app" { + response.Success(c, gin.H{ + "key": variable.Key, + "type": variable.VarType, + "value": variable.DefaultValue, + }) + return + } + + var userVar model.UserVariable + value := variable.DefaultValue + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, app.ID, key).First(&userVar).Error; err == nil { + value = userVar.VarValue + } + + response.Success(c, gin.H{ + "key": variable.Key, + "type": variable.VarType, + "value": value, + }) +} + +func handleAppDownloadVariable(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var variable model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.VarType != "binary" { + response.Error(c, 400, "该变量不是二进制类型") + return + } + + var filePath string + var originalName string + + if variable.Scope == "user" { + var userVar model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, app.ID, key).First(&userVar).Error; err == nil && userVar.FilePath != "" { + filePath = userVar.FilePath + originalName = userVar.OriginalName + } else { + filePath = variable.FilePath + originalName = variable.OriginalName + } + } else { + filePath = variable.FilePath + originalName = variable.OriginalName + } + + if filePath == "" { + response.Error(c, 400, "该变量没有关联文件") + return + } + + if strings.HasPrefix(filePath, "/") { + filePath = filePath[1:] + } + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + response.Error(c, 404, "文件不存在") + return + } + + encodedFilename := url.QueryEscape(originalName) + c.Header("Content-Description", "File Transfer") + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+encodedFilename) + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Expires", "0") + c.Header("Cache-Control", "must-revalidate") + c.Header("Pragma", "public") + c.FileAttachment(filePath, originalName) +} + +func handleAppUpdateVariables(c *gin.Context) { + appKey := c.Param("appKey") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var req struct { + Variables map[string]string `json:"variables"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var variables []model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ?", app.UserID, app.ID).Find(&variables).Error; err != nil { + response.Error(c, 500, "获取云端变量失败") + return + } + + varMap := make(map[string]model.CloudVariable) + for _, v := range variables { + varMap[v.Key] = v + } + + for key, value := range req.Variables { + variable, exists := varMap[key] + if !exists { + continue + } + + if variable.Scope == "app" { + variable.DefaultValue = value + database.DB.Save(&variable) + } else { + var userVar model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, app.ID, key).First(&userVar).Error; err != nil { + userVar = model.UserVariable{ + UserID: userID.(uint), + AppID: app.ID, + VarName: key, + VarValue: value, + VarType: variable.VarType, + } + database.DB.Create(&userVar) + } else { + userVar.VarValue = value + database.DB.Save(&userVar) + } + } + } + + response.Success(c, gin.H{ + "message": "更新成功", + }) +} + +func handleAppUploadVariableBinary(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var variable model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.VarType != "binary" { + response.Error(c, 400, "该变量不是二进制类型") + return + } + + if variable.WritePermission != "user" { + response.Error(c, 403, "该变量不允许用户写入") + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + response.Error(c, 400, "请选择要上传的文件") + return + } + defer file.Close() + + var developer model.User + if err := database.DB.First(&developer, app.UserID).Error; err != nil { + response.Error(c, 500, "获取开发者信息失败") + return + } + + if developer.CurrentPackageID != nil { + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", developer.CurrentPackageID).First(&permission).Error; err == nil { + maxStorageBytes := int64(permission.MaxStorage) * 1024 * 1024 + if developer.StorageUsed+header.Size > maxStorageBytes { + usedMB := float64(developer.StorageUsed) / 1024 / 1024 + maxMB := float64(permission.MaxStorage) + response.Error(c, 403, fmt.Sprintf("存储空间不足,已使用 %.2f MB / %.2f MB", usedMB, maxMB)) + return + } + } + } + + uploadDir := "uploads/cloud-files" + if err := os.MkdirAll(uploadDir, 0755); err != nil { + response.Error(c, 500, "创建上传目录失败") + return + } + + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d_%d_%d%s", app.UserID, userID, time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + + dst, err := os.Create(filePath) + if err != nil { + response.Error(c, 500, "创建文件失败") + return + } + defer dst.Close() + + hash := md5.New() + multiWriter := io.MultiWriter(dst, hash) + if _, err := io.Copy(multiWriter, file); err != nil { + response.Error(c, 500, "保存文件失败") + return + } + fileMD5 := hex.EncodeToString(hash.Sum(nil)) + + fileURL := "/uploads/cloud-files/" + filename + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "application/octet-stream" + } + + if variable.Scope == "app" { + if variable.FilePath != "" && variable.FileSize > 0 { + oldFilePath := variable.FilePath + if strings.HasPrefix(oldFilePath, "/") { + oldFilePath = oldFilePath[1:] + } + os.Remove(oldFilePath) + if err := middleware.UpdateStorageUsed(app.UserID, variable.FileSize, "delete"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + } + + variable.DefaultValue = fileURL + variable.FilePath = fileURL + variable.FileSize = header.Size + variable.MimeType = mimeType + variable.OriginalName = header.Filename + variable.FileMD5 = fileMD5 + database.DB.Save(&variable) + } else { + var userVar model.UserVariable + existingFile := false + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, app.ID, key).First(&userVar).Error; err == nil { + if userVar.FilePath != "" && userVar.FileSize > 0 { + existingFile = true + } + } + + userVar = model.UserVariable{ + UserID: userID.(uint), + AppID: app.ID, + VarName: key, + VarValue: fileURL, + VarType: "binary", + FilePath: fileURL, + FileSize: header.Size, + MimeType: mimeType, + OriginalName: header.Filename, + FileMD5: fileMD5, + } + + if existingFile { + var oldVar model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, app.ID, key).First(&oldVar).Error; err == nil { + if oldVar.FilePath != "" && oldVar.FileSize > 0 { + oldFilePath := oldVar.FilePath + if strings.HasPrefix(oldFilePath, "/") { + oldFilePath = oldFilePath[1:] + } + os.Remove(oldFilePath) + if err := middleware.UpdateStorageUsed(app.UserID, oldVar.FileSize, "delete"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + } + } + } + + database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, app.ID, key).Assign(userVar).FirstOrCreate(&userVar) + } + + if err := middleware.UpdateStorageUsed(app.UserID, header.Size, "upload"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + + response.Success(c, gin.H{ + "message": "上传成功", + "file_url": fileURL, + "file_size": header.Size, + "mime_type": mimeType, + "original_name": header.Filename, + "download_url": "/api/v1/app/" + appKey + "/variables/" + key + "/download", + }) +} + +func handleAppCallFunction(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + UserID uint `json:"user_id"` + Name string `json:"name"` + Params map[string]interface{} `json:"params"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + response.Success(c, gin.H{ + "result": nil, + }) +} + +func handleAppCreateVariableRecord(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var variable model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.DataType != "stream" { + response.Error(c, 400, "该变量不是流水类型") + return + } + + if variable.WritePermission != "user" && variable.WritePermission != "app_user" { + response.Error(c, 403, "该变量不允许应用用户写入") + return + } + + var req map[string]interface{} + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + dataBytes, err := json.Marshal(req) + if err != nil { + response.Error(c, 500, "序列化数据失败") + return + } + + record := model.CloudVariableRecord{ + CloudVariableID: variable.ID, + Data: string(dataBytes), + } + + if variable.Scope == "user" { + appUserID := userID.(uint) + record.AppUserID = &appUserID + } + + if err := database.DB.Create(&record).Error; err != nil { + response.Error(c, 500, "创建记录失败") + return + } + + if variable.MaxRecords > 0 { + var total int64 + database.DB.Model(&model.CloudVariableRecord{}).Where("cloud_variable_id = ?", variable.ID).Count(&total) + if int(total) > variable.MaxRecords { + deleteCount := int(total) - variable.MaxRecords + var oldRecords []model.CloudVariableRecord + database.DB.Where("cloud_variable_id = ?", variable.ID). + Order("created_at ASC"). + Limit(deleteCount). + Find(&oldRecords) + for _, r := range oldRecords { + database.DB.Delete(&r) + } + } + } + + response.Success(c, gin.H{ + "id": record.ID, + "created_at": record.CreatedAt, + }) +} + +func handleAppGetVariableRecords(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var variable model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.DataType != "stream" { + response.Error(c, 400, "该变量不是流水类型") + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + var total int64 + query := database.DB.Model(&model.CloudVariableRecord{}).Where("cloud_variable_id = ?", variable.ID) + if variable.Scope == "user" { + query = query.Where("app_user_id = ?", userID) + } + query.Count(&total) + + var records []model.CloudVariableRecord + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&records).Error; err != nil { + response.Error(c, 500, "获取记录失败") + return + } + + result := make([]gin.H, len(records)) + for i, r := range records { + var data map[string]interface{} + json.Unmarshal([]byte(r.Data), &data) + result[i] = gin.H{ + "id": r.ID, + "data": data, + "created_at": r.CreatedAt, + } + } + + response.Success(c, gin.H{ + "records": result, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +func handleAppDeleteVariableRecord(c *gin.Context) { + appKey := c.Param("appKey") + key := c.Param("key") + recordID := c.Param("record_id") + userID, _ := c.Get("user_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, 403, "无权访问该应用的云端变量") + return + } + + var variable model.CloudVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND key = ? AND status = ?", app.UserID, app.ID, key, "active").First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.DataType != "stream" { + response.Error(c, 400, "该变量不是流水类型") + return + } + + var record model.CloudVariableRecord + query := database.DB.Where("id = ? AND cloud_variable_id = ?", recordID, variable.ID) + if variable.Scope == "user" { + query = query.Where("app_user_id = ?", userID) + } + if err := query.First(&record).Error; err != nil { + response.Error(c, 404, "记录不存在") + return + } + + if err := database.DB.Delete(&record).Error; err != nil { + response.Error(c, 500, "删除记录失败") + return + } + + response.Success(c, nil) +} diff --git a/backend/internal/router/app/devices.go b/backend/internal/router/app/devices.go new file mode 100644 index 0000000..b06c638 --- /dev/null +++ b/backend/internal/router/app/devices.go @@ -0,0 +1,390 @@ +package app + +import ( + "fmt" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupDeviceRoutes(r *gin.RouterGroup) { + r.GET("/devices", handleAppGetDevices) + r.POST("/unbind-device", handleAppUnbindDevice) + r.GET("/device-count", handleAppGetDeviceCount) + r.GET("/instances", handleAppGetInstances) + r.POST("/instances/:instance_id/offline", handleAppForceOfflineInstance) +} + +func SetupDevicePublicRoutes(r *gin.RouterGroup) { + r.POST("/unbind-device-with-auth", handleAppUnbindDeviceWithAuth) + r.POST("/change-password", handleAppChangePassword) +} + +func handleAppUnbindDeviceWithAuth(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + DeviceID string `json:"device_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.Username == "" || req.Password == "" || req.DeviceID == "" { + response.Error(c, 400, "用户名、密码和设备ID不能为空") + return + } + + var user model.AppUser + if err := database.DB.Where("username = ? AND application_id = ?", req.Username, app.ID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + if user.Password != req.Password { + response.Error(c, 401, "密码错误") + return + } + + var device model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ? AND device_id = ?", user.ID, app.ID, req.DeviceID).First(&device).Error; err != nil { + response.Error(c, 404, "设备不存在") + return + } + + database.DB.Where("device_id = ?", device.ID).Delete(&model.DeviceSession{}) + + if err := database.DB.Delete(&device).Error; err != nil { + response.Error(c, 500, "解绑设备失败") + return + } + + if user.DeviceID == req.DeviceID { + now := time.Now() + database.DB.Model(&user).Updates(map[string]interface{}{ + "device_id": "", + "last_heartbeat_at": &now, + }) + } + + service.LogVerification(c, &app.ID, &user.ID, "unbind_device", fmt.Sprintf("用户解绑设备(认证): %s", req.DeviceID), req.DeviceID, nil) + + response.Success(c, gin.H{ + "message": "解绑成功", + }) +} + +func handleAppGetDevices(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var devices []model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ?", userID, app.ID).Find(&devices).Error; err != nil { + response.Error(c, 500, "获取设备列表失败") + return + } + + result := make([]gin.H, 0) + for _, device := range devices { + var onlineSessionCount int64 + tenMinutesAgo := time.Now().Add(-10 * time.Minute) + database.DB.Model(&model.DeviceSession{}). + Where("device_id = ? AND last_heartbeat > ?", device.ID, tenMinutesAgo). + Count(&onlineSessionCount) + + result = append(result, gin.H{ + "id": device.ID, + "device_id": device.DeviceID, + "device_name": device.DeviceName, + "device_type": device.DeviceType, + "status": device.Status, + "online_sessions": onlineSessionCount, + "created_at": device.CreatedAt, + }) + } + + response.Success(c, result) +} + +func handleAppGetDeviceCount(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + UserID uint `json:"user_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var count int64 + if err := database.DB.Model(&model.UserDevice{}).Where("user_id = ? AND application_id = ?", req.UserID, app.ID).Count(&count).Error; err != nil { + response.Error(c, 500, "获取设备数量失败") + return + } + + response.Success(c, gin.H{ + "count": count, + "max_devices": app.MaxDevices, + "remaining": app.MaxDevices - int(count), + }) +} + +func handleAppUnbindDevice(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + UserID uint `json:"user_id"` + DeviceID string `json:"device_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var device model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ? AND device_id = ?", req.UserID, app.ID, req.DeviceID).First(&device).Error; err != nil { + response.Error(c, 404, "设备不存在") + return + } + + database.DB.Where("device_id = ?", device.ID).Delete(&model.DeviceSession{}) + + if err := database.DB.Delete(&device).Error; err != nil { + response.Error(c, 500, "解绑设备失败") + return + } + + var user model.AppUser + if err := database.DB.First(&user, req.UserID).Error; err == nil { + if user.DeviceID == req.DeviceID { + now := time.Now() + database.DB.Model(&user).Updates(map[string]interface{}{ + "device_id": "", + "last_heartbeat_at": &now, + }) + } + } + + service.LogVerification(c, &app.ID, &user.ID, "unbind_device", fmt.Sprintf("用户解绑设备: %s", req.DeviceID), req.DeviceID, nil) + + response.Success(c, gin.H{ + "message": "解绑成功", + }) +} + +func handleAppGetInstances(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + UserID uint `json:"user_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var devices []model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ?", req.UserID, app.ID).Find(&devices).Error; err != nil { + response.Error(c, 500, "获取设备列表失败") + return + } + + deviceIDs := make([]uint, len(devices)) + for i, d := range devices { + deviceIDs[i] = d.ID + } + + heartbeatTimeout := app.HeartbeatTimeout + if heartbeatTimeout <= 0 { + heartbeatTimeout = 10 + } + timeoutThreshold := time.Now().Add(-time.Duration(heartbeatTimeout) * time.Minute) + + var sessions []model.DeviceSession + if len(deviceIDs) > 0 { + if err := database.DB.Where("device_id IN ?", deviceIDs).Order("last_heartbeat DESC").Find(&sessions).Error; err != nil { + response.Error(c, 500, "获取实例列表失败") + return + } + } + + result := make([]gin.H, 0) + for _, session := range sessions { + isOnline := session.LastHeartbeat != nil && session.LastHeartbeat.After(timeoutThreshold) + + var device model.UserDevice + for _, d := range devices { + if d.ID == session.DeviceID { + device = d + break + } + } + + result = append(result, gin.H{ + "id": session.ID, + "instance_id": session.InstanceID, + "device_id": device.DeviceID, + "device_name": device.DeviceName, + "is_online": isOnline, + "last_heartbeat": session.LastHeartbeat, + "created_at": session.CreatedAt, + }) + } + + response.Success(c, result) +} + +func handleAppForceOfflineInstance(c *gin.Context) { + appKey := c.Param("appKey") + instanceID := c.Param("instance_id") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + UserID uint `json:"user_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var devices []model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ?", req.UserID, app.ID).Find(&devices).Error; err != nil { + response.Error(c, 500, "获取设备列表失败") + return + } + + deviceIDs := make([]uint, len(devices)) + for i, d := range devices { + deviceIDs[i] = d.ID + } + + if len(deviceIDs) == 0 { + response.Error(c, 404, "实例不存在") + return + } + + result := database.DB.Where("instance_id = ? AND device_id IN ?", instanceID, deviceIDs).Delete(&model.DeviceSession{}) + if result.RowsAffected == 0 { + response.Error(c, 404, "实例不存在") + return + } + + service.LogVerification(c, &app.ID, &req.UserID, "force_offline", fmt.Sprintf("强制离线实例: %s", instanceID), instanceID, nil) + + response.Success(c, gin.H{ + "message": "已强制离线", + }) +} + +func handleAppChangePassword(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var req struct { + Username string `json:"username" binding:"required"` + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var user model.AppUser + if err := database.DB.Where("username = ? AND application_id = ?", req.Username, app.ID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + if user.Password != req.OldPassword { + response.Error(c, 400, "原密码错误") + return + } + + user.Password = req.NewPassword + if err := database.DB.Save(&user).Error; err != nil { + response.Error(c, 500, "密码修改失败") + return + } + + response.Success(c, gin.H{ + "message": "密码修改成功", + }) +} diff --git a/backend/internal/router/app/dynamic.go b/backend/internal/router/app/dynamic.go new file mode 100644 index 0000000..e6df595 --- /dev/null +++ b/backend/internal/router/app/dynamic.go @@ -0,0 +1,427 @@ +package app + +import ( + "net/http" + "strings" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/jwt" + "verification-platform-backend/pkg/response" + + "github.com/dop251/goja" + "github.com/gin-gonic/gin" +) + +func SetupDynamicRoutes(r *gin.RouterGroup) { + dynamicCode := r.Group("/dynamic-code") + { + dynamicCode.POST("/:key/execute", handleExecuteDynamicCode) + } +} + +func handleExecuteDynamicCode(c *gin.Context) { + var token string + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + token = parts[1] + } + } + + if token == "" { + token = c.Query("token") + } + + if token == "" { + response.Error(c, http.StatusUnauthorized, "Authorization header is required") + return + } + + claims, err := jwt.ParseToken(token) + if err != nil { + response.Error(c, http.StatusUnauthorized, "Invalid token") + return + } + + if time.Now().Unix() > claims.ExpiresAt.Unix() { + response.Error(c, http.StatusUnauthorized, "Token expired") + return + } + + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + + appKey := c.Param("appKey") + key := c.Param("key") + + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var appUser model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", claims.UserID, app.ID).First(&appUser).Error; err != nil { + response.Error(c, http.StatusForbidden, "无权访问该应用的动态代码") + return + } + + var dynamicCode model.DynamicCode + if err := database.DB.Where("application_id = ? AND key = ?", app.ID, key).First(&dynamicCode).Error; err != nil { + response.Error(c, 404, "动态代码不存在") + return + } + + if dynamicCode.Status != "active" { + response.Error(c, 400, "动态代码未启用") + return + } + + var req struct { + Params map[string]interface{} `json:"params"` + UserID *uint `json:"user_id,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + startTime := time.Now() + + vm := goja.New() + + appData := map[string]interface{}{ + "id": app.ID, + "name": app.Name, + "description": app.Description, + "app_key": app.AppKey, + "status": app.Status, + "billing_type": app.BillingType, + "deduction_mode": app.DeductionMode, + "deduction_type": app.DeductionType, + "deduction_interval": app.DeductionInterval, + "deduction_unit": app.DeductionUnit, + "deduction_amount": app.DeductionAmount, + "enable_trial": app.EnableTrial, + "trial_balance": app.TrialBalance, + "trial_days": app.TrialDays, + "enable_free_period": app.EnableFreePeriod, + "free_period_type": app.FreePeriodType, + "free_period_start": app.FreePeriodStart, + "free_period_end": app.FreePeriodEnd, + "free_period_weekdays": app.FreePeriodWeekdays, + "free_period_start_time": app.FreePeriodStartTime, + "free_period_end_time": app.FreePeriodEndTime, + "max_devices": app.MaxDevices, + "bind_type": app.BindType, + "multi_open": app.MultiOpen, + "multi_open_mode": app.MultiOpenMode, + "max_instances": app.MaxInstances, + "login_policy": app.LoginPolicy, + "max_attempts": app.MaxAttempts, + "lock_duration": app.LockDuration, + "heartbeat_interval": app.HeartbeatInterval, + "heartbeat_timeout": app.HeartbeatTimeout, + "change_limit": app.ChangeLimit, + "change_interval": app.ChangeInterval, + "change_exceed_action": app.ChangeExceedAction, + "change_deduct_amount": app.ChangeDeductAmount, + } + if err := vm.Set("app", appData); err != nil { + response.Error(c, 500, "应用数据设置失败") + return + } + + var constants []model.CloudConstant + database.DB.Where("app_id = ? AND status = ?", app.ID, "active").Find(&constants) + constantsData := make(map[string]interface{}) + for _, c := range constants { + constantsData[c.Key] = c.Value + } + if err := vm.Set("constants", constantsData); err != nil { + response.Error(c, 500, "云端常量设置失败") + return + } + + var cloudVariables []model.CloudVariable + database.DB.Where("app_id = ? AND status = ?", app.ID, "active").Find(&cloudVariables) + appVariables := make(map[string]interface{}) + for _, v := range cloudVariables { + appVariables[v.Key] = v.DefaultValue + } + if err := vm.Set("appVariables", appVariables); err != nil { + response.Error(c, 500, "云端变量设置失败") + return + } + + userData := map[string]interface{}{} + subscription := map[string]interface{}{} + userVariables := map[string]interface{}{} + devices := []map[string]interface{}{} + + if req.UserID != nil { + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", *req.UserID, app.ID).First(&user).Error; err == nil { + userData = map[string]interface{}{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "status": user.Status, + "device_id": user.DeviceID, + "avatar": user.Avatar, + "created_at": user.CreatedAt, + "last_login_at": user.LastLoginAt, + } + + subscription = map[string]interface{}{ + "balance": user.Balance, + "is_trial_user": user.IsTrialUser, + "trial_start_at": user.TrialStartAt, + "trial_end_at": user.TrialEndAt, + "expiry_at": user.ExpiryAt, + "is_expired": user.ExpiryAt != nil && user.ExpiryAt.Before(time.Now()), + "is_lifetime": user.Balance == -1, + "days_remaining": calculateDaysRemaining(user.ExpiryAt, user.Balance), + } + + var userDevices []model.UserDevice + database.DB.Where("user_id = ? AND application_id = ?", user.ID, app.ID).Find(&userDevices) + for _, d := range userDevices { + devices = append(devices, map[string]interface{}{ + "id": d.ID, + "device_id": d.DeviceID, + "device_name": d.DeviceName, + "device_type": d.DeviceType, + "status": d.Status, + "created_at": d.CreatedAt, + }) + } + + var userVars []model.UserVariable + database.DB.Where("user_id = ? AND app_id = ?", user.ID, app.ID).Find(&userVars) + for _, v := range userVars { + userVariables[v.VarName] = v.VarValue + } + } + } + + if err := vm.Set("user", userData); err != nil { + response.Error(c, 500, "用户数据设置失败") + return + } + if err := vm.Set("subscription", subscription); err != nil { + response.Error(c, 500, "订阅数据设置失败") + return + } + if err := vm.Set("userVariables", userVariables); err != nil { + response.Error(c, 500, "用户变量设置失败") + return + } + if err := vm.Set("devices", devices); err != nil { + response.Error(c, 500, "设备数据设置失败") + return + } + + for k, v := range req.Params { + if err := vm.Set(k, v); err != nil { + response.Error(c, 500, "参数设置失败") + return + } + } + + if err := vm.Set("params", req.Params); err != nil { + response.Error(c, 500, "参数设置失败") + return + } + + value, err := vm.RunString("(function() { " + dynamicCode.Code + " })()") + if err != nil { + response.Error(c, 400, "代码执行错误: "+err.Error()) + return + } + + executionTime := time.Since(startTime).Milliseconds() + + result := value.Export() + + if actionMap, ok := result.(map[string]interface{}); ok { + if action, hasAction := actionMap["action"]; hasAction { + switch action { + case "extend_time": + if err := handleExtendTime(app.ID, actionMap); err != nil { + response.Error(c, 500, "执行加时操作失败: "+err.Error()) + return + } + case "deduct_points": + if err := handleDeductPoints(app.ID, actionMap); err != nil { + response.Error(c, 500, "执行扣点操作失败: "+err.Error()) + return + } + case "update_user_variable": + appID := app.ID + if err := handleUpdateUserVariable(&appID, req.UserID, actionMap); err != nil { + response.Error(c, 500, "更新用户变量失败: "+err.Error()) + return + } + case "update_app_variable": + if err := handleUpdateAppVariable(app.ID, actionMap); err != nil { + response.Error(c, 500, "更新应用变量失败: "+err.Error()) + return + } + } + } + } + + response.Success(c, gin.H{ + "result": result, + "execution_time": executionTime, + }) +} + +func calculateDaysRemaining(expiryAt *time.Time, balance float64) int { + if balance == -1 { + return -1 + } + if expiryAt == nil { + return 0 + } + remaining := int(time.Until(*expiryAt).Hours() / 24) + if remaining < 0 { + return 0 + } + return remaining +} + +func handleExtendTime(appID uint, actionMap map[string]interface{}) error { + userID, ok := actionMap["user_id"].(float64) + if !ok { + return nil + } + + days, ok := actionMap["days"].(float64) + if !ok { + return nil + } + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", uint(userID), appID).First(&user).Error; err != nil { + return err + } + + if user.Balance == -1 { + return nil + } + + user.Balance += float64(days) + + return database.DB.Save(&user).Error +} + +func handleDeductPoints(appID uint, actionMap map[string]interface{}) error { + userID, ok := actionMap["user_id"].(float64) + if !ok { + return nil + } + + points, ok := actionMap["points"].(float64) + if !ok { + return nil + } + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", uint(userID), appID).First(&user).Error; err != nil { + return err + } + + if user.Balance == -1 { + return nil + } + + user.Balance -= points + if user.Balance < 0 { + user.Balance = 0 + } + + return database.DB.Save(&user).Error +} + +func handleUpdateUserVariable(appID *uint, userID *uint, actionMap map[string]interface{}) error { + if userID == nil { + return nil + } + + varName, ok := actionMap["name"].(string) + if !ok { + return nil + } + + varValue, ok := actionMap["value"].(string) + if !ok { + if v, ok := actionMap["value"]; ok { + varValue = toString(v) + } else { + return nil + } + } + + var userVar model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", *userID, appID, varName).First(&userVar).Error; err != nil { + userVar = model.UserVariable{ + UserID: *userID, + AppID: *appID, + VarName: varName, + VarValue: varValue, + } + return database.DB.Create(&userVar).Error + } + + userVar.VarValue = varValue + return database.DB.Save(&userVar).Error +} + +func handleUpdateAppVariable(appID uint, actionMap map[string]interface{}) error { + varName, ok := actionMap["name"].(string) + if !ok { + return nil + } + + varValue, ok := actionMap["value"].(string) + if !ok { + if v, ok := actionMap["value"]; ok { + varValue = toString(v) + } else { + return nil + } + } + + var appVar model.CloudVariable + if err := database.DB.Where("app_id = ? AND key = ?", appID, varName).First(&appVar).Error; err != nil { + return err + } + + appVar.DefaultValue = varValue + return database.DB.Save(&appVar).Error +} + +func toString(v interface{}) string { + switch val := v.(type) { + case string: + return val + case float64: + return string(rune(int(val))) + case int: + return string(rune(val)) + default: + return "" + } +} diff --git a/backend/internal/router/app/info.go b/backend/internal/router/app/info.go new file mode 100644 index 0000000..d8886ad --- /dev/null +++ b/backend/internal/router/app/info.go @@ -0,0 +1,146 @@ +package app + +import ( + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupInfoRoutes(r *gin.RouterGroup) { + r.GET("/info", handleAppGetInfo) + r.GET("/check-update", handleAppCheckUpdate) + r.GET("/announcements", handleAppGetAnnouncements) +} + +func handleAppGetInfo(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + response.Success(c, gin.H{ + "id": app.ID, + "name": app.Name, + "description": app.Description, + "icon_url": app.IconURL, + "status": app.Status, + "billing_type": app.BillingType, + "login_policy": app.LoginPolicy, + "max_devices": app.MaxDevices, + "multi_open": app.MultiOpen, + "multi_open_mode": app.MultiOpenMode, + "max_instances": app.MaxInstances, + "enable_trial": app.EnableTrial, + "trial_balance": app.TrialBalance, + "heartbeat_interval": app.HeartbeatInterval, + "heartbeat_timeout": app.HeartbeatTimeout, + }) +} + +func handleAppCheckUpdate(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + clientVersion := c.Query("version") + + var latestVersion model.Version + err := database.DB.Where("application_id = ? AND status = ?", app.ID, "active").Order("created_at DESC").Preload("Files").First(&latestVersion).Error + + if err != nil { + response.Success(c, gin.H{ + "has_update": false, + "latest_version": "", + "download_url": "", + "update_notes": "", + "update_strategy": "", + "update_method": "", + "files": []interface{}{}, + }) + return + } + + hasUpdate := clientVersion != latestVersion.Version + if latestVersion.ForceUpdate { + hasUpdate = true + } + + files := make([]gin.H, len(latestVersion.Files)) + for i, f := range latestVersion.Files { + files[i] = gin.H{ + "file_path": f.FilePath, + "file_name": f.FileName, + "file_size": f.FileSize, + "file_hash": f.FileHash, + "file_type": f.FileType, + "is_required": f.IsRequired, + } + } + + response.Success(c, gin.H{ + "has_update": hasUpdate, + "latest_version": latestVersion.Version, + "download_url": latestVersion.FilePath, + "file_size": latestVersion.FileSize, + "file_hash": latestVersion.FileHash, + "entry_file": latestVersion.EntryFile, + "update_notes": latestVersion.Description, + "update_strategy": latestVersion.UpdateStrategy, + "update_method": latestVersion.UpdateMethod, + "min_version": latestVersion.MinVersion, + "changelog": latestVersion.Changelog, + "files": files, + }) +} + +func handleAppGetAnnouncements(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var announcements []model.Announcement + if err := database.DB.Where("application_id = ? AND status = ?", app.ID, "active").Order("is_top DESC, created_at DESC").Find(&announcements).Error; err != nil { + response.Error(c, 500, "获取公告失败") + return + } + + result := make([]gin.H, len(announcements)) + for i, a := range announcements { + result[i] = gin.H{ + "id": a.ID, + "title": a.Title, + "content": a.Content, + "type": a.Type, + "is_top": a.IsTop, + "created_at": a.CreatedAt, + } + } + + response.Success(c, result) +} diff --git a/backend/internal/router/app/payment.go b/backend/internal/router/app/payment.go new file mode 100644 index 0000000..05762fe --- /dev/null +++ b/backend/internal/router/app/payment.go @@ -0,0 +1,217 @@ +package app + +import ( + "fmt" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupPaymentRoutes(r *gin.RouterGroup) { + r.POST("/recharge", handleAppRecharge) + r.POST("/trial", handleAppTrial) +} + +func handleAppRecharge(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + var req struct { + Username string `json:"username"` + CardKey string `json:"card_key"` + DeviceID string `json:"device_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if blocked, reason := checkRiskControl(c, app.ID, req.DeviceID, req.Username); blocked { + response.Error(c, 403, reason) + return + } + + var card model.Card + if err := database.DB.Preload("CardType").Where("card_key = ?", req.CardKey).First(&card).Error; err != nil { + service.LogVerification(c, &app.ID, nil, "recharge_failed", fmt.Sprintf("充值失败: 卡密不存在 - %s", req.CardKey), "", fmt.Errorf("卡密不存在")) + response.Error(c, 404, "卡密不存在") + return + } + + fmt.Printf("[DEBUG] 卡密信息: ID=%d, CardKey=%s, Status=%s, CardTypeID=%d\n", card.ID, card.CardKey, card.Status, card.CardTypeID) + fmt.Printf("[DEBUG] 卡类信息: ID=%d, Name=%s, Value=%f, Price=%f\n", card.CardType.ID, card.CardType.Name, card.CardType.Value, card.CardType.Price) + + if card.Status != "unused" { + service.LogVerification(c, &app.ID, nil, "recharge_failed", fmt.Sprintf("充值失败: 卡密已使用 - %s", req.CardKey), "", fmt.Errorf("卡密已使用")) + response.Error(c, 400, "卡密已使用") + return + } + + var user model.AppUser + if err := database.DB.Where("username = ? AND application_id = ?", req.Username, app.ID).First(&user).Error; err != nil { + service.LogVerification(c, &app.ID, nil, "recharge_failed", fmt.Sprintf("充值失败: 用户不存在 - %s", req.Username), "", fmt.Errorf("用户不存在")) + response.Error(c, 404, "用户不存在") + return + } + + if user.Balance == -1 { + service.LogVerification(c, &app.ID, &user.ID, "recharge_failed", fmt.Sprintf("充值失败: 用户已是永久会员 - %s", user.Username), "", fmt.Errorf("该用户已是永久会员,无法再次充值")) + response.Error(c, 400, "该用户已是永久会员,无法再次充值") + return + } + + fmt.Printf("[DEBUG] 充值前用户信息: ID=%d, Username=%s, Balance=%f, ExpiryAt=%v\n", user.ID, user.Username, user.Balance, user.ExpiryAt) + + tx := database.DB.Begin() + + card.Status = "used" + card.AppUserID = &user.ID + now := time.Now() + card.UsedAt = &now + + if err := tx.Save(&card).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "充值失败") + return + } + + user.IsTrialUser = false + + if card.CardType.Value == -1 { + user.Balance = -1 + user.ExpiryAt = nil + } else { + switch card.CardType.RechargeType { + case "subscription": + var baseTime time.Time + if user.ExpiryAt != nil && user.ExpiryAt.After(now) { + baseTime = *user.ExpiryAt + } else { + baseTime = now + } + var duration time.Duration + switch card.CardType.ValueUnit { + case "minute": + duration = time.Duration(card.CardType.Value) * time.Minute + case "hour": + duration = time.Duration(card.CardType.Value) * time.Hour + case "day": + duration = time.Duration(card.CardType.Value) * 24 * time.Hour + case "month": + duration = time.Duration(card.CardType.Value) * 30 * 24 * time.Hour + case "year": + duration = time.Duration(card.CardType.Value) * 365 * 24 * time.Hour + default: + duration = time.Duration(card.CardType.Value) * time.Second + } + newExpiry := baseTime.Add(duration) + user.ExpiryAt = &newExpiry + case "balance": + fallthrough + default: + user.Balance += card.CardType.Value + } + } + + fmt.Printf("[DEBUG] 充值后用户信息: Balance=%f, ExpiryAt=%v (CardType.Value=%f, BillingType=%s)\n", user.Balance, user.ExpiryAt, card.CardType.Value, app.BillingType) + + if err := tx.Save(&user).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "更新用户信息失败") + return + } + + rechargeRecord := model.RechargeRecord{ + UserID: user.ID, + OrderNo: generateOrderNo("R"), + CardID: &card.ID, + CardCode: card.CardKey, + Amount: card.CardType.Price, + Status: "success", + PaymentType: "card", + Remark: "卡密充值 - " + card.CardType.Name, + } + + if err := tx.Create(&rechargeRecord).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "创建充值记录失败") + return + } + + if err := tx.Commit().Error; err != nil { + response.Error(c, 500, "充值失败") + return + } + + service.LogVerification(c, &app.ID, &user.ID, "recharge", fmt.Sprintf("用户充值: %s, 卡密: %s, 金额: %.2f", user.Username, card.CardKey, card.CardType.Price), "", nil) + + response.SuccessWithMessage(c, "充值成功", gin.H{ + "message": "充值成功", + "value": card.CardType.Value, + }) +} + +func handleAppTrial(c *gin.Context) { + appKey := c.Param("appKey") + var app model.Application + if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "该应用已被禁用") + return + } + + if !app.EnableTrial { + response.Error(c, 400, "该应用不支持试用") + return + } + + var req struct { + UserID uint `json:"user_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var user model.AppUser + if err := database.DB.First(&user, req.UserID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + response.SuccessWithMessage(c, "试用成功", gin.H{ + "message": "试用成功", + "trial_balance": app.TrialBalance, + }) +} + +func generateOrderNo(prefix string) string { + return prefix + time.Now().Format("20060102150405") + randomString(6) +} + +func randomString(length int) string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[time.Now().Nanosecond()%len(charset)] + time.Sleep(1 * time.Nanosecond) + } + return string(b) +} diff --git a/backend/internal/router/app/response_encryption.go b/backend/internal/router/app/response_encryption.go new file mode 100644 index 0000000..54476ca --- /dev/null +++ b/backend/internal/router/app/response_encryption.go @@ -0,0 +1,73 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "verification-platform-backend/pkg/crypto" + + "github.com/gin-gonic/gin" +) + +type bodyLogWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *bodyLogWriter) Write(b []byte) (int, error) { + return w.body.Write(b) +} + +func (w *bodyLogWriter) WriteHeader(statusCode int) { + w.ResponseWriter.WriteHeader(statusCode) +} + +type EncryptedResponse struct { + Data string `json:"data"` +} + +func ResponseEncryption() gin.HandlerFunc { + return func(c *gin.Context) { + blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer} + c.Writer = blw + + c.Next() + + shouldEncrypt := c.GetBool("should_encrypt_response") + fmt.Printf("[ResponseEncryption] ShouldEncrypt: %v\n", shouldEncrypt) + + blw.ResponseWriter.Header().Set("Content-Type", "application/json; charset=utf-8") + + if shouldEncrypt { + var response map[string]interface{} + if err := json.Unmarshal(blw.body.Bytes(), &response); err == nil { + responseJSON, _ := json.Marshal(response) + fmt.Printf("[ResponseEncryption] Response to encrypt: %s\n", string(responseJSON)) + + var cryptoManager *crypto.CryptoManager + if cm, ok := c.Get("crypto_manager"); ok { + cryptoManager = cm.(*crypto.CryptoManager) + } + + if cryptoManager != nil { + encrypted, err := cryptoManager.Encrypt(string(responseJSON)) + if err == nil { + encryptedResponse := EncryptedResponse{Data: encrypted} + encryptedJSON, _ := json.Marshal(encryptedResponse) + blw.ResponseWriter.Write(encryptedJSON) + fmt.Printf("[ResponseEncryption] Encrypted response sent\n") + return + } else { + fmt.Printf("[ResponseEncryption] Encryption error: %v\n", err) + } + } else { + fmt.Printf("[ResponseEncryption] CryptoManager is nil\n") + } + } else { + fmt.Printf("[ResponseEncryption] JSON unmarshal error: %v\n", err) + } + } + + blw.ResponseWriter.Write(blw.body.Bytes()) + } +} diff --git a/backend/internal/router/developer/agent_apps.go b/backend/internal/router/developer/agent_apps.go new file mode 100644 index 0000000..7ef8bbd --- /dev/null +++ b/backend/internal/router/developer/agent_apps.go @@ -0,0 +1,776 @@ +package developer + +import ( + "fmt" + "log" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupAgentAppRoutes(r *gin.RouterGroup) { + agentApps := r.Group("/agent-apps") + { + agentApps.GET("", handleGetAgentApps) + agentApps.GET("/requests", handleGetAgentRequests) + agentApps.POST("/invite", handleInviteAgent) + agentApps.PUT("/requests/:id/approve", handleApproveRequest) + agentApps.PUT("/requests/:id/reject", handleRejectRequest) + agentApps.GET("/:id", handleGetAgentAppDetail) + agentApps.PUT("/:id", handleUpdateAgentApp) + agentApps.PUT("/:id/card-types", handleUpdateAgentCardTypes) + agentApps.DELETE("/:id", handleRemoveAgentApp) + } +} + +func SetupAgentAppRoutesWithoutPackage(r *gin.RouterGroup) { + agentApps := r.Group("/agent-apps") + { + agentApps.GET("/my-requests", handleGetMyRequests) + agentApps.POST("/request", handleRequestAuthorization) + agentApps.DELETE("/requests/:id", handleCancelRequest) + } +} + +func checkAgentPermission(userID uint) bool { + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + return false + } + + if user.CurrentPackageID == nil { + return false + } + + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND package_id = ? AND status = ?", + userID, user.CurrentPackageID, "active").First(&userPackage).Error; err != nil { + return false + } + + if userPackage.ExpiredAt != nil && userPackage.ExpiredAt.Before(time.Now()) { + return false + } + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err != nil { + return false + } + + return permission.AllowAgent +} + +func handleGetAgentApps(c *gin.Context) { + userID := c.GetUint("user_id") + log.Printf("[DEBUG] handleGetAgentApps called for developer %d\n", userID) + + var myAuthorizations []model.AgentApplication + if err := database.DB.Where("developer_id = ?", userID). + Preload("CardTypes.CardType"). + Find(&myAuthorizations).Error; err != nil { + response.Error(c, 500, "获取授权列表失败") + return + } + + var receivedAuthorizations []model.AgentApplication + if err := database.DB.Where("agent_id = ?", userID). + Preload("CardTypes.CardType"). + Find(&receivedAuthorizations).Error; err != nil { + response.Error(c, 500, "获取授权列表失败") + return + } + + log.Printf("[DEBUG] Found %d my authorizations and %d received authorizations for developer %d\n", + len(myAuthorizations), len(receivedAuthorizations), userID) + + var allAgentApps []model.AgentApplication + allAgentApps = append(allAgentApps, myAuthorizations...) + allAgentApps = append(allAgentApps, receivedAuthorizations...) + + var agentIDs []uint + var developerIDs []uint + var applicationIDs []uint + + for _, aa := range allAgentApps { + agentIDs = append(agentIDs, aa.AgentID) + developerIDs = append(developerIDs, aa.DeveloperID) + applicationIDs = append(applicationIDs, aa.ApplicationID) + } + + var users []model.User + if err := database.DB.Where("id IN ?", append(agentIDs, developerIDs...)).Find(&users).Error; err != nil { + log.Printf("[ERROR] Failed to query users: %v\n", err) + } else { + log.Printf("[DEBUG] Found %d users\n", len(users)) + } + + var applications []model.Application + if err := database.DB.Where("id IN ?", applicationIDs).Find(&applications).Error; err != nil { + log.Printf("[ERROR] Failed to query applications: %v\n", err) + } + + userMap := make(map[uint]model.User) + for _, user := range users { + userMap[user.ID] = user + } + + applicationMap := make(map[uint]model.Application) + for _, app := range applications { + applicationMap[app.ID] = app + } + + type CardTypeResponse struct { + ID uint `json:"id"` + CardTypeID uint `json:"card_type_id"` + Name string `json:"name"` + CanGenerate bool `json:"can_generate"` + } + + type AgentAppResponse struct { + ID uint `json:"id"` + AgentID uint `json:"agent_id"` + AgentName string `json:"agent_name"` + AgentEmail string `json:"agent_email"` + ApplicationID uint `json:"application_id"` + AppName string `json:"app_name"` + Commission float64 `json:"commission"` + Discount float64 `json:"discount"` + Status string `json:"status"` + CardTypes []CardTypeResponse `json:"card_types"` + CreatedAt string `json:"created_at"` + IsReceived bool `json:"is_received"` + } + + var result []AgentAppResponse + for _, aa := range allAgentApps { + agentName := "" + agentEmail := "" + if user, exists := userMap[aa.AgentID]; exists { + agentName = user.Username + if user.Email != nil { + agentEmail = *user.Email + } + } + + appName := "" + if app, exists := applicationMap[aa.ApplicationID]; exists { + appName = app.Name + } + + var cardTypes []CardTypeResponse + for _, ct := range aa.CardTypes { + cardTypes = append(cardTypes, CardTypeResponse{ + ID: ct.ID, + CardTypeID: ct.CardTypeID, + Name: ct.CardType.Name, + CanGenerate: ct.CanGenerate, + }) + } + + log.Printf("[DEBUG] Processing AgentApp: ID=%d, AgentID=%d, ApplicationID=%d, CardTypes count=%d, IsReceived=%v\n", + aa.ID, aa.AgentID, aa.ApplicationID, len(cardTypes), aa.AgentID == userID) + + result = append(result, AgentAppResponse{ + ID: aa.ID, + AgentID: aa.AgentID, + AgentName: agentName, + AgentEmail: agentEmail, + ApplicationID: aa.ApplicationID, + AppName: appName, + Commission: aa.Commission, + Discount: aa.Discount, + Status: aa.Status, + CardTypes: cardTypes, + CreatedAt: aa.CreatedAt.Format("2006-01-02 15:04:05"), + IsReceived: aa.AgentID == userID, + }) + } + + log.Printf("[DEBUG] Returning %d agent apps for user %d\n", len(result), userID) + response.Success(c, gin.H{ + "agent_apps": result, + "total": len(result), + }) +} + +func handleGetAgentRequests(c *gin.Context) { + userID := c.GetUint("user_id") + requestType := c.Query("type") + + query := database.DB.Where("developer_id = ?", userID) + if requestType == "invite" { + query = query.Where("type = ?", "invite") + } else if requestType == "request" { + query = query.Where("type = ?", "request") + } + + var requests []model.AgentApplicationRequest + if err := query. + Preload("Agent"). + Preload("Application"). + Order("created_at DESC"). + Find(&requests).Error; err != nil { + response.Error(c, 500, "获取申请列表失败") + return + } + + type RequestResponse struct { + ID uint `json:"id"` + AgentID uint `json:"agent_id"` + AgentName string `json:"agent_name"` + AgentEmail string `json:"agent_email"` + DeveloperID uint `json:"developer_id"` + ApplicationID uint `json:"application_id"` + AppName string `json:"app_name"` + Type string `json:"type"` + Status string `json:"status"` + Message string `json:"message"` + RejectReason string `json:"reject_reason"` + CreatedAt string `json:"created_at"` + } + + var result []RequestResponse + for _, req := range requests { + agentName := "" + agentEmail := "" + if req.Agent.ID != 0 { + agentName = req.Agent.Username + if req.Agent.Email != nil { + agentEmail = *req.Agent.Email + } + } + + appName := "" + if req.Application.ID != 0 { + appName = req.Application.Name + } + + result = append(result, RequestResponse{ + ID: req.ID, + AgentID: req.AgentID, + AgentName: agentName, + AgentEmail: agentEmail, + DeveloperID: req.DeveloperID, + ApplicationID: req.ApplicationID, + AppName: appName, + Type: req.Type, + Status: req.Status, + Message: req.Message, + RejectReason: req.RejectReason, + CreatedAt: req.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + response.Success(c, gin.H{ + "requests": result, + "total": len(result), + }) +} + +func handleGetMyRequests(c *gin.Context) { + userID := c.GetUint("user_id") + requestType := c.Query("type") + + query := database.DB.Where("agent_id = ?", userID) + if requestType == "invite" { + query = query.Where("type = ?", "invite") + } else if requestType == "request" { + query = query.Where("type = ?", "request") + } + + var requests []model.AgentApplicationRequest + if err := query. + Preload("Developer"). + Preload("Application"). + Order("created_at DESC"). + Find(&requests).Error; err != nil { + response.Error(c, 500, "获取申请列表失败") + return + } + + type RequestResponse struct { + ID uint `json:"id"` + AgentID uint `json:"agent_id"` + DeveloperID uint `json:"developer_id"` + DeveloperName string `json:"developer_name"` + ApplicationID uint `json:"application_id"` + AppName string `json:"app_name"` + Type string `json:"type"` + Status string `json:"status"` + Message string `json:"message"` + RejectReason string `json:"reject_reason"` + CreatedAt string `json:"created_at"` + } + + var result []RequestResponse + for _, req := range requests { + developerName := "" + if req.Developer.ID != 0 { + developerName = req.Developer.Username + } + + appName := "" + if req.Application.ID != 0 { + appName = req.Application.Name + } + + result = append(result, RequestResponse{ + ID: req.ID, + AgentID: req.AgentID, + DeveloperID: req.DeveloperID, + DeveloperName: developerName, + ApplicationID: req.ApplicationID, + AppName: appName, + Type: req.Type, + Status: req.Status, + Message: req.Message, + RejectReason: req.RejectReason, + CreatedAt: req.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + response.Success(c, gin.H{ + "requests": result, + "total": len(result), + }) +} + +func handleInviteAgent(c *gin.Context) { + userID := c.GetUint("user_id") + + if !checkAgentPermission(userID) { + response.Error(c, 403, "您的套餐不支持代理功能,请升级套餐") + return + } + + var req struct { + AgentID uint `json:"agent_id"` + ApplicationID uint `json:"application_id"` + Message string `json:"message"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var agent model.User + if err := database.DB.First(&agent, req.AgentID).Error; err != nil { + response.Error(c, 404, "开发者不存在") + return + } + if agent.Role != "developer" { + response.Error(c, 400, "该用户不是开发者") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var existing model.AgentApplication + if err := database.DB.Where("agent_id = ? AND application_id = ?", req.AgentID, req.ApplicationID).First(&existing).Error; err == nil { + response.Error(c, 400, "该开发者已获得此应用的授权") + return + } + + request := model.AgentApplicationRequest{ + AgentID: req.AgentID, + DeveloperID: userID, + ApplicationID: req.ApplicationID, + Type: "invite", + Status: "pending", + Message: req.Message, + } + if err := database.DB.Create(&request).Error; err != nil { + fmt.Printf("创建邀请错误: %v\n", err) + response.Error(c, 500, "创建邀请失败") + return + } + + response.Success(c, gin.H{ + "id": request.ID, + "agent_id": request.AgentID, + "agent_name": agent.Username, + "app_id": request.ApplicationID, + "app_name": app.Name, + "type": request.Type, + "status": request.Status, + }) +} + +func handleRequestAuthorization(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + DeveloperID uint `json:"developer_id"` + ApplicationID uint `json:"application_id"` + Message string `json:"message"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var developer model.User + if err := database.DB.First(&developer, req.DeveloperID).Error; err != nil { + response.Error(c, 404, "开发者不存在") + return + } + if developer.Role != "developer" { + response.Error(c, 400, "该用户不是开发者") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, req.DeveloperID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var existing model.AgentApplication + if err := database.DB.Where("agent_id = ? AND application_id = ?", userID, req.ApplicationID).First(&existing).Error; err == nil { + response.Error(c, 400, "您已获得此应用的授权") + return + } + + var existingRequest model.AgentApplicationRequest + if err := database.DB.Where("agent_id = ? AND developer_id = ? AND application_id = ? AND status = ?", + userID, req.DeveloperID, req.ApplicationID, "pending").First(&existingRequest).Error; err == nil { + response.Error(c, 400, "您已有待处理的申请") + return + } + + request := model.AgentApplicationRequest{ + AgentID: userID, + DeveloperID: req.DeveloperID, + ApplicationID: req.ApplicationID, + Type: "request", + Status: "pending", + Message: req.Message, + } + if err := database.DB.Create(&request).Error; err != nil { + fmt.Printf("创建申请错误: %v\n", err) + response.Error(c, 500, "创建申请失败") + return + } + + response.Success(c, gin.H{ + "id": request.ID, + "developer_id": request.DeveloperID, + "developer_name": developer.Username, + "app_id": request.ApplicationID, + "app_name": app.Name, + "type": request.Type, + "status": request.Status, + }) +} + +func handleApproveRequest(c *gin.Context) { + userID := c.GetUint("user_id") + requestID := c.Param("id") + + if !checkAgentPermission(userID) { + response.Error(c, 403, "您的套餐不支持代理功能,请升级套餐") + return + } + + var req model.AgentApplicationRequest + if err := database.DB.Where("id = ? AND developer_id = ?", requestID, userID).First(&req).Error; err != nil { + response.Error(c, 404, "申请不存在") + return + } + + if req.Status != "pending" { + response.Error(c, 400, "该申请已处理") + return + } + + tx := database.DB.Begin() + + agentApp := model.AgentApplication{ + AgentID: req.AgentID, + ApplicationID: req.ApplicationID, + DeveloperID: userID, + Commission: 0.1, + Discount: 1.0, + Status: "active", + IsReceived: true, + } + if err := tx.Create(&agentApp).Error; err != nil { + tx.Rollback() + fmt.Printf("创建授权错误: %v\n", err) + response.Error(c, 500, "创建授权失败") + return + } + + var cardTypes []model.CardType + database.DB.Where("application_id = ? OR application_id IS NULL", req.ApplicationID).Find(&cardTypes) + for _, ct := range cardTypes { + agentCardType := model.AgentApplicationCardType{ + AgentApplicationID: agentApp.ID, + CardTypeID: ct.ID, + CanGenerate: false, + } + tx.Create(&agentCardType) + } + + req.Status = "approved" + if err := tx.Save(&req).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "更新申请状态失败") + return + } + + tx.Commit() + + response.Success(c, gin.H{ + "id": agentApp.ID, + "status": "approved", + }) +} + +func handleRejectRequest(c *gin.Context) { + userID := c.GetUint("user_id") + requestID := c.Param("id") + + var reqBody struct { + RejectReason string `json:"reject_reason"` + } + if err := c.ShouldBindJSON(&reqBody); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var req model.AgentApplicationRequest + if err := database.DB.Where("id = ? AND developer_id = ?", requestID, userID).First(&req).Error; err != nil { + response.Error(c, 404, "申请不存在") + return + } + + if req.Status != "pending" { + response.Error(c, 400, "该申请已处理") + return + } + + req.Status = "rejected" + req.RejectReason = reqBody.RejectReason + if err := database.DB.Save(&req).Error; err != nil { + response.Error(c, 500, "更新申请状态失败") + return + } + + response.Success(c, gin.H{ + "id": req.ID, + "status": "rejected", + }) +} + +func handleGetAgentAppDetail(c *gin.Context) { + userID := c.GetUint("user_id") + agentAppID := c.Param("id") + + var agentApp model.AgentApplication + if err := database.DB.Where("id = ? AND developer_id = ?", agentAppID, userID). + Preload("CardTypes.CardType"). + First(&agentApp).Error; err != nil { + response.Error(c, 404, "授权记录不存在") + return + } + + var agent model.User + if err := database.DB.Where("id = ?", agentApp.AgentID).First(&agent).Error; err != nil { + log.Printf("[ERROR] Failed to query agent: %v\n", err) + } + + var application model.Application + if err := database.DB.Where("id = ?", agentApp.ApplicationID).First(&application).Error; err != nil { + log.Printf("[ERROR] Failed to query application: %v\n", err) + } + + type CardTypeResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + CanGenerate bool `json:"can_generate"` + } + + type AgentAppDetailResponse struct { + ID uint `json:"id"` + AgentID uint `json:"agent_id"` + AgentName string `json:"agent_name"` + AgentEmail string `json:"agent_email"` + ApplicationID uint `json:"application_id"` + AppName string `json:"app_name"` + Commission float64 `json:"commission"` + Discount float64 `json:"discount"` + Status string `json:"status"` + CardTypes []CardTypeResponse `json:"card_types"` + CreatedAt string `json:"created_at"` + } + + var cardTypes []CardTypeResponse + for _, ct := range agentApp.CardTypes { + cardTypes = append(cardTypes, CardTypeResponse{ + ID: ct.CardTypeID, + Name: ct.CardType.Name, + Price: ct.CardType.Price, + CanGenerate: ct.CanGenerate, + }) + } + + result := AgentAppDetailResponse{ + ID: agentApp.ID, + AgentID: agentApp.AgentID, + AgentName: agent.Username, + ApplicationID: agentApp.ApplicationID, + AppName: application.Name, + Commission: agentApp.Commission, + Discount: agentApp.Discount, + Status: agentApp.Status, + CardTypes: cardTypes, + CreatedAt: agentApp.CreatedAt.Format("2006-01-02 15:04:05"), + } + + if agent.Email != nil { + result.AgentEmail = *agent.Email + } + + response.Success(c, result) +} + +func handleUpdateAgentApp(c *gin.Context) { + userID := c.GetUint("user_id") + agentAppID := c.Param("id") + + var reqBody struct { + Commission *float64 `json:"commission"` + Discount *float64 `json:"discount"` + Status *string `json:"status"` + } + if err := c.ShouldBindJSON(&reqBody); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var agentApp model.AgentApplication + if err := database.DB.Where("id = ? AND developer_id = ?", agentAppID, userID).First(&agentApp).Error; err != nil { + response.Error(c, 404, "授权记录不存在") + return + } + + updates := make(map[string]interface{}) + if reqBody.Commission != nil { + updates["commission"] = *reqBody.Commission + } + if reqBody.Discount != nil { + updates["discount"] = *reqBody.Discount + } + if reqBody.Status != nil { + updates["status"] = *reqBody.Status + } + + if len(updates) > 0 { + if err := database.DB.Model(&agentApp).Updates(updates).Error; err != nil { + response.Error(c, 500, "更新失败") + return + } + } + + response.Success(c, gin.H{ + "id": agentApp.ID, + }) +} + +func handleUpdateAgentCardTypes(c *gin.Context) { + userID := c.GetUint("user_id") + agentAppID := c.Param("id") + + var reqBody struct { + CardTypes []struct { + CardTypeID uint `json:"card_type_id"` + CanGenerate bool `json:"can_generate"` + } `json:"card_types"` + } + if err := c.ShouldBindJSON(&reqBody); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var agentApp model.AgentApplication + if err := database.DB.Where("id = ? AND developer_id = ?", agentAppID, userID).First(&agentApp).Error; err != nil { + response.Error(c, 404, "授权记录不存在") + return + } + + tx := database.DB.Begin() + + if err := tx.Where("agent_application_id = ?", agentAppID).Delete(&model.AgentApplicationCardType{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "清除旧权限失败") + return + } + + for _, ct := range reqBody.CardTypes { + agentCardType := model.AgentApplicationCardType{ + AgentApplicationID: agentApp.ID, + CardTypeID: ct.CardTypeID, + CanGenerate: ct.CanGenerate, + } + if err := tx.Create(&agentCardType).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "创建权限失败") + return + } + } + + tx.Commit() + + response.Success(c, gin.H{ + "id": agentApp.ID, + }) +} + +func handleRemoveAgentApp(c *gin.Context) { + userID := c.GetUint("user_id") + agentAppID := c.Param("id") + + var agentApp model.AgentApplication + if err := database.DB.Where("id = ? AND developer_id = ?", agentAppID, userID).First(&agentApp).Error; err != nil { + response.Error(c, 404, "授权记录不存在") + return + } + + if err := database.DB.Delete(&agentApp).Error; err != nil { + response.Error(c, 500, "删除失败") + return + } + + response.Success(c, gin.H{ + "id": agentApp.ID, + }) +} + +func handleCancelRequest(c *gin.Context) { + userID := c.GetUint("user_id") + requestID := c.Param("id") + + var req model.AgentApplicationRequest + if err := database.DB.Where("id = ? AND agent_id = ? AND status = ?", requestID, userID, "pending").First(&req).Error; err != nil { + response.Error(c, 404, "申请不存在或已处理") + return + } + + if err := database.DB.Delete(&req).Error; err != nil { + response.Error(c, 500, "取消失败") + return + } + + response.Success(c, gin.H{ + "id": req.ID, + }) +} diff --git a/backend/internal/router/developer/agents.go b/backend/internal/router/developer/agents.go new file mode 100644 index 0000000..434c955 --- /dev/null +++ b/backend/internal/router/developer/agents.go @@ -0,0 +1,585 @@ +package developer + +import ( + "fmt" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +func SetupAgentsRoutes(r *gin.RouterGroup) { + agents := r.Group("/agents") + { + agents.GET("", handleGetAgents) + agents.GET("/tree", handleGetAgentsTree) + agents.POST("", handleCreateAgent) + agents.GET("/:id", handleGetAgentDetail) + agents.PUT("/:id", handleUpdateAgent) + agents.PUT("/:id/status", handleUpdateAgentStatus) + agents.DELETE("/:id", handleDeleteAgent) + agents.GET("/:id/cards", handleGetAgentCards) + agents.PUT("/:id/cards", handleUpdateAgentCards) + } +} + +type AgentTreeNode struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Status string `json:"status"` + Role string `json:"role"` + ParentAgentID *uint `json:"parent_agent_id"` + ParentAgentName string `json:"parent_agent_name"` + CreatedAt string `json:"created_at"` + LastLoginAt string `json:"last_login_at"` + Balance float64 `json:"balance"` + Commission float64 `json:"commission"` + CanCreateAgent bool `json:"can_create_agent"` + CardsCount int `json:"cards_count"` + ChildAgentsCount int `json:"child_agents_count"` + Children []AgentTreeNode `json:"children,omitempty"` +} + +func handleGetAgents(c *gin.Context) { + var users []model.User + if err := database.DB.Where("role = ?", "developer"). + Preload("ParentAgent"). + Order("created_at DESC"). + Find(&users).Error; err != nil { + response.Error(c, 500, "获取代理列表失败") + return + } + + type AgentResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Status string `json:"status"` + Role string `json:"role"` + ParentAgentID *uint `json:"parent_agent_id"` + ParentAgentName string `json:"parent_agent_name"` + CreatedAt string `json:"created_at"` + LastLoginAt string `json:"last_login_at"` + Balance float64 `json:"balance"` + Commission float64 `json:"commission"` + CanCreateAgent bool `json:"can_create_agent"` + CardsCount int `json:"cards_count"` + ChildAgentsCount int `json:"child_agents_count"` + } + + var result []AgentResponse + for _, user := range users { + var parentAgentName string + if user.ParentAgent != nil { + parentAgentName = user.ParentAgent.Username + } + + var childAgentsCount int64 + database.DB.Model(&model.User{}).Where("parent_agent_id = ?", user.ID).Count(&childAgentsCount) + + var cardsCount int64 + database.DB.Model(&model.Card{}).Where("creator_id = ?", user.ID).Count(&cardsCount) + + var lastLoginAt string + if user.LastLoginAt != nil { + lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + email := "" + if user.Email != nil { + email = *user.Email + } + + result = append(result, AgentResponse{ + ID: user.ID, + Username: user.Username, + Email: email, + Avatar: user.Avatar, + Status: user.Status, + Role: user.Role, + ParentAgentID: user.ParentAgentID, + ParentAgentName: parentAgentName, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + LastLoginAt: lastLoginAt, + Balance: user.Balance, + Commission: user.Commission, + CanCreateAgent: user.CanCreateAgent, + CardsCount: int(cardsCount), + ChildAgentsCount: int(childAgentsCount), + }) + } + + response.Success(c, gin.H{ + "agents": result, + "total": len(result), + }) +} + +func handleGetAgentsTree(c *gin.Context) { + var users []model.User + if err := database.DB.Where("role = ?", "developer"). + Preload("ParentAgent"). + Order("created_at DESC"). + Find(&users).Error; err != nil { + response.Error(c, 500, "获取代理列表失败") + return + } + + userMap := make(map[uint]model.User) + for _, user := range users { + userMap[user.ID] = user + } + + childrenMap := make(map[uint][]uint) + var rootUsers []uint + for _, user := range users { + if user.ParentAgentID != nil { + childrenMap[*user.ParentAgentID] = append(childrenMap[*user.ParentAgentID], user.ID) + } else { + rootUsers = append(rootUsers, user.ID) + } + } + + var buildTree func(userID uint) AgentTreeNode + buildTree = func(userID uint) AgentTreeNode { + user := userMap[userID] + + var parentAgentName string + if user.ParentAgent != nil { + parentAgentName = user.ParentAgent.Username + } + + var cardsCount int64 + database.DB.Model(&model.Card{}).Where("creator_id = ?", user.ID).Count(&cardsCount) + + var childAgentsCount int64 + database.DB.Model(&model.User{}).Where("parent_agent_id = ?", user.ID).Count(&childAgentsCount) + + var lastLoginAt string + if user.LastLoginAt != nil { + lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + email := "" + if user.Email != nil { + email = *user.Email + } + + node := AgentTreeNode{ + ID: user.ID, + Username: user.Username, + Email: email, + Avatar: user.Avatar, + Status: user.Status, + Role: user.Role, + ParentAgentID: user.ParentAgentID, + ParentAgentName: parentAgentName, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + LastLoginAt: lastLoginAt, + Balance: user.Balance, + Commission: user.Commission, + CanCreateAgent: user.CanCreateAgent, + CardsCount: int(cardsCount), + ChildAgentsCount: int(childAgentsCount), + } + + if childIDs, exists := childrenMap[userID]; exists { + for _, childID := range childIDs { + node.Children = append(node.Children, buildTree(childID)) + } + } + + return node + } + + var tree []AgentTreeNode + for _, rootID := range rootUsers { + tree = append(tree, buildTree(rootID)) + } + + response.Success(c, gin.H{ + "tree": tree, + "total": len(users), + }) +} + +func handleCreateAgent(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Email string `json:"email"` + Password string `json:"password" binding:"required"` + ParentAgentID *uint `json:"parent_agent_id"` + Commission float64 `json:"commission"` + CanCreateAgent bool `json:"can_create_agent"` + Balance float64 `json:"balance"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var existingUser model.User + if err := database.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil { + response.Error(c, 400, "用户名已存在") + return + } + + if req.Email != "" { + if err := database.DB.Where("email = ?", req.Email).First(&existingUser).Error; err == nil { + response.Error(c, 400, "邮箱已被使用") + return + } + } + + if req.ParentAgentID != nil { + var parentAgent model.User + if err := database.DB.Where("id = ? AND role = ?", *req.ParentAgentID, "developer").First(&parentAgent).Error; err != nil { + response.Error(c, 404, "上级代理不存在") + return + } + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + response.Error(c, 500, "密码加密失败") + return + } + + user := model.User{ + Username: req.Username, + Password: string(hashedPassword), + Role: "developer", + Status: "active", + ParentAgentID: req.ParentAgentID, + Commission: req.Commission, + CanCreateAgent: req.CanCreateAgent, + Balance: req.Balance, + } + + if req.Email != "" { + user.Email = &req.Email + } + + if err := database.DB.Create(&user).Error; err != nil { + response.Error(c, 500, "创建代理失败") + return + } + + service.LogOperation(c, "create", "agent", &user.ID, fmt.Sprintf("创建代理: %s", user.Username), nil) + + response.Success(c, gin.H{ + "id": user.ID, + "username": user.Username, + }) +} + +func handleGetAgentDetail(c *gin.Context) { + agentID := c.Param("id") + + var user model.User + if err := database.DB.Where("id = ? AND role = ?", agentID, "developer"). + Preload("ParentAgent"). + First(&user).Error; err != nil { + response.Error(c, 404, "代理不存在") + return + } + + var agentApps []model.AgentApplication + database.DB.Where("agent_id = ?", user.ID). + Preload("Application"). + Find(&agentApps) + + var childAgents []model.User + database.DB.Where("parent_agent_id = ?", user.ID).Find(&childAgents) + + var cards []model.Card + database.DB.Where("creator_id = ?", user.ID). + Preload("Application"). + Preload("CardType"). + Find(&cards) + + type CardResponse struct { + ID uint `json:"id"` + CardKey string `json:"card_key"` + ApplicationID uint `json:"application_id"` + AppName string `json:"app_name"` + CardTypeID uint `json:"card_type_id"` + CardTypeName string `json:"card_type_name"` + Price float64 `json:"price"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + } + + var cardsResponse []CardResponse + for _, card := range cards { + appName := "" + if card.Application != nil { + appName = card.Application.Name + } + cardTypeName := "" + if card.CardType.ID != 0 { + cardTypeName = card.CardType.Name + } + cardsResponse = append(cardsResponse, CardResponse{ + ID: card.ID, + CardKey: card.CardKey, + ApplicationID: card.ApplicationID, + AppName: appName, + CardTypeID: card.CardTypeID, + CardTypeName: cardTypeName, + Price: card.CardType.Price, + Status: card.Status, + CreatedAt: card.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + var lastLoginAt string + if user.LastLoginAt != nil { + lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + email := "" + if user.Email != nil { + email = *user.Email + } + + var parentAgentName string + if user.ParentAgent != nil { + parentAgentName = user.ParentAgent.Username + } + + response.Success(c, gin.H{ + "id": user.ID, + "username": user.Username, + "email": email, + "avatar": user.Avatar, + "status": user.Status, + "role": user.Role, + "parent_agent_id": user.ParentAgentID, + "parent_agent_name": parentAgentName, + "commission": user.Commission, + "can_create_agent": user.CanCreateAgent, + "balance": user.Balance, + "created_at": user.CreatedAt.Format("2006-01-02 15:04:05"), + "last_login_at": lastLoginAt, + "cards": cardsResponse, + "cards_count": len(cardsResponse), + "child_agents_count": len(childAgents), + }) +} + +func handleUpdateAgent(c *gin.Context) { + agentID := c.Param("id") + + var user model.User + if err := database.DB.Where("id = ? AND role = ?", agentID, "developer").First(&user).Error; err != nil { + response.Error(c, 404, "代理不存在") + return + } + + var req struct { + ParentAgentID *uint `json:"parent_agent_id"` + Commission *float64 `json:"commission"` + Email *string `json:"email"` + Password *string `json:"password"` + CanCreateAgent *bool `json:"can_create_agent"` + Balance *float64 `json:"balance"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.ParentAgentID != nil { + if *req.ParentAgentID == user.ID { + response.Error(c, 400, "不能将自己设为上级代理") + return + } + var parentAgent model.User + if err := database.DB.Where("id = ? AND role = ?", *req.ParentAgentID, "developer").First(&parentAgent).Error; err != nil { + response.Error(c, 404, "上级代理不存在") + return + } + user.ParentAgentID = req.ParentAgentID + } + + if req.Email != nil { + user.Email = req.Email + } + if req.Commission != nil { + user.Commission = *req.Commission + } + if req.Password != nil && *req.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + response.Error(c, 500, "密码加密失败") + return + } + user.Password = string(hashedPassword) + } + if req.CanCreateAgent != nil { + user.CanCreateAgent = *req.CanCreateAgent + } + if req.Balance != nil { + user.Balance = *req.Balance + } + + if err := database.DB.Save(&user).Error; err != nil { + response.Error(c, 500, "更新失败") + return + } + + service.LogOperation(c, "update", "agent", &user.ID, fmt.Sprintf("更新代理: %s", user.Username), nil) + + response.Success(c, gin.H{ + "id": user.ID, + }) +} + +func handleUpdateAgentStatus(c *gin.Context) { + agentID := c.Param("id") + + var user model.User + if err := database.DB.Where("id = ? AND role = ?", agentID, "developer").First(&user).Error; err != nil { + response.Error(c, 404, "代理不存在") + return + } + + var req struct { + Status string `json:"status" binding:"required,oneof=active inactive banned"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + user.Status = req.Status + if err := database.DB.Save(&user).Error; err != nil { + response.Error(c, 500, "更新状态失败") + return + } + + service.LogOperation(c, "update_status", "agent", &user.ID, fmt.Sprintf("更新代理状态: %s -> %s", user.Username, user.Status), nil) + + response.Success(c, gin.H{ + "id": user.ID, + "status": user.Status, + }) +} + +func handleDeleteAgent(c *gin.Context) { + agentID := c.Param("id") + + var user model.User + if err := database.DB.Where("id = ? AND role = ?", agentID, "developer").First(&user).Error; err != nil { + response.Error(c, 404, "代理不存在") + return + } + + database.DB.Model(&model.User{}).Where("parent_agent_id = ?", user.ID).Update("parent_agent_id", nil) + + if err := database.DB.Delete(&user).Error; err != nil { + response.Error(c, 500, "删除失败") + return + } + + service.LogOperation(c, "delete", "agent", &user.ID, fmt.Sprintf("删除代理: %s", user.Username), nil) + + response.Success(c, gin.H{ + "id": user.ID, + }) +} + +func handleGetAgentCards(c *gin.Context) { + agentID := c.Param("id") + + var user model.User + if err := database.DB.Where("id = ? AND role = ?", agentID, "developer").First(&user).Error; err != nil { + response.Error(c, 404, "代理不存在") + return + } + + var cards []model.Card + database.DB.Where("creator_id = ?", user.ID). + Preload("Application"). + Preload("CardType"). + Order("created_at DESC"). + Find(&cards) + + type CardResponse struct { + ID uint `json:"id"` + CardKey string `json:"card_key"` + ApplicationID uint `json:"application_id"` + AppName string `json:"app_name"` + CardTypeID uint `json:"card_type_id"` + CardTypeName string `json:"card_type_name"` + Price float64 `json:"price"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + } + + var result []CardResponse + for _, card := range cards { + appName := "" + if card.Application != nil { + appName = card.Application.Name + } + cardTypeName := "" + if card.CardType.ID != 0 { + cardTypeName = card.CardType.Name + } + result = append(result, CardResponse{ + ID: card.ID, + CardKey: card.CardKey, + ApplicationID: card.ApplicationID, + AppName: appName, + CardTypeID: card.CardTypeID, + CardTypeName: cardTypeName, + Price: card.CardType.Price, + Status: card.Status, + CreatedAt: card.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + response.Success(c, gin.H{ + "cards": result, + "total": len(result), + }) +} + +func handleUpdateAgentCards(c *gin.Context) { + agentID := c.Param("id") + + var user model.User + if err := database.DB.Where("id = ? AND role = ?", agentID, "developer").First(&user).Error; err != nil { + response.Error(c, 404, "代理不存在") + return + } + + var req struct { + Cards []struct { + CardID uint `json:"card_id"` + Status string `json:"status"` + } `json:"cards"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + for _, cardReq := range req.Cards { + database.DB.Model(&model.Card{}). + Where("id = ? AND creator_id = ?", cardReq.CardID, user.ID). + Update("status", cardReq.Status) + } + + response.Success(c, gin.H{ + "message": "更新成功", + }) +} diff --git a/backend/internal/router/developer/announcements.go b/backend/internal/router/developer/announcements.go new file mode 100644 index 0000000..f28f538 --- /dev/null +++ b/backend/internal/router/developer/announcements.go @@ -0,0 +1,190 @@ +package developer + +import ( + "fmt" + "log" + "strconv" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupAnnouncementRoutes(r *gin.RouterGroup) { + announcements := r.Group("/announcements") + { + announcements.GET("", handleGetAllAnnouncements) + announcements.GET("/:id", handleGetAnnouncementByID) + announcements.DELETE("/batch", handleBatchDeleteAnnouncements) + } +} + +func handleGetAllAnnouncements(c *gin.Context) { + userID := c.GetUint("user_id") + log.Printf("[DEBUG] handleGetAllAnnouncements called, userID: %d\n", userID) + + page := c.DefaultQuery("page", "1") + pageSize := c.DefaultQuery("page_size", "20") + + var total int64 + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + log.Printf("[DEBUG] Found %d user apps\n", len(userApps)) + for i, app := range userApps { + log.Printf("[DEBUG] App %d: ID=%d, Name=%s\n", i, app.ID, app.Name) + } + + if len(userApps) == 0 { + response.Success(c, gin.H{ + "announcements": []interface{}{}, + "total": 0, + }) + return + } + + appIDs := make([]uint, len(userApps)) + appNameMap := make(map[uint]string) + for i, app := range userApps { + appIDs[i] = app.ID + appNameMap[app.ID] = app.Name + } + + log.Printf("[DEBUG] appNameMap: %v\n", appNameMap) + + database.DB.Model(&model.Announcement{}).Where("application_id IN ?", appIDs).Count(&total) + + var announcements []model.Announcement + offset := 0 + if pageInt, err := strconv.Atoi(page); err == nil && pageInt > 1 { + offset = (pageInt - 1) * 20 + } + + limit := 20 + if pageSizeInt, err := strconv.Atoi(pageSize); err == nil && pageSizeInt > 0 { + limit = pageSizeInt + } + + if err := database.DB.Where("application_id IN ?", appIDs).Order("is_top DESC, created_at DESC").Limit(limit).Offset(offset).Find(&announcements).Error; err != nil { + response.Error(c, 500, "获取公告列表失败") + return + } + + log.Printf("[DEBUG] Found %d announcements\n", len(announcements)) + for i, a := range announcements { + log.Printf("[DEBUG] Announcement %d: ID=%d, ApplicationID=%d, Title=%s\n", i, a.ID, a.ApplicationID, a.Title) + } + + type AnnouncementWithAppName struct { + model.Announcement + ApplicationName string `json:"application_name"` + } + + result := make([]AnnouncementWithAppName, len(announcements)) + for i, a := range announcements { + appName := appNameMap[a.ApplicationID] + result[i] = AnnouncementWithAppName{ + Announcement: a, + ApplicationName: appName, + } + } + + response.Success(c, gin.H{ + "announcements": result, + "total": total, + }) +} + +func handleGetAnnouncementByID(c *gin.Context) { + userID := c.GetUint("user_id") + announcementID := c.Param("id") + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + appNameMap := make(map[uint]string) + for i, app := range userApps { + appIDs[i] = app.ID + appNameMap[app.ID] = app.Name + } + + var announcement model.Announcement + if err := database.DB.Where("id = ? AND application_id IN ?", announcementID, appIDs).First(&announcement).Error; err != nil { + response.Error(c, 404, "公告不存在") + return + } + + response.Success(c, gin.H{ + "id": announcement.ID, + "application_id": announcement.ApplicationID, + "application_name": appNameMap[announcement.ApplicationID], + "title": announcement.Title, + "content": announcement.Content, + "type": announcement.Type, + "status": announcement.Status, + "is_top": announcement.IsTop, + "created_at": announcement.CreatedAt, + "updated_at": announcement.UpdatedAt, + }) +} + +func handleBatchDeleteAnnouncements(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要删除的公告") + return + } + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + for i, app := range userApps { + appIDs[i] = app.ID + } + + var announcements []model.Announcement + if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, appIDs).Find(&announcements).Error; err != nil { + response.Error(c, 500, "获取公告失败") + return + } + + for _, announcement := range announcements { + for _, app := range userApps { + if app.ID == announcement.ApplicationID && service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, fmt.Sprintf("应用 %s 已被禁用,无法删除其公告", app.Name)) + return + } + } + } + + if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, appIDs).Delete(&model.Announcement{}).Error; err != nil { + response.Error(c, 500, "批量删除公告失败") + return + } + + response.Success(c, nil) +} diff --git a/backend/internal/router/developer/applications.go b/backend/internal/router/developer/applications.go new file mode 100644 index 0000000..785a9fb --- /dev/null +++ b/backend/internal/router/developer/applications.go @@ -0,0 +1,1057 @@ +package developer + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/middleware" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupApplicationRoutes(r *gin.RouterGroup) { + applications := r.Group("/applications") + { + applications.GET("", handleGetApplications) + applications.POST("", handleCreateApplication) + applications.PUT("/:id", handleUpdateApplication) + applications.DELETE("/:id", handleDeleteApplication) + applications.GET("/:id", handleGetApplication) + applications.POST("/:id/icon", handleUploadIcon) + applications.POST("/:id/upload", handleUploadVersionFile) + applications.GET("/:id/versions", handleGetVersions) + applications.POST("/:id/versions", handleCreateVersion) + applications.GET("/:id/versions/:versionId", handleGetVersion) + applications.PUT("/:id/versions/:versionId", handleUpdateVersion) + applications.DELETE("/:id/versions/:versionId", handleDeleteVersion) + applications.POST("/:id/versions/:versionId/publish", handlePublishVersion) + applications.GET("/:id/announcements", handleGetApplicationAnnouncements) + applications.POST("/:id/announcements", handleCreateAnnouncement) + applications.PUT("/:id/announcements/:announcementId", handleUpdateAnnouncement) + applications.DELETE("/:id/announcements/:announcementId", handleDeleteAnnouncement) + applications.PUT("/:id/announcements/:announcementId/top", handleToggleAnnouncementTop) + applications.POST("/:id/deduct", handleManualDeduct) + } +} + +func handleGetApplications(c *gin.Context) { + userID := c.GetUint("user_id") + + applications, err := service.GetApplicationsWithDisabledStatus(userID) + if err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + fmt.Printf("找到 %d 个应用\n", len(applications)) + + response.Success(c, gin.H{ + "applications": applications, + }) +} + +func parseIntWithDefault(s string, defaultVal int) int { + if s == "" { + return defaultVal + } + val, err := strconv.Atoi(s) + if err != nil { + return defaultVal + } + return val +} + +func parseFloatWithDefault(s string, defaultVal float64) float64 { + if s == "" { + return defaultVal + } + val, err := strconv.ParseFloat(s, 64) + if err != nil { + return defaultVal + } + return val +} + +func handleCreateApplication(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + Name string `form:"name" json:"name"` + Description string `form:"description" json:"description"` + BillingType string `form:"billing_type" json:"billing_type"` + LoginPolicy string `form:"login_policy" json:"login_policy"` + EncryptType string `form:"encrypt_type" json:"encrypt_type"` + SecretKey string `form:"secret_key" json:"secret_key"` + BindType string `form:"bind_type" json:"bind_type"` + MaxDevices string `form:"max_devices" json:"max_devices"` + ChangeLimit string `form:"change_limit" json:"change_limit"` + ChangeInterval string `form:"change_interval" json:"change_interval"` + ChangeExceedAction string `form:"change_exceed_action" json:"change_exceed_action"` + ChangeDeductAmount string `form:"change_deduct_amount" json:"change_deduct_amount"` + MultiOpenMode string `form:"multi_open_mode" json:"multi_open_mode"` + MaxInstances string `form:"max_instances" json:"max_instances"` + MultiOpen string `form:"multi_open" json:"multi_open"` + EnableTrial string `form:"enable_trial" json:"enable_trial"` + TrialBalance string `form:"trial_balance" json:"trial_balance"` + TrialDays string `form:"trial_days" json:"trial_days"` + EnableFreePeriod string `form:"enable_free_period" json:"enable_free_period"` + FreePeriodType string `form:"free_period_type" json:"free_period_type"` + FreePeriodStart string `form:"free_period_start" json:"free_period_start"` + FreePeriodEnd string `form:"free_period_end" json:"free_period_end"` + FreePeriodWeekdays string `form:"free_period_weekdays" json:"free_period_weekdays"` + FreePeriodStartTime string `form:"free_period_start_time" json:"free_period_start_time"` + FreePeriodEndTime string `form:"free_period_end_time" json:"free_period_end_time"` + HeartbeatInterval string `form:"heartbeat_interval" json:"heartbeat_interval"` + HeartbeatTimeout string `form:"heartbeat_timeout" json:"heartbeat_timeout"` + MaxAttempts string `form:"max_attempts" json:"max_attempts"` + LockDuration string `form:"lock_duration" json:"lock_duration"` + DeductionMode string `form:"deduction_mode" json:"deduction_mode"` + DeductionType string `form:"deduction_type" json:"deduction_type"` + DeductionInterval string `form:"deduction_interval" json:"deduction_interval"` + DeductionUnit string `form:"deduction_unit" json:"deduction_unit"` + DeductionAmount string `form:"deduction_amount" json:"deduction_amount"` + DeductionCycle string `form:"deduction_cycle" json:"deduction_cycle"` + } + if err := c.ShouldBind(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + multiOpen := req.MultiOpen == "true" + enableTrial := req.EnableTrial == "true" + enableFreePeriod := req.EnableFreePeriod == "true" + + maxDevices := parseIntWithDefault(req.MaxDevices, 1) + changeLimit := parseIntWithDefault(req.ChangeLimit, 3) + changeInterval := parseIntWithDefault(req.ChangeInterval, 7) + changeDeductAmount := parseFloatWithDefault(req.ChangeDeductAmount, 1) + maxInstances := parseIntWithDefault(req.MaxInstances, 1) + trialBalance := parseFloatWithDefault(req.TrialBalance, 0) + trialDays := parseIntWithDefault(req.TrialDays, 0) + heartbeatInterval := parseIntWithDefault(req.HeartbeatInterval, 60) + heartbeatTimeout := parseIntWithDefault(req.HeartbeatTimeout, 300) + maxAttempts := parseIntWithDefault(req.MaxAttempts, 5) + lockDuration := parseIntWithDefault(req.LockDuration, 30) + deductionInterval := parseIntWithDefault(req.DeductionInterval, 1) + deductionAmount := parseFloatWithDefault(req.DeductionAmount, 1) + + appKey := generateAppKey() + secretKey := req.SecretKey + if secretKey == "" { + secretKey = generateSecretKey() + } + + encryptType := req.EncryptType + if encryptType == "" { + encryptType = "none" + } + + loginPolicy := req.LoginPolicy + if loginPolicy == "" { + loginPolicy = "loose" + } + + changeExceedAction := req.ChangeExceedAction + if changeExceedAction == "" { + changeExceedAction = "deny" + } + + multiOpenMode := req.MultiOpenMode + if multiOpenMode == "" { + multiOpenMode = "forbidden" + } + + deductionUnit := req.DeductionUnit + if deductionUnit == "" && req.DeductionCycle != "" { + deductionUnit = req.DeductionCycle + } + + app := model.Application{ + UserID: userID, + Name: req.Name, + Description: req.Description, + AppKey: appKey, + SecretKey: secretKey, + BillingType: req.BillingType, + LoginPolicy: loginPolicy, + EncryptType: encryptType, + BindType: req.BindType, + MaxDevices: maxDevices, + ChangeLimit: changeLimit, + ChangeInterval: changeInterval, + ChangeExceedAction: changeExceedAction, + ChangeDeductAmount: changeDeductAmount, + MultiOpenMode: multiOpenMode, + MaxInstances: maxInstances, + MultiOpen: multiOpen, + EnableTrial: enableTrial, + TrialBalance: trialBalance, + TrialDays: trialDays, + EnableFreePeriod: enableFreePeriod, + FreePeriodType: req.FreePeriodType, + FreePeriodStart: req.FreePeriodStart, + FreePeriodEnd: req.FreePeriodEnd, + FreePeriodWeekdays: req.FreePeriodWeekdays, + FreePeriodStartTime: req.FreePeriodStartTime, + FreePeriodEndTime: req.FreePeriodEndTime, + HeartbeatInterval: heartbeatInterval, + HeartbeatTimeout: heartbeatTimeout, + MaxAttempts: maxAttempts, + LockDuration: lockDuration, + DeductionMode: req.DeductionMode, + DeductionType: req.DeductionType, + DeductionInterval: deductionInterval, + DeductionUnit: deductionUnit, + DeductionAmount: deductionAmount, + Status: "active", + } + + if err := database.DB.Create(&app).Error; err != nil { + fmt.Printf("创建应用失败: %v\n", err) + response.Error(c, 500, "创建应用失败: "+err.Error()) + return + } + + file, header, err := c.Request.FormFile("icon") + if err == nil { + defer file.Close() + uploadDir := "./uploads/icons" + if err := os.MkdirAll(uploadDir, 0755); err == nil { + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d_%d%s", app.ID, time.Now().Unix(), ext) + filePath := filepath.Join(uploadDir, filename) + dst, err := os.Create(filePath) + if err == nil { + defer dst.Close() + if _, err := io.Copy(dst, file); err == nil { + app.IconURL = "/uploads/icons/" + filename + database.DB.Model(&app).Update("icon_url", app.IconURL) + } + } + } + } + + service.LogOperation(c, "create", "application", &app.ID, fmt.Sprintf("创建应用: %s", app.Name), nil) + + response.Success(c, gin.H{ + "application": app, + }) +} + +func generateAppKey() string { + bytes := make([]byte, 16) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +func generateSecretKey() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +func handleUpdateApplication(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + Name string `form:"name" json:"name"` + Description string `form:"description" json:"description"` + BillingType string `form:"billing_type" json:"billing_type"` + LoginPolicy string `form:"login_policy" json:"login_policy"` + AllowRegister bool `form:"allow_register" json:"allow_register"` + RegisterMethods string `form:"register_methods" json:"register_methods"` + EncryptType string `form:"encrypt_type" json:"encrypt_type"` + SecretKey string `form:"secret_key" json:"secret_key"` + BindType string `form:"bind_type" json:"bind_type"` + MaxDevices int `form:"max_devices" json:"max_devices"` + ChangeLimit int `form:"change_limit" json:"change_limit"` + ChangeInterval int `form:"change_interval" json:"change_interval"` + ChangeExceedAction string `form:"change_exceed_action" json:"change_exceed_action"` + ChangeDeductAmount float64 `form:"change_deduct_amount" json:"change_deduct_amount"` + MultiOpenMode string `form:"multi_open_mode" json:"multi_open_mode"` + MaxInstances int `form:"max_instances" json:"max_instances"` + MultiOpen bool `form:"multi_open" json:"multi_open"` + EnableTrial bool `form:"enable_trial" json:"enable_trial"` + TrialBalance float64 `form:"trial_balance" json:"trial_balance"` + TrialDays int `form:"trial_days" json:"trial_days"` + EnableFreePeriod bool `form:"enable_free_period" json:"enable_free_period"` + FreePeriodType string `form:"free_period_type" json:"free_period_type"` + FreePeriodStart string `form:"free_period_start" json:"free_period_start"` + FreePeriodEnd string `form:"free_period_end" json:"free_period_end"` + FreePeriodWeekdays string `form:"free_period_weekdays" json:"free_period_weekdays"` + FreePeriodStartTime string `form:"free_period_start_time" json:"free_period_start_time"` + FreePeriodEndTime string `form:"free_period_end_time" json:"free_period_end_time"` + HeartbeatInterval int `form:"heartbeat_interval" json:"heartbeat_interval"` + HeartbeatTimeout int `form:"heartbeat_timeout" json:"heartbeat_timeout"` + MaxAttempts int `form:"max_attempts" json:"max_attempts"` + LockDuration int `form:"lock_duration" json:"lock_duration"` + Status string `form:"status" json:"status"` + DeductionMode string `form:"deduction_mode" json:"deduction_mode"` + DeductionType string `form:"deduction_type" json:"deduction_type"` + DeductionInterval int `form:"deduction_interval" json:"deduction_interval"` + DeductionUnit string `form:"deduction_unit" json:"deduction_unit"` + DeductionAmount float64 `form:"deduction_amount" json:"deduction_amount"` + } + + contentType := c.GetHeader("Content-Type") + var err error + if contentType != "" && len(contentType) >= 19 && contentType[:19] == "multipart/form-data" { + err = c.ShouldBind(&req) + } else { + err = c.ShouldBindJSON(&req) + } + if err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + fmt.Printf("更新应用请求: EncryptType=%s, SecretKey=%s\n", req.EncryptType, req.SecretKey) + + id := c.Param("id") + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + fmt.Printf("更新应用前: ID=%d, EncryptType=%s, SecretKey=%s\n", app.ID, app.EncryptType, app.SecretKey) + + app.Name = req.Name + app.Description = req.Description + app.BillingType = req.BillingType + app.LoginPolicy = req.LoginPolicy + app.AllowRegister = req.AllowRegister + app.RegisterMethods = req.RegisterMethods + app.EncryptType = req.EncryptType + if req.SecretKey != "" { + app.SecretKey = req.SecretKey + } + app.BindType = req.BindType + app.MaxDevices = req.MaxDevices + app.ChangeLimit = req.ChangeLimit + app.ChangeInterval = req.ChangeInterval + app.ChangeExceedAction = req.ChangeExceedAction + app.ChangeDeductAmount = req.ChangeDeductAmount + app.MultiOpenMode = req.MultiOpenMode + app.MaxInstances = req.MaxInstances + app.MultiOpen = req.MultiOpen + app.EnableTrial = req.EnableTrial + app.TrialBalance = req.TrialBalance + app.TrialDays = req.TrialDays + app.EnableFreePeriod = req.EnableFreePeriod + app.FreePeriodType = req.FreePeriodType + app.FreePeriodStart = req.FreePeriodStart + app.FreePeriodEnd = req.FreePeriodEnd + app.FreePeriodWeekdays = req.FreePeriodWeekdays + app.FreePeriodStartTime = req.FreePeriodStartTime + app.FreePeriodEndTime = req.FreePeriodEndTime + if req.HeartbeatInterval > 0 { + app.HeartbeatInterval = req.HeartbeatInterval + } + if req.HeartbeatTimeout > 0 { + app.HeartbeatTimeout = req.HeartbeatTimeout + } + if req.MaxAttempts > 0 { + app.MaxAttempts = req.MaxAttempts + } + if req.LockDuration > 0 { + app.LockDuration = req.LockDuration + } + app.Status = req.Status + app.DeductionMode = req.DeductionMode + app.DeductionType = req.DeductionType + app.DeductionInterval = req.DeductionInterval + app.DeductionUnit = req.DeductionUnit + app.DeductionAmount = req.DeductionAmount + + file, header, err := c.Request.FormFile("icon") + if err == nil { + defer file.Close() + uploadDir := "./uploads/icons" + if err := os.MkdirAll(uploadDir, 0755); err != nil { + response.Error(c, 500, "创建上传目录失败") + return + } + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d_%d%s", app.ID, time.Now().Unix(), ext) + filePath := filepath.Join(uploadDir, filename) + dst, err := os.Create(filePath) + if err != nil { + response.Error(c, 500, "保存文件失败") + return + } + defer dst.Close() + if _, err := io.Copy(dst, file); err != nil { + response.Error(c, 500, "保存文件失败") + return + } + app.IconURL = "/uploads/icons/" + filename + } + + fmt.Printf("更新应用: ID=%d, EncryptType=%s, SecretKey=%s\n", app.ID, app.EncryptType, app.SecretKey) + + if err := database.DB.Save(&app).Error; err != nil { + response.Error(c, 500, "更新应用失败") + return + } + + service.LogOperation(c, "update", "application", &app.ID, fmt.Sprintf("更新应用: %s", app.Name), nil) + + response.Success(c, app) +} + +func handleDeleteApplication(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + appName := app.Name + appID := app.ID + + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + if err := tx.Where("id = ? AND user_id = ?", id, userID).Delete(&model.Application{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除应用失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.Version{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除版本失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.CardType{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除卡密类型失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.Card{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除卡密失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.Announcement{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除公告失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.AppUser{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除应用用户失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.AgentApplication{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除代理授权失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.Ticket{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除工单失败") + return + } + + if err := tx.Where("app_id = ?", id).Delete(&model.UserVariable{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除用户变量失败") + return + } + + if err := tx.Where("application_id = ?", id).Delete(&model.UserDevice{}).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "删除用户设备失败") + return + } + + if err := tx.Commit().Error; err != nil { + response.Error(c, 500, "删除应用失败") + return + } + + service.LogOperation(c, "delete", "application", &appID, fmt.Sprintf("删除应用: %s", appName), nil) + + response.Success(c, nil) +} + +func handleGetApplication(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + response.Success(c, gin.H{ + "application": app, + }) +} + +func handleGetApplicationAnnouncements(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + page := c.DefaultQuery("page", "1") + pageSize := c.DefaultQuery("page_size", "10") + + var total int64 + database.DB.Model(&model.Announcement{}).Where("application_id = ?", appID).Count(&total) + + var announcements []model.Announcement + offset := 0 + if pageInt, err := strconv.Atoi(page); err == nil && pageInt > 1 { + offset = (pageInt - 1) * 10 + } + + limit := 10 + if pageSizeInt, err := strconv.Atoi(pageSize); err == nil && pageSizeInt > 0 { + limit = pageSizeInt + } + + if err := database.DB.Where("application_id = ?", appID).Order("is_top DESC, created_at DESC").Limit(limit).Offset(offset).Find(&announcements).Error; err != nil { + response.Error(c, 500, "获取公告列表失败") + return + } + + response.Success(c, gin.H{ + "announcements": announcements, + "total": total, + }) +} + +func handleCreateAnnouncement(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var req struct { + Title string `json:"title"` + Content string `json:"content"` + Type string `json:"type"` + Status string `json:"status"` + IsTop bool `json:"is_top"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + announcement := model.Announcement{ + ApplicationID: app.ID, + Title: req.Title, + Content: req.Content, + Type: req.Type, + Status: req.Status, + IsTop: req.IsTop, + } + + if err := database.DB.Create(&announcement).Error; err != nil { + response.Error(c, 500, "创建公告失败") + return + } + + response.Success(c, announcement) +} + +func handleUpdateAnnouncement(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + announcementID := c.Param("announcementId") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var req struct { + Title string `json:"title"` + Content string `json:"content"` + Type string `json:"type"` + Status string `json:"status"` + IsTop bool `json:"is_top"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var announcement model.Announcement + if err := database.DB.Where("id = ? AND application_id = ?", announcementID, appID).First(&announcement).Error; err != nil { + response.Error(c, 404, "公告不存在") + return + } + + announcement.Title = req.Title + announcement.Content = req.Content + announcement.Type = req.Type + announcement.Status = req.Status + announcement.IsTop = req.IsTop + + if err := database.DB.Save(&announcement).Error; err != nil { + response.Error(c, 500, "更新公告失败") + return + } + + response.Success(c, announcement) +} + +func handleDeleteAnnouncement(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + announcementID := c.Param("announcementId") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if err := database.DB.Where("id = ? AND application_id = ?", announcementID, appID).Delete(&model.Announcement{}).Error; err != nil { + response.Error(c, 500, "删除公告失败") + return + } + + response.Success(c, nil) +} + +func handleToggleAnnouncementTop(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + announcementID := c.Param("announcementId") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var announcement model.Announcement + if err := database.DB.Where("id = ? AND application_id = ?", announcementID, appID).First(&announcement).Error; err != nil { + response.Error(c, 404, "公告不存在") + return + } + + announcement.IsTop = !announcement.IsTop + + if err := database.DB.Save(&announcement).Error; err != nil { + response.Error(c, 500, "更新公告失败") + return + } + + response.Success(c, announcement) +} + +func handleUploadIcon(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + file, err := c.FormFile("icon") + if err != nil { + response.Error(c, 400, "请上传文件") + return + } + + filename := fmt.Sprintf("app_%d_%s", app.ID, file.Filename) + if err := c.SaveUploadedFile(file, "uploads/"+filename); err != nil { + response.Error(c, 500, "文件保存失败") + return + } + + app.IconURL = "/uploads/" + filename + database.DB.Save(&app) + + response.Success(c, gin.H{ + "icon": app.IconURL, + }) +} + +func handleUploadVersionFile(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + file, err := c.FormFile("file") + if err != nil { + response.Error(c, 400, "请上传文件") + return + } + + fileSize := file.Size + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + return + } + + if user.CurrentPackageID != nil { + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err == nil { + maxStorageBytes := int64(permission.MaxStorage) * 1024 * 1024 + if user.StorageUsed+fileSize > maxStorageBytes { + usedMB := float64(user.StorageUsed) / 1024 / 1024 + maxMB := float64(permission.MaxStorage) + response.Error(c, 403, fmt.Sprintf("存储空间不足,已使用 %.2f MB / %.2f MB", usedMB, maxMB)) + return + } + } + } + + ext := filepath.Ext(file.Filename) + timestamp := time.Now().Unix() + filename := fmt.Sprintf("version_%d_%d%s", app.ID, timestamp, ext) + dst := filepath.Join("uploads", filename) + + if err := c.SaveUploadedFile(file, dst); err != nil { + response.Error(c, 500, "文件保存失败") + return + } + + url := fmt.Sprintf("/uploads/%s", filename) + absolutePath := filepath.Join(".", "uploads", filename) + + if info, err := os.Stat(absolutePath); err == nil { + if err := middleware.UpdateStorageUsed(userID, info.Size(), "upload"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + + usage := model.StorageUsage{ + UserID: userID, + ApplicationID: &app.ID, + ResourceType: "version", + ResourceID: 0, + FileName: file.Filename, + FileSize: info.Size(), + Action: "upload", + CreatedAt: time.Now(), + } + database.DB.Create(&usage) + + response.Success(c, gin.H{ + "url": url, + "size": info.Size(), + }) + } else { + response.Error(c, 500, "获取文件大小失败") + return + } +} + +func handleGetVersions(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var versions []model.Version + if err := database.DB.Where("application_id = ?", appID).Find(&versions).Error; err != nil { + response.Success(c, gin.H{ + "versions": []interface{}{}, + }) + return + } + + response.Success(c, gin.H{ + "versions": versions, + }) +} + +func handleCreateVersion(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var req struct { + Version string `json:"version"` + Description string `json:"description"` + FilePath string `json:"file_path"` + ForceUpdate bool `json:"force_update"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + fmt.Printf("接收到的完整请求: Version=%s, Description=%s, FilePath=%s, ForceUpdate=%v\n", + req.Version, req.Description, req.FilePath, req.ForceUpdate) + + finalFilePath := req.FilePath + fileSize := int64(0) + + absoluteFilePath := filepath.Join(".", req.FilePath) + fmt.Printf("请求的文件路径: %s, 绝对路径: %s\n", req.FilePath, absoluteFilePath) + + if info, err := os.Stat(absoluteFilePath); err == nil { + fileSize = info.Size() + fmt.Printf("文件大小: %d\n", fileSize) + } else { + fmt.Printf("获取文件大小失败: %v\n", err) + } + + filePath := finalFilePath + if !strings.HasPrefix(filePath, "/") { + filePath = "/" + filepath.ToSlash(filePath) + } + + version := model.Version{ + ApplicationID: app.ID, + Version: req.Version, + Description: req.Description, + FilePath: filePath, + ForceUpdate: req.ForceUpdate, + Status: "active", + FileSize: fileSize, + } + + fmt.Printf("创建版本,文件大小: %d, 文件路径: %s\n", fileSize, filePath) + + if err := database.DB.Create(&version).Error; err != nil { + response.Error(c, 500, "创建版本失败") + return + } + + response.Success(c, version) +} + +func handleGetVersion(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + versionID := c.Param("versionId") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id = ?", versionID, appID).First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + response.Success(c, version) +} + +func handleUpdateVersion(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + versionID := c.Param("versionId") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var req struct { + Version string `json:"version"` + Description string `json:"description"` + FilePath string `json:"file_path"` + ForceUpdate bool `json:"force_update"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id = ?", versionID, appID).First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + version.Version = req.Version + version.Description = req.Description + version.FilePath = req.FilePath + version.ForceUpdate = req.ForceUpdate + version.Status = req.Status + + if err := database.DB.Save(&version).Error; err != nil { + response.Error(c, 500, "更新版本失败") + return + } + + response.Success(c, version) +} + +func handleDeleteVersion(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + versionID := c.Param("versionId") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if err := database.DB.Where("id = ? AND application_id = ?", versionID, appID).Delete(&model.Version{}).Error; err != nil { + response.Error(c, 500, "删除版本失败") + return + } + + response.Success(c, nil) +} + +func handlePublishVersion(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + versionID := c.Param("versionId") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id = ?", versionID, appID).First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + version.Status = "published" + if err := database.DB.Save(&version).Error; err != nil { + response.Error(c, 500, "发布版本失败") + return + } + + response.Success(c, version) +} + +func handleManualDeduct(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.BillingType != "balance" { + response.Error(c, 400, "只有余额模式的应用才支持手动扣费") + return + } + + if app.DeductionMode != "manual" { + response.Error(c, 400, "该应用未开启手动扣费模式,请先在设置中修改扣费方式为手动扣费") + return + } + + var req struct { + UserID uint `json:"user_id" binding:"required"` + Amount float64 `json:"amount" binding:"required,gt=0"` + Description string `json:"description"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", req.UserID, appID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + if user.Balance == -1 { + response.Error(c, 400, "该用户为永久会员,无法扣费") + return + } + + if user.Balance < req.Amount { + response.Error(c, 400, fmt.Sprintf("用户余额不足,当前余额: %.2f,需扣除: %.2f", user.Balance, req.Amount)) + return + } + + user.Balance -= req.Amount + if err := database.DB.Save(&user).Error; err != nil { + response.Error(c, 500, "扣费失败") + return + } + + record := model.ConsumptionRecord{ + UserID: user.ID, + ApplicationID: app.ID, + Amount: req.Amount, + Type: "manual_deduct", + Description: req.Description, + BalanceAfter: user.Balance, + } + + if err := database.DB.Create(&record).Error; err != nil { + fmt.Printf("创建消费记录失败: %v\n", err) + } + + response.Success(c, gin.H{ + "user_id": user.ID, + "username": user.Username, + "amount": req.Amount, + "balance_before": user.Balance + req.Amount, + "balance_after": user.Balance, + "description": req.Description, + "message": "扣费成功", + }) +} diff --git a/backend/internal/router/developer/cards.go b/backend/internal/router/developer/cards.go new file mode 100644 index 0000000..fad0a52 --- /dev/null +++ b/backend/internal/router/developer/cards.go @@ -0,0 +1,969 @@ +package developer + +import ( + "fmt" + "log" + "strconv" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + "verification-platform-backend/pkg/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func SetupCardRoutes(r *gin.RouterGroup) { + cardTypes := r.Group("/card-types") + { + cardTypes.GET("", handleGetCardTypes) + cardTypes.GET("/:id", handleGetCardType) + cardTypes.POST("", handleCreateCardType) + cardTypes.PUT("/:id", handleUpdateCardType) + cardTypes.DELETE("/:id", handleDeleteCardType) + } + + cards := r.Group("/cards") + { + cards.GET("", handleGetCards) + cards.POST("", handleCreateCards) + cards.GET("/:id", handleGetCard) + cards.PUT("/:id", handleUpdateCard) + cards.DELETE("/:id", handleDeleteCard) + cards.PUT("/:id/status", handleUpdateCardStatus) + cards.PUT("/batch/status", handleBatchUpdateCardStatus) + cards.DELETE("/batch", handleBatchDeleteCards) + cards.POST("/use", handleUseCard) + cards.GET("/export", handleExportCards) + } +} + +func SetupCardRoutesWithoutPackage(r *gin.RouterGroup) { + cards := r.Group("/cards") + { + cards.POST("/batch", handleBatchGenerateCards) + } +} + +func checkDeveloperPackageValid(developerID uint) bool { + var user model.User + if err := database.DB.First(&user, developerID).Error; err != nil { + return false + } + + if user.CurrentPackageID == nil { + return false + } + + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND package_id = ? AND status = ?", + developerID, user.CurrentPackageID, "active").First(&userPackage).Error; err != nil { + return false + } + + if userPackage.ExpiredAt != nil && userPackage.ExpiredAt.Before(time.Now()) { + return false + } + + return true +} + +func handleGetCardTypes(c *gin.Context) { + userID := c.GetUint("user_id") + applicationID := c.Query("application_id") + + log.Printf("[DEBUG] handleGetCardTypes called: userID=%d, applicationID=%s\n", userID, applicationID) + + var cardTypes []model.CardType + var query *gorm.DB + + if applicationID != "" { + appID, err := strconv.ParseUint(applicationID, 10, 32) + if err == nil { + var authorizedApps []model.AgentApplication + database.DB.Where("agent_id = ? AND application_id = ? AND status = ?", userID, uint(appID), "active"). + Preload("CardTypes"). + Find(&authorizedApps) + + log.Printf("[DEBUG] Found %d authorized apps for user %d and application %d\n", len(authorizedApps), userID, uint(appID)) + + var authorizedCardTypeIDs []uint + for _, aa := range authorizedApps { + for _, ct := range aa.CardTypes { + if ct.CanGenerate { + authorizedCardTypeIDs = append(authorizedCardTypeIDs, ct.CardTypeID) + log.Printf("[DEBUG] Authorized card type - ID: %d\n", ct.CardTypeID) + } + } + } + + if len(authorizedCardTypeIDs) > 0 { + query = database.DB.Where("(user_id = ? AND application_id = ?) OR (id IN (?))", userID, uint(appID), authorizedCardTypeIDs) + log.Printf("[DEBUG] Querying card types with authorizedCardTypeIDs: %v\n", authorizedCardTypeIDs) + } else { + query = database.DB.Where("user_id = ? AND application_id = ?", userID, uint(appID)) + } + } else { + query = database.DB.Where("user_id = ?", userID) + } + } else { + query = database.DB.Where("user_id = ?", userID) + } + + if err := query.Preload("Application").Find(&cardTypes).Error; err != nil { + log.Printf("[ERROR] Failed to query card types: %v\n", err) + response.Error(c, 500, "获取卡密类型失败") + return + } + + log.Printf("[DEBUG] Found %d card types\n", len(cardTypes)) + for i, ct := range cardTypes { + log.Printf("[DEBUG] CardType %d: ID=%d, Name=%s, ApplicationID=%v\n", i, ct.ID, ct.Name, ct.ApplicationID) + } + + var cardTypesWithCount []map[string]interface{} + for _, ct := range cardTypes { + var count int64 + database.DB.Model(&model.Card{}).Where("card_type_id = ?", ct.ID).Count(&count) + log.Printf("[DEBUG] CardType ID=%d, Name=%s, GeneratedCount=%d\n", ct.ID, ct.Name, count) + + cardTypeMap := map[string]interface{}{ + "id": ct.ID, + "user_id": ct.UserID, + "application_id": ct.ApplicationID, + "name": ct.Name, + "value": ct.Value, + "price": ct.Price, + "description": ct.Description, + "status": ct.Status, + "created_at": ct.CreatedAt, + "updated_at": ct.UpdatedAt, + "generatedCount": count, + } + if ct.Application != nil { + cardTypeMap["application"] = ct.Application + } + cardTypesWithCount = append(cardTypesWithCount, cardTypeMap) + } + + response.Success(c, gin.H{ + "card_types": cardTypesWithCount, + }) +} + +func handleGetCardType(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var cardType model.CardType + if err := database.DB.Preload("Application").First(&cardType, id).Error; err != nil { + response.Error(c, 404, "卡类不存在") + return + } + + if cardType.UserID != userID { + var app model.Application + if err := database.DB.First(&app, cardType.ApplicationID).Error; err != nil { + response.Error(c, 403, "无权限查看该卡类") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", cardType.ApplicationID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限查看该卡类") + return + } + } + } + + response.Success(c, gin.H{ + "card_type": cardType, + }) +} + +func handleCreateCardType(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + Name string `json:"name"` + Description string `json:"description"` + RechargeType string `json:"recharge_type"` + Value float64 `json:"value"` + ValueUnit string `json:"value_unit"` + Price float64 `json:"price"` + ApplicationID uint `json:"application_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fmt.Printf("创建卡密类型参数错误: %v\n", err) + response.Error(c, 400, "参数错误") + return + } + + fmt.Printf("创建卡密类型请求: Name=%s, RechargeType=%s, Value=%f, ValueUnit=%s, Price=%f, ApplicationID=%d\n", + req.Name, req.RechargeType, req.Value, req.ValueUnit, req.Price, req.ApplicationID) + + if req.Name == "" { + response.Error(c, 400, "卡密类型名称不能为空") + return + } + + if req.ApplicationID == 0 { + response.Error(c, 400, "所属应用不能为空") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 400, "所属应用不存在或无权限") + return + } + + if req.Price < 0 { + response.Error(c, 400, "价格不能为负数") + return + } + + if req.Value == 0 { + response.Error(c, 400, "面值不能为0") + return + } + + if req.Value < -1 { + response.Error(c, 400, "面值无效,必须大于0或为-1(表示永久有效)") + return + } + + rechargeType := req.RechargeType + if rechargeType == "" { + rechargeType = "balance" + } + + valueUnit := req.ValueUnit + if rechargeType == "subscription" && valueUnit == "" { + valueUnit = "day" + } + + cardType := model.CardType{ + UserID: userID, + Name: req.Name, + Description: req.Description, + RechargeType: rechargeType, + Value: req.Value, + ValueUnit: valueUnit, + Price: req.Price, + ApplicationID: req.ApplicationID, + } + + if err := database.DB.Create(&cardType).Error; err != nil { + response.Error(c, 500, "创建卡密类型失败") + return + } + + service.LogOperation(c, "create", "card_type", &cardType.ID, fmt.Sprintf("创建卡密类型: %s", cardType.Name), nil) + + response.Success(c, cardType) +} + +func handleUpdateCardType(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + Name string `json:"name"` + Description string `json:"description"` + RechargeType string `json:"recharge_type"` + Value float64 `json:"value"` + ValueUnit string `json:"value_unit"` + Price float64 `json:"price"` + ApplicationID uint `json:"application_id"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + id := c.Param("id") + var cardType model.CardType + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&cardType).Error; err != nil { + response.Error(c, 404, "卡密类型不存在") + return + } + + if req.Name != "" { + cardType.Name = req.Name + } + if req.Description != "" { + cardType.Description = req.Description + } + if req.RechargeType != "" { + cardType.RechargeType = req.RechargeType + } + if req.ValueUnit != "" { + cardType.ValueUnit = req.ValueUnit + } + if req.Price >= 0 { + cardType.Price = req.Price + } + if req.Value > 0 || req.Value == -1 { + cardType.Value = req.Value + } + if req.Price > 0 { + cardType.Price = req.Price + } + if req.ApplicationID > 0 { + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 400, "所属应用不存在或无权限") + return + } + cardType.ApplicationID = req.ApplicationID + } + if req.Status != "" { + cardType.Status = req.Status + } + + if err := database.DB.Save(&cardType).Error; err != nil { + response.Error(c, 500, "更新卡密类型失败") + return + } + + service.LogOperation(c, "update", "card_type", &cardType.ID, fmt.Sprintf("更新卡密类型: %s", cardType.Name), nil) + + response.Success(c, cardType) +} + +func handleDeleteCardType(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var cardType model.CardType + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&cardType).Error; err != nil { + response.Error(c, 404, "卡密类型不存在") + return + } + + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&model.CardType{}).Error; err != nil { + response.Error(c, 500, "删除卡密类型失败") + return + } + + service.LogOperation(c, "delete", "card_type", &cardType.ID, fmt.Sprintf("删除卡密类型: %s", cardType.Name), nil) + + response.Success(c, nil) +} + +func handleGetCards(c *gin.Context) { + userID := c.GetUint("user_id") + fmt.Printf("[DEBUG] handleGetCards called, userID: %d\n", userID) + + applicationID := c.Query("application_id") + cardTypeID := c.Query("card_type_id") + status := c.Query("status") + search := c.Query("search") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + var cards []model.Card + + var authorizedAppIDs []uint + database.DB.Model(&model.AgentApplication{}). + Where("agent_id = ? AND status = ?", userID, "active"). + Pluck("application_id", &authorizedAppIDs) + + fmt.Printf("[DEBUG] Authorized app IDs: %v\n", authorizedAppIDs) + + query := database.DB.Model(&model.Card{}). + Joins("JOIN card_types ON cards.card_type_id = card_types.id"). + Joins("JOIN applications ON card_types.application_id = applications.id"). + Preload("Application"). + Preload("CardType"). + Preload("Creator"). + Preload("AppUser") + + if len(authorizedAppIDs) > 0 { + query = query.Where("applications.user_id = ? OR (cards.application_id IN ? AND cards.creator_id = ?)", userID, authorizedAppIDs, userID) + } else { + query = query.Where("applications.user_id = ?", userID) + } + + if applicationID != "" { + appID, err := strconv.ParseUint(applicationID, 10, 32) + if err == nil { + query = query.Where("cards.application_id = ?", uint(appID)) + } + } + + if cardTypeID != "" { + ctID, err := strconv.ParseUint(cardTypeID, 10, 32) + if err == nil { + query = query.Where("cards.card_type_id = ?", uint(ctID)) + } + } + + if status != "" { + query = query.Where("cards.status = ?", status) + } + + if search != "" { + searchPattern := "%" + search + "%" + query = query.Where("cards.card_key LIKE ? OR card_types.name LIKE ? OR applications.name LIKE ?", searchPattern, searchPattern, searchPattern) + } + + if startDate != "" { + query = query.Where("cards.created_at >= ?", startDate+" 00:00:00") + } + + if endDate != "" { + query = query.Where("cards.created_at <= ?", endDate+" 23:59:59") + } + + if err := query.Order("cards.created_at DESC").Find(&cards).Error; err != nil { + fmt.Printf("[DEBUG] Error fetching cards: %v\n", err) + response.Error(c, 500, "获取卡密列表失败") + return + } + + fmt.Printf("[DEBUG] Found %d cards for user %d\n", len(cards), userID) + + response.Success(c, cards) +} + +func handleCreateCards(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + CardTypeID uint `json:"card_type_id"` + Count int `json:"count"` + Prefix string `json:"prefix"` + Length int `json:"length"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var cardType model.CardType + if err := database.DB.Where("id = ? AND user_id = ?", req.CardTypeID, userID).First(&cardType).Error; err != nil { + response.Error(c, 404, "卡密类型不存在") + return + } + + response.Success(c, gin.H{ + "message": "卡密创建成功", + "count": req.Count, + }) +} + +func handleUpdateCard(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + id := c.Param("id") + var card model.Card + if err := database.DB.Joins("JOIN card_types ON cards.card_type_id = card_types.id"). + Joins("JOIN applications ON card_types.application_id = applications.id"). + Where("cards.id = ? AND applications.user_id = ?", id, userID).First(&card).Error; err != nil { + response.Error(c, 404, "卡密不存在") + return + } + + card.Status = req.Status + + if err := database.DB.Save(&card).Error; err != nil { + response.Error(c, 500, "更新卡密失败") + return + } + + response.Success(c, card) +} + +func handleDeleteCard(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + fmt.Printf("删除卡密请求: ID=%s, UserID=%d\n", id, userID) + + var card model.Card + if err := database.DB.Preload("CardType").Preload("CardType.Application").First(&card, id).Error; err != nil { + fmt.Printf("卡密不存在: %v\n", err) + response.Error(c, 404, "卡密不存在") + return + } + + fmt.Printf("查询到的卡密: ID=%d, CardTypeID=%d, CreatorID=%d\n", card.ID, card.CardTypeID, card.CreatorID) + + // 如果创建者是当前用户,直接允许删除 + if card.CreatorID == userID { + fmt.Printf("创建者匹配,允许删除\n") + if err := database.DB.Delete(&card).Error; err != nil { + fmt.Printf("删除卡密失败: %v\n", err) + response.Error(c, 500, "删除卡密失败") + return + } + fmt.Printf("删除卡密成功: ID=%s\n", id) + response.Success(c, nil) + return + } + + // 如果创建者不匹配,检查应用的所有者 + if card.CardType.ID > 0 && card.CardType.Application != nil { + fmt.Printf("卡密类型和应用存在,检查应用所有者\n") + if card.CardType.Application.UserID == userID { + fmt.Printf("应用所有者匹配,允许删除\n") + if err := database.DB.Delete(&card).Error; err != nil { + fmt.Printf("删除卡密失败: %v\n", err) + response.Error(c, 500, "删除卡密失败") + return + } + fmt.Printf("删除卡密成功: ID=%s\n", id) + response.Success(c, nil) + return + } + } + + // 如果卡密类型或应用不存在,尝试通过ApplicationID查询应用(包括软删除的) + if card.CardType.ID == 0 || card.CardType.Application == nil { + fmt.Printf("卡密类型或应用不存在,尝试查询应用\n") + if card.CardType.ID == 0 { + // 卡密类型不存在,直接通过CardTypeID查询卡密类型(包括软删除的) + var cardType model.CardType + if err := database.DB.Unscoped().First(&cardType, card.CardTypeID).Error; err != nil { + fmt.Printf("卡密类型不存在: %v\n", err) + response.Error(c, 404, "卡密类型不存在") + return + } + card.CardType = cardType + } + + if card.CardType.Application == nil { + // 应用不存在,直接通过ApplicationID查询应用(包括软删除的) + var app model.Application + if err := database.DB.Unscoped().First(&app, card.CardType.ApplicationID).Error; err != nil { + fmt.Printf("应用不存在: %v\n", err) + response.Error(c, 404, "应用不存在") + return + } + card.CardType.Application = &app + } + + fmt.Printf("查询到的应用: ID=%d, UserID=%d\n", card.CardType.Application.ID, card.CardType.Application.UserID) + if card.CardType.Application.UserID == userID { + fmt.Printf("应用所有者匹配,允许删除\n") + if err := database.DB.Delete(&card).Error; err != nil { + fmt.Printf("删除卡密失败: %v\n", err) + response.Error(c, 500, "删除卡密失败") + return + } + fmt.Printf("删除卡密成功: ID=%s\n", id) + response.Success(c, nil) + return + } + } + + fmt.Printf("无权限删除此卡密\n") + response.Error(c, 403, "无权限删除此卡密") +} + +func handleGetCard(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + var card model.Card + if err := database.DB.Joins("JOIN card_types ON cards.card_type_id = card_types.id"). + Joins("JOIN applications ON card_types.application_id = applications.id"). + Where("cards.id = ? AND applications.user_id = ?", id, userID).First(&card).Error; err != nil { + response.Error(c, 404, "卡密不存在") + return + } + response.Success(c, card) +} + +func handleUseCard(c *gin.Context) { + var req struct { + CardKey string `json:"card_key"` + Username string `json:"username"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var card model.Card + if err := database.DB.Where("card_key = ?", req.CardKey).First(&card).Error; err != nil { + response.Error(c, 404, "卡密不存在") + return + } + + if card.Status != "unused" { + response.Error(c, 400, "卡密已被使用或已禁用") + return + } + + card.Status = "used" + database.DB.Save(&card) + + response.Success(c, gin.H{ + "message": "卡密使用成功", + }) +} + +func handleBatchGenerateCards(c *gin.Context) { + userID := c.GetUint("user_id") + fmt.Printf("[DEBUG] handleBatchGenerateCards called, userID: %d\n", userID) + + var req struct { + ApplicationID uint `json:"application_id"` + CardTypeID uint `json:"card_type_id"` + Count int `json:"count"` + Prefix string `json:"prefix"` + Length int `json:"length"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fmt.Printf("[DEBUG] JSON bind error: %v\n", err) + response.Error(c, 400, "参数错误") + return + } + + fmt.Printf("[DEBUG] Received parameters - ApplicationID: %v, CardTypeID: %d, Count: %d, Prefix: %s, Length: %d\n", + req.ApplicationID, req.CardTypeID, req.Count, req.Prefix, req.Length) + + if req.Count <= 0 || req.Count > 1000 { + response.Error(c, 400, "生成数量需在1-1000之间") + return + } + + if req.Length <= 0 { + req.Length = 16 + } + if req.Prefix == "" { + req.Prefix = "CK" + } + + var cardType model.CardType + if err := database.DB.Where("id = ?", req.CardTypeID).First(&cardType).Error; err != nil { + fmt.Printf("[DEBUG] CardType not found error: %v\n", err) + response.Error(c, 404, "卡密类型不存在") + return + } + + fmt.Printf("[DEBUG] CardType found - ID: %d, Name: %s, UserID: %d\n", cardType.ID, cardType.Name, cardType.UserID) + + if req.ApplicationID == 0 { + req.ApplicationID = cardType.ApplicationID + } + + var agentApp *model.AgentApplication + var effectiveUserID uint = userID + + if cardType.UserID != userID { + fmt.Printf("[DEBUG] CardType belongs to another user, checking authorization...\n") + + if req.ApplicationID == 0 { + fmt.Printf("[DEBUG] ApplicationID is required for authorized card types\n") + response.Error(c, 400, "生成授权应用的卡密需要指定应用ID") + return + } + + if err := database.DB.Where("agent_id = ? AND application_id = ? AND status = ?", userID, req.ApplicationID, "active"). + Preload("CardTypes", "card_type_id = ?", req.CardTypeID). + First(&agentApp).Error; err != nil { + fmt.Printf("[DEBUG] Authorization not found error: %v\n", err) + response.Error(c, 403, "您没有权限生成此卡密类型") + return + } + + fmt.Printf("[DEBUG] AgentApp found - ID: %d, DeveloperID: %d\n", agentApp.ID, agentApp.DeveloperID) + + var cardTypePerm *model.AgentApplicationCardType + for _, ct := range agentApp.CardTypes { + if ct.CardTypeID == req.CardTypeID { + cardTypePerm = &ct + break + } + } + + if cardTypePerm == nil || !cardTypePerm.CanGenerate { + fmt.Printf("[DEBUG] No permission to generate this card type\n") + response.Error(c, 403, "您没有权限生成此卡密类型") + return + } + + var agentUser model.User + if err := database.DB.First(&agentUser, userID).Error; err != nil { + response.Error(c, 500, "获取代理信息失败") + return + } + + totalCost := float64(req.Count) * cardType.Price + fmt.Printf("[DEBUG] Total cost: %f, Balance: %f\n", totalCost, agentUser.Balance) + + if agentUser.Balance < totalCost { + fmt.Printf("[DEBUG] Insufficient balance\n") + response.Error(c, 400, "余额不足") + return + } + + if err := database.DB.Model(&agentUser).Update("balance", agentUser.Balance-totalCost).Error; err != nil { + fmt.Printf("[DEBUG] Update balance error: %v\n", err) + response.Error(c, 500, "扣款失败") + return + } + + fmt.Printf("[DEBUG] Balance updated successfully\n") + } + + cards := make([]model.Card, 0, req.Count) + for i := 0; i < req.Count; i++ { + cardKey := req.Prefix + utils.GenerateRandomString(req.Length) + card := model.Card{ + ApplicationID: req.ApplicationID, + CardTypeID: req.CardTypeID, + CardKey: cardKey, + CreatorID: effectiveUserID, + Status: "unused", + } + cards = append(cards, card) + } + + fmt.Printf("[DEBUG] Generated %d cards, saving to database...\n", len(cards)) + + if err := database.DB.Create(&cards).Error; err != nil { + fmt.Printf("[DEBUG] Database create error: %v\n", err) + response.Error(c, 500, "生成卡密失败") + return + } + + fmt.Printf("[DEBUG] Successfully created %d cards\n", len(cards)) + + response.Success(c, gin.H{ + "message": "批量生成成功", + "count": req.Count, + }) +} + +func handleExportCards(c *gin.Context) { + userID := c.GetUint("user_id") + + applicationID := c.Query("application_id") + cardTypeID := c.Query("card_type_id") + status := c.Query("status") + search := c.Query("search") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + var cards []model.Card + query := database.DB.Model(&model.Card{}). + Joins("JOIN card_types ON cards.card_type_id = card_types.id"). + Joins("JOIN applications ON card_types.application_id = applications.id"). + Preload("Application"). + Preload("CardType"). + Where("applications.user_id = ?", userID) + + if applicationID != "" { + appID, err := strconv.ParseUint(applicationID, 10, 32) + if err == nil { + query = query.Where("cards.application_id = ?", uint(appID)) + } + } + + if cardTypeID != "" { + ctID, err := strconv.ParseUint(cardTypeID, 10, 32) + if err == nil { + query = query.Where("cards.card_type_id = ?", uint(ctID)) + } + } + + if status != "" { + query = query.Where("cards.status = ?", status) + } + + if search != "" { + searchPattern := "%" + search + "%" + query = query.Where("cards.card_key LIKE ? OR card_types.name LIKE ? OR applications.name LIKE ?", searchPattern, searchPattern, searchPattern) + } + + if startDate != "" { + query = query.Where("cards.created_at >= ?", startDate+" 00:00:00") + } + + if endDate != "" { + query = query.Where("cards.created_at <= ?", endDate+" 23:59:59") + } + + if err := query.Order("cards.created_at DESC").Find(&cards).Error; err != nil { + response.Error(c, 500, "导出卡密失败") + return + } + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=cards_export.csv") + + csv := "卡密,应用,卡类,状态,创建时间,使用时间\n" + for _, card := range cards { + statusMap := map[string]string{ + "unused": "未使用", + "used": "已使用", + "banned": "已禁用", + } + statusText := statusMap[card.Status] + if statusText == "" { + statusText = card.Status + } + + appName := "" + if card.Application != nil { + appName = card.Application.Name + } + + cardTypeName := card.CardType.Name + + usedAt := "" + if card.UsedAt != nil { + usedAt = card.UsedAt.Format("2006-01-02 15:04:05") + } + + csv += fmt.Sprintf("%s,%s,%s,%s,%s,%s\n", + card.CardKey, + appName, + cardTypeName, + statusText, + card.CreatedAt.Format("2006-01-02 15:04:05"), + usedAt, + ) + } + + c.String(200, csv) +} + +func handleUpdateCardStatus(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var req struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.Status != "unused" && req.Status != "used" && req.Status != "banned" { + response.Error(c, 400, "无效的状态") + return + } + + var card model.Card + if err := database.DB.Preload("CardType").Preload("CardType.Application").First(&card, id).Error; err != nil { + response.Error(c, 404, "卡密不存在") + return + } + + if card.CreatorID != userID && (card.CardType.Application == nil || card.CardType.Application.UserID != userID) { + response.Error(c, 403, "无权限修改此卡密") + return + } + + card.Status = req.Status + if err := database.DB.Save(&card).Error; err != nil { + response.Error(c, 500, "更新状态失败") + return + } + + response.Success(c, card) +} + +func handleBatchUpdateCardStatus(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.Status != "unused" && req.Status != "used" && req.Status != "banned" { + response.Error(c, 400, "无效的状态") + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要更新的卡密") + return + } + + var cards []model.Card + if err := database.DB.Preload("CardType").Preload("CardType.Application").Where("id IN ?", req.IDs).Find(&cards).Error; err != nil { + response.Error(c, 500, "查询卡密失败") + return + } + + var validIDs []uint + for _, card := range cards { + if card.CreatorID == userID || (card.CardType.Application != nil && card.CardType.Application.UserID == userID) { + validIDs = append(validIDs, card.ID) + } + } + + if len(validIDs) == 0 { + response.Error(c, 403, "无权限修改选中的卡密") + return + } + + if err := database.DB.Model(&model.Card{}).Where("id IN ?", validIDs).Update("status", req.Status).Error; err != nil { + response.Error(c, 500, "批量更新状态失败") + return + } + + response.Success(c, gin.H{"updated_count": len(validIDs)}) +} + +func handleBatchDeleteCards(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要删除的卡密") + return + } + + var cards []model.Card + if err := database.DB.Preload("CardType").Preload("CardType.Application").Where("id IN ?", req.IDs).Find(&cards).Error; err != nil { + response.Error(c, 500, "查询卡密失败") + return + } + + var validIDs []uint + for _, card := range cards { + if card.CreatorID == userID || (card.CardType.Application != nil && card.CardType.Application.UserID == userID) { + validIDs = append(validIDs, card.ID) + } + } + + if len(validIDs) == 0 { + response.Error(c, 403, "无权限删除选中的卡密") + return + } + + if err := database.DB.Where("id IN ?", validIDs).Delete(&model.Card{}).Error; err != nil { + response.Error(c, 500, "批量删除失败") + return + } + + response.Success(c, gin.H{"deleted_count": len(validIDs)}) +} diff --git a/backend/internal/router/developer/cloud.go b/backend/internal/router/developer/cloud.go new file mode 100644 index 0000000..775429f --- /dev/null +++ b/backend/internal/router/developer/cloud.go @@ -0,0 +1,827 @@ +package developer + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/middleware" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupCloudRoutes(r *gin.RouterGroup) { + cloudConstants := r.Group("/cloud-constants") + { + cloudConstants.GET("", handleGetCloudConstants) + cloudConstants.GET("/:id", handleGetCloudConstant) + cloudConstants.GET("/:id/download", handleDownloadCloudConstant) + cloudConstants.POST("", handleCreateCloudConstant) + cloudConstants.POST("/upload", handleUploadCloudConstant) + cloudConstants.PUT("/:id", handleUpdateCloudConstant) + cloudConstants.DELETE("/:id", handleDeleteCloudConstant) + } + + cloudVariables := r.Group("/cloud-variables") + { + cloudVariables.GET("", handleGetCloudVariables) + cloudVariables.GET("/:id", handleGetCloudVariable) + cloudVariables.GET("/:id/download", handleDownloadCloudVariable) + cloudVariables.POST("", handleCreateCloudVariable) + cloudVariables.POST("/upload", handleUploadCloudVariable) + cloudVariables.PUT("/:id", handleUpdateCloudVariable) + cloudVariables.DELETE("/:id", handleDeleteCloudVariable) + cloudVariables.GET("/:id/records", handleGetCloudVariableRecords) + cloudVariables.DELETE("/:id/records", handleDeleteCloudVariableRecords) + } +} + +func handleGetCloudConstants(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Query("app_id") + + var constants []model.CloudConstant + query := database.DB.Where("user_id = ?", userID) + if appID != "" { + query = query.Where("app_id = ?", appID) + } + if err := query.Find(&constants).Error; err != nil { + response.Error(c, 500, "获取云端常量失败") + return + } + response.Success(c, gin.H{ + "constants": constants, + "total": len(constants), + }) +} + +func handleGetCloudConstant(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var constant model.CloudConstant + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&constant).Error; err != nil { + response.Error(c, 404, "云端常量不存在") + return + } + + response.Success(c, constant) +} + +func handleCreateCloudConstant(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + AppID uint `json:"app_id"` + Key string `json:"key"` + Value string `json:"value"` + VarType string `json:"var_type"` + Description string `json:"description"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.Key == "" { + response.Error(c, 400, "变量名不能为空") + return + } + + if req.Status != "active" && req.Status != "inactive" { + req.Status = "active" + } + + if req.VarType == "" { + req.VarType = "string" + } + + constant := model.CloudConstant{ + UserID: userID, + AppID: &req.AppID, + Key: req.Key, + Value: req.Value, + VarType: req.VarType, + Description: req.Description, + Status: req.Status, + } + + if err := database.DB.Create(&constant).Error; err != nil { + response.Error(c, 500, "创建云端常量失败") + return + } + + service.LogOperation(c, "create", "cloud_constant", &constant.ID, fmt.Sprintf("创建云端常量: %s", constant.Key), nil) + + response.Success(c, constant) +} + +func handleUpdateCloudConstant(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + Key string `json:"key"` + Value string `json:"value"` + VarType string `json:"var_type"` + Description string `json:"description"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + id := c.Param("id") + var constant model.CloudConstant + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&constant).Error; err != nil { + response.Error(c, 404, "云端常量不存在") + return + } + + constant.Key = req.Key + constant.Value = req.Value + constant.VarType = req.VarType + constant.Description = req.Description + if req.Status == "active" || req.Status == "inactive" { + constant.Status = req.Status + } + + if err := database.DB.Save(&constant).Error; err != nil { + response.Error(c, 500, "更新云端常量失败") + return + } + + service.LogOperation(c, "update", "cloud_constant", &constant.ID, fmt.Sprintf("更新云端常量: %s", constant.Key), nil) + + response.Success(c, constant) +} + +func handleDeleteCloudConstant(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var constant model.CloudConstant + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&constant).Error; err != nil { + response.Error(c, 404, "云端常量不存在") + return + } + + if constant.VarType == "binary" && constant.FilePath != "" { + if err := middleware.UpdateStorageUsed(userID, constant.FileSize, "delete"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + filePath := strings.TrimPrefix(constant.FilePath, "/") + if _, err := os.Stat(filePath); err == nil { + os.Remove(filePath) + } + } + + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&model.CloudConstant{}).Error; err != nil { + response.Error(c, 500, "删除云端常量失败") + return + } + + service.LogOperation(c, "delete", "cloud_constant", &constant.ID, fmt.Sprintf("删除云端常量: %s", constant.Key), nil) + + response.Success(c, nil) +} + +func handleUploadCloudConstant(c *gin.Context) { + userID := c.GetUint("user_id") + + file, header, err := c.Request.FormFile("file") + if err != nil { + response.Error(c, 400, "请选择要上传的文件") + return + } + defer file.Close() + + appIDStr := c.PostForm("app_id") + key := c.PostForm("key") + description := c.PostForm("description") + status := c.PostForm("status") + + if appIDStr == "" { + response.Error(c, 400, "请选择应用") + return + } + + if key == "" { + response.Error(c, 400, "请输入变量名") + return + } + + var appID uint + fmt.Sscanf(appIDStr, "%d", &appID) + + if status != "active" && status != "inactive" { + status = "active" + } + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + return + } + + if user.CurrentPackageID != nil { + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err == nil { + maxStorageBytes := int64(permission.MaxStorage) * 1024 * 1024 + if user.StorageUsed+header.Size > maxStorageBytes { + usedMB := float64(user.StorageUsed) / 1024 / 1024 + maxMB := float64(permission.MaxStorage) + response.Error(c, 403, fmt.Sprintf("存储空间不足,已使用 %.2f MB / %.2f MB", usedMB, maxMB)) + return + } + } + } + + uploadDir := "uploads/cloud-files" + if err := os.MkdirAll(uploadDir, 0755); err != nil { + response.Error(c, 500, "创建上传目录失败") + return + } + + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d_%d%s", userID, time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + + dst, err := os.Create(filePath) + if err != nil { + response.Error(c, 500, "创建文件失败") + return + } + defer dst.Close() + + hash := md5.New() + multiWriter := io.MultiWriter(dst, hash) + if _, err := io.Copy(multiWriter, file); err != nil { + response.Error(c, 500, "保存文件失败") + return + } + fileMD5 := hex.EncodeToString(hash.Sum(nil)) + + fileURL := "/uploads/cloud-files/" + filename + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "application/octet-stream" + } + + constant := model.CloudConstant{ + UserID: userID, + AppID: &appID, + Key: key, + Value: fileURL, + VarType: "binary", + FilePath: fileURL, + FileSize: header.Size, + MimeType: mimeType, + OriginalName: header.Filename, + FileMD5: fileMD5, + Description: description, + Status: status, + } + + if err := database.DB.Create(&constant).Error; err != nil { + os.Remove(filePath) + response.Error(c, 500, "创建云端常量失败") + return + } + + if err := middleware.UpdateStorageUsed(userID, header.Size, "upload"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + + usage := model.StorageUsage{ + UserID: userID, + ApplicationID: &appID, + ResourceType: "cloud_constant", + ResourceID: constant.ID, + FileName: header.Filename, + FileSize: header.Size, + Action: "upload", + CreatedAt: time.Now(), + } + database.DB.Create(&usage) + + service.LogOperation(c, "create", "cloud_constant", &constant.ID, fmt.Sprintf("上传文件常量: %s", constant.Key), nil) + + response.Success(c, constant) +} + +func handleDownloadCloudConstant(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var constant model.CloudConstant + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&constant).Error; err != nil { + response.Error(c, 404, "云端常量不存在") + return + } + + if constant.VarType != "binary" || constant.FilePath == "" { + response.Error(c, 400, "该常量不是文件类型") + return + } + + filePath := constant.FilePath + if strings.HasPrefix(filePath, "/") { + filePath = filePath[1:] + } + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + response.Error(c, 404, "文件不存在") + return + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", constant.OriginalName)) + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Expires", "0") + c.Header("Cache-Control", "must-revalidate") + c.Header("Pragma", "public") + c.FileAttachment(filePath, constant.OriginalName) +} + +func handleGetCloudVariables(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Query("app_id") + + var variables []model.CloudVariable + query := database.DB.Where("user_id = ?", userID) + if appID != "" { + query = query.Where("app_id = ?", appID) + } + if err := query.Find(&variables).Error; err != nil { + response.Error(c, 500, "获取云端变量失败") + return + } + response.Success(c, gin.H{ + "variables": variables, + "total": len(variables), + }) +} + +func handleGetCloudVariable(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var variable model.CloudVariable + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + response.Success(c, variable) +} + +func handleCreateCloudVariable(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + AppID uint `json:"app_id"` + Key string `json:"key"` + DefaultValue string `json:"default_value"` + VarType string `json:"var_type"` + DataType string `json:"data_type"` + MaxRecords int `json:"max_records"` + Scope string `json:"scope"` + WritePermission string `json:"write_permission"` + Description string `json:"description"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.Key == "" { + response.Error(c, 400, "变量名不能为空") + return + } + + if req.Scope != "app" && req.Scope != "user" { + req.Scope = "app" + } + + if req.WritePermission != "developer" && req.WritePermission != "user" && req.WritePermission != "app_user" { + req.WritePermission = "developer" + } + + if req.Status != "active" && req.Status != "inactive" { + req.Status = "active" + } + + if req.VarType == "" { + req.VarType = "string" + } + + if req.DataType != "single" && req.DataType != "stream" { + req.DataType = "single" + } + + variable := model.CloudVariable{ + UserID: userID, + AppID: &req.AppID, + Key: req.Key, + DefaultValue: req.DefaultValue, + VarType: req.VarType, + DataType: req.DataType, + MaxRecords: req.MaxRecords, + Scope: req.Scope, + WritePermission: req.WritePermission, + Description: req.Description, + Status: req.Status, + } + + if err := database.DB.Create(&variable).Error; err != nil { + response.Error(c, 500, "创建云端变量失败") + return + } + + response.Success(c, variable) +} + +func handleUpdateCloudVariable(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + Key string `json:"key"` + DefaultValue string `json:"default_value"` + VarType string `json:"var_type"` + DataType string `json:"data_type"` + MaxRecords int `json:"max_records"` + WritePermission string `json:"write_permission"` + Description string `json:"description"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + id := c.Param("id") + var variable model.CloudVariable + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + variable.Key = req.Key + variable.DefaultValue = req.DefaultValue + variable.VarType = req.VarType + if req.DataType == "single" || req.DataType == "stream" { + variable.DataType = req.DataType + } + variable.MaxRecords = req.MaxRecords + if req.WritePermission == "developer" || req.WritePermission == "user" || req.WritePermission == "app_user" { + variable.WritePermission = req.WritePermission + } + variable.Description = req.Description + if req.Status == "active" || req.Status == "inactive" { + variable.Status = req.Status + } + + if err := database.DB.Save(&variable).Error; err != nil { + response.Error(c, 500, "更新云端变量失败") + return + } + + response.Success(c, variable) +} + +func handleDeleteCloudVariable(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var variable model.CloudVariable + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.VarType == "binary" && variable.FilePath != "" { + if err := middleware.UpdateStorageUsed(userID, variable.FileSize, "delete"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + filePath := strings.TrimPrefix(variable.FilePath, "/") + if _, err := os.Stat(filePath); err == nil { + os.Remove(filePath) + } + } + + if err := database.DB.Delete(&variable).Error; err != nil { + response.Error(c, 500, "删除云端变量失败") + return + } + response.Success(c, nil) +} + +func handleUploadCloudVariable(c *gin.Context) { + userID := c.GetUint("user_id") + + file, header, err := c.Request.FormFile("file") + if err != nil { + response.Error(c, 400, "请选择要上传的文件") + return + } + defer file.Close() + + appIDStr := c.PostForm("app_id") + key := c.PostForm("key") + description := c.PostForm("description") + status := c.PostForm("status") + scope := c.PostForm("scope") + writePermission := c.PostForm("write_permission") + + if appIDStr == "" { + response.Error(c, 400, "请选择应用") + return + } + + if key == "" { + response.Error(c, 400, "请输入变量名") + return + } + + var appID uint + fmt.Sscanf(appIDStr, "%d", &appID) + + if status != "active" && status != "inactive" { + status = "active" + } + + if scope != "app" && scope != "user" { + scope = "app" + } + + if writePermission != "developer" && writePermission != "user" { + writePermission = "developer" + } + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + return + } + + if user.CurrentPackageID != nil { + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err == nil { + maxStorageBytes := int64(permission.MaxStorage) * 1024 * 1024 + if user.StorageUsed+header.Size > maxStorageBytes { + usedMB := float64(user.StorageUsed) / 1024 / 1024 + maxMB := float64(permission.MaxStorage) + response.Error(c, 403, fmt.Sprintf("存储空间不足,已使用 %.2f MB / %.2f MB", usedMB, maxMB)) + return + } + } + } + + uploadDir := "uploads/cloud-files" + if err := os.MkdirAll(uploadDir, 0755); err != nil { + response.Error(c, 500, "创建上传目录失败") + return + } + + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d_%d%s", userID, time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + + dst, err := os.Create(filePath) + if err != nil { + response.Error(c, 500, "创建文件失败") + return + } + defer dst.Close() + + hash := md5.New() + multiWriter := io.MultiWriter(dst, hash) + if _, err := io.Copy(multiWriter, file); err != nil { + response.Error(c, 500, "保存文件失败") + return + } + fileMD5 := hex.EncodeToString(hash.Sum(nil)) + + fileURL := "/uploads/cloud-files/" + filename + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "application/octet-stream" + } + + variable := model.CloudVariable{ + UserID: userID, + AppID: &appID, + Key: key, + DefaultValue: fileURL, + VarType: "binary", + FilePath: fileURL, + FileSize: header.Size, + MimeType: mimeType, + OriginalName: header.Filename, + FileMD5: fileMD5, + Scope: scope, + WritePermission: writePermission, + Description: description, + Status: status, + } + + if err := database.DB.Create(&variable).Error; err != nil { + os.Remove(filePath) + response.Error(c, 500, "创建云端变量失败") + return + } + + if err := middleware.UpdateStorageUsed(userID, header.Size, "upload"); err != nil { + fmt.Printf("更新存储使用量失败: %v\n", err) + } + + usage := model.StorageUsage{ + UserID: userID, + ApplicationID: &appID, + ResourceType: "cloud_variable", + ResourceID: variable.ID, + FileName: header.Filename, + FileSize: header.Size, + Action: "upload", + CreatedAt: time.Now(), + } + database.DB.Create(&usage) + + service.LogOperation(c, "create", "cloud_variable", &variable.ID, fmt.Sprintf("上传文件变量: %s", variable.Key), nil) + + response.Success(c, variable) +} + +func handleDownloadCloudVariable(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var variable model.CloudVariable + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.VarType != "binary" || variable.FilePath == "" { + response.Error(c, 400, "该变量不是文件类型") + return + } + + filePath := variable.FilePath + if strings.HasPrefix(filePath, "/") { + filePath = filePath[1:] + } + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + response.Error(c, 404, "文件不存在") + return + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", variable.OriginalName)) + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Expires", "0") + c.Header("Cache-Control", "must-revalidate") + c.Header("Pragma", "public") + c.FileAttachment(filePath, variable.OriginalName) +} + +func handleGetCloudVariableRecords(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var variable model.CloudVariable + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.DataType != "stream" { + response.Error(c, 400, "该变量不是流水类型") + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + userIDFilter := c.Query("user_id") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + var total int64 + query := database.DB.Model(&model.CloudVariableRecord{}).Where("cloud_variable_id = ?", variable.ID) + if userIDFilter != "" { + query = query.Where("app_user_id = ?", userIDFilter) + } + if startDate != "" { + query = query.Where("created_at >= ?", startDate+" 00:00:00") + } + if endDate != "" { + query = query.Where("created_at <= ?", endDate+" 23:59:59") + } + query.Count(&total) + + var records []model.CloudVariableRecord + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&records).Error; err != nil { + response.Error(c, 500, "获取记录失败") + return + } + + result := make([]gin.H, len(records)) + for i, r := range records { + var data map[string]interface{} + json.Unmarshal([]byte(r.Data), &data) + record := gin.H{ + "id": r.ID, + "data": data, + "created_at": r.CreatedAt, + } + if r.AppUserID != nil { + record["user_id"] = r.AppUserID + var appUser model.AppUser + if err := database.DB.Select("id, username").First(&appUser, *r.AppUserID).Error; err == nil { + record["user"] = gin.H{ + "id": appUser.ID, + "username": appUser.Username, + } + } + } + result[i] = record + } + + response.Success(c, gin.H{ + "records": result, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +func handleDeleteCloudVariableRecords(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var variable model.CloudVariable + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&variable).Error; err != nil { + response.Error(c, 404, "云端变量不存在") + return + } + + if variable.DataType != "stream" { + response.Error(c, 400, "该变量不是流水类型") + return + } + + var req struct { + IDs []uint `json:"ids"` + Before string `json:"before"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if len(req.IDs) > 0 { + if err := database.DB.Where("cloud_variable_id = ? AND id IN ?", variable.ID, req.IDs).Delete(&model.CloudVariableRecord{}).Error; err != nil { + response.Error(c, 500, "删除记录失败") + return + } + } else if req.Before != "" { + if err := database.DB.Where("cloud_variable_id = ? AND created_at < ?", variable.ID, req.Before).Delete(&model.CloudVariableRecord{}).Error; err != nil { + response.Error(c, 500, "删除记录失败") + return + } + } else { + response.Error(c, 400, "请指定要删除的记录") + return + } + + response.Success(c, nil) +} diff --git a/backend/internal/router/developer/dashboard.go b/backend/internal/router/developer/dashboard.go new file mode 100644 index 0000000..9673636 --- /dev/null +++ b/backend/internal/router/developer/dashboard.go @@ -0,0 +1,169 @@ +package developer + +import ( + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupDashboardRoutes(r *gin.RouterGroup) { + r.GET("/dashboard", handleGetDashboard) +} + +func handleGetDashboard(c *gin.Context) { + userID := c.GetUint("user_id") + + var stats struct { + TotalApplications int64 `json:"totalApplications"` + TotalUsers int64 `json:"totalUsers"` + TotalCards int64 `json:"totalCards"` + MonthlyRevenue float64 `json:"monthlyRevenue"` + } + + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Count(&stats.TotalApplications) + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + if len(appIDs) > 0 { + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Count(&stats.TotalUsers) + } + + database.DB.Model(&model.Card{}).Where("creator_id = ?", userID).Count(&stats.TotalCards) + + var monthlyRevenue float64 + if len(appIDs) > 0 { + var appUserIDs []uint + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Pluck("id", &appUserIDs) + if len(appUserIDs) > 0 { + database.DB.Model(&model.RechargeRecord{}). + Where("user_id IN ? AND status = ? AND created_at >= ?", appUserIDs, "success", time.Now().AddDate(0, -1, 0)). + Select("COALESCE(SUM(amount), 0)"). + Scan(&monthlyRevenue) + } + } + stats.MonthlyRevenue = monthlyRevenue + + var subscription struct { + Plan string `json:"plan"` + Status string `json:"status"` + ExpireDate string `json:"expireDate"` + APIQuota int `json:"apiQuota"` + APIUsed int `json:"apiUsed"` + AppCount int `json:"appCount"` + CanCreateAgent bool `json:"canCreateAgent"` + StorageQuota int64 `json:"storageQuota"` + StorageUsed int64 `json:"storageUsed"` + } + + var user model.User + if err := database.DB.First(&user, userID).Error; err == nil { + subscription.Plan = "基础版" + if user.Role == "admin" { + subscription.Plan = "管理员" + } + subscription.Status = "正常" + if user.Status == "banned" { + subscription.Status = "已禁用" + } + subscription.ExpireDate = "永久" + } + subscription.AppCount = int(stats.TotalApplications) + subscription.APIQuota = 10000 + subscription.StorageQuota = 100 * 1024 * 1024 + + var apiUsed int64 + database.DB.Model(&model.ApiUsage{}).Where("user_id = ?", userID).Count(&apiUsed) + subscription.APIUsed = int(apiUsed) + + var storageUsed int64 + database.DB.Model(&model.StorageUsage{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size), 0)").Scan(&storageUsed) + subscription.StorageUsed = storageUsed + + userDistribution := gin.H{ + "provinces": []gin.H{}, + "overseas": []gin.H{}, + } + + if len(appIDs) > 0 { + type DeviceCount struct { + DeviceType string + Count int + } + var deviceCounts []DeviceCount + database.DB.Model(&model.UserDevice{}). + Select("device_type, COUNT(*) as count"). + Where("application_id IN ?", appIDs). + Group("device_type"). + Order("count DESC"). + Find(&deviceCounts) + + provinces := make([]gin.H, 0, len(deviceCounts)) + for _, dc := range deviceCounts { + provinces = append(provinces, gin.H{ + "name": dc.DeviceType, + "count": dc.Count, + }) + } + userDistribution["provinces"] = provinces + } + + onlineTrend := make([]gin.H, 0, 30) + for i := 29; i >= 0; i-- { + date := time.Now().AddDate(0, 0, -i) + dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + dateEnd := dateStart.Add(24 * time.Hour) + + var count int64 + if len(appIDs) > 0 { + database.DB.Model(&model.DeviceSession{}). + Joins("JOIN user_devices ON device_sessions.device_id = user_devices.id"). + Where("user_devices.application_id IN ? AND device_sessions.last_heartbeat >= ? AND device_sessions.last_heartbeat < ?", appIDs, dateStart, dateEnd). + Count(&count) + } + + onlineTrend = append(onlineTrend, gin.H{ + "date": date.Format("01-02"), + "value": count, + }) + } + + recentActivities := make([]gin.H, 0, 10) + var logs []model.Log + database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(10).Find(&logs) + for _, log := range logs { + recentActivities = append(recentActivities, gin.H{ + "id": log.ID, + "action": log.Action, + "details": log.Details, + "resource": log.Resource, + "log_type": log.LogType, + "status": log.Status, + "created_at": log.CreatedAt, + }) + } + + recentTickets := make([]gin.H, 0, 5) + var tickets []model.Ticket + database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(5).Find(&tickets) + for _, ticket := range tickets { + recentTickets = append(recentTickets, gin.H{ + "id": ticket.ID, + "title": ticket.Title, + "status": ticket.Status, + "priority": ticket.Priority, + "created_at": ticket.CreatedAt, + }) + } + + response.Success(c, gin.H{ + "stats": stats, + "subscription": subscription, + "userDistribution": userDistribution, + "onlineTrend": onlineTrend, + "recentActivities": recentActivities, + "recentTickets": recentTickets, + }) +} diff --git a/backend/internal/router/developer/developer.go b/backend/internal/router/developer/developer.go new file mode 100644 index 0000000..7108b4d --- /dev/null +++ b/backend/internal/router/developer/developer.go @@ -0,0 +1,58 @@ +package developer + +import ( + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.RouterGroup) { + SetupDashboardRoutes(r) + SetupApplicationRoutes(r) + SetupCardRoutes(r) + SetupUserRoutes(r) + SetupDeviceRoutes(r) + SetupFinanceRoutes(r) + SetupLogRoutes(r) + SetupTicketRoutes(r) + SetupAgentAppRoutes(r) + SetupAgentsRoutes(r) + SetupCloudRoutes(r) + SetupDynamicRoutes(r) + SetupExtensionRoutes(r) + SetupOrderRoutes(r) + SetupUsageRoutes(r) + SetupAnnouncementRoutes(r) + SetupVersionRoutes(r) + SetupProfileRoutes(r) + SetupEmailRoutes(r) +} + +func SetupRoutesWithoutPackage(r *gin.RouterGroup) { + SetupAgentAppRoutesWithoutPackage(r) + SetupCardRoutesWithoutPackage(r) +} + +func SetupExtensionRoutes(r *gin.RouterGroup) { + extension := r.Group("/extension") + { + // Webhook配置 + extension.GET("/webhooks", handleGetWebhooks) + extension.POST("/webhooks", handleCreateWebhook) + extension.PUT("/webhooks/:id", handleUpdateWebhook) + extension.PUT("/webhooks/:id/status", handleUpdateWebhookStatus) + extension.DELETE("/webhooks/:id", handleDeleteWebhook) + extension.PUT("/webhooks/batch/status", handleBatchUpdateWebhookStatus) + extension.DELETE("/webhooks/batch", handleBatchDeleteWebhooks) + extension.GET("/webhooks/logs", handleGetWebhookLogs) + extension.POST("/webhooks/:id/test", handleTestWebhook) + + // API密钥 + extension.GET("/api-keys", handleGetAPIKeys) + extension.POST("/api-keys", handleCreateAPIKey) + extension.PUT("/api-keys/:id", handleUpdateAPIKey) + extension.PUT("/api-keys/:id/status", handleUpdateAPIKeyStatus) + extension.DELETE("/api-keys/:id", handleDeleteAPIKey) + extension.PUT("/api-keys/batch/status", handleBatchUpdateAPIKeyStatus) + extension.DELETE("/api-keys/batch", handleBatchDeleteAPIKeys) + extension.POST("/api-keys/:id/regenerate", handleRegenerateAPIKey) + } +} diff --git a/backend/internal/router/developer/devices.go b/backend/internal/router/developer/devices.go new file mode 100644 index 0000000..0d5a6bd --- /dev/null +++ b/backend/internal/router/developer/devices.go @@ -0,0 +1,506 @@ +package developer + +import ( + "fmt" + "log" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type DeviceWithDetails struct { + model.UserDevice + OnlineSessions int `json:"online_sessions"` +} + +func SetupDeviceRoutes(r *gin.RouterGroup) { + devices := r.Group("/devices") + { + devices.GET("", handleGetDevices) + devices.PUT("/:id/status", handleUpdateDeviceStatus) + devices.DELETE("/:id", handleUnbindDevice) + devices.DELETE("/batch", handleBatchUnbindDevices) + devices.POST("/batch/status", handleBatchUpdateDeviceStatus) + devices.POST("/:id/force-offline", handleForceOfflineDevice) + } + + sessions := r.Group("/sessions") + { + sessions.GET("", handleGetSessions) + sessions.DELETE("/:id", handleDeleteSession) + } +} + +func handleGetDevices(c *gin.Context) { + userID := c.GetUint("user_id") + log.Printf("[DEBUG] handleGetDevices called, userID: %d", userID) + + userIDFilter := c.Query("user_id") + deviceIDFilter := c.Query("device_id") + + var devices []model.UserDevice + var appHeartbeatTimeoutMap map[uint]int + + var ownApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&ownApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var agentApps []model.AgentApplication + if err := database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps).Error; err != nil { + log.Printf("[DEBUG] Failed to get agent apps: %v", err) + } + + appHeartbeatTimeoutMap = make(map[uint]int) + var appIDs []uint + + for _, app := range ownApps { + timeout := app.HeartbeatTimeout + if timeout == 0 { + timeout = 300 + } + appHeartbeatTimeoutMap[app.ID] = timeout + appIDs = append(appIDs, app.ID) + } + + for _, agentApp := range agentApps { + var app model.Application + if err := database.DB.First(&app, agentApp.ApplicationID).Error; err != nil { + continue + } + timeout := app.HeartbeatTimeout + if timeout == 0 { + timeout = 300 + } + appHeartbeatTimeoutMap[app.ID] = timeout + appIDs = append(appIDs, app.ID) + } + + if len(appIDs) == 0 { + response.Success(c, gin.H{"devices": []DeviceWithDetails{}}) + return + } + + query := database.DB.Preload("User").Preload("Application").Where("application_id IN ?", appIDs) + + if userIDFilter != "" { + query = query.Where("user_id = ?", userIDFilter) + } + + if deviceIDFilter != "" { + query = query.Where("device_id = ?", deviceIDFilter) + } + + if err := query.Find(&devices).Error; err != nil { + response.Error(c, 500, "获取设备列表失败") + return + } + + devicesWithDetails := make([]DeviceWithDetails, 0, len(devices)) + for _, device := range devices { + heartbeatTimeout := appHeartbeatTimeoutMap[device.ApplicationID] + timeoutThreshold := time.Now().Add(-time.Duration(heartbeatTimeout) * time.Second) + + var onlineSessionCount int64 + database.DB.Model(&model.DeviceSession{}). + Where("device_id = ? AND last_heartbeat > ?", device.ID, timeoutThreshold). + Count(&onlineSessionCount) + + devicesWithDetails = append(devicesWithDetails, DeviceWithDetails{ + UserDevice: device, + OnlineSessions: int(onlineSessionCount), + }) + } + + response.Success(c, gin.H{"devices": devicesWithDetails}) +} + +func handleUpdateDeviceStatus(c *gin.Context) { + userID := c.GetUint("user_id") + deviceID := c.Param("id") + + var req struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var device model.UserDevice + if err := database.DB.First(&device, deviceID).Error; err != nil { + response.Error(c, 404, "设备不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, device.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限修改该设备") + return + } + } + + device.Status = req.Status + if err := database.DB.Save(&device).Error; err != nil { + response.Error(c, 500, "更新设备状态失败") + return + } + + response.Success(c, device) +} + +func handleUnbindDevice(c *gin.Context) { + userID := c.GetUint("user_id") + deviceID := c.Param("id") + + var device model.UserDevice + if err := database.DB.First(&device, deviceID).Error; err != nil { + response.Error(c, 404, "设备不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, device.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限解绑该设备") + return + } + } + + database.DB.Where("device_id = ?", device.ID).Delete(&model.DeviceSession{}) + + if err := database.DB.Delete(&device).Error; err != nil { + response.Error(c, 500, "解绑设备失败") + return + } + + service.LogOperation(c, "unbind", "device", &device.ID, fmt.Sprintf("解绑设备: %s (应用: %s)", device.DeviceID, app.Name), nil) + + response.Success(c, nil) +} + +func handleBatchUnbindDevices(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + DeviceIDs []uint `json:"device_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var devices []model.UserDevice + if err := database.DB.Where("id IN ?", req.DeviceIDs).Find(&devices).Error; err != nil { + response.Error(c, 500, "获取设备列表失败") + return + } + + var validDeviceIDs []uint + for _, device := range devices { + var app model.Application + if err := database.DB.First(&app, device.ApplicationID).Error; err != nil { + continue + } + + if app.UserID == userID { + validDeviceIDs = append(validDeviceIDs, device.ID) + } else { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err == nil { + validDeviceIDs = append(validDeviceIDs, device.ID) + } + } + } + + if len(validDeviceIDs) > 0 { + database.DB.Where("device_id IN ?", validDeviceIDs).Delete(&model.DeviceSession{}) + if err := database.DB.Where("id IN ?", validDeviceIDs).Delete(&model.UserDevice{}).Error; err != nil { + response.Error(c, 500, "批量解绑失败") + return + } + } + + service.LogOperation(c, "batch_unbind", "device", nil, fmt.Sprintf("批量解绑设备: %d个", len(validDeviceIDs)), nil) + + response.Success(c, nil) +} + +func handleBatchUpdateDeviceStatus(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + DeviceIDs []uint `json:"device_ids"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var devices []model.UserDevice + if err := database.DB.Where("id IN ?", req.DeviceIDs).Find(&devices).Error; err != nil { + response.Error(c, 500, "获取设备列表失败") + return + } + + var validDeviceIDs []uint + for _, device := range devices { + var app model.Application + if err := database.DB.First(&app, device.ApplicationID).Error; err != nil { + continue + } + + if app.UserID == userID { + validDeviceIDs = append(validDeviceIDs, device.ID) + } else { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err == nil { + validDeviceIDs = append(validDeviceIDs, device.ID) + } + } + } + + if len(validDeviceIDs) > 0 { + if err := database.DB.Model(&model.UserDevice{}).Where("id IN ?", validDeviceIDs).Update("status", req.Status).Error; err != nil { + response.Error(c, 500, "批量更新状态失败") + return + } + } + + response.Success(c, nil) +} + +type SessionWithDetails struct { + model.DeviceSession + DeviceID string `json:"device_identifier"` + DeviceName string `json:"device_name"` + Username string `json:"username"` + AppName string `json:"app_name"` + IsOnline bool `json:"is_online"` +} + +func handleGetSessions(c *gin.Context) { + userID := c.GetUint("user_id") + deviceIDFilter := c.Query("device_id") + appIDFilter := c.Query("app_id") + + var ownApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&ownApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var agentApps []model.AgentApplication + if err := database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps).Error; err != nil { + log.Printf("[DEBUG] Failed to get agent apps: %v", err) + } + + appHeartbeatTimeoutMap := make(map[uint]int) + var appIDs []uint + + for _, app := range ownApps { + timeout := app.HeartbeatTimeout + if timeout == 0 { + timeout = 300 + } + appHeartbeatTimeoutMap[app.ID] = timeout + appIDs = append(appIDs, app.ID) + } + + for _, agentApp := range agentApps { + var app model.Application + if err := database.DB.First(&app, agentApp.ApplicationID).Error; err != nil { + continue + } + timeout := app.HeartbeatTimeout + if timeout == 0 { + timeout = 300 + } + appHeartbeatTimeoutMap[app.ID] = timeout + appIDs = append(appIDs, app.ID) + } + + if len(appIDs) == 0 { + response.Success(c, gin.H{"sessions": []SessionWithDetails{}}) + return + } + + query := database.DB.Model(&model.DeviceSession{}).Where("application_id IN ?", appIDs) + + if deviceIDFilter != "" { + query = query.Where("device_id = ?", deviceIDFilter) + } + + if appIDFilter != "" { + query = query.Where("application_id = ?", appIDFilter) + } + + var sessions []model.DeviceSession + if err := query.Find(&sessions).Error; err != nil { + response.Error(c, 500, "获取会话列表失败") + return + } + + sessionsWithDetails := make([]SessionWithDetails, 0, len(sessions)) + for _, session := range sessions { + var device model.UserDevice + if err := database.DB.First(&device, session.DeviceID).Error; err != nil { + continue + } + + var user model.AppUser + if err := database.DB.First(&user, session.UserID).Error; err != nil { + continue + } + + var app model.Application + if err := database.DB.First(&app, session.ApplicationID).Error; err != nil { + continue + } + + heartbeatTimeout := appHeartbeatTimeoutMap[session.ApplicationID] + timeoutThreshold := time.Now().Add(-time.Duration(heartbeatTimeout) * time.Second) + isOnline := session.LastHeartbeat != nil && session.LastHeartbeat.After(timeoutThreshold) + + sessionsWithDetails = append(sessionsWithDetails, SessionWithDetails{ + DeviceSession: session, + DeviceID: device.DeviceID, + DeviceName: device.DeviceName, + Username: user.Username, + AppName: app.Name, + IsOnline: isOnline, + }) + } + + response.Success(c, gin.H{"sessions": sessionsWithDetails}) +} + +func handleDeleteSession(c *gin.Context) { + userID := c.GetUint("user_id") + sessionID := c.Param("id") + + if sessionID == "" { + response.Error(c, 400, "会话ID不能为空") + return + } + + var session model.DeviceSession + if err := database.DB.First(&session, sessionID).Error; err != nil { + response.Error(c, 404, "会话不存在") + return + } + + var ownApps []model.Application + database.DB.Where("user_id = ?", userID).Find(&ownApps) + ownAppIDs := make([]uint, len(ownApps)) + for i, app := range ownApps { + ownAppIDs[i] = app.ID + } + + var agentApps []model.AgentApplication + database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps) + agentAppIDs := make([]uint, 0) + for _, agentApp := range agentApps { + agentAppIDs = append(agentAppIDs, agentApp.ApplicationID) + } + + validAppIDs := append(ownAppIDs, agentAppIDs...) + isValid := false + for _, appID := range validAppIDs { + if session.ApplicationID == appID { + isValid = true + break + } + } + + if !isValid { + response.Error(c, 403, "无权操作此会话") + return + } + + if err := database.DB.Delete(&session).Error; err != nil { + response.Error(c, 500, "删除会话失败") + return + } + + response.Success(c, nil) +} + +func handleForceOfflineDevice(c *gin.Context) { + userID := c.GetUint("user_id") + deviceID := c.Param("id") + + if deviceID == "" { + response.Error(c, 400, "设备ID不能为空") + return + } + + var device model.UserDevice + if err := database.DB.First(&device, deviceID).Error; err != nil { + response.Error(c, 404, "设备不存在") + return + } + + var ownApps []model.Application + database.DB.Where("user_id = ?", userID).Find(&ownApps) + ownAppIDs := make([]uint, len(ownApps)) + for i, app := range ownApps { + ownAppIDs[i] = app.ID + } + + var agentApps []model.AgentApplication + database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps) + agentAppIDs := make([]uint, 0) + for _, agentApp := range agentApps { + agentAppIDs = append(agentAppIDs, agentApp.ApplicationID) + } + + validAppIDs := append(ownAppIDs, agentAppIDs...) + isValid := false + for _, appID := range validAppIDs { + if device.ApplicationID == appID { + isValid = true + break + } + } + + if !isValid { + response.Error(c, 403, "无权操作此设备") + return + } + + var sessions []model.DeviceSession + if err := database.DB.Where("device_id = ?", device.ID).Find(&sessions).Error; err != nil { + response.Error(c, 500, "获取会话列表失败") + return + } + + if len(sessions) == 0 { + response.Success(c, gin.H{"count": 0}) + return + } + + if err := database.DB.Where("device_id = ?", device.ID).Delete(&model.DeviceSession{}).Error; err != nil { + response.Error(c, 500, "强制离线失败") + return + } + + response.Success(c, gin.H{"count": len(sessions)}) +} diff --git a/backend/internal/router/developer/dynamic.go b/backend/internal/router/developer/dynamic.go new file mode 100644 index 0000000..d7ad999 --- /dev/null +++ b/backend/internal/router/developer/dynamic.go @@ -0,0 +1,805 @@ +package developer + +import ( + "log" + "strings" + "time" + "unicode" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/dop251/goja" + "github.com/gin-gonic/gin" +) + +func normalizeKey(name string) string { + key := strings.ToLower(strings.TrimSpace(name)) + key = strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' { + return r + } + return '_' + }, key) + return key +} + +func validateDynamicCode(code string) error { + vm := goja.New() + vm.Set("params", vm.NewObject()) + vm.Set("user", vm.NewObject()) + vm.Set("app", vm.NewObject()) + wrappedCode := "(function() { " + code + " })()" + _, err := vm.RunString(wrappedCode) + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "ReferenceError") || strings.Contains(errStr, "is not defined") { + return nil + } + return err + } + return nil +} + +func SetupDynamicRoutes(r *gin.RouterGroup) { + dynamicCodes := r.Group("/dynamic-codes") + { + dynamicCodes.GET("", handleListDynamicCodes) + dynamicCodes.GET("/:id", handleGetDynamicCode) + dynamicCodes.POST("", handleCreateDynamicCode) + dynamicCodes.PUT("/:id", handleUpdateDynamicCode) + dynamicCodes.DELETE("/:id", handleDeleteDynamicCode) + dynamicCodes.PUT("/:id/status", handleUpdateDynamicCodeStatus) + dynamicCodes.DELETE("/batch", handleBatchDeleteDynamicCodes) + dynamicCodes.PUT("/batch/status", handleBatchUpdateDynamicCodeStatus) + } + + riskControl := r.Group("/risk-control") + { + riskControl.GET("/rules", handleGetRiskControlRules) + riskControl.POST("/rules", handleCreateRiskControlRule) + riskControl.PUT("/rules/:id", handleUpdateRiskControlRule) + riskControl.DELETE("/rules/:id", handleDeleteRiskControlRule) + riskControl.PUT("/rules/:id/status", handleUpdateRiskControlRuleStatus) + riskControl.DELETE("/rules/batch", handleBatchDeleteRiskControlRules) + riskControl.PUT("/rules/batch/status", handleBatchUpdateRiskControlRuleStatus) + } +} + +func handleListDynamicCodes(c *gin.Context) { + userID := c.GetUint("user_id") + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + var dynamicCodes []model.DynamicCode + if err := database.DB.Preload("Application").Preload("Creator").Where("application_id IN ?", applicationIDs).Find(&dynamicCodes).Error; err != nil { + response.Error(c, 500, "获取动态代码列表失败") + return + + } + + type DynamicCodeResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description"` + ApplicationID uint `json:"application_id"` + ApplicationName string `json:"application_name"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Creator *struct { + ID uint `json:"id"` + Username string `json:"username"` + } `json:"creator,omitempty"` + } + + var result []DynamicCodeResponse + for _, dc := range dynamicCodes { + var creator *struct { + ID uint `json:"id"` + Username string `json:"username"` + } + if dc.UserID != nil && dc.Creator.ID != 0 { + creator = &struct { + ID uint `json:"id"` + Username string `json:"username"` + }{ + ID: dc.Creator.ID, + Username: dc.Creator.Username, + } + } + + result = append(result, DynamicCodeResponse{ + ID: dc.ID, + Name: dc.Name, + Code: dc.Code, + Description: dc.Description, + ApplicationID: dc.ApplicationID, + ApplicationName: dc.Application.Name, + Enabled: dc.Status == "active", + CreatedAt: dc.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: dc.UpdatedAt.Format("2006-01-02 15:04:05"), + Creator: creator, + }) + } + + response.Success(c, result) +} + +func handleGetDynamicCode(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var dynamicCode model.DynamicCode + if err := database.DB.Preload("Application").Preload("Creator").Where("id = ?", id).First(&dynamicCode).Error; err != nil { + response.Error(c, 404, "动态代码不存在") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", dynamicCode.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 403, "无权限访问此动态代码") + return + } + + type DynamicCodeResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description"` + ApplicationID uint `json:"application_id"` + ApplicationName string `json:"application_name"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Creator *struct { + ID uint `json:"id"` + Username string `json:"username"` + } `json:"creator,omitempty"` + } + + var creator *struct { + ID uint `json:"id"` + Username string `json:"username"` + } + if dynamicCode.UserID != nil && dynamicCode.Creator.ID != 0 { + creator = &struct { + ID uint `json:"id"` + Username string `json:"username"` + }{ + ID: dynamicCode.Creator.ID, + Username: dynamicCode.Creator.Username, + } + } + + result := DynamicCodeResponse{ + ID: dynamicCode.ID, + Name: dynamicCode.Name, + Code: dynamicCode.Code, + Description: dynamicCode.Description, + ApplicationID: dynamicCode.ApplicationID, + ApplicationName: dynamicCode.Application.Name, + Enabled: dynamicCode.Status == "active", + CreatedAt: dynamicCode.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: dynamicCode.UpdatedAt.Format("2006-01-02 15:04:05"), + Creator: creator, + } + + response.Success(c, result) +} + +func handleCreateDynamicCode(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + Name string `json:"name" binding:"required"` + Code string `json:"code" binding:"required"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + ApplicationID uint `json:"application_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if err := validateDynamicCode(req.Code); err != nil { + response.Error(c, 400, "代码语法错误: "+err.Error()) + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + status := "inactive" + if req.Enabled { + status = "active" + } + + key := normalizeKey(req.Name) + + var existingCode model.DynamicCode + err := database.DB.Unscoped().Where("key = ?", key).First(&existingCode).Error + if err == nil { + if existingCode.DeletedAt.Valid { + database.DB.Unscoped().Delete(&existingCode) + } else { + response.Error(c, 400, "该名称的动态代码已存在") + return + } + } + + dynamicCode := model.DynamicCode{ + UserID: &userID, + ApplicationID: req.ApplicationID, + Name: req.Name, + Key: key, + Code: req.Code, + Description: req.Description, + Status: status, + } + + if err := database.DB.Create(&dynamicCode).Error; err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "constraint failed") { + response.Error(c, 400, "该名称的动态代码已存在") + return + } + response.Error(c, 500, "创建失败: "+err.Error()) + return + } + + response.Success(c, dynamicCode) +} + +func handleUpdateDynamicCode(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var dynamicCode model.DynamicCode + if err := database.DB.Where("id = ?", id).First(&dynamicCode).Error; err != nil { + response.Error(c, 404, "动态代码不存在") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", dynamicCode.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 403, "无权限操作此动态代码") + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Code string `json:"code" binding:"required"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + ApplicationID uint `json:"application_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if err := validateDynamicCode(req.Code); err != nil { + response.Error(c, 400, "代码语法错误: "+err.Error()) + return + } + + if req.ApplicationID != dynamicCode.ApplicationID { + var newApp model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&newApp).Error; err != nil { + response.Error(c, 403, "无权限操作此应用") + return + } + dynamicCode.ApplicationID = req.ApplicationID + } + + status := "inactive" + if req.Enabled { + status = "active" + } + + dynamicCode.Name = req.Name + dynamicCode.Key = normalizeKey(req.Name) + dynamicCode.Code = req.Code + dynamicCode.Description = req.Description + dynamicCode.Status = status + + if err := database.DB.Save(&dynamicCode).Error; err != nil { + response.Error(c, 500, "更新失败") + return + } + + response.Success(c, dynamicCode) +} + +func handleDeleteDynamicCode(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var dynamicCode model.DynamicCode + if err := database.DB.Where("id = ?", id).First(&dynamicCode).Error; err != nil { + response.Error(c, 404, "动态代码不存在") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", dynamicCode.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 403, "无权限操作此动态代码") + return + } + + if err := database.DB.Delete(&dynamicCode).Error; err != nil { + response.Error(c, 500, "删除失败") + return + } + + response.Success(c, gin.H{"message": "删除成功"}) +} + +func handleUpdateDynamicCodeStatus(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var dynamicCode model.DynamicCode + if err := database.DB.Where("id = ?", id).First(&dynamicCode).Error; err != nil { + response.Error(c, 404, "动态代码不存在") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", dynamicCode.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 403, "无权限操作此动态代码") + return + } + + var req struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + status := "inactive" + if req.Enabled { + status = "active" + } + + dynamicCode.Status = status + + if err := database.DB.Save(&dynamicCode).Error; err != nil { + response.Error(c, 500, "更新状态失败") + return + } + + response.Success(c, dynamicCode) +} + +func handleBatchDeleteDynamicCodes(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, applicationIDs).Delete(&model.DynamicCode{}).Error; err != nil { + response.Error(c, 500, "批量删除失败") + return + } + + response.Success(c, gin.H{"message": "批量删除成功"}) +} + +func handleBatchUpdateDynamicCodeStatus(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + status := "inactive" + if req.Enabled { + status = "active" + } + + if err := database.DB.Model(&model.DynamicCode{}).Where("id IN ? AND application_id IN ?", req.IDs, applicationIDs).Update("status", status).Error; err != nil { + response.Error(c, 500, "批量更新状态失败") + return + } + + response.Success(c, gin.H{"message": "批量更新状态成功"}) +} + +type RiskControlRule struct { + ID uint `json:"id"` + Type string `json:"type"` + Value string `json:"value"` + Reason string `json:"reason"` + Status string `json:"status"` + ExpiresAt *string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +func handleGetRiskControlRules(c *gin.Context) { + userID := c.GetUint("user_id") + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + var rules []model.RiskControlRule + if err := database.DB.Where("user_id = ? OR application_id IN ?", userID, applicationIDs).Order("created_at DESC").Find(&rules).Error; err != nil { + response.Error(c, 500, "获取风控规则失败") + return + } + + var result []gin.H + for _, rule := range rules { + var expiresAt *string + if rule.ExpiresAt != nil { + t := rule.ExpiresAt.Format("2006-01-02 15:04:05") + expiresAt = &t + } + + var appID *uint + if rule.ApplicationID != nil { + appID = rule.ApplicationID + } + + result = append(result, gin.H{ + "id": rule.ID, + "type": rule.Type, + "value": rule.Value, + "reason": rule.Reason, + "status": rule.Status, + "expires_at": expiresAt, + "application_id": appID, + "is_global": rule.ApplicationID == nil, + "created_at": rule.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + response.Success(c, result) +} + +func handleCreateRiskControlRule(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + Type string `json:"type" binding:"required"` + Value string `json:"value" binding:"required"` + Reason string `json:"reason"` + ExpiresAt *string `json:"expires_at"` + ApplicationID *uint `json:"application_id"` + IsGlobal bool `json:"is_global"` + } + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[ERROR] Failed to bind JSON: %v", err) + response.Error(c, 400, "参数错误") + return + } + + log.Printf("[DEBUG] Create risk control rule: type=%s, value=%s, is_global=%v, application_id=%v", req.Type, req.Value, req.IsGlobal, req.ApplicationID) + + var appID *uint + switch req.Type { + case "user": + if req.ApplicationID == nil { + response.Error(c, 400, "用户封禁规则必须指定应用") + return + } + var application model.Application + if err := database.DB.Where("id = ? AND user_id = ?", *req.ApplicationID, userID).First(&application).Error; err != nil { + response.Error(c, 404, "应用不存在或无权限") + return + } + appID = req.ApplicationID + case "ip", "device", "region": + if !req.IsGlobal && req.ApplicationID != nil { + var application model.Application + if err := database.DB.Where("id = ? AND user_id = ?", *req.ApplicationID, userID).First(&application).Error; err != nil { + response.Error(c, 404, "应用不存在或无权限") + return + } + appID = req.ApplicationID + } else { + appID = nil + } + default: + response.Error(c, 400, "不支持的规则类型") + return + } + + rule := model.RiskControlRule{ + UserID: userID, + ApplicationID: appID, + Type: req.Type, + Value: req.Value, + Reason: req.Reason, + Status: "active", + } + + if req.ExpiresAt != nil && *req.ExpiresAt != "" { + t, err := time.Parse("2006-01-02T15:04", *req.ExpiresAt) + if err == nil { + rule.ExpiresAt = &t + } else { + log.Printf("[WARN] Failed to parse expires_at: %v", err) + } + } + + log.Printf("[DEBUG] Creating rule: %+v", rule) + + if err := database.DB.Create(&rule).Error; err != nil { + log.Printf("[ERROR] Failed to create risk control rule: %v", err) + response.Error(c, 500, "创建风控规则失败") + return + } + + var expiresAt *string + if rule.ExpiresAt != nil { + t := rule.ExpiresAt.Format("2006-01-02 15:04:05") + expiresAt = &t + } + + var appIDResp *uint + if rule.ApplicationID != nil { + appIDResp = rule.ApplicationID + } + + response.Success(c, gin.H{ + "id": rule.ID, + "type": rule.Type, + "value": rule.Value, + "reason": rule.Reason, + "status": rule.Status, + "expires_at": expiresAt, + "application_id": appIDResp, + "is_global": rule.ApplicationID == nil, + "created_at": rule.CreatedAt.Format("2006-01-02 15:04:05"), + }) +} + +func handleUpdateRiskControlRule(c *gin.Context) { + userID := c.GetUint("user_id") + ruleID := c.Param("id") + + var req struct { + Type string `json:"type" binding:"required"` + Value string `json:"value" binding:"required"` + Reason string `json:"reason"` + ExpiresAt *string `json:"expires_at"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + var rule model.RiskControlRule + if err := database.DB.Where("id = ? AND application_id IN ?", ruleID, applicationIDs).First(&rule).Error; err != nil { + response.Error(c, 404, "风控规则不存在") + return + } + + rule.Type = req.Type + rule.Value = req.Value + rule.Reason = req.Reason + + if req.ExpiresAt != nil && *req.ExpiresAt != "" { + t, err := time.Parse("2006-01-02T15:04", *req.ExpiresAt) + if err == nil { + rule.ExpiresAt = &t + } + } else { + rule.ExpiresAt = nil + } + + if err := database.DB.Save(&rule).Error; err != nil { + response.Error(c, 500, "更新风控规则失败") + return + } + + var expiresAt *string + if rule.ExpiresAt != nil { + t := rule.ExpiresAt.Format("2006-01-02 15:04:05") + expiresAt = &t + } + + response.Success(c, RiskControlRule{ + ID: rule.ID, + Type: rule.Type, + Value: rule.Value, + Reason: rule.Reason, + Status: rule.Status, + ExpiresAt: expiresAt, + CreatedAt: rule.CreatedAt.Format("2006-01-02 15:04:05"), + }) +} + +func handleDeleteRiskControlRule(c *gin.Context) { + userID := c.GetUint("user_id") + ruleID := c.Param("id") + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + if err := database.DB.Where("id = ? AND application_id IN ?", ruleID, applicationIDs).Delete(&model.RiskControlRule{}).Error; err != nil { + response.Error(c, 500, "删除风控规则失败") + return + } + + response.Success(c, gin.H{"message": "删除成功"}) +} + +func handleUpdateRiskControlRuleStatus(c *gin.Context) { + userID := c.GetUint("user_id") + ruleID := c.Param("id") + + var req struct { + Status string `json:"status" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + var rule model.RiskControlRule + if err := database.DB.Where("id = ? AND application_id IN ?", ruleID, applicationIDs).First(&rule).Error; err != nil { + response.Error(c, 404, "风控规则不存在") + return + } + + rule.Status = req.Status + if err := database.DB.Save(&rule).Error; err != nil { + response.Error(c, 500, "更新状态失败") + return + } + + response.Success(c, gin.H{"message": "状态更新成功"}) +} + +func handleBatchDeleteRiskControlRules(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, applicationIDs).Delete(&model.RiskControlRule{}).Error; err != nil { + response.Error(c, 500, "批量删除失败") + return + } + + response.Success(c, gin.H{"message": "批量删除成功"}) +} + +func handleBatchUpdateRiskControlRuleStatus(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + Status string `json:"status" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var applications []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&applications).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var applicationIDs []uint + for _, app := range applications { + applicationIDs = append(applicationIDs, app.ID) + } + + if err := database.DB.Model(&model.RiskControlRule{}).Where("id IN ? AND application_id IN ?", req.IDs, applicationIDs).Update("status", req.Status).Error; err != nil { + response.Error(c, 500, "批量更新状态失败") + return + } + + response.Success(c, gin.H{"message": "批量更新状态成功"}) +} diff --git a/backend/internal/router/developer/email.go b/backend/internal/router/developer/email.go new file mode 100644 index 0000000..262d8b7 --- /dev/null +++ b/backend/internal/router/developer/email.go @@ -0,0 +1,481 @@ +package developer + +import ( + "fmt" + "math/rand" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupEmailRoutes(r *gin.RouterGroup) { + r.GET("/applications/:id/email-config", handleGetEmailConfig) + r.PUT("/applications/:id/email-config", handleUpdateEmailConfig) + r.POST("/applications/:id/email-config/test", handleTestEmailConfig) + r.GET("/applications/:id/email-templates", handleGetEmailTemplates) + r.GET("/applications/:id/email-templates/:template_id", handleGetEmailTemplate) + r.POST("/applications/:id/email-templates", handleCreateEmailTemplate) + r.PUT("/applications/:id/email-templates/:template_id", handleUpdateEmailTemplate) + r.DELETE("/applications/:id/email-templates/:template_id", handleDeleteEmailTemplate) + r.POST("/applications/:id/send-verify-code", handleSendVerifyCode) +} + +func handleGetEmailConfig(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var permission model.PackagePermission + hasPermission := checkEmailPermission(userID.(uint), &permission) + if !hasPermission { + response.Error(c, 403, "您的套餐不支持邮件功能") + return + } + + var smtpConfig model.AppSMTPConfig + database.DB.Where("application_id = ?", app.ID).First(&smtpConfig) + + result := gin.H{ + "enable_email_verify": app.EnableEmailVerify, + "require_email_verify": app.RequireEmailVerify, + "enable_password_reset": app.EnablePasswordReset, + "permission": gin.H{ + "allow_email": permission.AllowEmail, + }, + } + + if smtpConfig.ID > 0 { + result["smtp_config"] = gin.H{ + "id": smtpConfig.ID, + "host": smtpConfig.Host, + "port": smtpConfig.Port, + "user": smtpConfig.User, + "from_name": smtpConfig.FromName, + "from_email": smtpConfig.FromEmail, + "use_ssl": smtpConfig.UseSSL, + "status": smtpConfig.Status, + } + } else { + result["smtp_config"] = nil + } + + response.Success(c, result) +} + +func handleUpdateEmailConfig(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var permission model.PackagePermission + hasPermission := checkEmailPermission(userID.(uint), &permission) + if !hasPermission { + response.Error(c, 403, "您的套餐不支持邮件功能") + return + } + + var req struct { + EnableEmailVerify bool `json:"enable_email_verify"` + RequireEmailVerify bool `json:"require_email_verify"` + EnablePasswordReset bool `json:"enable_password_reset"` + SMTPConfig *struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + FromName string `json:"from_name"` + FromEmail string `json:"from_email"` + UseSSL bool `json:"use_ssl"` + } `json:"smtp_config"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + updates := map[string]interface{}{ + "enable_email_verify": req.EnableEmailVerify, + "require_email_verify": req.RequireEmailVerify, + "enable_password_reset": req.EnablePasswordReset, + } + + if err := database.DB.Model(&app).Updates(updates).Error; err != nil { + response.Error(c, 500, "更新失败") + return + } + + if req.SMTPConfig != nil { + var smtpConfig model.AppSMTPConfig + database.DB.Where("application_id = ?", app.ID).First(&smtpConfig) + + smtpConfig.ApplicationID = app.ID + smtpConfig.Host = req.SMTPConfig.Host + smtpConfig.Port = req.SMTPConfig.Port + smtpConfig.User = req.SMTPConfig.User + if req.SMTPConfig.Password != "" { + smtpConfig.Password = req.SMTPConfig.Password + } + smtpConfig.FromName = req.SMTPConfig.FromName + smtpConfig.FromEmail = req.SMTPConfig.FromEmail + smtpConfig.UseSSL = req.SMTPConfig.UseSSL + + if smtpConfig.ID > 0 { + database.DB.Save(&smtpConfig) + } else { + database.DB.Create(&smtpConfig) + } + } + + response.Success(c, gin.H{ + "message": "更新成功", + }) +} + +func handleTestEmailConfig(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var req struct { + Email string `json:"email" binding:"required,email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "请输入有效的邮箱地址") + return + } + + var smtpConfig model.AppSMTPConfig + if err := database.DB.Where("application_id = ?", app.ID).First(&smtpConfig).Error; err != nil { + response.Error(c, 400, "请先配置SMTP") + return + } + + emailService := service.NewEmailService() + config := service.EmailConfig{ + Host: smtpConfig.Host, + Port: smtpConfig.Port, + User: smtpConfig.User, + Password: smtpConfig.Password, + FromName: smtpConfig.FromName, + FromEmail: smtpConfig.FromEmail, + UseSSL: smtpConfig.UseSSL, + } + + if err := emailService.SendTestEmail(config, req.Email, app.Name); err != nil { + response.Error(c, 500, fmt.Sprintf("发送失败: %v", err)) + return + } + + response.Success(c, gin.H{ + "message": "测试邮件已发送", + }) +} + +func handleGetEmailTemplates(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var permission model.PackagePermission + hasPermission := checkEmailPermission(userID.(uint), &permission) + if !hasPermission { + response.Error(c, 403, "您的套餐不支持邮箱验证功能") + return + } + + var templates []model.EmailTemplate + database.DB.Where("application_id = ?", app.ID).Order("created_at DESC").Find(&templates) + + response.Success(c, templates) +} + +func handleGetEmailTemplate(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + templateID := c.Param("template_id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var template model.EmailTemplate + if err := database.DB.Where("id = ? AND application_id = ?", templateID, app.ID).First(&template).Error; err != nil { + response.Error(c, 404, "模板不存在") + return + } + + response.Success(c, template) +} + +func handleCreateEmailTemplate(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var permission model.PackagePermission + hasPermission := checkEmailPermission(userID.(uint), &permission) + if !hasPermission { + response.Error(c, 403, "您的套餐不支持邮件功能") + return + } + + var req struct { + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` + Subject string `json:"subject" binding:"required"` + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + template := model.EmailTemplate{ + ApplicationID: app.ID, + Type: req.Type, + Name: req.Name, + Subject: req.Subject, + Content: req.Content, + Status: "active", + } + + if err := database.DB.Create(&template).Error; err != nil { + response.Error(c, 500, "创建失败") + return + } + + response.Success(c, template) +} + +func handleUpdateEmailTemplate(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + templateID := c.Param("template_id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var permission model.PackagePermission + hasPermission := checkEmailPermission(userID.(uint), &permission) + if !hasPermission { + response.Error(c, 403, "您的套餐不支持邮件功能") + return + } + + var template model.EmailTemplate + if err := database.DB.Where("id = ? AND application_id = ?", templateID, app.ID).First(&template).Error; err != nil { + response.Error(c, 404, "模板不存在") + return + } + + var req struct { + Type string `json:"type"` + Name string `json:"name"` + Subject string `json:"subject"` + Content string `json:"content"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + updates := map[string]interface{}{} + if req.Type != "" { + updates["type"] = req.Type + } + if req.Name != "" { + updates["name"] = req.Name + } + if req.Subject != "" { + updates["subject"] = req.Subject + } + if req.Content != "" { + updates["content"] = req.Content + } + if req.Status != "" { + updates["status"] = req.Status + } + + if err := database.DB.Model(&template).Updates(updates).Error; err != nil { + response.Error(c, 500, "更新失败") + return + } + + response.Success(c, template) +} + +func handleDeleteEmailTemplate(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + templateID := c.Param("template_id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var permission model.PackagePermission + hasPermission := checkEmailPermission(userID.(uint), &permission) + if !hasPermission { + response.Error(c, 403, "您的套餐不支持邮件功能") + return + } + + if err := database.DB.Where("id = ? AND application_id = ?", templateID, app.ID).Delete(&model.EmailTemplate{}).Error; err != nil { + response.Error(c, 500, "删除失败") + return + } + + response.Success(c, gin.H{ + "message": "删除成功", + }) +} + +func handleSendVerifyCode(c *gin.Context) { + userID, _ := c.Get("user_id") + appID := c.Param("id") + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var req struct { + Email string `json:"email" binding:"required,email"` + Purpose string `json:"purpose"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "请输入有效的邮箱地址") + return + } + + if req.Purpose == "" { + req.Purpose = "register" + } + + var smtpConfig model.AppSMTPConfig + if err := database.DB.Where("application_id = ?", app.ID).First(&smtpConfig).Error; err != nil { + response.Error(c, 400, "请先配置SMTP") + return + } + + var template model.EmailTemplate + database.DB.Where("application_id = ? AND type = ? AND status = ?", app.ID, req.Purpose, "active"). + Order("is_default DESC").First(&template) + + code := generateVerifyCode() + expireAt := time.Now().Add(15 * time.Minute) + + verifyCode := model.EmailVerifyCode{ + ApplicationID: app.ID, + Email: req.Email, + Code: code, + Purpose: req.Purpose, + ExpiresAt: expireAt, + } + database.DB.Create(&verifyCode) + + emailService := service.NewEmailService() + config := service.EmailConfig{ + Host: smtpConfig.Host, + Port: smtpConfig.Port, + User: smtpConfig.User, + Password: smtpConfig.Password, + FromName: smtpConfig.FromName, + FromEmail: smtpConfig.FromEmail, + UseSSL: smtpConfig.UseSSL, + } + + subject := fmt.Sprintf("验证码 - %s", app.Name) + content := fmt.Sprintf(` + + + + +
+

邮箱验证

+

您的验证码是:%s

+

验证码有效期为15分钟,请尽快使用。

+
+

此邮件由 %s 系统自动发送,请勿回复。

+
+ + +`, code, app.Name) + + if template.ID > 0 { + subject = template.Subject + content = template.Content + } + + if err := emailService.SendEmail(config, req.Email, subject, content); err != nil { + response.Error(c, 500, fmt.Sprintf("发送失败: %v", err)) + return + } + + response.Success(c, gin.H{ + "message": "验证码已发送", + }) +} + +func checkEmailPermission(userID uint, permission *model.PackagePermission) bool { + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND status = ?", userID, "active"). + Preload("Package").First(&userPackage).Error; err != nil { + return false + } + + if userPackage.ExpiredAt != nil && userPackage.ExpiredAt.Before(time.Now()) { + return false + } + + if err := database.DB.Where("package_id = ?", userPackage.PackageID).First(permission).Error; err != nil { + return false + } + + return permission.AllowEmail +} + +func generateVerifyCode() string { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + return fmt.Sprintf("%06d", r.Intn(1000000)) +} diff --git a/backend/internal/router/developer/extension.go b/backend/internal/router/developer/extension.go new file mode 100644 index 0000000..621ca23 --- /dev/null +++ b/backend/internal/router/developer/extension.go @@ -0,0 +1,650 @@ +package developer + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func handleGetWebhooks(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Query("application_id") + + var webhooks []struct { + model.WebhookConfig + ApplicationName string `json:"application_name"` + } + + query := database.DB.Model(&model.WebhookConfig{}). + Select("webhook_configs.*, applications.name as application_name"). + Joins("JOIN applications ON webhook_configs.application_id = applications.id"). + Where("applications.user_id = ?", userID) + + if appID != "" && appID != "all" { + query = query.Where("webhook_configs.application_id = ?", appID) + } + + if err := query.Find(&webhooks).Error; err != nil { + response.Error(c, 500, "获取Webhook配置失败") + return + } + + response.Success(c, webhooks) +} + +func handleCreateWebhook(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + ApplicationID uint `json:"application_id" binding:"required"` + Name string `json:"name" binding:"required"` + URL string `json:"url" binding:"required,url"` + SecretKey string `json:"secret_key"` + Events []string `json:"events" binding:"required"` + RetryCount int `json:"retry_count"` + Timeout int `json:"timeout"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + eventsJSON, _ := json.Marshal(req.Events) + if req.RetryCount == 0 { + req.RetryCount = 3 + } + if req.Timeout == 0 { + req.Timeout = 10 + } + + webhook := model.WebhookConfig{ + ApplicationID: req.ApplicationID, + Name: req.Name, + URL: req.URL, + SecretKey: req.SecretKey, + Events: string(eventsJSON), + Status: "active", + RetryCount: req.RetryCount, + Timeout: req.Timeout, + } + + if err := database.DB.Create(&webhook).Error; err != nil { + response.Error(c, 500, "创建Webhook配置失败") + return + } + + service.LogOperation(c, "create", "webhook", &webhook.ID, fmt.Sprintf("创建Webhook: %s", webhook.Name), nil) + + response.Success(c, webhook) +} + +func handleUpdateWebhook(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var req struct { + Name string `json:"name"` + URL string `json:"url" binding:"omitempty,url"` + SecretKey string `json:"secret_key"` + Events []string `json:"events"` + Status string `json:"status"` + RetryCount int `json:"retry_count"` + Timeout int `json:"timeout"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var webhook model.WebhookConfig + if err := database.DB.Joins("JOIN applications ON webhook_configs.application_id = applications.id"). + Where("webhook_configs.id = ? AND applications.user_id = ?", id, userID). + First(&webhook).Error; err != nil { + response.Error(c, 404, "Webhook配置不存在") + return + } + + updates := make(map[string]interface{}) + if req.Name != "" { + updates["name"] = req.Name + } + if req.URL != "" { + updates["url"] = req.URL + } + if req.SecretKey != "" { + updates["secret_key"] = req.SecretKey + } + if len(req.Events) > 0 { + eventsJSON, _ := json.Marshal(req.Events) + updates["events"] = string(eventsJSON) + } + if req.Status != "" { + updates["status"] = req.Status + } + if req.RetryCount > 0 { + updates["retry_count"] = req.RetryCount + } + if req.Timeout > 0 { + updates["timeout"] = req.Timeout + } + + if err := database.DB.Model(&webhook).Updates(updates).Error; err != nil { + response.Error(c, 500, "更新Webhook配置失败") + return + } + + response.Success(c, webhook) +} + +func handleDeleteWebhook(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var webhook model.WebhookConfig + if err := database.DB.Joins("JOIN applications ON webhook_configs.application_id = applications.id"). + Where("webhook_configs.id = ? AND applications.user_id = ?", id, userID). + First(&webhook).Error; err != nil { + response.Error(c, 404, "Webhook配置不存在") + return + } + + webhookName := webhook.Name + webhookID := webhook.ID + + if err := database.DB.Delete(&webhook).Error; err != nil { + response.Error(c, 500, "删除Webhook配置失败") + return + } + + service.LogOperation(c, "delete", "webhook", &webhookID, fmt.Sprintf("删除Webhook: %s", webhookName), nil) + + response.Success(c, nil) +} + +func handleGetWebhookLogs(c *gin.Context) { + userID := c.GetUint("user_id") + webhookID := c.Query("webhook_id") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + var logs []model.WebhookLog + var total int64 + + query := database.DB.Model(&model.WebhookLog{}). + Joins("JOIN webhook_configs ON webhook_logs.webhook_id = webhook_configs.id"). + Joins("JOIN applications ON webhook_configs.application_id = applications.id"). + Where("applications.user_id = ?", userID) + + if webhookID != "" { + query = query.Where("webhook_logs.webhook_id = ?", webhookID) + } + + query.Count(&total) + + offset := (page - 1) * pageSize + if err := query.Order("webhook_logs.created_at DESC"). + Offset(offset).Limit(pageSize). + Find(&logs).Error; err != nil { + response.Error(c, 500, "获取Webhook日志失败") + return + } + + response.Success(c, gin.H{ + "logs": logs, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +func handleTestWebhook(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var webhook model.WebhookConfig + if err := database.DB.Joins("JOIN applications ON webhook_configs.application_id = applications.id"). + Where("webhook_configs.id = ? AND applications.user_id = ?", id, userID). + First(&webhook).Error; err != nil { + response.Error(c, 404, "Webhook配置不存在") + return + } + + testData := map[string]interface{}{ + "event": "test", + "timestamp": time.Now().Unix(), + "data": map[string]interface{}{ + "message": "This is a test webhook", + }, + } + + go sendWebhook(&webhook, testData) + + response.Success(c, gin.H{"message": "测试请求已发送"}) +} + +func handleGetAPIKeys(c *gin.Context) { + userID := c.GetUint("user_id") + appID := c.Query("application_id") + + var apiKeys []struct { + model.ExtensionAPIKey + ApplicationName string `json:"application_name"` + } + + query := database.DB.Model(&model.ExtensionAPIKey{}). + Select("extension_api_keys.*, applications.name as application_name"). + Joins("JOIN applications ON extension_api_keys.application_id = applications.id"). + Where("applications.user_id = ?", userID) + + if appID != "" && appID != "all" { + query = query.Where("extension_api_keys.application_id = ?", appID) + } + + if err := query.Find(&apiKeys).Error; err != nil { + response.Error(c, 500, "获取API密钥失败") + return + } + + response.Success(c, apiKeys) +} + +func handleCreateAPIKey(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + ApplicationID uint `json:"application_id" binding:"required"` + Name string `json:"name" binding:"required"` + Permissions []string `json:"permissions"` + ExpiresAt *string `json:"expires_at"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + accessKey := generateRandomKey(32) + secretKey := generateRandomKey(32) + + permissionsJSON, _ := json.Marshal(req.Permissions) + + apiKey := model.ExtensionAPIKey{ + ApplicationID: req.ApplicationID, + Name: req.Name, + AccessKey: accessKey, + SecretKey: secretKey, + Permissions: string(permissionsJSON), + Status: "active", + } + + if req.ExpiresAt != nil { + expiresAt, err := time.Parse(time.RFC3339, *req.ExpiresAt) + if err == nil { + apiKey.ExpiresAt = &expiresAt + } + } + + if err := database.DB.Create(&apiKey).Error; err != nil { + response.Error(c, 500, "创建API密钥失败") + return + } + + response.Success(c, apiKey) +} + +func handleUpdateAPIKey(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var req struct { + Name string `json:"name"` + Permissions []string `json:"permissions"` + Status string `json:"status"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var apiKey model.ExtensionAPIKey + if err := database.DB.Joins("JOIN applications ON extension_api_keys.application_id = applications.id"). + Where("extension_api_keys.id = ? AND applications.user_id = ?", id, userID). + First(&apiKey).Error; err != nil { + response.Error(c, 404, "API密钥不存在") + return + } + + updates := make(map[string]interface{}) + if req.Name != "" { + updates["name"] = req.Name + } + if len(req.Permissions) > 0 { + permissionsJSON, _ := json.Marshal(req.Permissions) + updates["permissions"] = string(permissionsJSON) + } + if req.Status != "" { + updates["status"] = req.Status + } + + if err := database.DB.Model(&apiKey).Updates(updates).Error; err != nil { + response.Error(c, 500, "更新API密钥失败") + return + } + + response.Success(c, apiKey) +} + +func handleDeleteAPIKey(c *gin.Context) { + userID := c.GetUint("user_id") + + id := c.Param("id") + + var apiKey model.ExtensionAPIKey + if err := database.DB.Joins("JOIN applications ON extension_api_keys.application_id = applications.id"). + Where("extension_api_keys.id = ? AND applications.user_id = ?", id, userID). + First(&apiKey).Error; err != nil { + response.Error(c, 404, "API密钥不存在") + return + } + + if err := database.DB.Delete(&apiKey).Error; err != nil { + response.Error(c, 500, "删除API密钥失败") + return + } + + response.Success(c, nil) +} + +func handleRegenerateAPIKey(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var apiKey model.ExtensionAPIKey + if err := database.DB.Joins("JOIN applications ON extension_api_keys.application_id = applications.id"). + Where("extension_api_keys.id = ? AND applications.user_id = ?", id, userID). + First(&apiKey).Error; err != nil { + response.Error(c, 404, "API密钥不存在") + return + } + + newSecretKey := generateRandomKey(32) + if err := database.DB.Model(&apiKey).Update("secret_key", newSecretKey).Error; err != nil { + response.Error(c, 500, "重新生成密钥失败") + return + } + + apiKey.SecretKey = newSecretKey + response.Success(c, apiKey) +} + +func generateRandomKey(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "" + } + return hex.EncodeToString(bytes)[:length*2] +} + +func sendWebhook(webhook *model.WebhookConfig, data map[string]interface{}) { + jsonData, _ := json.Marshal(data) + + startTime := time.Now() + client := &http.Client{ + Timeout: time.Duration(webhook.Timeout) * time.Second, + } + + req, err := http.NewRequest("POST", webhook.URL, bytes.NewReader(jsonData)) + if err != nil { + logWebhookError(webhook.ID, data, err, time.Since(startTime).Milliseconds()) + return + } + + req.Header.Set("Content-Type", "application/json") + if webhook.SecretKey != "" { + req.Header.Set("X-Webhook-Secret", webhook.SecretKey) + } + + resp, err := client.Do(req) + duration := time.Since(startTime).Milliseconds() + + if err != nil { + logWebhookError(webhook.ID, data, err, duration) + return + } + defer resp.Body.Close() + + logWebhookSuccess(webhook.ID, data, resp.StatusCode, duration) +} + +func logWebhookSuccess(webhookID uint, requestData map[string]interface{}, statusCode int, duration int64) { + requestJSON, _ := json.Marshal(requestData) + log := model.WebhookLog{ + WebhookID: webhookID, + Event: requestData["event"].(string), + RequestData: string(requestJSON), + ResponseCode: statusCode, + Status: "success", + Duration: int(duration), + } + database.DB.Create(&log) +} + +func logWebhookError(webhookID uint, requestData map[string]interface{}, err error, duration int64) { + requestJSON, _ := json.Marshal(requestData) + log := model.WebhookLog{ + WebhookID: webhookID, + Event: requestData["event"].(string), + RequestData: string(requestJSON), + Status: "failed", + ErrorMessage: err.Error(), + Duration: int(duration), + } + database.DB.Create(&log) +} + +func handleUpdateWebhookStatus(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var req struct { + Status string `json:"status" binding:"required,oneof=active inactive"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var webhook model.WebhookConfig + if err := database.DB.Joins("JOIN applications ON webhook_configs.application_id = applications.id"). + Where("webhook_configs.id = ? AND applications.user_id = ?", id, userID). + First(&webhook).Error; err != nil { + response.Error(c, 404, "Webhook配置不存在") + return + } + + if err := database.DB.Model(&webhook).Update("status", req.Status).Error; err != nil { + response.Error(c, 500, "更新状态失败") + return + } + + response.Success(c, webhook) +} + +func handleBatchUpdateWebhookStatus(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + Status string `json:"status" binding:"required,oneof=active inactive"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要更新的Webhook") + return + } + + result := database.DB.Model(&model.WebhookConfig{}). + Joins("JOIN applications ON webhook_configs.application_id = applications.id"). + Where("applications.user_id = ? AND webhook_configs.id IN ?", userID, req.IDs). + Update("status", req.Status) + + if result.Error != nil { + response.Error(c, 500, "批量更新状态失败") + return + } + + response.Success(c, gin.H{"updated_count": result.RowsAffected}) +} + +func handleBatchDeleteWebhooks(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要删除的Webhook") + return + } + + result := database.DB.Where("id IN (?) AND application_id IN (SELECT id FROM applications WHERE user_id = ?)", req.IDs, userID). + Delete(&model.WebhookConfig{}) + + if result.Error != nil { + response.Error(c, 500, "批量删除失败") + return + } + + response.Success(c, gin.H{"deleted_count": result.RowsAffected}) +} + +func handleUpdateAPIKeyStatus(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var req struct { + Status string `json:"status" binding:"required,oneof=active inactive"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var apiKey model.ExtensionAPIKey + if err := database.DB.Joins("JOIN applications ON extension_api_keys.application_id = applications.id"). + Where("extension_api_keys.id = ? AND applications.user_id = ?", id, userID). + First(&apiKey).Error; err != nil { + response.Error(c, 404, "API密钥不存在") + return + } + + if err := database.DB.Model(&apiKey).Update("status", req.Status).Error; err != nil { + response.Error(c, 500, "更新状态失败") + return + } + + response.Success(c, apiKey) +} + +func handleBatchUpdateAPIKeyStatus(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + Status string `json:"status" binding:"required,oneof=active inactive"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要更新的API密钥") + return + } + + result := database.DB.Model(&model.ExtensionAPIKey{}). + Joins("JOIN applications ON extension_api_keys.application_id = applications.id"). + Where("applications.user_id = ? AND extension_api_keys.id IN ?", userID, req.IDs). + Update("status", req.Status) + + if result.Error != nil { + response.Error(c, 500, "批量更新状态失败") + return + } + + response.Success(c, gin.H{"updated_count": result.RowsAffected}) +} + +func handleBatchDeleteAPIKeys(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要删除的API密钥") + return + } + + result := database.DB.Where("id IN (?) AND application_id IN (SELECT id FROM applications WHERE user_id = ?)", req.IDs, userID). + Delete(&model.ExtensionAPIKey{}) + + if result.Error != nil { + response.Error(c, 500, "批量删除失败") + return + } + + response.Success(c, gin.H{"deleted_count": result.RowsAffected}) +} diff --git a/backend/internal/router/developer/finance.go b/backend/internal/router/developer/finance.go new file mode 100644 index 0000000..a779b75 --- /dev/null +++ b/backend/internal/router/developer/finance.go @@ -0,0 +1,526 @@ +package developer + +import ( + "fmt" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type FinanceRecordResponse struct { + ID uint `json:"id"` + OrderNo string `json:"order_no"` + Type string `json:"type"` + UserID uint `json:"user_id"` + AppID uint `json:"app_id"` + Amount float64 `json:"amount"` + Detail string `json:"detail"` + Status string `json:"status"` + PaymentType string `json:"payment_type"` + Remark string `json:"remark"` + CreatedAt string `json:"created_at"` + User *struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + } `json:"user,omitempty"` +} + +func SetupFinanceRoutes(r *gin.RouterGroup) { + finance := r.Group("/finance") + { + finance.GET("/stats", handleGetFinanceStatistics) + finance.GET("/statistics", handleGetFinanceStatistics) + finance.GET("/recharge-records", handleGetRechargeRecords) + finance.GET("/consumption-records", handleGetConsumptionRecords) + finance.GET("/records", handleGetFinanceRecords) + finance.DELETE("/records/:id", handleDeleteFinanceRecord) + finance.POST("/records/batch-delete", handleBatchDeleteFinanceRecords) + } +} + +func handleGetFinanceStatistics(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + + if len(appIDs) == 0 { + response.Success(c, gin.H{ + "total_income": 0, + "total_expense": 0, + "net_profit": 0, + "monthly_transactions": 0, + }) + return + } + + var appUserIDs []uint + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Pluck("id", &appUserIDs) + + if len(appUserIDs) == 0 { + response.Success(c, gin.H{ + "total_income": 0, + "total_expense": 0, + "net_profit": 0, + "monthly_transactions": 0, + }) + return + } + + var totalIncome float64 + var totalExpense float64 + + database.DB.Model(&model.RechargeRecord{}). + Where("user_id IN ? AND status = ?", appUserIDs, "success"). + Select("COALESCE(SUM(amount), 0)"). + Scan(&totalIncome) + + database.DB.Model(&model.ConsumptionRecord{}). + Where("user_id IN ? AND status = ?", appUserIDs, "success"). + Select("COALESCE(SUM(amount), 0)"). + Scan(&totalExpense) + + response.Success(c, gin.H{ + "total_income": totalIncome, + "total_expense": totalExpense, + "net_profit": totalIncome - totalExpense, + }) +} + +func handleGetRechargeRecords(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + + if len(appIDs) == 0 { + response.Success(c, gin.H{ + "records": []model.RechargeRecord{}, + "total": 0, + }) + return + } + + var appUserIDs []uint + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Pluck("id", &appUserIDs) + + if len(appUserIDs) == 0 { + response.Success(c, gin.H{ + "records": []model.RechargeRecord{}, + "total": 0, + }) + return + } + + var records []model.RechargeRecord + query := database.DB.Model(&model.RechargeRecord{}).Preload("AppUser").Where("user_id IN ?", appUserIDs) + + search := c.Query("search") + status := c.Query("status") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + if search != "" { + query = query.Where("order_no LIKE ? OR card_code LIKE ?", "%"+search+"%", "%"+search+"%") + } + if status != "" && status != "all" { + query = query.Where("status = ?", status) + } + if startDate != "" { + query = query.Where("created_at >= ?", startDate) + } + if endDate != "" { + query = query.Where("created_at <= ?", endDate+" 23:59:59") + } + + if err := query.Order("created_at DESC").Find(&records).Error; err != nil { + response.Error(c, 500, "获取充值记录失败") + return + } + response.Success(c, gin.H{ + "records": records, + "total": len(records), + }) +} + +func handleGetConsumptionRecords(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + + if len(appIDs) == 0 { + response.Success(c, gin.H{ + "records": []model.ConsumptionRecord{}, + "total": 0, + }) + return + } + + var appUserIDs []uint + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Pluck("id", &appUserIDs) + + if len(appUserIDs) == 0 { + response.Success(c, gin.H{ + "records": []model.ConsumptionRecord{}, + "total": 0, + }) + return + } + + var records []model.ConsumptionRecord + query := database.DB.Model(&model.ConsumptionRecord{}).Preload("AppUser").Where("user_id IN ?", appUserIDs) + + search := c.Query("search") + recordType := c.Query("type") + status := c.Query("status") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + if search != "" { + query = query.Where("order_no LIKE ? OR content LIKE ?", "%"+search+"%", "%"+search+"%") + } + if recordType != "" && recordType != "all" { + query = query.Where("type = ?", recordType) + } + if status != "" && status != "all" { + query = query.Where("status = ?", status) + } + if startDate != "" { + query = query.Where("created_at >= ?", startDate) + } + if endDate != "" { + query = query.Where("created_at <= ?", endDate+" 23:59:59") + } + + if err := query.Order("created_at DESC").Find(&records).Error; err != nil { + response.Error(c, 500, "获取消费记录失败") + return + } + response.Success(c, gin.H{ + "records": records, + "total": len(records), + }) +} + +func handleGetFinanceRecords(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + + if len(appIDs) == 0 { + response.Success(c, gin.H{ + "recharge_records": []FinanceRecordResponse{}, + "consumption_records": []FinanceRecordResponse{}, + "total": 0, + }) + return + } + + var appUserIDs []uint + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Pluck("id", &appUserIDs) + + if len(appUserIDs) == 0 { + response.Success(c, gin.H{ + "recharge_records": []FinanceRecordResponse{}, + "consumption_records": []FinanceRecordResponse{}, + "total": 0, + }) + return + } + + page := c.DefaultQuery("page", "1") + pageSize := c.DefaultQuery("page_size", "10") + search := c.Query("search") + recordType := c.Query("type") + status := c.Query("status") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + var rechargeRecords []model.RechargeRecord + rechargeQuery := database.DB.Model(&model.RechargeRecord{}).Preload("AppUser").Where("user_id IN ?", appUserIDs) + + if search != "" { + rechargeQuery = rechargeQuery.Where("order_no LIKE ? OR card_code LIKE ?", "%"+search+"%", "%"+search+"%") + } + if recordType == "recharge" || recordType == "" || recordType == "all" { + if status != "" && status != "all" { + rechargeQuery = rechargeQuery.Where("status = ?", status) + } + if startDate != "" { + rechargeQuery = rechargeQuery.Where("created_at >= ?", startDate) + } + if endDate != "" { + rechargeQuery = rechargeQuery.Where("created_at <= ?", endDate+" 23:59:59") + } + if err := rechargeQuery.Order("created_at DESC").Find(&rechargeRecords).Error; err != nil { + response.Error(c, 500, "获取充值记录失败") + return + } + } + + var consumptionRecords []model.ConsumptionRecord + consumptionQuery := database.DB.Model(&model.ConsumptionRecord{}).Preload("AppUser").Where("user_id IN ?", appUserIDs) + + if search != "" { + consumptionQuery = consumptionQuery.Where("order_no LIKE ? OR content LIKE ?", "%"+search+"%", "%"+search+"%") + } + if recordType == "consumption" || recordType == "" || recordType == "all" { + if status != "" && status != "all" { + consumptionQuery = consumptionQuery.Where("status = ?", status) + } + if startDate != "" { + consumptionQuery = consumptionQuery.Where("created_at >= ?", startDate) + } + if endDate != "" { + consumptionQuery = consumptionQuery.Where("created_at <= ?", endDate+" 23:59:59") + } + if err := consumptionQuery.Order("created_at DESC").Find(&consumptionRecords).Error; err != nil { + response.Error(c, 500, "获取消费记录失败") + return + } + } + + var allRecords []FinanceRecordResponse + for _, r := range rechargeRecords { + var user *struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + } + if r.AppUser != nil && r.AppUser.ID != 0 { + user = &struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + }{ + ID: r.AppUser.ID, + Username: r.AppUser.Username, + Email: r.AppUser.Email, + } + } + allRecords = append(allRecords, FinanceRecordResponse{ + ID: r.ID, + OrderNo: r.OrderNo, + Type: "recharge", + UserID: r.UserID, + AppID: r.AppUser.ApplicationID, + Amount: r.Amount, + Detail: r.CardCode, + Status: r.Status, + PaymentType: r.PaymentType, + Remark: r.Remark, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + User: user, + }) + } + + for _, r := range consumptionRecords { + var user *struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + } + if r.AppUser != nil && r.AppUser.ID != 0 { + user = &struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + }{ + ID: r.AppUser.ID, + Username: r.AppUser.Username, + Email: r.AppUser.Email, + } + } + allRecords = append(allRecords, FinanceRecordResponse{ + ID: r.ID, + OrderNo: r.OrderNo, + Type: "consumption", + UserID: r.UserID, + AppID: r.AppUser.ApplicationID, + Amount: r.Amount, + Detail: r.Content, + Status: r.Status, + PaymentType: r.PaymentType, + Remark: r.Remark, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + User: user, + }) + } + + total := len(allRecords) + start := 0 + end := total + if p, err := parseInt(page); err == nil && p > 0 { + if ps, err := parseInt(pageSize); err == nil && ps > 0 { + start = (p - 1) * ps + end = start + ps + if start > total { + start = total + } + if end > total { + end = total + } + } + } + + if start > end { + start = end + } + + response.Success(c, gin.H{ + "records": allRecords[start:end], + "total": total, + }) +} + +func parseInt(s string) (int, error) { + var result int + _, err := fmt.Sscanf(s, "%d", &result) + return result, err +} + +func handleDeleteFinanceRecord(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + recordID := c.Param("id") + recordType := c.Query("type") + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + if len(appIDs) == 0 { + response.Error(c, 404, "记录不存在") + return + } + + var appUserIDs []uint + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Pluck("id", &appUserIDs) + if len(appUserIDs) == 0 { + response.Error(c, 404, "记录不存在") + return + } + + var deletedType string + var deletedAmount float64 + + if recordType == "recharge" { + var record model.RechargeRecord + if err := database.DB.Where("id = ? AND user_id IN ?", recordID, appUserIDs).First(&record).Error; err != nil { + response.Error(c, 404, "记录不存在") + return + } + if err := database.DB.Delete(&record).Error; err != nil { + response.Error(c, 500, "删除失败") + return + } + deletedType = "充值记录" + deletedAmount = record.Amount + } else if recordType == "consumption" { + var record model.ConsumptionRecord + if err := database.DB.Where("id = ? AND user_id IN ?", recordID, appUserIDs).First(&record).Error; err != nil { + response.Error(c, 404, "记录不存在") + return + } + if err := database.DB.Delete(&record).Error; err != nil { + response.Error(c, 500, "删除失败") + return + } + deletedType = "消费记录" + deletedAmount = record.Amount + } else { + var rechargeRecord model.RechargeRecord + var consumptionRecord model.ConsumptionRecord + + if err := database.DB.Where("id = ? AND user_id IN ?", recordID, appUserIDs).First(&rechargeRecord).Error; err == nil { + database.DB.Delete(&rechargeRecord) + deletedType = "充值记录" + deletedAmount = rechargeRecord.Amount + service.LogOperation(c, "delete", "finance_record", nil, fmt.Sprintf("删除%s: %.2f", deletedType, deletedAmount), nil) + response.Success(c, nil) + return + } + + if err := database.DB.Where("id = ? AND user_id IN ?", recordID, appUserIDs).First(&consumptionRecord).Error; err == nil { + database.DB.Delete(&consumptionRecord) + deletedType = "消费记录" + deletedAmount = consumptionRecord.Amount + service.LogOperation(c, "delete", "finance_record", nil, fmt.Sprintf("删除%s: %.2f", deletedType, deletedAmount), nil) + response.Success(c, nil) + return + } + + response.Error(c, 404, "记录不存在") + return + } + + service.LogOperation(c, "delete", "finance_record", nil, fmt.Sprintf("删除%s: %.2f", deletedType, deletedAmount), nil) + + response.Success(c, nil) +} + +func handleBatchDeleteFinanceRecords(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var req struct { + IDs []uint `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要删除的记录") + return + } + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + if len(appIDs) == 0 { + response.Error(c, 404, "记录不存在") + return + } + + var appUserIDs []uint + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Pluck("id", &appUserIDs) + if len(appUserIDs) == 0 { + response.Error(c, 404, "记录不存在") + return + } + + database.DB.Where("id IN ? AND user_id IN ?", req.IDs, appUserIDs).Delete(&model.RechargeRecord{}) + database.DB.Where("id IN ? AND user_id IN ?", req.IDs, appUserIDs).Delete(&model.ConsumptionRecord{}) + + service.LogOperation(c, "batch_delete", "finance_record", nil, fmt.Sprintf("批量删除财务记录: %d条", len(req.IDs)), nil) + + response.Success(c, gin.H{"deleted": len(req.IDs)}) +} diff --git a/backend/internal/router/developer/logs.go b/backend/internal/router/developer/logs.go new file mode 100644 index 0000000..c33d5cb --- /dev/null +++ b/backend/internal/router/developer/logs.go @@ -0,0 +1,84 @@ +package developer + +import ( + "strconv" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupLogRoutes(r *gin.RouterGroup) { + logs := r.Group("/logs") + { + logs.GET("", handleGetLogs) + } +} + +func handleGetLogs(c *gin.Context) { + userID := c.GetUint("user_id") + + var appIDs []uint + database.DB.Model(&model.Application{}).Where("user_id = ?", userID).Pluck("id", &appIDs) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + logType := c.Query("type") + status := c.Query("status") + applicationID := c.Query("application_id") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + search := c.Query("search") + + var logs []model.Log + var total int64 + + query := database.DB.Model(&model.Log{}) + + if len(appIDs) > 0 { + query = query.Where("application_id IN ? OR user_id = ?", appIDs, userID) + } else { + query = query.Where("user_id = ?", userID) + } + + if logType != "" && logType != "all" { + query = query.Where("log_type = ?", logType) + } + + if status != "" && status != "all" { + query = query.Where("status = ?", status) + } + + if applicationID != "" && applicationID != "all" { + query = query.Where("application_id = ?", applicationID) + } + + if startDate != "" { + query = query.Where("created_at >= ?", startDate+" 00:00:00") + } + + if endDate != "" { + query = query.Where("created_at <= ?", endDate+" 23:59:59") + } + + if search != "" { + query = query.Where("action LIKE ? OR details LIKE ? OR resource LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%") + } + + query.Count(&total) + + offset := (page - 1) * pageSize + if err := query.Preload("User").Preload("Application").Preload("AppUser").Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + response.Error(c, 500, "获取日志失败") + return + } + + response.Success(c, gin.H{ + "logs": logs, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} diff --git a/backend/internal/router/developer/orders.go b/backend/internal/router/developer/orders.go new file mode 100644 index 0000000..3215e85 --- /dev/null +++ b/backend/internal/router/developer/orders.go @@ -0,0 +1,282 @@ +package developer + +import ( + "fmt" + "strconv" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupOrderRoutes(r *gin.RouterGroup) { + r.GET("/orders", handleGetOrders) + r.GET("/orders/:id", handleGetOrder) + r.POST("/orders/:id/refund", handleRefundOrder) + r.GET("/orders/stats", handleGetOrderStats) +} + +func handleGetOrders(c *gin.Context) { + userID := c.GetUint("user_id") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + orderType := c.Query("order_type") + status := c.Query("status") + applicationID := c.Query("application_id") + search := c.Query("search") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + var orders []model.Order + var total int64 + + query := database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID) + + if orderType != "" { + query = query.Where("orders.order_type = ?", orderType) + } + + if status != "" { + query = query.Where("orders.status = ?", status) + } + + if applicationID != "" && applicationID != "all" { + query = query.Where("orders.application_id = ?", applicationID) + } + + if search != "" { + query = query.Where("orders.order_no LIKE ? OR orders.title LIKE ?", "%"+search+"%", "%"+search+"%") + } + + if startDate != "" { + query = query.Where("orders.created_at >= ?", startDate+" 00:00:00") + } + + if endDate != "" { + query = query.Where("orders.created_at <= ?", endDate+" 23:59:59") + } + + query.Count(&total) + + offset := (page - 1) * pageSize + if err := query.Preload("User").Preload("Application"). + Order("orders.created_at DESC"). + Offset(offset).Limit(pageSize). + Find(&orders).Error; err != nil { + response.Error(c, 500, "获取订单列表失败") + return + } + + response.Success(c, gin.H{ + "orders": orders, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +func handleGetOrder(c *gin.Context) { + userID := c.GetUint("user_id") + orderID := c.Param("id") + + var order model.Order + if err := database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("orders.id = ? AND (applications.user_id = ? OR orders.application_id IS NULL)", orderID, userID). + Preload("User").Preload("Application"). + First(&order).Error; err != nil { + response.Error(c, 404, "订单不存在") + return + } + + response.Success(c, order) +} + +func handleRefundOrder(c *gin.Context) { + userID := c.GetUint("user_id") + orderID := c.Param("id") + + var req struct { + Reason string `json:"reason" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var order model.Order + if err := database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("orders.id = ? AND (applications.user_id = ? OR orders.application_id IS NULL)", orderID, userID). + First(&order).Error; err != nil { + response.Error(c, 404, "订单不存在") + return + } + + if order.Status != "paid" { + response.Error(c, 400, "只能退款已支付的订单") + return + } + + tx := database.DB.Begin() + + now := time.Now() + order.Status = "refunded" + order.RefundAt = &now + order.RefundReason = req.Reason + + if err := tx.Save(&order).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "退款失败") + return + } + + switch order.OrderType { + case "card_recharge": + var rechargeRecord model.RechargeRecord + if err := tx.Where("order_no = ?", order.OrderNo).First(&rechargeRecord).Error; err == nil { + rechargeRecord.Status = "refunded" + tx.Save(&rechargeRecord) + } + } + + tx.Commit() + + response.Success(c, gin.H{ + "message": "退款成功", + "order": order, + }) +} + +func handleGetOrderStats(c *gin.Context) { + userID := c.GetUint("user_id") + applicationID := c.Query("application_id") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + query := database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID) + + if applicationID != "" && applicationID != "all" { + query = query.Where("orders.application_id = ?", applicationID) + } + + if startDate != "" { + query = query.Where("orders.created_at >= ?", startDate+" 00:00:00") + } + + if endDate != "" { + query = query.Where("orders.created_at <= ?", endDate+" 23:59:59") + } + + var totalOrders, pendingOrders, paidOrders, refundedOrders int64 + var totalAmount, paidAmount, refundedAmount float64 + + query.Count(&totalOrders) + + database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID). + Where("orders.status = ?", "pending"). + Count(&pendingOrders) + + database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID). + Where("orders.status = ?", "paid"). + Count(&paidOrders) + + database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID). + Where("orders.status = ?", "refunded"). + Count(&refundedOrders) + + database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID). + Where("orders.status IN ?", []string{"paid", "refunded"}). + Select("COALESCE(SUM(amount), 0)"). + Scan(&totalAmount) + + database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID). + Where("orders.status = ?", "paid"). + Select("COALESCE(SUM(amount), 0)"). + Scan(&paidAmount) + + database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID). + Where("orders.status = ?", "refunded"). + Select("COALESCE(SUM(amount), 0)"). + Scan(&refundedAmount) + + var typeStats []struct { + OrderType string + Count int64 + TotalAmount float64 + } + + database.DB.Model(&model.Order{}). + Joins("LEFT JOIN applications ON orders.application_id = applications.id"). + Where("applications.user_id = ? OR orders.application_id IS NULL", userID). + Select("order_type, COUNT(*) as count, COALESCE(SUM(amount), 0) as total_amount"). + Group("order_type"). + Scan(&typeStats) + + response.Success(c, gin.H{ + "total_orders": totalOrders, + "pending_orders": pendingOrders, + "paid_orders": paidOrders, + "refunded_orders": refundedOrders, + "total_amount": totalAmount, + "paid_amount": paidAmount, + "refunded_amount": refundedAmount, + "type_stats": typeStats, + }) +} + +func CreateOrder(orderType string, userID uint, applicationID *uint, title string, amount float64, paymentType string, description string) (*model.Order, error) { + orderNo := fmt.Sprintf("ORD%d%d", time.Now().Unix(), userID) + + order := model.Order{ + OrderNo: orderNo, + UserID: userID, + ApplicationID: applicationID, + OrderType: orderType, + Title: title, + Amount: amount, + PaymentType: paymentType, + Status: "pending", + Description: description, + } + + if err := database.DB.Create(&order).Error; err != nil { + return nil, err + } + + return &order, nil +} + +func PayOrder(orderNo string) error { + var order model.Order + if err := database.DB.Where("order_no = ?", orderNo).First(&order).Error; err != nil { + return err + } + + now := time.Now() + order.Status = "paid" + order.PaymentAt = &now + + return database.DB.Save(&order).Error +} diff --git a/backend/internal/router/developer/profile.go b/backend/internal/router/developer/profile.go new file mode 100644 index 0000000..6f1b016 --- /dev/null +++ b/backend/internal/router/developer/profile.go @@ -0,0 +1,383 @@ +package developer + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +func SetupProfileRoutes(r *gin.RouterGroup) { + profile := r.Group("/profile") + { + profile.GET("", handleGetProfile) + profile.PUT("", handleUpdateProfile) + profile.PUT("/password", handleChangePassword) + profile.POST("/avatar", handleUploadAvatar) + profile.POST("/api-token", handleGenerateApiToken) + } +} + +func handleGetProfile(c *gin.Context) { + userID := c.GetUint("user_id") + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + profile := struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Phone string `json:"phone"` + Avatar string `json:"avatar"` + Role string `json:"role"` + Status string `json:"status"` + ApiToken string `json:"api_token"` + CreatedAt time.Time `json:"created_at"` + LastLoginAt *time.Time `json:"last_login_at"` + }{ + ID: user.ID, + Username: user.Username, + Email: "", + Phone: "", + Avatar: user.Avatar, + Role: user.Role, + Status: user.Status, + ApiToken: user.ApiToken, + CreatedAt: user.CreatedAt, + LastLoginAt: user.LastLoginAt, + } + + if user.Email != nil { + profile.Email = *user.Email + } + + subscription := getSubscriptionInfo(&user) + + transactions := getRecentTransactions(userID) + + response.Success(c, gin.H{ + "user": profile, + "subscription": subscription, + "transactions": transactions, + }) +} + +func getSubscriptionInfo(user *model.User) gin.H { + var pkg model.Package + var packagePermission model.PackagePermission + + defaultQuota := 10000 + defaultStorage := int64(100 * 1024 * 1024) + + if user.CurrentPackageID != nil { + if err := database.DB.First(&pkg, *user.CurrentPackageID).Error; err == nil { + database.DB.Where("package_id = ?", pkg.ID).First(&packagePermission) + } + } + + planName := "基础版" + if pkg.ID > 0 { + planName = pkg.Name + } + + apiQuota := defaultQuota + if packagePermission.MaxApiCalls > 0 { + apiQuota = packagePermission.MaxApiCalls + } + + storageQuota := defaultStorage + if packagePermission.MaxStorage > 0 { + storageQuota = int64(packagePermission.MaxStorage) * 1024 * 1024 + } + + var expireDate string + if pkg.Period == "monthly" { + expireDate = time.Now().AddDate(0, 1, 0).Format("2006-01-02") + } else if pkg.Period == "yearly" { + expireDate = time.Now().AddDate(1, 0, 0).Format("2006-01-02") + } else { + expireDate = "永久有效" + } + + return gin.H{ + "plan": planName, + "status": "active", + "expire_date": expireDate, + "api_quota": apiQuota, + "api_used": user.ApiCallsUsed, + "storage_quota": storageQuota, + "storage_used": user.StorageUsed, + } +} + +func getRecentTransactions(userID uint) []gin.H { + var orders []model.Order + database.DB.Where("user_id = ? AND status = ?", userID, "paid"). + Order("created_at DESC"). + Limit(5). + Find(&orders) + + transactions := make([]gin.H, 0, len(orders)) + for _, order := range orders { + txType := "consume" + if order.OrderType == "user_recharge" || order.OrderType == "card_recharge" { + txType = "recharge" + } else if order.OrderType == "refund" { + txType = "refund" + } + + transactions = append(transactions, gin.H{ + "id": order.ID, + "type": txType, + "amount": order.Amount, + "description": order.Title, + "created_at": order.CreatedAt, + }) + } + + return transactions +} + +type UpdateProfileRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Phone string `json:"phone"` +} + +func handleUpdateProfile(c *gin.Context) { + userID := c.GetUint("user_id") + + var req UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "请求参数错误") + return + } + + if req.Username == "" { + response.Error(c, 400, "用户名不能为空") + return + } + + if req.Email == "" { + response.Error(c, 400, "邮箱不能为空") + return + } + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var existingUser model.User + if err := database.DB.Where("username = ? AND id != ?", req.Username, userID).First(&existingUser).Error; err == nil { + response.Error(c, 400, "用户名已被使用") + return + } + + if err := database.DB.Where("email = ? AND id != ?", req.Email, userID).First(&existingUser).Error; err == nil { + response.Error(c, 400, "邮箱已被使用") + return + } + + user.Username = req.Username + email := req.Email + user.Email = &email + + if err := database.DB.Save(&user).Error; err != nil { + log.Printf("更新用户信息失败: %v", err) + response.Error(c, 500, "更新用户信息失败") + return + } + + response.Success(c, gin.H{ + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "avatar": user.Avatar, + "role": user.Role, + "status": user.Status, + }, + }) +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func handleChangePassword(c *gin.Context) { + userID := c.GetUint("user_id") + + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "请求参数错误") + return + } + + if req.CurrentPassword == "" { + response.Error(c, 400, "请输入当前密码") + return + } + + if req.NewPassword == "" { + response.Error(c, 400, "请输入新密码") + return + } + + if len(req.NewPassword) < 6 { + response.Error(c, 400, "密码长度至少6位") + return + } + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.CurrentPassword)); err != nil { + response.Error(c, 400, "当前密码错误") + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("密码加密失败: %v", err) + response.Error(c, 500, "密码加密失败") + return + } + + user.Password = string(hashedPassword) + if err := database.DB.Save(&user).Error; err != nil { + log.Printf("更新密码失败: %v", err) + response.Error(c, 500, "更新密码失败") + return + } + + response.Success(c, gin.H{"message": "密码修改成功"}) +} + +func handleUploadAvatar(c *gin.Context) { + userID := c.GetUint("user_id") + + file, header, err := c.Request.FormFile("avatar") + if err != nil { + response.Error(c, 400, "请选择要上传的文件") + return + } + defer file.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + allowedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, + } + + if !allowedExts[ext] { + response.Error(c, 400, "不支持的文件格式,仅支持 JPG、PNG、GIF、WEBP") + return + } + + const maxSize = 2 * 1024 * 1024 + if header.Size > maxSize { + response.Error(c, 400, "文件大小不能超过2MB") + return + } + + uploadDir := "uploads/avatars" + if err := os.MkdirAll(uploadDir, 0755); err != nil { + log.Printf("创建上传目录失败: %v", err) + response.Error(c, 500, "创建上传目录失败") + return + } + + filename := fmt.Sprintf("%d_%d%s", userID, time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + + dst, err := os.Create(filePath) + if err != nil { + log.Printf("创建文件失败: %v", err) + response.Error(c, 500, "创建文件失败") + return + } + defer dst.Close() + + if _, err := io.Copy(dst, file); err != nil { + log.Printf("保存文件失败: %v", err) + response.Error(c, 500, "保存文件失败") + return + } + + avatarURL := "/uploads/avatars/" + filename + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + if user.Avatar != "" && strings.HasPrefix(user.Avatar, "/uploads/avatars/") { + oldPath := "." + user.Avatar + if _, err := os.Stat(oldPath); err == nil { + os.Remove(oldPath) + } + } + + user.Avatar = avatarURL + if err := database.DB.Save(&user).Error; err != nil { + log.Printf("更新头像失败: %v", err) + response.Error(c, 500, "更新头像失败") + return + } + + response.Success(c, gin.H{"avatar": avatarURL}) +} + +func handleGenerateApiToken(c *gin.Context) { + userID := c.GetUint("user_id") + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + log.Printf("生成API Token失败: %v", err) + response.Error(c, 500, "生成API Token失败") + return + } + apiToken := hex.EncodeToString(bytes) + + user.ApiToken = apiToken + if err := database.DB.Save(&user).Error; err != nil { + log.Printf("保存API Token失败: %v", err) + response.Error(c, 500, "保存API Token失败") + return + } + + response.Success(c, gin.H{ + "api_token": apiToken, + }) +} diff --git a/backend/internal/router/developer/tickets.go b/backend/internal/router/developer/tickets.go new file mode 100644 index 0000000..8c4bd1f --- /dev/null +++ b/backend/internal/router/developer/tickets.go @@ -0,0 +1,413 @@ +package developer + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupTicketRoutes(r *gin.RouterGroup) { + tickets := r.Group("/tickets") + { + tickets.GET("/stats", handleGetTicketStats) + tickets.GET("", handleGetTickets) + tickets.GET("/:id", handleGetTicket) + tickets.POST("", handleCreateTicket) + tickets.PUT("/:id", handleUpdateTicket) + tickets.PUT("/:id/status", handleUpdateTicketStatus) + tickets.DELETE("/:id", handleDeleteTicket) + tickets.DELETE("/batch", handleBatchDeleteTickets) + tickets.GET("/:id/replies", handleGetTicketReplies) + tickets.POST("/:id/replies", handleCreateTicketReply) + } +} + +func handleGetTicketStats(c *gin.Context) { + userID := c.GetUint("user_id") + var total, open, processing, resolved, closed int64 + + database.DB.Model(&model.Ticket{}).Where("user_id = ? OR assigned_to = ?", userID, userID).Count(&total) + database.DB.Model(&model.Ticket{}).Where("(user_id = ? OR assigned_to = ?) AND status = ?", userID, userID, "open").Count(&open) + database.DB.Model(&model.Ticket{}).Where("(user_id = ? OR assigned_to = ?) AND status = ?", userID, userID, "processing").Count(&processing) + database.DB.Model(&model.Ticket{}).Where("(user_id = ? OR assigned_to = ?) AND status = ?", userID, userID, "resolved").Count(&resolved) + database.DB.Model(&model.Ticket{}).Where("(user_id = ? OR assigned_to = ?) AND status = ?", userID, userID, "closed").Count(&closed) + + response.Success(c, gin.H{ + "total_count": total, + "open_count": open, + "processing_count": processing, + "resolved_count": resolved, + "closed_count": closed, + }) +} + +func handleGetTickets(c *gin.Context) { + userID := c.GetUint("user_id") + var tickets []model.Ticket + if err := database.DB.Where("user_id = ? OR assigned_to = ?", userID, userID). + Preload("Replies"). + Preload("Application"). + Preload("User"). + Preload("AssignedUser"). + Find(&tickets).Error; err != nil { + response.Error(c, 500, "获取工单列表失败") + return + } + + type TicketWithNames struct { + model.Ticket + AppName string `json:"app_name"` + UserName string `json:"user_name"` + } + + var result []TicketWithNames + for _, ticket := range tickets { + appName := "" + if ticket.Application != nil { + appName = ticket.Application.Name + } + userName := "" + if ticket.User.ID != 0 { + userName = ticket.User.Username + } + result = append(result, TicketWithNames{ + Ticket: ticket, + AppName: appName, + UserName: userName, + }) + } + + response.Success(c, gin.H{ + "tickets": result, + "total": len(result), + }) +} + +func handleGetTicket(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + var ticket model.Ticket + if err := database.DB.Where("id = ? AND (user_id = ? OR assigned_to = ?)", id, userID, userID). + Preload("Application"). + Preload("User"). + Preload("AssignedUser"). + First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + type TicketWithNames struct { + model.Ticket + AppName string `json:"app_name"` + UserName string `json:"user_name"` + } + + appName := "" + if ticket.Application != nil { + appName = ticket.Application.Name + } + userName := "" + if ticket.User.ID != 0 { + userName = ticket.User.Username + } + + result := TicketWithNames{ + Ticket: ticket, + AppName: appName, + UserName: userName, + } + + response.Success(c, gin.H{ + "ticket": result, + }) +} + +func handleCreateTicket(c *gin.Context) { + log.Printf("handleCreateTicket called\n") + userID := c.GetUint("user_id") + + log.Printf("Starting ParseMultipartForm...\n") + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + log.Printf("ParseMultipartForm error: %v\n", err) + response.Error(c, 400, "解析表单数据失败: "+err.Error()) + return + } + log.Printf("ParseMultipartForm succeeded\n") + + ticketType := c.PostForm("type") + title := c.PostForm("title") + content := c.PostForm("content") + priority := c.PostForm("priority") + applicationIDStr := c.PostForm("application_id") + + log.Printf("Received ticket data - type: '%s', title: '%s', content: '%s', priority: '%s', application_id: '%s'\n", + ticketType, title, content, priority, applicationIDStr) + + if ticketType == "" { + ticketType = "system" + } + + if title == "" { + log.Printf("Title is empty\n") + response.Error(c, 400, "标题不能为空") + return + } + if content == "" { + log.Printf("Content is empty\n") + response.Error(c, 400, "描述不能为空") + return + } + if priority == "" { + priority = "normal" + } + + var applicationID *uint + if ticketType == "application" && applicationIDStr != "" { + var appID uint + if _, err := fmt.Sscanf(applicationIDStr, "%d", &appID); err == nil { + applicationID = &appID + } + } + + ticket := model.Ticket{ + UserID: userID, + ApplicationID: applicationID, + Title: title, + Content: content, + Category: "other", + Priority: priority, + Status: "open", + Type: ticketType, + } + + if applicationID != nil { + var application model.Application + if err := database.DB.First(&application, *applicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + ticket.AssignedTo = &application.UserID + } + + log.Printf("Creating ticket in database...\n") + if err := database.DB.Create(&ticket).Error; err != nil { + log.Printf("Create ticket error: %v\n", err) + response.Error(c, 500, "创建工单失败") + return + } + log.Printf("Ticket created with ID: %d\n", ticket.ID) + + service.LogOperation(c, "create", "ticket", &ticket.ID, fmt.Sprintf("创建工单: %s", ticket.Title), nil) + + log.Printf("Loading ticket details...\n") + if err := database.DB.Preload("User").Preload("Application").First(&ticket, ticket.ID).Error; err != nil { + response.Error(c, 500, "获取工单信息失败") + return + } + + type TicketWithNames struct { + model.Ticket + AppName string `json:"app_name"` + UserName string `json:"user_name"` + } + + appName := "" + if ticket.Application != nil { + appName = ticket.Application.Name + } + userName := "" + if ticket.User.ID != 0 { + userName = ticket.User.Username + } + + result := TicketWithNames{ + Ticket: ticket, + AppName: appName, + UserName: userName, + } + + log.Printf("Returning success response\n") + response.Success(c, result) +} + +func handleUpdateTicket(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + id := c.Param("id") + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + ticket.Status = req.Status + + if err := database.DB.Save(&ticket).Error; err != nil { + response.Error(c, 500, "更新工单失败") + return + } + + response.Success(c, ticket) +} + +func handleUpdateTicketStatus(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + id := c.Param("id") + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + ticket.Status = req.Status + + if err := database.DB.Save(&ticket).Error; err != nil { + response.Error(c, 500, "更新工单状态失败") + return + } + + response.Success(c, ticket) +} + +func handleDeleteTicket(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var ticket model.Ticket + if err := database.DB.Where("id = ? AND (user_id = ? OR assigned_to = ?)", id, userID, userID).First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + if err := database.DB.Delete(&ticket).Error; err != nil { + response.Error(c, 500, "删除工单失败") + return + } + + service.LogOperation(c, "delete", "ticket", &ticket.ID, fmt.Sprintf("删除工单: %s", ticket.Title), nil) + + response.Success(c, nil) +} + +func handleBatchDeleteTickets(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要删除的工单") + return + } + + var tickets []model.Ticket + if err := database.DB.Where("id IN ? AND (user_id = ? OR assigned_to = ?)", req.IDs, userID, userID).Find(&tickets).Error; err != nil { + response.Error(c, 500, "查询工单失败") + return + } + + if len(tickets) == 0 { + response.Error(c, 404, "没有找到可删除的工单") + return + } + + var validIDs []uint + for _, ticket := range tickets { + validIDs = append(validIDs, ticket.ID) + } + + if err := database.DB.Where("id IN ?", validIDs).Delete(&model.Ticket{}).Error; err != nil { + response.Error(c, 500, "批量删除工单失败") + return + } + + response.Success(c, gin.H{ + "deleted": len(validIDs), + }) +} + +func handleGetTicketReplies(c *gin.Context) { + userID := c.GetUint("user_id") + ticketID := c.Param("id") + + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + var replies []model.TicketReply + if err := database.DB.Where("ticket_id = ?", ticketID).Preload("User").Order("created_at ASC").Find(&replies).Error; err != nil { + response.Error(c, 500, "获取回复列表失败") + return + } + + response.Success(c, gin.H{ + "replies": replies, + "total": len(replies), + }) +} + +func handleCreateTicketReply(c *gin.Context) { + userID := c.GetUint("user_id") + ticketID := c.Param("id") + + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + var req struct { + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + reply := model.TicketReply{ + TicketID: parseUint(ticketID), + UserID: userID, + Content: req.Content, + } + + if err := database.DB.Create(&reply).Error; err != nil { + response.Error(c, 500, "创建回复失败") + return + } + + response.Success(c, reply) +} + +func parseUint(s string) uint { + var val uint + fmt.Sscanf(s, "%d", &val) + return val +} diff --git a/backend/internal/router/developer/usage.go b/backend/internal/router/developer/usage.go new file mode 100644 index 0000000..9e2c7fb --- /dev/null +++ b/backend/internal/router/developer/usage.go @@ -0,0 +1,296 @@ +package developer + +import ( + "fmt" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupUsageRoutes(r *gin.RouterGroup) { + usage := r.Group("/usage") + { + usage.GET("/stats", handleGetUsageStats) + usage.GET("/api-history", handleGetApiUsageHistory) + usage.GET("/storage-history", handleGetStorageUsageHistory) + usage.GET("/alerts", handleGetUsageAlerts) + usage.GET("/notifications", handleGetNotifications) + usage.PUT("/notifications/:id/read", handleMarkNotificationAsRead) + usage.GET("/notifications/unread-count", handleGetUnreadNotificationCount) + } +} + +func handleGetUsageStats(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var user model.User + if err := database.DB.Preload("CurrentPackage").First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + return + } + + var permission *model.PackagePermission + if user.CurrentPackageID != nil { + var perm model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&perm).Error; err == nil { + permission = &perm + } + } + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + var todayApiCalls int64 + database.DB.Model(&model.ApiUsage{}). + Where("user_id = ? AND created_at >= ?", userID, today). + Count(&todayApiCalls) + + var last30DaysApiCalls int64 + database.DB.Model(&model.ApiUsage{}). + Where("user_id = ? AND created_at >= ?", userID, now.AddDate(0, 0, -30)). + Count(&last30DaysApiCalls) + + var totalApiCalls int64 + database.DB.Model(&model.ApiUsage{}). + Where("user_id = ?", userID). + Count(&totalApiCalls) + + storageUsedMB := float64(user.StorageUsed) / 1024 / 1024 + maxStorageMB := 0.0 + if permission != nil { + maxStorageMB = float64(permission.MaxStorage) + } + + usageData := gin.H{ + "api_calls": gin.H{ + "today": todayApiCalls, + "last_30_days": last30DaysApiCalls, + "total": totalApiCalls, + "limit": 0, + "used_today": user.ApiCallsUsed, + "reset_at": user.ApiCallsResetAt, + }, + "storage": gin.H{ + "used_mb": storageUsedMB, + "max_mb": maxStorageMB, + "used_bytes": user.StorageUsed, + "max_bytes": int64(maxStorageMB * 1024 * 1024), + "usage_percent": 0.0, + }, + "package": gin.H{ + "id": nil, + "name": nil, + "expired_at": nil, + }, + } + + if permission != nil { + usageData["api_calls"].(gin.H)["limit"] = permission.MaxApiCalls + if maxStorageMB > 0 { + usageData["storage"].(gin.H)["usage_percent"] = (storageUsedMB / maxStorageMB) * 100 + } + } + + if user.CurrentPackage != nil { + usageData["package"].(gin.H)["id"] = user.CurrentPackage.ID + usageData["package"].(gin.H)["name"] = user.CurrentPackage.Name + + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND package_id = ? AND status = ?", + userID, user.CurrentPackageID, "active"). + First(&userPackage).Error; err == nil { + usageData["package"].(gin.H)["expired_at"] = userPackage.ExpiredAt + } + } + + response.Success(c, usageData) +} + +func handleGetApiUsageHistory(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + page := 1 + pageSize := 20 + if p, ok := c.GetQuery("page"); ok { + fmt.Sscanf(p, "%d", &page) + } + if ps, ok := c.GetQuery("page_size"); ok { + fmt.Sscanf(ps, "%d", &pageSize) + } + + var total int64 + database.DB.Model(&model.ApiUsage{}).Where("user_id = ?", userID).Count(&total) + + var usages []model.ApiUsage + offset := (page - 1) * pageSize + if err := database.DB.Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&usages).Error; err != nil { + response.Error(c, 500, "获取API调用历史失败") + return + } + + response.Success(c, gin.H{ + "list": usages, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +func handleGetUsageAlerts(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + alertService := service.NewUsageAlertService() + alerts, err := alertService.CheckAndCreateAlerts(userID.(uint)) + if err != nil { + response.Error(c, 500, "检查用量告警失败") + return + } + + history, err := alertService.GetUserAlerts(userID.(uint), 20) + if err != nil { + response.Error(c, 500, "获取告警历史失败") + return + } + + response.Success(c, gin.H{ + "current_alerts": alerts, + "history": history, + }) +} + +func handleGetNotifications(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + page := 1 + pageSize := 20 + if p, ok := c.GetQuery("page"); ok { + fmt.Sscanf(p, "%d", &page) + } + if ps, ok := c.GetQuery("page_size"); ok { + fmt.Sscanf(ps, "%d", &pageSize) + } + + var total int64 + database.DB.Model(&model.Notification{}).Where("user_id = ?", userID).Count(&total) + + var notifications []model.Notification + offset := (page - 1) * pageSize + if err := database.DB.Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(¬ifications).Error; err != nil { + response.Error(c, 500, "获取通知失败") + return + } + + response.Success(c, gin.H{ + "list": notifications, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +func handleMarkNotificationAsRead(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + notificationID := c.Param("id") + + var notification model.Notification + if err := database.DB.Where("id = ? AND user_id = ?", notificationID, userID).First(¬ification).Error; err != nil { + response.Error(c, 404, "通知不存在") + return + } + + notification.IsRead = true + if err := database.DB.Save(¬ification).Error; err != nil { + response.Error(c, 500, "标记通知失败") + return + } + + response.Success(c, nil) +} + +func handleGetUnreadNotificationCount(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var count int64 + database.DB.Model(&model.Notification{}). + Where("user_id = ? AND is_read = ?", userID, false). + Count(&count) + + response.Success(c, gin.H{ + "count": count, + }) +} + +func handleGetStorageUsageHistory(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + page := 1 + pageSize := 20 + if p, ok := c.GetQuery("page"); ok { + fmt.Sscanf(p, "%d", &page) + } + if ps, ok := c.GetQuery("page_size"); ok { + fmt.Sscanf(ps, "%d", &pageSize) + } + + var total int64 + database.DB.Model(&model.StorageUsage{}).Where("user_id = ?", userID).Count(&total) + + var usages []model.StorageUsage + offset := (page - 1) * pageSize + if err := database.DB.Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&usages).Error; err != nil { + response.Error(c, 500, "获取存储使用历史失败") + return + } + + response.Success(c, gin.H{ + "list": usages, + "total": total, + "page": page, + "page_size": pageSize, + }) +} diff --git a/backend/internal/router/developer/users.go b/backend/internal/router/developer/users.go new file mode 100644 index 0000000..f68f7b2 --- /dev/null +++ b/backend/internal/router/developer/users.go @@ -0,0 +1,673 @@ +package developer + +import ( + "fmt" + "log" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type UserWithStatus struct { + model.AppUser + OnlineStatus string `json:"online_status"` + DeviceCount int `json:"device_count"` +} + +func getUserOnlineStatus(user model.AppUser, heartbeatTimeout int) string { + if user.Status == "banned" { + return "banned" + } + + if user.LastHeartbeatAt == nil { + return "offline" + } + + offlineThreshold := time.Duration(heartbeatTimeout) * time.Second + if time.Since(*user.LastHeartbeatAt) > offlineThreshold { + return "offline" + } + + return "online" +} + +func SetupUserRoutes(r *gin.RouterGroup) { + appUsers := r.Group("/app-users") + { + appUsers.GET("", handleGetUsers) + appUsers.POST("", handleCreateUser) + appUsers.GET("/:id", handleGetUser) + appUsers.PUT("/:id", handleUpdateUser) + appUsers.DELETE("/:id", handleDeleteUser) + appUsers.GET("/:id/devices", handleGetUserDevices) + appUsers.DELETE("/:id/devices/:deviceId", handleUnbindDevice) + appUsers.PUT("/:id/expiry", handleUpdateExpiry) + appUsers.POST("/batch/status", handleBatchUpdateStatus) + appUsers.DELETE("/batch", handleBatchDelete) + } +} + +func handleGetUsers(c *gin.Context) { + userID := c.GetUint("user_id") + log.Printf("[DEBUG] handleGetUsers called, userID: %d", userID) + + var users []model.AppUser + var appHeartbeatTimeoutMap map[uint]int + + applicationID := c.Query("application_id") + if applicationID != "" { + var appID uint + if _, err := fmt.Sscanf(applicationID, "%d", &appID); err != nil { + response.Error(c, 400, "应用ID格式错误") + return + } + + var app model.Application + if err := database.DB.First(&app, appID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + appHeartbeatTimeoutMap = make(map[uint]int) + timeout := app.HeartbeatTimeout + if timeout == 0 { + timeout = 300 + } + appHeartbeatTimeoutMap[app.ID] = timeout + + if app.UserID == userID { + if err := database.DB.Preload("Application").Where("application_id = ?", appID).Find(&users).Error; err != nil { + response.Error(c, 500, "获取用户列表失败") + return + } + } else { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", appID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限查看该应用的用户") + return + } + + var cardUserIDs []uint + if err := database.DB.Model(&model.Card{}). + Where("creator_id = ? AND application_id = ? AND app_user_id IS NOT NULL", userID, appID). + Pluck("app_user_id", &cardUserIDs).Error; err != nil { + response.Error(c, 500, "获取用户列表失败") + return + } + + if len(cardUserIDs) > 0 { + if err := database.DB.Preload("Application").Where("id IN ?", cardUserIDs).Find(&users).Error; err != nil { + response.Error(c, 500, "获取用户列表失败") + return + } + } + } + } else { + var ownApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&ownApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + var agentApps []model.AgentApplication + if err := database.DB.Where("agent_id = ? AND is_received = ?", userID, true).Find(&agentApps).Error; err != nil { + log.Printf("[DEBUG] Failed to get agent apps: %v", err) + response.Error(c, 500, "获取授权列表失败") + return + } + + log.Printf("[DEBUG] Found %d agent apps for user %d", len(agentApps), userID) + + appHeartbeatTimeoutMap = make(map[uint]int) + + for _, app := range ownApps { + timeout := app.HeartbeatTimeout + if timeout == 0 { + timeout = 300 + } + appHeartbeatTimeoutMap[app.ID] = timeout + + var appUsers []model.AppUser + if err := database.DB.Preload("Application").Where("application_id = ?", app.ID).Find(&appUsers).Error; err != nil { + response.Error(c, 500, "获取用户列表失败") + return + } + users = append(users, appUsers...) + } + + for _, agentApp := range agentApps { + var app model.Application + if err := database.DB.First(&app, agentApp.ApplicationID).Error; err != nil { + log.Printf("[DEBUG] Failed to get application %d: %v", agentApp.ApplicationID, err) + continue + } + + log.Printf("[DEBUG] Processing agent app: ApplicationID=%d, AppName=%s", app.ID, app.Name) + + timeout := app.HeartbeatTimeout + if timeout == 0 { + timeout = 300 + } + appHeartbeatTimeoutMap[app.ID] = timeout + + var cardUserIDs []uint + if err := database.DB.Model(&model.Card{}). + Where("creator_id = ? AND application_id = ? AND app_user_id IS NOT NULL", userID, app.ID). + Pluck("app_user_id", &cardUserIDs).Error; err != nil { + log.Printf("[DEBUG] Failed to get card user IDs for app %d: %v", app.ID, err) + continue + } + + log.Printf("[DEBUG] Found %d card user IDs for app %d: %v", len(cardUserIDs), app.ID, cardUserIDs) + + if len(cardUserIDs) > 0 { + var appUsers []model.AppUser + if err := database.DB.Preload("Application").Where("id IN ?", cardUserIDs).Find(&appUsers).Error; err != nil { + log.Printf("[DEBUG] Failed to get users for app %d: %v", app.ID, err) + continue + } + log.Printf("[DEBUG] Found %d users for app %d", len(appUsers), app.ID) + users = append(users, appUsers...) + } + } + } + + totalCount := len(users) + onlineCount := 0 + offlineCount := 0 + bannedCount := 0 + + usersWithStatus := make([]UserWithStatus, 0, len(users)) + for _, user := range users { + log.Printf("[DEBUG] User ID=%d, Username=%s, LastLoginAt=%v, LastHeartbeatAt=%v", user.ID, user.Username, user.LastLoginAt, user.LastHeartbeatAt) + + heartbeatTimeout := 300 + if applicationID != "" { + heartbeatTimeout = appHeartbeatTimeoutMap[user.ApplicationID] + } else if appHeartbeatTimeoutMap != nil { + heartbeatTimeout = appHeartbeatTimeoutMap[user.ApplicationID] + } + + onlineStatus := getUserOnlineStatus(user, heartbeatTimeout) + + switch onlineStatus { + case "online": + onlineCount++ + case "offline": + offlineCount++ + case "banned": + bannedCount++ + } + + var deviceCount int64 + database.DB.Model(&model.UserDevice{}).Where("user_id = ?", user.ID).Count(&deviceCount) + + log.Printf("[DEBUG] 用户 %s (ID=%d) 余额: %f", user.Username, user.ID, user.Balance) + + usersWithStatus = append(usersWithStatus, UserWithStatus{ + AppUser: user, + OnlineStatus: onlineStatus, + DeviceCount: int(deviceCount), + }) + } + + responseData := gin.H{ + "users": usersWithStatus, + "total": totalCount, + "online_count": onlineCount, + "offline_count": offlineCount, + "banned_count": bannedCount, + } + log.Printf("[DEBUG] Response data: %+v", responseData) + response.Success(c, responseData) +} + +func handleCreateUser(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + ApplicationID uint `json:"application_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.Username == "" { + response.Error(c, 400, "用户名不能为空") + return + } + + if req.Password == "" { + response.Error(c, 400, "密码不能为空") + return + } + + if req.ApplicationID == 0 { + response.Error(c, 400, "所属应用不能为空") + return + } + + var app model.Application + if err := database.DB.First(&app, req.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", req.ApplicationID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限在该应用下创建用户") + return + } + } + + var existingUser model.AppUser + if err := database.DB.Where("username = ? AND application_id = ?", req.Username, app.ID).First(&existingUser).Error; err == nil { + response.Error(c, 400, "用户已存在") + return + } + + user := model.AppUser{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + Avatar: "", + Status: "active", + ApplicationID: app.ID, + } + + if err := database.DB.Create(&user).Error; err != nil { + response.Error(c, 500, "创建用户失败") + return + } + + service.LogOperation(c, "create", "app_user", &user.ID, fmt.Sprintf("创建用户: %s (应用: %s)", user.Username, app.Name), nil) + + response.Success(c, user) +} + +func handleGetUser(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + var user model.AppUser + if err := database.DB.Preload("Application").First(&user, id).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, user.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限查看该用户") + return + } + + var card model.Card + if err := database.DB.Where("creator_id = ? AND app_user_id = ?", userID, user.ID).First(&card).Error; err != nil { + response.Error(c, 403, "无权限查看该用户") + return + } + } + + response.Success(c, gin.H{ + "user": user, + }) +} + +func handleUpdateUser(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + var req struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Status string `json:"status"` + ApplicationID uint `json:"application_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var user model.AppUser + if err := database.DB.First(&user, id).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, user.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限修改该用户") + return + } + + var card model.Card + if err := database.DB.Where("creator_id = ? AND app_user_id = ?", userID, user.ID).First(&card).Error; err != nil { + response.Error(c, 403, "无权限修改该用户") + return + } + } + + if req.Username != "" { + user.Username = req.Username + } + if req.Email != "" { + user.Email = req.Email + } + if req.Password != "" { + user.Password = req.Password + } + if req.Status != "" { + user.Status = req.Status + } + if req.ApplicationID != 0 { + var newApp model.Application + if err := database.DB.First(&newApp, req.ApplicationID).Error; err != nil { + response.Error(c, 404, "目标应用不存在") + return + } + if newApp.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", req.ApplicationID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限将用户转移到该应用") + return + } + } + user.ApplicationID = req.ApplicationID + } + + if err := database.DB.Save(&user).Error; err != nil { + response.Error(c, 500, "更新用户失败") + return + } + + service.LogOperation(c, "update", "app_user", &user.ID, fmt.Sprintf("更新用户: %s", user.Username), nil) + + response.Success(c, user) +} + +func handleDeleteUser(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var user model.AppUser + if err := database.DB.First(&user, id).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, user.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限删除该用户") + return + } + + var card model.Card + if err := database.DB.Where("creator_id = ? AND app_user_id = ?", userID, user.ID).First(&card).Error; err != nil { + response.Error(c, 403, "无权限删除该用户") + return + } + } + + if err := database.DB.Delete(&user).Error; err != nil { + response.Error(c, 500, "删除用户失败") + return + } + + service.LogOperation(c, "delete", "app_user", &user.ID, fmt.Sprintf("删除用户: %s (应用: %s)", user.Username, app.Name), nil) + + response.Success(c, nil) +} + +func handleGetUserDevices(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + + var user model.AppUser + if err := database.DB.First(&user, id).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, user.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限查看该用户设备") + return + } + } + + var devices []model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ?", user.ID, app.ID).Find(&devices).Error; err != nil { + response.Error(c, 500, "获取设备列表失败") + return + } + + response.Success(c, devices) +} + +func handleUpdateExpiry(c *gin.Context) { + userID := c.GetUint("user_id") + id := c.Param("id") + var req struct { + Amount float64 `json:"amount"` + Type string `json:"type"` + Field string `json:"field"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var appUser model.AppUser + if err := database.DB.First(&appUser, id).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var app model.Application + if err := database.DB.First(&app, appUser.ApplicationID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if app.UserID != userID { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权限修改该用户") + return + } + + var card model.Card + if err := database.DB.Where("creator_id = ? AND app_user_id = ?", userID, appUser.ID).First(&card).Error; err != nil { + response.Error(c, 403, "无权限修改该用户") + return + } + } + + if req.Amount == 0 { + response.Error(c, 400, "数值不能为0") + return + } + + if appUser.Balance == -1 { + response.Error(c, 400, "该用户为永久会员,无需操作") + return + } + + now := time.Now() + + if app.BillingType == "subscription" || req.Field == "days" { + if req.Type == "recharge" { + var baseTime time.Time + if appUser.ExpiryAt != nil && appUser.ExpiryAt.After(now) { + baseTime = *appUser.ExpiryAt + } else { + baseTime = now + } + duration := time.Duration(req.Amount) * 24 * time.Hour + newExpiry := baseTime.Add(duration) + appUser.ExpiryAt = &newExpiry + } else if req.Type == "deduct" { + if appUser.ExpiryAt == nil || appUser.ExpiryAt.Before(now) { + response.Error(c, 400, "用户订阅已过期") + return + } + duration := time.Duration(req.Amount) * 24 * time.Hour + newExpiry := appUser.ExpiryAt.Add(-duration) + if newExpiry.Before(now) { + newExpiry = now + } + appUser.ExpiryAt = &newExpiry + } else { + response.Error(c, 400, "操作类型错误") + return + } + } else { + if req.Type == "recharge" { + appUser.Balance += req.Amount + } else if req.Type == "deduct" { + if appUser.Balance < req.Amount { + response.Error(c, 400, "余额不足") + return + } + appUser.Balance -= req.Amount + } else { + response.Error(c, 400, "操作类型错误") + return + } + } + + if err := database.DB.Save(&appUser).Error; err != nil { + response.Error(c, 500, "更新失败") + return + } + + response.Success(c, appUser) +} + +func handleBatchUpdateStatus(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + UserIDs []uint `json:"user_ids"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var users []model.AppUser + if err := database.DB.Where("id IN ?", req.UserIDs).Find(&users).Error; err != nil { + response.Error(c, 500, "获取用户列表失败") + return + } + + var validUserIDs []uint + for _, user := range users { + var app model.Application + if err := database.DB.First(&app, user.ApplicationID).Error; err != nil { + continue + } + + if app.UserID == userID { + validUserIDs = append(validUserIDs, user.ID) + } else { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err == nil { + var card model.Card + if err := database.DB.Where("creator_id = ? AND app_user_id = ?", userID, user.ID).First(&card).Error; err == nil { + validUserIDs = append(validUserIDs, user.ID) + } + } + } + } + + if len(validUserIDs) > 0 { + if err := database.DB.Model(&model.AppUser{}).Where("id IN ?", validUserIDs).Update("status", req.Status).Error; err != nil { + response.Error(c, 500, "批量更新状态失败") + return + } + } + + response.Success(c, nil) +} + +func handleBatchDelete(c *gin.Context) { + userID := c.GetUint("user_id") + var req struct { + UserIDs []uint `json:"user_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var users []model.AppUser + if err := database.DB.Where("id IN ?", req.UserIDs).Find(&users).Error; err != nil { + response.Error(c, 500, "获取用户列表失败") + return + } + + var validUserIDs []uint + for _, user := range users { + var app model.Application + if err := database.DB.First(&app, user.ApplicationID).Error; err != nil { + continue + } + + if app.UserID == userID { + validUserIDs = append(validUserIDs, user.ID) + } else { + var agentApp model.AgentApplication + if err := database.DB.Where("application_id = ? AND agent_id = ? AND is_received = ?", app.ID, userID, true).First(&agentApp).Error; err == nil { + var card model.Card + if err := database.DB.Where("creator_id = ? AND app_user_id = ?", userID, user.ID).First(&card).Error; err == nil { + validUserIDs = append(validUserIDs, user.ID) + } + } + } + } + + if len(validUserIDs) > 0 { + if err := database.DB.Where("id IN ?", validUserIDs).Delete(&model.AppUser{}).Error; err != nil { + response.Error(c, 500, "批量删除失败") + return + } + } + + response.Success(c, nil) +} diff --git a/backend/internal/router/developer/versions.go b/backend/internal/router/developer/versions.go new file mode 100644 index 0000000..272bc5d --- /dev/null +++ b/backend/internal/router/developer/versions.go @@ -0,0 +1,565 @@ +package developer + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/middleware" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupVersionRoutes(r *gin.RouterGroup) { + versions := r.Group("/versions") + { + versions.GET("", handleGetAllVersions) + versions.GET("/:id", handleGetVersionByID) + versions.POST("", handleCreateVersionGlobal) + versions.PUT("/:id", handleUpdateVersionGlobal) + versions.DELETE("/batch", handleBatchDeleteVersions) + versions.POST("/upload-zip", handleUploadVersionZip) + } +} + +func handleGetAllVersions(c *gin.Context) { + userID := c.GetUint("user_id") + log.Printf("[DEBUG] handleGetAllVersions called, userID: %d\n", userID) + + page := c.DefaultQuery("page", "1") + pageSize := c.DefaultQuery("page_size", "20") + + var total int64 + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + log.Printf("[DEBUG] Found %d user apps\n", len(userApps)) + for i, app := range userApps { + log.Printf("[DEBUG] App %d: ID=%d, Name=%s\n", i, app.ID, app.Name) + } + + if len(userApps) == 0 { + response.Success(c, gin.H{ + "versions": []interface{}{}, + "total": 0, + }) + return + } + + appIDs := make([]uint, len(userApps)) + appNameMap := make(map[uint]string) + for i, app := range userApps { + appIDs[i] = app.ID + appNameMap[app.ID] = app.Name + } + + log.Printf("[DEBUG] appNameMap: %v\n", appNameMap) + + database.DB.Model(&model.Version{}).Where("application_id IN ?", appIDs).Count(&total) + + var versions []model.Version + offset := 0 + if pageInt, err := strconv.Atoi(page); err == nil && pageInt > 1 { + offset = (pageInt - 1) * 20 + } + + limit := 20 + if pageSizeInt, err := strconv.Atoi(pageSize); err == nil && pageSizeInt > 0 { + limit = pageSizeInt + } + + if err := database.DB.Where("application_id IN ?", appIDs).Order("created_at DESC").Limit(limit).Offset(offset).Find(&versions).Error; err != nil { + response.Error(c, 500, "获取版本列表失败") + return + } + + log.Printf("[DEBUG] Found %d versions\n", len(versions)) + for i, v := range versions { + log.Printf("[DEBUG] Version %d: ID=%d, ApplicationID=%d, Version=%s\n", i, v.ID, v.ApplicationID, v.Version) + } + + type VersionWithAppName struct { + model.Version + ApplicationName string `json:"application_name"` + } + + result := make([]VersionWithAppName, len(versions)) + for i, v := range versions { + appName := appNameMap[v.ApplicationID] + log.Printf("[DEBUG] Mapping version %d: ApplicationID=%d -> AppName=%s\n", i, v.ApplicationID, appName) + result[i] = VersionWithAppName{ + Version: v, + ApplicationName: appName, + } + } + + response.Success(c, gin.H{ + "versions": result, + "total": total, + }) +} + +func handleGetVersionByID(c *gin.Context) { + userID := c.GetUint("user_id") + versionID := c.Param("id") + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + appNameMap := make(map[uint]string) + for i, app := range userApps { + appIDs[i] = app.ID + appNameMap[app.ID] = app.Name + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).Preload("Files").First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + response.Success(c, gin.H{ + "id": version.ID, + "application_id": version.ApplicationID, + "application_name": appNameMap[version.ApplicationID], + "version": version.Version, + "description": version.Description, + "file_path": version.FilePath, + "file_size": version.FileSize, + "file_hash": version.FileHash, + "force_update": version.ForceUpdate, + "update_strategy": version.UpdateStrategy, + "update_method": version.UpdateMethod, + "min_version": version.MinVersion, + "changelog": version.Changelog, + "status": version.Status, + "files": version.Files, + "created_at": version.CreatedAt, + "updated_at": version.UpdatedAt, + }) +} + +type CreateVersionRequest struct { + ApplicationID uint `json:"application_id"` + Version string `json:"version"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + FileHash string `json:"file_hash"` + EntryFile string `json:"entry_file"` + ForceUpdate bool `json:"force_update"` + UpdateStrategy string `json:"update_strategy"` + UpdateMethod string `json:"update_method"` + MinVersion string `json:"min_version"` + Description string `json:"description"` + Changelog string `json:"changelog"` +} + +func handleCreateVersionGlobal(c *gin.Context) { + userID := c.GetUint("user_id") + + var req CreateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var app model.Application + if err := database.DB.Where("id = ? AND user_id = ?", req.ApplicationID, userID).First(&app).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + if service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, "应用已被禁用,无法创建版本") + return + } + + var existingVersion model.Version + if err := database.DB.Where("application_id = ? AND version = ?", req.ApplicationID, req.Version).First(&existingVersion).Error; err == nil { + response.Error(c, 400, "该版本号已存在") + return + } + + version := model.Version{ + ApplicationID: req.ApplicationID, + Version: req.Version, + FilePath: req.FilePath, + FileSize: req.FileSize, + FileHash: req.FileHash, + EntryFile: req.EntryFile, + ForceUpdate: req.ForceUpdate, + UpdateStrategy: req.UpdateStrategy, + UpdateMethod: req.UpdateMethod, + MinVersion: req.MinVersion, + Description: req.Description, + Changelog: req.Changelog, + Status: "active", + } + + if version.UpdateStrategy == "" { + version.UpdateStrategy = "optional" + } + if version.UpdateMethod == "" { + version.UpdateMethod = "manual" + } + + if err := database.DB.Create(&version).Error; err != nil { + response.Error(c, 500, "创建版本失败") + return + } + + userIDPtr := &userID + versionIDPtr := &version.ID + service.CreateLog(service.LogParams{ + UserID: userIDPtr, + LogType: "version", + Action: "create", + Resource: "version", + ResourceID: versionIDPtr, + Details: fmt.Sprintf("创建版本: %s (应用ID: %d)", version.Version, version.ApplicationID), + }) + + response.Success(c, gin.H{ + "id": version.ID, + }) +} + +type UpdateVersionRequest struct { + Version string `json:"version"` + ForceUpdate bool `json:"force_update"` + UpdateStrategy string `json:"update_strategy"` + UpdateMethod string `json:"update_method"` + MinVersion string `json:"min_version"` + Description string `json:"description"` + Changelog string `json:"changelog"` + Status string `json:"status"` +} + +func handleUpdateVersionGlobal(c *gin.Context) { + userID := c.GetUint("user_id") + versionID := c.Param("id") + + var req UpdateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + for i, app := range userApps { + appIDs[i] = app.ID + } + + var version model.Version + if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).First(&version).Error; err != nil { + response.Error(c, 404, "版本不存在") + return + } + + if req.Version != "" && req.Version != version.Version { + var existingVersion model.Version + if err := database.DB.Where("application_id = ? AND version = ? AND id != ?", version.ApplicationID, req.Version, version.ID).First(&existingVersion).Error; err == nil { + response.Error(c, 400, "该版本号已存在") + return + } + version.Version = req.Version + } + + if req.Version != "" { + version.Version = req.Version + } + version.ForceUpdate = req.ForceUpdate + if req.UpdateStrategy != "" { + version.UpdateStrategy = req.UpdateStrategy + } + if req.UpdateMethod != "" { + version.UpdateMethod = req.UpdateMethod + } + version.MinVersion = req.MinVersion + version.Description = req.Description + version.Changelog = req.Changelog + if req.Status != "" { + version.Status = req.Status + } + + if err := database.DB.Save(&version).Error; err != nil { + response.Error(c, 500, "更新版本失败") + return + } + + userIDPtr := &userID + versionIDPtr := &version.ID + service.CreateLog(service.LogParams{ + UserID: userIDPtr, + LogType: "version", + Action: "update", + Resource: "version", + ResourceID: versionIDPtr, + Details: fmt.Sprintf("更新版本: %s (应用ID: %d)", version.Version, version.ApplicationID), + }) + + response.Success(c, nil) +} + +func handleBatchDeleteVersions(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + IDs []uint `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if len(req.IDs) == 0 { + response.Error(c, 400, "请选择要删除的版本") + return + } + + var userApps []model.Application + if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + appIDs := make([]uint, len(userApps)) + for i, app := range userApps { + appIDs[i] = app.ID + } + + var versions []model.Version + if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, appIDs).Find(&versions).Error; err != nil { + response.Error(c, 500, "获取版本失败") + return + } + + for _, version := range versions { + for _, app := range userApps { + if app.ID == version.ApplicationID && service.GetApplicationDisabledStatus(app.ID) { + response.Error(c, 403, fmt.Sprintf("应用 %s 已被禁用,无法删除其版本", app.Name)) + return + } + } + } + + if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, appIDs).Delete(&model.Version{}).Error; err != nil { + response.Error(c, 500, "批量删除版本失败") + return + } + + response.Success(c, nil) +} + +type UploadVersionZipResponse struct { + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + FileHash string `json:"file_hash"` + Files []VersionFileInfo `json:"files"` +} + +type VersionFileInfo struct { + FilePath string `json:"file_path"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + FileHash string `json:"file_hash"` + FileType string `json:"file_type"` +} + +func handleUploadVersionZip(c *gin.Context) { + userID := c.GetUint("user_id") + + file, header, err := c.Request.FormFile("file") + if err != nil { + response.Error(c, 400, "请上传ZIP文件") + return + } + defer file.Close() + + if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") { + response.Error(c, 400, "只支持ZIP格式文件") + return + } + + fileSize := header.Size + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 500, "获取用户信息失败") + return + } + + if user.CurrentPackageID != nil { + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err == nil { + maxStorageBytes := int64(permission.MaxStorage) * 1024 * 1024 + if user.StorageUsed+fileSize > maxStorageBytes { + usedMB := float64(user.StorageUsed) / 1024 / 1024 + maxMB := float64(permission.MaxStorage) + response.Error(c, 403, fmt.Sprintf("存储空间不足,已使用 %.2f MB / %.2f MB", usedMB, maxMB)) + return + } + } + } + + timestamp := time.Now().Unix() + filename := fmt.Sprintf("version_%d_%d.zip", userID, timestamp) + dst := filepath.Join("uploads", "versions", filename) + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + response.Error(c, 500, "创建目录失败") + return + } + + dstFile, err := os.Create(dst) + if err != nil { + response.Error(c, 500, "创建文件失败") + return + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, file); err != nil { + response.Error(c, 500, "保存文件失败") + return + } + + zipHash, err := calculateFileHash(dst) + if err != nil { + os.Remove(dst) + response.Error(c, 500, "计算文件哈希失败") + return + } + + files, err := parseZipFile(dst) + if err != nil { + os.Remove(dst) + response.Error(c, 500, fmt.Sprintf("解析ZIP文件失败: %v", err)) + return + } + + if err := middleware.UpdateStorageUsed(userID, fileSize, "upload"); err != nil { + log.Printf("更新存储使用量失败: %v\n", err) + } + + usage := model.StorageUsage{ + UserID: userID, + ResourceType: "version", + ResourceID: 0, + FileName: header.Filename, + FileSize: fileSize, + Action: "upload", + CreatedAt: time.Now(), + } + database.DB.Create(&usage) + + response.Success(c, UploadVersionZipResponse{ + FilePath: fmt.Sprintf("/uploads/versions/%s", filename), + FileSize: fileSize, + FileHash: zipHash, + Files: files, + }) +} + +func calculateFileHash(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func parseZipFile(zipPath string) ([]VersionFileInfo, error) { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return nil, err + } + defer reader.Close() + + var files []VersionFileInfo + + for _, f := range reader.File { + if f.FileInfo().IsDir() { + continue + } + + rc, err := f.Open() + if err != nil { + continue + } + + hash := sha256.New() + if _, err := io.Copy(hash, rc); err != nil { + rc.Close() + continue + } + rc.Close() + + fileHash := hex.EncodeToString(hash.Sum(nil)) + fileType := getFileType(f.Name) + + files = append(files, VersionFileInfo{ + FilePath: f.Name, + FileName: filepath.Base(f.Name), + FileSize: int64(f.UncompressedSize64), + FileHash: fileHash, + FileType: fileType, + }) + } + + return files, nil +} + +func getFileType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".exe", ".dll", ".so", ".dylib", ".app": + return "executable" + case ".json", ".xml", ".yaml", ".yml", ".ini", ".conf", ".cfg": + return "config" + case ".txt", ".md", ".log": + return "text" + case ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".flac": + return "audio" + case ".mp4", ".avi", ".mkv", ".mov", ".wmv": + return "video" + case ".db", ".sqlite", ".sqlite3": + return "database" + default: + return "resource" + } +} diff --git a/backend/internal/router/extension/extension.go b/backend/internal/router/extension/extension.go new file mode 100644 index 0000000..e56fb6b --- /dev/null +++ b/backend/internal/router/extension/extension.go @@ -0,0 +1,749 @@ +package extension + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "strconv" + "strings" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.RouterGroup) { + ext := r.Group("/ext") + ext.Use(ExtensionAuthMiddleware()) + { + // 用户相关 + ext.GET("/user/:userId", handleGetUser) + ext.GET("/users", handleGetUsers) + ext.POST("/user/:userId/recharge", handleRechargeUser) + ext.POST("/user/:userId/deduct", handleDeductUser) + + // 用户变量相关 + ext.GET("/user/:userId/variables", handleGetUserVariables) + ext.POST("/user/:userId/variables", handleUpdateUserVariables) + + // 卡密相关 + ext.GET("/cards", handleGetCards) + ext.POST("/cards/generate", handleGenerateCards) + ext.GET("/card/:cardId", handleGetCard) + + // 通知相关 + ext.POST("/notification", handleSendNotification) + ext.POST("/notification/batch", handleSendBatchNotification) + + // 应用信息 + ext.GET("/app/info", handleGetAppInfo) + ext.GET("/app/stats", handleGetAppStats) + + // 应用变量相关 + ext.GET("/app/variables", handleGetAppVariables) + ext.POST("/app/variables", handleUpdateAppVariables) + } +} + +func ExtensionAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + accessKey := c.GetHeader("X-Access-Key") + signature := c.GetHeader("X-Signature") + timestamp := c.GetHeader("X-Timestamp") + + if accessKey == "" || signature == "" || timestamp == "" { + response.Error(c, 401, "缺少认证信息") + c.Abort() + return + } + + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + response.Error(c, 401, "时间戳格式错误") + c.Abort() + return + } + + if time.Now().Unix()-ts > 300 { + response.Error(c, 401, "请求已过期") + c.Abort() + return + } + + var apiKey model.ExtensionAPIKey + if err := database.DB.Where("access_key = ? AND status = ?", accessKey, "active"). + Preload("Application").First(&apiKey).Error; err != nil { + response.Error(c, 401, "API密钥无效") + c.Abort() + return + } + + if apiKey.ExpiresAt != nil && apiKey.ExpiresAt.Before(time.Now()) { + response.Error(c, 401, "API密钥已过期") + c.Abort() + return + } + + bodyBytes, _ := io.ReadAll(c.Request.Body) + c.Set("requestBody", bodyBytes) + c.Request.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + + stringToSign := fmt.Sprintf("%s%s%s%s", c.Request.Method, c.Request.URL.Path, timestamp, string(bodyBytes)) + expectedSignature := generateSignature(apiKey.SecretKey, stringToSign) + + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + response.Error(c, 401, "签名验证失败") + c.Abort() + return + } + + c.Set("apiKey", apiKey) + c.Set("applicationID", apiKey.ApplicationID) + + var app model.Application + if err := database.DB.First(&app, apiKey.ApplicationID).Error; err == nil { + c.Set("user_id", app.UserID) + c.Set("app_id", apiKey.ApplicationID) + + var user model.User + if err := database.DB.Preload("CurrentPackage").First(&user, app.UserID).Error; err == nil { + if user.CurrentPackageID != nil { + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err == nil { + now := time.Now() + if user.ApiCallsResetAt == nil || now.Sub(*user.ApiCallsResetAt) >= 24*time.Hour { + user.ApiCallsUsed = 0 + user.ApiCallsResetAt = &now + database.DB.Save(&user) + } + + if user.ApiCallsUsed >= permission.MaxApiCalls { + response.Error(c, 403, fmt.Sprintf("API调用次数已达上限(%d次/天),请升级套餐", permission.MaxApiCalls)) + c.Abort() + return + } + } + } + } + } + + now := time.Now() + database.DB.Model(&apiKey).Update("last_used_at", now) + + c.Next() + + if _, exists := c.Get("app_id"); exists { + if userID, exists := c.Get("user_id"); exists { + var user model.User + if err := database.DB.First(&user, userID).Error; err == nil { + user.ApiCallsUsed++ + database.DB.Save(&user) + } + } + } + } +} + +func generateSignature(secretKey, data string) string { + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func handleGetUser(c *gin.Context) { + appID := c.GetUint("applicationID") + userID := c.Param("userId") + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, appID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + response.Success(c, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "status": user.Status, + "balance": user.Balance, + "expiry_at": user.ExpiryAt, + "lastLoginAt": user.LastLoginAt, + "lastHeartbeatAt": user.LastHeartbeatAt, + "isTrialUser": user.IsTrialUser, + "createdAt": user.CreatedAt, + }) +} + +func handleGetUsers(c *gin.Context) { + appID := c.GetUint("applicationID") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + status := c.Query("status") + search := c.Query("search") + + var users []model.AppUser + var total int64 + + query := database.DB.Model(&model.AppUser{}).Where("application_id = ?", appID) + + if status != "" { + query = query.Where("status = ?", status) + } + + if search != "" { + query = query.Where("username LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%") + } + + query.Count(&total) + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { + response.Error(c, 500, "获取用户列表失败") + return + } + + response.Success(c, gin.H{ + "users": users, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +func handleRechargeUser(c *gin.Context) { + appID := c.GetUint("applicationID") + userID := c.Param("userId") + + var req struct { + Amount int `json:"amount" binding:"required"` + Type string `json:"type" binding:"required"` // balance, days + Description string `json:"description"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var app model.Application + if err := database.DB.First(&app, appID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, appID).First(&user).Error; err != nil { + service.LogVerification(c, &app.ID, nil, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 用户不存在 - %s", userID), "", fmt.Errorf("用户不存在")) + response.Error(c, 404, "用户不存在") + return + } + + if user.Balance == -1 { + service.LogVerification(c, &app.ID, &user.ID, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 用户已是永久会员 - %s", user.Username), "", fmt.Errorf("该用户为永久会员,无需充值")) + response.Error(c, 400, "该用户为永久会员,无需充值") + return + } + + now := time.Now() + + switch req.Type { + case "days": + var baseTime time.Time + if user.ExpiryAt != nil && user.ExpiryAt.After(now) { + baseTime = *user.ExpiryAt + } else { + baseTime = now + } + duration := time.Duration(req.Amount) * 24 * time.Hour + newExpiry := baseTime.Add(duration) + user.ExpiryAt = &newExpiry + case "balance": + user.Balance += float64(req.Amount) + default: + service.LogVerification(c, &app.ID, &user.ID, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 类型无效 - %s", req.Type), "", fmt.Errorf("充值类型无效")) + response.Error(c, 400, "充值类型无效,仅支持days或balance类型") + return + } + + if err := database.DB.Save(&user).Error; err != nil { + service.LogVerification(c, &app.ID, &user.ID, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 保存失败 - %s", user.Username), "", err) + response.Error(c, 500, "充值失败") + return + } + + record := model.RechargeRecord{ + UserID: user.ID, + OrderNo: fmt.Sprintf("EXT%d%d", time.Now().Unix(), user.ID), + Amount: float64(req.Amount), + Status: "success", + PaymentType: "extension_api", + Remark: req.Description, + } + database.DB.Create(&record) + + service.LogVerification(c, &app.ID, &user.ID, "extension_recharge", fmt.Sprintf("扩展API充值: 用户%s, 类型:%s, 数量:%d", user.Username, req.Type, req.Amount), "", nil) + + response.Success(c, gin.H{ + "message": "充值成功", + "user": user, + }) +} + +func handleDeductUser(c *gin.Context) { + appID := c.GetUint("applicationID") + userID := c.Param("userId") + + var req struct { + Amount int `json:"amount" binding:"required"` + Type string `json:"type" binding:"required"` // balance, days + Description string `json:"description"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var app model.Application + database.DB.First(&app, appID) + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, appID).First(&user).Error; err != nil { + service.LogVerification(c, &app.ID, nil, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 用户不存在 - %s", userID), "", fmt.Errorf("用户不存在")) + response.Error(c, 404, "用户不存在") + return + } + + if user.Balance == -1 { + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 用户已是永久会员 - %s", user.Username), "", fmt.Errorf("该用户为永久会员,无法扣除")) + response.Error(c, 400, "该用户为永久会员,无法扣除") + return + } + + now := time.Now() + + switch req.Type { + case "days": + if user.ExpiryAt == nil || user.ExpiryAt.Before(now) { + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 用户订阅已过期 - %s", user.Username), "", fmt.Errorf("用户订阅已过期")) + response.Error(c, 400, "用户订阅已过期") + return + } + duration := time.Duration(req.Amount) * 24 * time.Hour + newExpiry := user.ExpiryAt.Add(-duration) + if newExpiry.Before(now) { + newExpiry = now + } + user.ExpiryAt = &newExpiry + case "balance": + if user.Balance < float64(req.Amount) { + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 余额不足 - %s", user.Username), "", fmt.Errorf("余额不足")) + response.Error(c, 400, "余额不足") + return + } + user.Balance -= float64(req.Amount) + default: + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 类型无效 - %s", req.Type), "", fmt.Errorf("扣除类型无效")) + response.Error(c, 400, "扣除类型无效,仅支持days或balance类型") + return + } + + if err := database.DB.Save(&user).Error; err != nil { + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 保存失败 - %s", user.Username), "", err) + response.Error(c, 500, "扣除失败") + return + } + + record := model.ConsumptionRecord{ + UserID: user.ID, + OrderNo: fmt.Sprintf("EXT%d%d", time.Now().Unix(), user.ID), + Type: req.Type, + Amount: float64(req.Amount), + Status: "success", + PaymentType: "extension_api", + Remark: req.Description, + } + database.DB.Create(&record) + + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct", fmt.Sprintf("扩展API扣费: 用户%s, 类型:%s, 数量:%d", user.Username, req.Type, req.Amount), "", nil) + + response.Success(c, gin.H{ + "message": "扣除成功", + "user": user, + }) +} + +func handleGetUserVariables(c *gin.Context) { + appID := c.GetUint("applicationID") + userID := c.Param("userId") + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, appID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var variables []model.CloudVariable + if err := database.DB.Where("app_id = ?", appID).Find(&variables).Error; err != nil { + response.Error(c, 500, "获取变量定义失败") + return + } + + var userVariables []model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ?", userID, appID).Find(&userVariables).Error; err != nil { + response.Error(c, 500, "获取用户变量失败") + return + } + + userVarMap := make(map[string]string) + for _, uv := range userVariables { + userVarMap[uv.VarName] = uv.VarValue + } + + result := make(map[string]interface{}) + for _, v := range variables { + if v.Scope == "app" { + result[v.Key] = gin.H{ + "value": v.DefaultValue, + "scope": "app", + } + } else { + value := v.DefaultValue + if uv, ok := userVarMap[v.Key]; ok { + value = uv + } + result[v.Key] = gin.H{ + "value": value, + "scope": "user", + } + } + } + + response.Success(c, result) +} + +func handleUpdateUserVariables(c *gin.Context) { + appID := c.GetUint("applicationID") + userID := c.Param("userId") + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", userID, appID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var req struct { + Variables map[string]string `json:"variables"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var variables []model.CloudVariable + if err := database.DB.Where("app_id = ?", appID).Find(&variables).Error; err != nil { + response.Error(c, 500, "获取变量定义失败") + return + } + + varMap := make(map[string]model.CloudVariable) + for _, v := range variables { + varMap[v.Key] = v + } + + for key, value := range req.Variables { + variable, exists := varMap[key] + if !exists { + continue + } + + if variable.Scope == "app" { + variable.DefaultValue = value + database.DB.Save(&variable) + } else { + var userVar model.UserVariable + if err := database.DB.Where("user_id = ? AND app_id = ? AND var_name = ?", userID, appID, key).First(&userVar).Error; err != nil { + userVar = model.UserVariable{ + UserID: user.ID, + AppID: appID, + VarName: key, + VarValue: value, + VarType: variable.VarType, + } + database.DB.Create(&userVar) + } else { + userVar.VarValue = value + database.DB.Save(&userVar) + } + } + } + + response.Success(c, gin.H{ + "message": "更新成功", + }) +} + +func handleGetAppVariables(c *gin.Context) { + appID := c.GetUint("applicationID") + + var variables []model.CloudVariable + if err := database.DB.Where("app_id = ? AND scope = ?", appID, "app").Find(&variables).Error; err != nil { + response.Error(c, 500, "获取应用变量失败") + return + } + + result := make(map[string]string) + for _, v := range variables { + result[v.Key] = v.DefaultValue + } + + response.Success(c, result) +} + +func handleUpdateAppVariables(c *gin.Context) { + appID := c.GetUint("applicationID") + + var req struct { + Variables map[string]string `json:"variables"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + for key, value := range req.Variables { + var variable model.CloudVariable + if err := database.DB.Where("app_id = ? AND key = ? AND scope = ?", appID, key, "app").First(&variable).Error; err == nil { + variable.DefaultValue = value + database.DB.Save(&variable) + } + } + + response.Success(c, gin.H{ + "message": "更新成功", + }) +} + +func handleGetCards(c *gin.Context) { + appID := c.GetUint("applicationID") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + status := c.Query("status") + cardTypeID := c.Query("card_type_id") + + var cards []model.Card + var total int64 + + query := database.DB.Model(&model.Card{}).Where("application_id = ?", appID) + + if status != "" { + query = query.Where("status = ?", status) + } + + if cardTypeID != "" { + query = query.Where("card_type_id = ?", cardTypeID) + } + + query.Count(&total) + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&cards).Error; err != nil { + response.Error(c, 500, "获取卡密列表失败") + return + } + + response.Success(c, gin.H{ + "cards": cards, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +func handleGenerateCards(c *gin.Context) { + appID := c.GetUint("applicationID") + + var req struct { + CardTypeID uint `json:"card_type_id" binding:"required"` + Count int `json:"count" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var cardType model.CardType + if err := database.DB.Where("id = ? AND application_id = ?", req.CardTypeID, appID).First(&cardType).Error; err != nil { + response.Error(c, 404, "卡密类型不存在") + return + } + + cards := make([]model.Card, req.Count) + for i := 0; i < req.Count; i++ { + cardKey := generateCardKey() + cards[i] = model.Card{ + ApplicationID: appID, + CardTypeID: req.CardTypeID, + CardKey: cardKey, + Status: "unused", + } + } + + if err := database.DB.Create(&cards).Error; err != nil { + response.Error(c, 500, "生成卡密失败") + return + } + + response.Success(c, gin.H{ + "message": "生成成功", + "count": req.Count, + "cards": cards, + }) +} + +func handleGetCard(c *gin.Context) { + appID := c.GetUint("applicationID") + cardID := c.Param("cardId") + + var card model.Card + if err := database.DB.Where("id = ? AND application_id = ?", cardID, appID).First(&card).Error; err != nil { + response.Error(c, 404, "卡密不存在") + return + } + + response.Success(c, card) +} + +func handleSendNotification(c *gin.Context) { + appID := c.GetUint("applicationID") + + var req struct { + UserID uint `json:"user_id" binding:"required"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Type string `json:"type"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + var user model.AppUser + if err := database.DB.Where("id = ? AND application_id = ?", req.UserID, appID).First(&user).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + notification := model.Announcement{ + ApplicationID: appID, + Title: req.Title, + Content: req.Content, + Type: req.Type, + Status: "active", + } + + if err := database.DB.Create(¬ification).Error; err != nil { + response.Error(c, 500, "发送通知失败") + return + } + + response.Success(c, gin.H{ + "message": "发送成功", + "notification": notification, + }) +} + +func handleSendBatchNotification(c *gin.Context) { + appID := c.GetUint("applicationID") + + var req struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Type string `json:"type"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误: "+err.Error()) + return + } + + notification := model.Announcement{ + ApplicationID: appID, + Title: req.Title, + Content: req.Content, + Type: req.Type, + Status: "active", + } + + if err := database.DB.Create(¬ification).Error; err != nil { + response.Error(c, 500, "发送通知失败") + return + } + + response.Success(c, gin.H{ + "message": "发送成功", + "notification": notification, + }) +} + +func handleGetAppInfo(c *gin.Context) { + appID := c.GetUint("applicationID") + + var app model.Application + if err := database.DB.First(&app, appID).Error; err != nil { + response.Error(c, 404, "应用不存在") + return + } + + response.Success(c, gin.H{ + "id": app.ID, + "name": app.Name, + "description": app.Description, + "billing_type": app.BillingType, + "encrypt_type": app.EncryptType, + "bind_type": app.BindType, + "max_devices": app.MaxDevices, + "multi_open": app.MultiOpen, + "enable_trial": app.EnableTrial, + "trial_balance": app.TrialBalance, + "status": app.Status, + "created_at": app.CreatedAt, + }) +} + +func handleGetAppStats(c *gin.Context) { + appID := c.GetUint("applicationID") + + var userCount, activeUserCount, cardCount, usedCardCount int64 + + database.DB.Model(&model.AppUser{}).Where("application_id = ?", appID).Count(&userCount) + database.DB.Model(&model.AppUser{}).Where("application_id = ? AND status = ?", appID, "active").Count(&activeUserCount) + database.DB.Model(&model.Card{}).Where("application_id = ?", appID).Count(&cardCount) + database.DB.Model(&model.Card{}).Where("application_id = ? AND status = ?", appID, "used").Count(&usedCardCount) + + response.Success(c, gin.H{ + "user_count": userCount, + "active_user_count": activeUserCount, + "card_count": cardCount, + "used_card_count": usedCardCount, + }) +} + +func generateCardKey() string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 16) + for i := range b { + b[i] = charset[i%len(charset)] + } + return string(b) +} diff --git a/backend/internal/router/frontend/frontend.go b/backend/internal/router/frontend/frontend.go new file mode 100644 index 0000000..ed10ed1 --- /dev/null +++ b/backend/internal/router/frontend/frontend.go @@ -0,0 +1,805 @@ +package frontend + +import ( + "encoding/json" + "fmt" + "strings" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service" + "verification-platform-backend/internal/service/payment" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.Engine) { + auth := r.Group("/api/v1/auth") + { + auth.POST("/register", handleRegister) + auth.POST("/login", handleLogin) + auth.POST("/logout", handleLogout) + auth.POST("/refresh", handleRefreshToken) + } + + api := r.Group("/api/v1") + { + api.POST("/login", handleLogin) + api.POST("/register", handleRegister) + api.POST("/logout", handleLogout) + api.POST("/refresh", handleRefreshToken) + api.POST("/forgot-password", handleForgotPassword) + api.POST("/reset-password", handleResetPassword) + api.POST("/send-sms", handleSendSms) + api.POST("/verify", handleCardVerification) + api.GET("/verify/status", handleGetVerificationStatus) + } + + docs := r.Group("/api/v1/docs") + { + docs.GET("", handleGetDocs) + docs.GET("/:id", handleGetDoc) + docs.GET("/slug/:slug", handleGetDocBySlug) + } + + docCategories := r.Group("/api/v1/doc-categories") + { + docCategories.GET("", handleGetDocCategories) + } + + pricing := r.Group("/api/v1/pricing") + { + pricing.GET("", handleGetPricing) + } + + packages := r.Group("/api/v1/packages") + { + packages.GET("", handleGetPricing) + } + + site := r.Group("/api/v1/site") + { + site.GET("/info", handleGetSiteInfo) + site.GET("/contact", handleGetContact) + } + + captcha := r.Group("/api/v1/captcha") + { + captcha.GET("", handleGetCaptcha) + } + + settings := r.Group("/api/v1/settings") + { + settings.GET("", handleGetSettings) + settings.GET("/payment-channels", handleGetActivePaymentChannels) + } + + public := r.Group("/api/v1/public") + { + public.GET("/stats", handleGetPublicStats) + } +} + +func handleGetCaptcha(c *gin.Context) { + authService := service.NewAuthService() + result, err := authService.GetCaptcha() + if err != nil { + response.Error(c, 500, "获取验证码失败") + return + } + response.Success(c, result) +} + +func handleGetSettings(c *gin.Context) { + settingService := service.NewSettingService() + settings, err := settingService.GetSettings() + if err != nil { + response.Error(c, 500, "获取设置失败") + return + } + response.Success(c, settings) +} + +func handleGetActivePaymentChannels(c *gin.Context) { + var channels []model.PaymentChannel + if err := database.DB.Where("status = ?", "active").Order("sort asc, id asc").Find(&channels).Error; err != nil { + response.Error(c, 500, "获取支付通道失败") + return + } + response.Success(c, gin.H{"channels": channels}) +} + +func handleRegister(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Phone string `json:"phone"` + Role string `json:"role"` + Type string `json:"type"` + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fmt.Printf("注册参数错误: %v\n", err) + response.Error(c, 400, "参数错误") + return + } + + fmt.Printf("注册请求: username=%s, email=%s, phone=%s, role=%s, type=%s\n", + req.Username, req.Email, req.Phone, req.Role, req.Type) + + if req.Role == "" { + req.Role = "developer" + } + + username := req.Username + email := req.Email + phone := req.Phone + password := req.Password + + if req.Type == "email" { + username = email + email = req.Email + password = req.Password + } else if req.Type == "sms" { + username = phone + phone = req.Phone + password = req.Password + } + + fmt.Printf("处理后的注册数据: username=%s, email=%s, phone=%s, password_len=%d, role=%s\n", + username, email, phone, len(password), req.Role) + + authService := service.NewAuthService() + err := authService.RegisterWithRole(username, email, phone, password, req.Role) + if err != nil { + fmt.Printf("注册失败: %v\n", err) + response.Error(c, 400, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "注册成功", + }) +} + +func handleLogin(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + AgentPath string `json:"agent_path"` + CaptchaID string `json:"captcha_id"` + Captcha string `json:"captcha"` + Type string `json:"type"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fmt.Printf("登录参数错误: %v\n", err) + response.Error(c, 400, "参数错误") + return + } + + fmt.Printf("登录请求: username=%s, password_len=%d, agent_path=%s\n", + req.Username, len(req.Password), req.AgentPath) + + // 检查是否启用了验证码 + var enableCaptchaSetting model.Setting + err := database.DB.Where("category = ? AND key = ?", "basic", "enableCaptcha").First(&enableCaptchaSetting).Error + enableCaptcha := false + if err == nil && enableCaptchaSetting.Value == "true" { + enableCaptcha = true + } + + // 如果启用了验证码,验证验证码 + if enableCaptcha { + if req.CaptchaID == "" || req.Captcha == "" { + response.Error(c, 400, "请输入验证码") + return + } + + // 查询验证码 + var captcha model.Captcha + if err := database.DB.Where("captcha_id = ?", req.CaptchaID).First(&captcha).Error; err != nil { + response.Error(c, 400, "验证码错误或已过期") + return + } + + // 检查验证码是否过期 + if time.Now().After(captcha.ExpiresAt) { + database.DB.Delete(&captcha) + response.Error(c, 400, "验证码已过期") + return + } + + // 验证码比较(忽略大小写和空格) + if strings.ToLower(strings.TrimSpace(req.Captcha)) != strings.ToLower(strings.TrimSpace(captcha.Code)) { + database.DB.Delete(&captcha) + response.Error(c, 400, "验证码错误") + return + } + + // 删除已使用的验证码 + database.DB.Delete(&captcha) + } + + authService := service.NewAuthService() + result, err := authService.Login(req.Username, req.Password, req.AgentPath) + if err != nil { + fmt.Printf("登录失败: %v\n", err) + response.Error(c, 401, err.Error()) + return + } + + fmt.Printf("登录成功: username=%s\n", req.Username) + response.Success(c, result) +} + +func handleLogout(c *gin.Context) { + response.Success(c, gin.H{ + "message": "登出成功", + }) +} + +func handleRefreshToken(c *gin.Context) { + response.Success(c, gin.H{ + "token": "", + }) +} + +func handleGetDocs(c *gin.Context) { + var docs []model.Doc + if err := database.DB.Where("status = ?", "published").Preload("Category").Find(&docs).Error; err != nil { + response.Error(c, 500, "获取文档列表失败") + return + } + response.Success(c, docs) +} + +func handleGetDoc(c *gin.Context) { + id := c.Param("id") + var doc model.Doc + if err := database.DB.Where("id = ? AND status = ?", id, "published").Preload("Category").First(&doc).Error; err != nil { + response.Error(c, 404, "文档不存在") + return + } + response.Success(c, doc) +} + +func handleGetDocBySlug(c *gin.Context) { + slug := c.Param("slug") + var doc model.Doc + if err := database.DB.Where("slug = ? AND status = ?", slug, "published").Preload("Category").First(&doc).Error; err != nil { + response.Error(c, 404, "文档不存在") + return + } + response.Success(c, doc) +} + +func handleGetDocCategories(c *gin.Context) { + var categories []model.DocCategory + if err := database.DB.Find(&categories).Error; err != nil { + response.Error(c, 500, "获取文档分类失败") + return + } + response.Success(c, categories) +} + +func handleGetPricing(c *gin.Context) { + var packages []model.Package + if err := database.DB.Where("status = ?", "active").Find(&packages).Error; err != nil { + response.Error(c, 500, "获取价格信息失败") + return + } + + var result []gin.H + for _, pkg := range packages { + var permission model.PackagePermission + database.DB.Where("package_id = ?", pkg.ID).First(&permission) + + result = append(result, gin.H{ + "id": pkg.ID, + "name": pkg.Name, + "name_en": pkg.NameEn, + "price": pkg.Price, + "currency": pkg.Currency, + "period": pkg.Period, + "description": pkg.Description, + "description_en": pkg.DescriptionEn, + "status": pkg.Status, + "sort": pkg.Sort, + "is_recommended": pkg.IsRecommended, + "allow_upgrade": pkg.AllowUpgrade, + "created_at": pkg.CreatedAt, + "updated_at": pkg.UpdatedAt, + "permissions": permission, + }) + } + + response.Success(c, gin.H{ + "packages": result, + }) +} + +func handleGetSiteInfo(c *gin.Context) { + response.Success(c, gin.H{ + "site_name": "验证平台", + "site_url": "http://localhost:3000", + "description": "专业的应用验证平台", + "contact_email": "admin@example.com", + }) +} + +func handleGetContact(c *gin.Context) { + response.Success(c, gin.H{ + "email": "admin@example.com", + "qq": "123456789", + "wechat": "example_wechat", + "phone": "400-123-4567", + }) +} + +func handleForgotPassword(c *gin.Context) { + var req struct { + Email string `json:"email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + authService := service.NewAuthService() + err := authService.ForgotPassword(req.Email) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "重置链接已发送到您的邮箱", + }) +} + +func handleResetPassword(c *gin.Context) { + var req struct { + Token string `json:"token"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + authService := service.NewAuthService() + err := authService.ResetPassword(req.Token, req.Password) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "密码重置成功", + }) +} + +func handleSendSms(c *gin.Context) { + var req struct { + Phone string `json:"phone"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + response.Success(c, gin.H{ + "message": "验证码已发送", + }) +} + +func HandleGetProfile(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + authService := service.NewAuthService() + user, err := authService.GetProfile(userID.(uint)) + if err != nil { + response.Error(c, 404, "用户不存在") + return + } + + response.Success(c, user) +} + +func HandleUpdateProfile(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var req struct { + Email string `json:"email"` + Phone string `json:"phone"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + authService := service.NewAuthService() + err := authService.UpdateProfile(userID.(uint), req.Email, req.Phone) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "资料更新成功", + }) +} + +func HandleChangePassword(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + authService := service.NewAuthService() + err := authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword) + if err != nil { + response.Error(c, 400, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "密码修改成功", + }) +} + +func HandleUploadAvatar(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "未授权") + return + } + + file, err := c.FormFile("avatar") + if err != nil { + response.Error(c, 400, "请上传文件") + return + } + + filename := fmt.Sprintf("avatar_%d_%s", userID, file.Filename) + if err := c.SaveUploadedFile(file, "uploads/"+filename); err != nil { + response.Error(c, 500, "文件保存失败") + return + } + + response.Success(c, gin.H{ + "avatar": "/uploads/" + filename, + }) +} + +func handleCardVerification(c *gin.Context) { + var req struct { + CardKey string `json:"card_key"` + Username string `json:"username"` + DeviceID string `json:"device_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var card model.Card + if err := database.DB.Where("card_key = ?", req.CardKey).First(&card).Error; err != nil { + response.Error(c, 404, "卡密不存在") + return + } + + if card.Status != "unused" { + response.Error(c, 400, "卡密已被使用或已禁用") + return + } + + response.Success(c, gin.H{ + "message": "验证成功", + "card_id": card.ID, + "card_type": card.CardType, + }) +} + +func handleGetVerificationStatus(c *gin.Context) { + cardKey := c.Query("card_key") + if cardKey == "" { + response.Error(c, 400, "请提供卡密") + return + } + + var card model.Card + if err := database.DB.Where("card_key = ?", cardKey).First(&card).Error; err != nil { + response.Error(c, 404, "卡密不存在") + return + } + + response.Success(c, gin.H{ + "status": card.Status, + "card_id": card.ID, + "used_at": card.UsedAt, + }) +} + +func HandleCreateOrder(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "请先登录") + return + } + + var req struct { + PackageID uint `json:"package_id"` + PaymentChannelID uint `json:"payment_channel_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.PackageID == 0 { + response.Error(c, 400, "请选择套餐") + return + } + + var pkg model.Package + if err := database.DB.Where("id = ? AND status = ?", req.PackageID, "active").First(&pkg).Error; err != nil { + response.Error(c, 404, "套餐不存在或已下架") + return + } + + orderNo := fmt.Sprintf("ORD%d%d", time.Now().Unix(), userID.(uint)) + + if pkg.Price == 0 { + now := time.Now() + pkgID := pkg.ID + order := model.Order{ + OrderNo: orderNo, + UserID: userID.(uint), + PackageID: &pkgID, + OrderType: "package", + Title: fmt.Sprintf("领取套餐 - %s", pkg.Name), + Amount: 0, + PaymentType: "free", + Status: "paid", + PaymentAt: &now, + Description: fmt.Sprintf("免费套餐: %s, 周期: %s", pkg.Name, pkg.Period), + } + + if err := database.DB.Create(&order).Error; err != nil { + response.Error(c, 500, "创建订单失败") + return + } + + var expiredAt *time.Time + if pkg.Period != "" && pkg.Period != "permanent" { + duration := parsePeriod(pkg.Period) + if duration > 0 { + exp := now.Add(duration) + expiredAt = &exp + } + } + + userPackage := model.UserPackage{ + UserID: userID.(uint), + PackageID: pkg.ID, + ExpiredAt: expiredAt, + Status: "active", + } + + if err := database.DB.Create(&userPackage).Error; err != nil { + response.Error(c, 500, "创建套餐授权失败") + return + } + + if err := database.DB.Model(&model.User{}).Where("id = ?", userID).Update("current_package_id", pkg.ID).Error; err != nil { + response.Error(c, 500, "更新用户套餐失败") + return + } + + response.Success(c, gin.H{ + "order_no": order.OrderNo, + "order_id": order.ID, + "amount": 0, + "status": "paid", + "message": "免费套餐领取成功", + }) + return + } + + if req.PaymentChannelID == 0 { + response.Error(c, 400, "请选择支付方式") + return + } + + var paymentChannel model.PaymentChannel + if err := database.DB.Where("id = ? AND status = ?", req.PaymentChannelID, "active").First(&paymentChannel).Error; err != nil { + response.Error(c, 404, "支付通道不存在或已禁用") + return + } + + pkgID := pkg.ID + order := model.Order{ + OrderNo: orderNo, + UserID: userID.(uint), + PackageID: &pkgID, + OrderType: "package", + Title: fmt.Sprintf("购买套餐 - %s", pkg.Name), + Amount: pkg.Price, + PaymentType: paymentChannel.Type, + PaymentMethod: paymentChannel.Type, + Status: "pending", + Description: fmt.Sprintf("套餐ID: %d, 套餐名称: %s, 周期: %s, 支付通道: %s", pkg.ID, pkg.Name, pkg.Period, paymentChannel.Name), + } + + if err := database.DB.Create(&order).Error; err != nil { + response.Error(c, 500, "创建订单失败") + return + } + + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + host := c.Request.Host + callbackBaseURL := fmt.Sprintf("%s://%s", scheme, host) + + switch paymentChannel.Type { + case "bepusdt": + paymentService, err := payment.GetPaymentService(paymentChannel, callbackBaseURL) + if err != nil { + response.Error(c, 500, "支付服务初始化失败: "+err.Error()) + return + } + + notifyURL := fmt.Sprintf("%s/api/v1/payment/callback/bepusdt", callbackBaseURL) + redirectURL := fmt.Sprintf("%s/developer/finance?order=%s", callbackBaseURL, orderNo) + + result, err := paymentService.CreateOrder(orderNo, pkg.Price, notifyURL, redirectURL, order.Title) + if err != nil { + response.Error(c, 500, "创建支付订单失败: "+err.Error()) + return + } + + extraData := map[string]interface{}{ + "trade_id": result.TradeID, + "payment_url": result.PaymentURL, + "actual_amount": result.ActualAmount, + "token": result.Token, + "expiration_time": result.ExpirationTime, + "channel_id": paymentChannel.ID, + } + extraJSON, _ := json.Marshal(extraData) + order.ExtraData = string(extraJSON) + database.DB.Save(&order) + + response.Success(c, gin.H{ + "order_no": order.OrderNo, + "order_id": order.ID, + "amount": order.Amount, + "actual_amount": result.ActualAmount, + "payment_url": result.PaymentURL, + "trade_id": result.TradeID, + "token": result.Token, + "expiration_time": result.ExpirationTime, + }) + return + } + + response.Success(c, gin.H{ + "order_no": order.OrderNo, + "order_id": order.ID, + "amount": order.Amount, + "pay_url": "", + }) +} + +func HandleGetOrder(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "请先登录") + return + } + + orderNo := c.Param("order_no") + if orderNo == "" { + response.Error(c, 400, "订单号不能为空") + return + } + + var order model.Order + if err := database.DB.Where("order_no = ? AND user_id = ?", orderNo, userID).First(&order).Error; err != nil { + response.Error(c, 404, "订单不存在") + return + } + + response.Success(c, gin.H{ + "order": gin.H{ + "id": order.ID, + "order_no": order.OrderNo, + "amount": order.Amount, + "status": order.Status, + "payment_type": order.PaymentType, + "title": order.Title, + "extra_data": order.ExtraData, + "created_at": order.CreatedAt, + "payment_at": order.PaymentAt, + }, + }) +} + +func HandleGetOrderStatus(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Error(c, 401, "请先登录") + return + } + + orderNo := c.Param("order_no") + if orderNo == "" { + response.Error(c, 400, "订单号不能为空") + return + } + + var order model.Order + if err := database.DB.Where("order_no = ? AND user_id = ?", orderNo, userID).First(&order).Error; err != nil { + response.Error(c, 404, "订单不存在") + return + } + + response.Success(c, gin.H{ + "status": order.Status, + "payment_at": order.PaymentAt, + }) +} + +func parsePeriod(period string) time.Duration { + switch period { + case "daily": + return 24 * time.Hour + case "weekly": + return 7 * 24 * time.Hour + case "monthly": + return 30 * 24 * time.Hour + case "quarterly": + return 90 * 24 * time.Hour + case "yearly": + return 365 * 24 * time.Hour + default: + return 0 + } +} + +func handleGetPublicStats(c *gin.Context) { + var totalApps, totalUsers, totalVerifications int64 + + database.DB.Model(&model.Application{}).Count(&totalApps) + database.DB.Model(&model.User{}).Where("role = ?", "developer").Count(&totalUsers) + database.DB.Model(&model.Card{}).Where("status = ?", "used").Count(&totalVerifications) + + response.Success(c, gin.H{ + "totalApps": totalApps, + "totalUsers": totalUsers, + "totalVerifications": totalVerifications, + }) +} diff --git a/backend/internal/router/frontend/payment_callback.go b/backend/internal/router/frontend/payment_callback.go new file mode 100644 index 0000000..b443baf --- /dev/null +++ b/backend/internal/router/frontend/payment_callback.go @@ -0,0 +1,115 @@ +package frontend + +import ( + "encoding/json" + "fmt" + "io" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/internal/service/payment" + + "github.com/gin-gonic/gin" +) + +func SetupPaymentCallbackRoutes(r *gin.Engine) { + r.POST("/api/v1/payment/callback/bepusdt", handleBEpusdtCallback) +} + +func handleBEpusdtCallback(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.String(200, "fail") + return + } + + var callbackData payment.CallbackData + if err := json.Unmarshal(body, &callbackData); err != nil { + c.String(200, "fail") + return + } + + var order model.Order + if err := database.DB.Where("order_no = ?", callbackData.OrderID).First(&order).Error; err != nil { + c.String(200, "fail") + return + } + + if order.Status != "pending" { + c.String(200, "success") + return + } + + var extraData struct { + TradeID string `json:"trade_id"` + ChannelID uint `json:"channel_id"` + ExpirationTime string `json:"expiration_time"` + } + if order.ExtraData != "" { + json.Unmarshal([]byte(order.ExtraData), &extraData) + } + + if extraData.ExpirationTime != "" { + expirationTime, err := time.Parse(time.RFC3339, extraData.ExpirationTime) + if err == nil && time.Now().After(expirationTime) { + c.String(200, "fail") + return + } + } + + var paymentChannel model.PaymentChannel + if extraData.ChannelID > 0 { + database.DB.First(&paymentChannel, extraData.ChannelID) + } + + if paymentChannel.ID > 0 { + paymentService, err := payment.GetPaymentService(paymentChannel, "") + if err == nil { + if !paymentService.VerifyCallback(callbackData) { + c.String(200, "fail") + return + } + } + } + + if callbackData.Status == 2 { + now := time.Now() + order.Status = "paid" + order.PaymentAt = &now + order.PaymentMethod = "bepusdt" + + if err := database.DB.Save(&order).Error; err != nil { + c.String(200, "fail") + return + } + + if order.OrderType == "package" { + var pkg model.Package + if err := database.DB.First(&pkg, order.PackageID).Error; err == nil { + var expiredAt *time.Time + if pkg.Period != "" && pkg.Period != "permanent" { + duration := parsePeriod(pkg.Period) + if duration > 0 { + exp := now.Add(duration) + expiredAt = &exp + } + } + + userPackage := model.UserPackage{ + UserID: order.UserID, + PackageID: pkg.ID, + ExpiredAt: expiredAt, + Status: "active", + } + + if err := database.DB.Create(&userPackage).Error; err != nil { + fmt.Printf("创建套餐授权失败: %v\n", err) + } else { + database.DB.Model(&model.User{}).Where("id = ?", order.UserID).Update("current_package_id", pkg.ID) + } + } + } + } + + c.String(200, "success") +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..721db55 --- /dev/null +++ b/backend/internal/router/router.go @@ -0,0 +1,71 @@ +package router + +import ( + "time" + + "verification-platform-backend/internal/middleware" + "verification-platform-backend/internal/router/app" + "verification-platform-backend/internal/router/developer" + "verification-platform-backend/internal/router/extension" + "verification-platform-backend/internal/router/frontend" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.Engine) { + r.Use(middleware.Cors()) + r.Use(middleware.Logger()) + + r.GET("/health", func(c *gin.Context) { + response.Success(c, gin.H{ + "status": "ok", + "time": time.Now().Format(time.RFC3339), + }) + }) + + frontend.SetupRoutes(r) + + api := r.Group("/api/v1") + { + devGroup := api.Group("/dev") + { + devGroup.Use(middleware.JWT()) + devGroup.Use(middleware.DeveloperAuth()) + developer.SetupRoutes(devGroup) + developer.SetupRoutesWithoutPackage(devGroup) + } + + appGroup := api.Group("/app/:appKey") + { + appGroup.Use(app.AppCryptoMiddleware()) + appGroup.Use(app.ResponseEncryption()) + app.SetupInfoRoutes(appGroup) + } + + appGroupWithStatusCheck := api.Group("/app/:appKey") + { + appGroupWithStatusCheck.Use(middleware.CheckAppStatus()) + appGroupWithStatusCheck.Use(app.AppCryptoMiddleware()) + appGroupWithStatusCheck.Use(app.ResponseEncryption()) + app.SetupRoutes(appGroupWithStatusCheck) + app.SetupAuthUserRoutes(appGroupWithStatusCheck) + app.SetupDevicePublicRoutes(appGroupWithStatusCheck) + } + + appAuthGroup := api.Group("/app/:appKey") + { + appAuthGroup.Use(middleware.JWT()) + appAuthGroup.Use(middleware.CheckAppStatus()) + appAuthGroup.Use(app.AppCryptoMiddleware()) + appAuthGroup.Use(app.ResponseEncryption()) + app.SetupCloudRoutes(appAuthGroup) + app.SetupAuthRoutes(appAuthGroup) + } + + extGroup := api.Group("") + { + extension.SetupRoutes(extGroup) + } + } +} diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go new file mode 100644 index 0000000..bed5827 --- /dev/null +++ b/backend/internal/service/auth.go @@ -0,0 +1,342 @@ +package service + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/jwt" + "verification-platform-backend/pkg/utils" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// AuthService 认证服务 +type AuthService struct{} + +// NewAuthService 创建认证服务实例 +func NewAuthService() *AuthService { + return &AuthService{} +} + +// Login 用户登录 +func (s *AuthService) Login(username, password, agentPath string) (map[string]interface{}, error) { + // 查找用户 + var user model.User + err := database.DB.Where("username = ?", username).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("用户名或密码错误") + } + return nil, err + } + + // 验证密码 + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + if err != nil { + return nil, errors.New("用户名或密码错误") + } + + // 更新最后登录时间 + user.LastLoginAt = &time.Time{} + *user.LastLoginAt = time.Now() + database.DB.Save(&user) + + // 生成JWT令牌 + token, err := jwt.GenerateToken(user.ID, user.Username, user.Role) + if err != nil { + return nil, err + } + + // 返回用户信息和令牌 + return map[string]interface{}{ + "user": map[string]interface{}{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "avatar": user.Avatar, + "role": user.Role, + "status": user.Status, + }, + "token": token, + }, nil +} + +// Register 用户注册 +func (s *AuthService) Register(username, email, password string) error { + return s.RegisterWithRole(username, email, "", password, "developer") +} + +// RegisterWithRole 用户注册(带角色) +func (s *AuthService) RegisterWithRole(username, email, phone, password, role string) error { + if username == "" { + return errors.New("用户名不能为空") + } + if password == "" { + return errors.New("密码不能为空") + } + + var existingUser model.User + err := database.DB.Where("username = ?", username).First(&existingUser).Error + if err == nil { + return errors.New("用户名已存在") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + if email != "" { + var emailStr string = email + err = database.DB.Where("email = ?", emailStr).First(&existingUser).Error + if err == nil { + return errors.New("邮箱已被注册") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + if phone != "" { + err = database.DB.Where("device_id = ?", phone).First(&existingUser).Error + if err == nil { + return errors.New("手机号已被注册") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + if role == "" { + role = "developer" + } + + user := model.User{ + Username: username, + DeviceID: phone, + Password: string(hashedPassword), + Role: role, + Status: "active", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if email != "" { + user.Email = &email + } + + err = database.DB.Create(&user).Error + if err != nil { + // 处理数据库唯一约束错误 + errMsg := err.Error() + if strings.Contains(errMsg, "UNIQUE constraint failed") { + if strings.Contains(errMsg, "users.username") { + return errors.New("用户名已存在") + } + if strings.Contains(errMsg, "users.email") { + return errors.New("邮箱已被注册") + } + if strings.Contains(errMsg, "users.device_id") { + return errors.New("手机号已被注册") + } + return errors.New("该账号已被注册") + } + return err + } + + return nil +} + +// GetProfile 获取用户资料 +func (s *AuthService) GetProfile(userID uint) (*model.User, error) { + var user model.User + err := database.DB.First(&user, userID).Error + if err != nil { + return nil, err + } + + // 不返回密码 + user.Password = "" + return &user, nil +} + +// UpdateProfile 更新用户资料 +func (s *AuthService) UpdateProfile(userID uint, email, phone string) error { + // 检查邮箱是否已被其他用户使用 + if email != "" { + var existingUser model.User + err := database.DB.Where("email = ? AND id != ?", email, userID).First(&existingUser).Error + if err == nil { + return errors.New("邮箱已被其他用户使用") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + // 更新用户信息 + updates := map[string]interface{}{ + "email": email, + "phone": phone, + "updated_at": time.Now(), + } + + err := database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error + if err != nil { + return err + } + + return nil +} + +// ChangePassword 修改密码 +func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error { + // 获取用户 + var user model.User + err := database.DB.First(&user, userID).Error + if err != nil { + return err + } + + // 验证旧密码 + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)) + if err != nil { + return errors.New("原密码错误") + } + + // 加密新密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + // 更新密码 + user.Password = string(hashedPassword) + user.UpdatedAt = time.Now() + + err = database.DB.Save(&user).Error + if err != nil { + return err + } + + return nil +} + +// ForgotPassword 忘记密码 +func (s *AuthService) ForgotPassword(email string) error { + // 查找用户 + var user model.User + err := database.DB.Where("email = ?", email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("邮箱未注册") + } + return err + } + + // 生成重置token + resetToken := utils.GenerateRandomString(32) + user.ResetToken = resetToken + expiresAt := time.Now().Add(24 * time.Hour) // 24小时有效期 + user.ResetTokenExpiresAt = &expiresAt + + err = database.DB.Save(&user).Error + if err != nil { + return err + } + + // 这里应该发送邮件,简化处理 + _ = resetToken + + return nil +} + +// ResetPassword 重置密码 +func (s *AuthService) ResetPassword(token, newPassword string) error { + // 查找用户 + var user model.User + err := database.DB.Where("reset_token = ? AND reset_token_expires_at > ?", token, time.Now()).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("重置链接无效或已过期") + } + return err + } + + // 加密新密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + // 更新密码 + user.Password = string(hashedPassword) + user.ResetToken = "" + var emptyTime time.Time + user.ResetTokenExpiresAt = &emptyTime + user.UpdatedAt = time.Now() + + err = database.DB.Save(&user).Error + if err != nil { + return err + } + + return nil +} + +// GetCaptcha 获取验证码 +func (s *AuthService) GetCaptcha() (map[string]interface{}, error) { + // 生成随机验证码 + captchaText := utils.GenerateRandomString(6) + + // 生成验证码ID + captchaID := utils.GenerateRandomString(16) + + // 存储验证码到数据库 + captcha := model.Captcha{ + CaptchaID: captchaID, + Code: captchaText, + ExpiresAt: time.Now().Add(5 * time.Minute), + CreatedAt: time.Now(), + } + + if err := database.DB.Create(&captcha).Error; err != nil { + return nil, fmt.Errorf("存储验证码失败") + } + + // 创建一个简单的验证码图片 + // 这里使用SVG格式生成一个简单的验证码图片 + svg := generateCaptchaSVG(captchaText) + + // 将SVG转换为base64 + captchaImage := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)) + + return map[string]interface{}{ + "captcha_id": captchaID, + "captcha_image": captchaImage, + }, nil +} + +// generateCaptchaSVG 生成验证码SVG图片 +func generateCaptchaSVG(text string) string { + width := 120 + height := 40 + + // 生成随机颜色 + colors := []string{"#FF5722", "#4CAF50", "#2196F3", "#FF9800", "#9C27B0"} + bgColor := "#F5F5F5" + + // 创建SVG + svg := fmt.Sprintf(` + + %s + `, width, height, width, height, bgColor, colors[0], text) + + return svg +} diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go new file mode 100644 index 0000000..9097524 --- /dev/null +++ b/backend/internal/service/email.go @@ -0,0 +1,154 @@ +package service + +import ( + "crypto/tls" + "fmt" + "mime" + "net/smtp" + "strconv" + "strings" +) + +type EmailService struct{} + +func NewEmailService() *EmailService { + return &EmailService{} +} + +type EmailConfig struct { + Host string + Port int + User string + Password string + FromName string + FromEmail string + UseSSL bool +} + +func (s *EmailService) SendEmail(config EmailConfig, to, subject, body string) error { + if config.Host == "" || config.User == "" || config.Password == "" { + return fmt.Errorf("邮件配置不完整") + } + + from := config.FromEmail + if from == "" { + from = config.User + } + + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + + msg := s.buildMessage(config.FromName, from, to, subject, body) + + auth := smtp.PlainAuth("", config.User, config.Password, config.Host) + + if config.UseSSL || config.Port == 465 || config.Port == 587 { + return s.sendWithTLS(addr, auth, from, []string{to}, []byte(msg)) + } + + return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg)) +} + +func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { + host := strings.Split(addr, ":")[0] + + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: host, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("TLS连接失败: %v", err) + } + + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("创建SMTP客户端失败: %v", err) + } + defer client.Close() + + if err = client.Auth(auth); err != nil { + return fmt.Errorf("SMTP认证失败: %v", err) + } + + if err = client.Mail(from); err != nil { + return fmt.Errorf("设置发件人失败: %v", err) + } + + for _, addr := range to { + if err = client.Rcpt(addr); err != nil { + return fmt.Errorf("设置收件人失败: %v", err) + } + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("准备邮件数据失败: %v", err) + } + + _, err = w.Write(msg) + if err != nil { + return fmt.Errorf("写入邮件内容失败: %v", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("关闭邮件写入失败: %v", err) + } + + return client.Quit() +} + +func (s *EmailService) buildMessage(fromName, from, to, subject, body string) string { + msg := "" + + if fromName != "" { + encodedName := mime.QEncoding.Encode("UTF-8", fromName) + msg += fmt.Sprintf("From: %s <%s>\r\n", encodedName, from) + } else { + msg += fmt.Sprintf("From: %s\r\n", from) + } + + msg += fmt.Sprintf("To: %s\r\n", to) + msg += fmt.Sprintf("Subject: %s\r\n", mime.QEncoding.Encode("UTF-8", subject)) + msg += "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n" + msg += body + + return msg +} + +func (s *EmailService) SendTestEmail(config EmailConfig, to, siteName string) error { + subject := fmt.Sprintf("测试邮件 - %s", siteName) + body := fmt.Sprintf(` + + + + + + +
+

邮件发送测试成功

+

这是一封测试邮件,如果您收到此邮件,说明您的SMTP配置正确。

+
+

此邮件由 %s 系统自动发送,请勿回复。

+
+ + +`, siteName) + return s.SendEmail(config, to, subject, body) +} + +func ParseBool(value string) bool { + return value == "true" || value == "1" || strings.ToLower(value) == "yes" +} + +func ParseInt(value string, defaultValue int) int { + if value == "" { + return defaultValue + } + val, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + return val +} diff --git a/backend/internal/service/log_service.go b/backend/internal/service/log_service.go new file mode 100644 index 0000000..069b620 --- /dev/null +++ b/backend/internal/service/log_service.go @@ -0,0 +1,151 @@ +package service + +import ( + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + + "github.com/gin-gonic/gin" +) + +type LogService struct{} + +type LogParams struct { + UserID *uint + ApplicationID *uint + AppUserID *uint + LogType string + Action string + Resource string + ResourceID *uint + Details string + IPAddress string + UserAgent string + DeviceID string + Status string + Level string + ErrorMessage string + StackTrace string +} + +func NewLogService() *LogService { + return &LogService{} +} + +func (s *LogService) CreateLog(params LogParams) error { + log := model.Log{ + UserID: params.UserID, + ApplicationID: params.ApplicationID, + AppUserID: params.AppUserID, + LogType: params.LogType, + Action: params.Action, + Resource: params.Resource, + ResourceID: params.ResourceID, + Details: params.Details, + IPAddress: params.IPAddress, + UserAgent: params.UserAgent, + DeviceID: params.DeviceID, + Status: params.Status, + Level: params.Level, + ErrorMessage: params.ErrorMessage, + StackTrace: params.StackTrace, + } + + if log.LogType == "" { + log.LogType = "operation" + } + if log.Status == "" { + log.Status = "success" + } + if log.Level == "" { + log.Level = "info" + } + + return database.DB.Create(&log).Error +} + +func (s *LogService) CreateLogFromContext(c *gin.Context, params LogParams) error { + params.IPAddress = c.ClientIP() + params.UserAgent = c.GetHeader("User-Agent") + + if userID, exists := c.Get("user_id"); exists && params.UserID == nil { + if uid, ok := userID.(uint); ok { + params.UserID = &uid + } + } + + return s.CreateLog(params) +} + +func (s *LogService) LogOperation(c *gin.Context, action, resource string, resourceID *uint, details string, err error) error { + params := LogParams{ + LogType: "operation", + Action: action, + Resource: resource, + ResourceID: resourceID, + Details: details, + } + + if err != nil { + params.Status = "failed" + params.Level = "error" + params.ErrorMessage = err.Error() + } + + return s.CreateLogFromContext(c, params) +} + +func (s *LogService) LogVerification(c *gin.Context, applicationID *uint, appUserID *uint, action, details string, deviceID string, err error) error { + params := LogParams{ + LogType: "verification", + ApplicationID: applicationID, + AppUserID: appUserID, + Action: action, + Resource: "verification", + Details: details, + DeviceID: deviceID, + } + + if err != nil { + params.Status = "failed" + params.Level = "warning" + params.ErrorMessage = err.Error() + } + + return s.CreateLogFromContext(c, params) +} + +func (s *LogService) LogException(c *gin.Context, action, errorMessage, stackTrace string) error { + params := LogParams{ + LogType: "exception", + Action: action, + Resource: "system", + Status: "failed", + Level: "error", + ErrorMessage: errorMessage, + StackTrace: stackTrace, + } + + return s.CreateLogFromContext(c, params) +} + +var logService = NewLogService() + +func CreateLog(params LogParams) error { + return logService.CreateLog(params) +} + +func CreateLogFromContext(c *gin.Context, params LogParams) error { + return logService.CreateLogFromContext(c, params) +} + +func LogOperation(c *gin.Context, action, resource string, resourceID *uint, details string, err error) error { + return logService.LogOperation(c, action, resource, resourceID, details, err) +} + +func LogVerification(c *gin.Context, applicationID *uint, appUserID *uint, action, details string, DeviceFingerprint string, err error) error { + return logService.LogVerification(c, applicationID, appUserID, action, details, DeviceFingerprint, err) +} + +func LogException(c *gin.Context, action, errorMessage, stackTrace string) error { + return logService.LogException(c, action, errorMessage, stackTrace) +} diff --git a/backend/internal/service/package_limit.go b/backend/internal/service/package_limit.go new file mode 100644 index 0000000..c30073a --- /dev/null +++ b/backend/internal/service/package_limit.go @@ -0,0 +1,192 @@ +package service + +import ( + "fmt" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +type PackageFeature string + +const ( + FeatureCloudData PackageFeature = "cloud_data" + FeatureDynamicCode PackageFeature = "dynamic_code" + FeatureEmail PackageFeature = "email" + FeatureExtension PackageFeature = "extension" + FeatureAgent PackageFeature = "agent" +) + +func GetUserPackagePermission(userID uint) (*model.PackagePermission, error) { + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + return nil, err + } + + if user.CurrentPackageID == nil { + return nil, fmt.Errorf("用户没有当前套餐") + } + + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND package_id = ? AND status = ?", userID, *user.CurrentPackageID, "active").First(&userPackage).Error; err != nil { + return nil, fmt.Errorf("用户套餐已过期或无效") + } + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", *user.CurrentPackageID).First(&permission).Error; err != nil { + return nil, err + } + + return &permission, nil +} + +func CheckPackageFeature(userID uint, feature PackageFeature) (bool, error) { + permission, err := GetUserPackagePermission(userID) + if err != nil { + return false, err + } + + switch feature { + case FeatureCloudData: + return permission.AllowCloudData, nil + case FeatureDynamicCode: + return permission.AllowDynamicCode, nil + case FeatureEmail: + return permission.AllowEmail, nil + case FeatureExtension: + return permission.AllowExtension, nil + case FeatureAgent: + return permission.AllowAgent, nil + default: + return false, fmt.Errorf("未知的功能: %s", feature) + } +} + +func GetApplicationDisabledStatus(appID uint) bool { + return false +} + +type ApplicationWithStatus struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Name string `json:"name"` + Description string `json:"description"` + AppKey string `json:"app_key"` + IconURL string `json:"icon_url"` + Status string `json:"status"` + BillingType string `json:"billing_type"` + LoginPolicy string `json:"login_policy"` + MaxDevices int `json:"max_devices"` + MultiOpen bool `json:"multi_open"` + MultiOpenMode string `json:"multi_open_mode"` + EnableTrial bool `json:"enable_trial"` + TrialDays int `json:"trial_days"` + EnableFreePeriod bool `json:"enable_free_period"` + FreePeriodType string `json:"free_period_type"` + FreePeriodStart string `json:"free_period_start"` + FreePeriodEnd string `json:"free_period_end"` + FreePeriodWeekdays string `json:"free_period_weekdays"` + FreePeriodStartTime string `json:"free_period_start_time"` + FreePeriodEndTime string `json:"free_period_end_time"` + HeartbeatInterval int `json:"heartbeat_interval"` + HeartbeatTimeout int `json:"heartbeat_timeout"` + MaxAttempts int `json:"max_attempts"` + LockDuration int `json:"lock_duration"` + DeductionMode string `json:"deduction_mode"` + DeductionType string `json:"deduction_type"` + DeductionInterval int `json:"deduction_interval"` + DeductionUnit string `json:"deduction_unit"` + DeductionAmount float64 `json:"deduction_amount"` + AllowRegister bool `json:"allow_register"` + RegisterMethods string `json:"register_methods"` + EnableEmailVerify bool `json:"enable_email_verify"` + RequireEmailVerify bool `json:"require_email_verify"` + EnablePasswordReset bool `json:"enable_password_reset"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DisabledByPackage bool `json:"disabled_by_package"` +} + +func GetApplicationsWithDisabledStatus(userID uint) ([]ApplicationWithStatus, error) { + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + return nil, err + } + + var apps []model.Application + if err := database.DB.Where("user_id = ?", userID).Order("created_at ASC").Find(&apps).Error; err != nil { + return nil, err + } + + result := make([]ApplicationWithStatus, len(apps)) + for i, app := range apps { + result[i] = ApplicationWithStatus{ + ID: app.ID, + UserID: app.UserID, + Name: app.Name, + Description: app.Description, + AppKey: app.AppKey, + IconURL: app.IconURL, + Status: app.Status, + BillingType: app.BillingType, + LoginPolicy: app.LoginPolicy, + MaxDevices: app.MaxDevices, + MultiOpen: app.MultiOpen, + MultiOpenMode: app.MultiOpenMode, + EnableTrial: app.EnableTrial, + TrialDays: app.TrialDays, + EnableFreePeriod: app.EnableFreePeriod, + FreePeriodType: app.FreePeriodType, + FreePeriodStart: app.FreePeriodStart, + FreePeriodEnd: app.FreePeriodEnd, + FreePeriodWeekdays: app.FreePeriodWeekdays, + FreePeriodStartTime: app.FreePeriodStartTime, + FreePeriodEndTime: app.FreePeriodEndTime, + HeartbeatInterval: app.HeartbeatInterval, + HeartbeatTimeout: app.HeartbeatTimeout, + MaxAttempts: app.MaxAttempts, + LockDuration: app.LockDuration, + DeductionMode: app.DeductionMode, + DeductionType: app.DeductionType, + DeductionInterval: app.DeductionInterval, + DeductionUnit: app.DeductionUnit, + DeductionAmount: app.DeductionAmount, + AllowRegister: app.AllowRegister, + RegisterMethods: app.RegisterMethods, + EnableEmailVerify: app.EnableEmailVerify, + RequireEmailVerify: app.RequireEmailVerify, + EnablePasswordReset: app.EnablePasswordReset, + CreatedAt: app.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: app.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + if user.CurrentPackageID == nil { + for i := range result { + result[i].DisabledByPackage = true + } + return result, nil + } + + var userPackage model.UserPackage + if err := database.DB.Where("user_id = ? AND package_id = ? AND status = ?", userID, *user.CurrentPackageID, "active").First(&userPackage).Error; err != nil { + for i := range result { + result[i].DisabledByPackage = true + } + return result, nil + } + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", *user.CurrentPackageID).First(&permission).Error; err != nil { + return result, nil + } + + if permission.MaxApplications <= 0 { + return result, nil + } + + for i := range result { + result[i].DisabledByPackage = i >= permission.MaxApplications + } + + return result, nil +} diff --git a/backend/internal/service/payment/bepusdt.go b/backend/internal/service/payment/bepusdt.go new file mode 100644 index 0000000..ec0ab97 --- /dev/null +++ b/backend/internal/service/payment/bepusdt.go @@ -0,0 +1,211 @@ +package payment + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +type BEpusdtConfig struct { + ApiURL string + ApiToken string + TradeType string + Fiat string + Timeout int + Rate string +} + +type BEpusdtClient struct { + config BEpusdtConfig + client *http.Client +} + +type CreateTransactionRequest struct { + OrderID string `json:"order_id"` + Amount float64 `json:"amount"` + NotifyURL string `json:"notify_url"` + RedirectURL string `json:"redirect_url"` + Signature string `json:"signature"` + TradeType string `json:"trade_type,omitempty"` + Fiat string `json:"fiat,omitempty"` + Name string `json:"name,omitempty"` + Timeout int `json:"timeout,omitempty"` + Rate string `json:"rate,omitempty"` + Address string `json:"address,omitempty"` +} + +type CreateTransactionResponse struct { + StatusCode int `json:"status_code"` + Message string `json:"message"` + Data struct { + Fiat string `json:"fiat"` + TradeID string `json:"trade_id"` + OrderID string `json:"order_id"` + Amount interface{} `json:"amount"` + ActualAmount interface{} `json:"actual_amount"` + Status int `json:"status"` + Token string `json:"token"` + ExpirationTime int `json:"expiration_time"` + PaymentURL string `json:"payment_url"` + } `json:"data"` + RequestID string `json:"request_id"` +} + +type CallbackData struct { + TradeID string `json:"trade_id"` + OrderID string `json:"order_id"` + Amount float64 `json:"amount"` + ActualAmount float64 `json:"actual_amount"` + Token string `json:"token"` + BlockTransactionID string `json:"block_transaction_id"` + Signature string `json:"signature"` + Status int `json:"status"` +} + +func NewBEpusdtClient(config BEpusdtConfig) *BEpusdtClient { + if config.Fiat == "" { + config.Fiat = "CNY" + } + if config.Timeout == 0 { + config.Timeout = 600 + } + + return &BEpusdtClient{ + config: config, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *BEpusdtClient) GenerateSignature(params map[string]string) string { + keys := make([]string, 0, len(params)) + for k := range params { + if params[k] != "" && k != "signature" && k != "sign_type" { + keys = append(keys, k) + } + } + sort.Strings(keys) + + var parts []string + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", k, params[k])) + } + signStr := strings.Join(parts, "&") + c.config.ApiToken + + hash := md5.New() + hash.Write([]byte(signStr)) + return hex.EncodeToString(hash.Sum(nil)) +} + +func (c *BEpusdtClient) CreateTransaction(orderID string, amount float64, notifyURL, redirectURL, name string) (*CreateTransactionResponse, error) { + params := map[string]string{ + "order_id": orderID, + "amount": fmt.Sprintf("%.2f", amount), + "notify_url": notifyURL, + "redirect_url": redirectURL, + } + + if c.config.TradeType != "" { + params["trade_type"] = c.config.TradeType + } + if c.config.Fiat != "" { + params["fiat"] = c.config.Fiat + } + if name != "" { + params["name"] = name + } + if c.config.Timeout > 0 { + params["timeout"] = fmt.Sprintf("%d", c.config.Timeout) + } + if c.config.Rate != "" { + params["rate"] = c.config.Rate + } + + params["signature"] = c.GenerateSignature(params) + + jsonData, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + apiURL := strings.TrimRight(c.config.ApiURL, "/") + reqURL := apiURL + "/api/v1/order/create-transaction" + + req, err := http.NewRequest("POST", reqURL, strings.NewReader(string(jsonData))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result CreateTransactionResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if result.StatusCode != 200 { + return nil, fmt.Errorf("API error: %s (code: %d)", result.Message, result.StatusCode) + } + + return &result, nil +} + +func (c *BEpusdtClient) VerifyCallback(data CallbackData) bool { + params := map[string]string{ + "trade_id": data.TradeID, + "order_id": data.OrderID, + "amount": fmt.Sprintf("%.2f", data.Amount), + "actual_amount": fmt.Sprintf("%.2f", data.ActualAmount), + "token": data.Token, + "block_transaction_id": data.BlockTransactionID, + "status": fmt.Sprintf("%d", data.Status), + } + + expectedSign := c.GenerateSignature(params) + return strings.EqualFold(expectedSign, data.Signature) +} + +func ParseCallbackFromQuery(query url.Values) CallbackData { + var data CallbackData + data.TradeID = query.Get("trade_id") + data.OrderID = query.Get("order_id") + data.Token = query.Get("token") + data.BlockTransactionID = query.Get("block_transaction_id") + data.Signature = query.Get("signature") + + if amount := query.Get("amount"); amount != "" { + fmt.Sscanf(amount, "%f", &data.Amount) + } + if actualAmount := query.Get("actual_amount"); actualAmount != "" { + fmt.Sscanf(actualAmount, "%f", &data.ActualAmount) + } + if status := query.Get("status"); status != "" { + fmt.Sscanf(status, "%d", &data.Status) + } + + return data +} + +func ParseCallbackFromJSON(body []byte) (CallbackData, error) { + var data CallbackData + err := json.Unmarshal(body, &data) + return data, err +} diff --git a/backend/internal/service/payment/payment.go b/backend/internal/service/payment/payment.go new file mode 100644 index 0000000..f829a21 --- /dev/null +++ b/backend/internal/service/payment/payment.go @@ -0,0 +1,105 @@ +package payment + +import ( + "encoding/json" + "fmt" + "strconv" + + "verification-platform-backend/internal/model" +) + +type PaymentResult struct { + OrderID string + TradeID string + PaymentURL string + ActualAmount string + Token string + ExpirationTime int +} + +type PaymentService interface { + CreateOrder(orderID string, amount float64, notifyURL, redirectURL, name string) (*PaymentResult, error) + VerifyCallback(data interface{}) bool +} + +func GetPaymentService(channel model.PaymentChannel, callbackBaseURL string) (PaymentService, error) { + switch channel.Type { + case "bepusdt": + config, err := parseBEpusdtConfig(channel.Config) + if err != nil { + return nil, fmt.Errorf("parse bepusdt config: %w", err) + } + return NewBEpusdtAdapter(config, callbackBaseURL), nil + default: + return nil, fmt.Errorf("unsupported payment type: %s", channel.Type) + } +} + +func parseBEpusdtConfig(configStr string) (BEpusdtConfig, error) { + var config BEpusdtConfig + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return config, fmt.Errorf("invalid config format: %w", err) + } + + if config.ApiURL == "" { + return config, fmt.Errorf("api_url is required") + } + if config.ApiToken == "" { + return config, fmt.Errorf("api_token is required") + } + if config.TradeType == "" { + config.TradeType = "usdt.trc20" + } + + return config, nil +} + +func interfaceToString(v interface{}) string { + switch val := v.(type) { + case string: + return val + case float64: + return strconv.FormatFloat(val, 'f', -1, 64) + case int: + return strconv.Itoa(val) + default: + return fmt.Sprintf("%v", val) + } +} + +type BEpusdtAdapter struct { + client *BEpusdtClient + callbackBaseURL string +} + +func NewBEpusdtAdapter(config BEpusdtConfig, callbackBaseURL string) *BEpusdtAdapter { + return &BEpusdtAdapter{ + client: NewBEpusdtClient(config), + callbackBaseURL: callbackBaseURL, + } +} + +func (a *BEpusdtAdapter) CreateOrder(orderID string, amount float64, notifyURL, redirectURL, name string) (*PaymentResult, error) { + resp, err := a.client.CreateTransaction(orderID, amount, notifyURL, redirectURL, name) + if err != nil { + return nil, err + } + + return &PaymentResult{ + OrderID: resp.Data.OrderID, + TradeID: resp.Data.TradeID, + PaymentURL: resp.Data.PaymentURL, + ActualAmount: interfaceToString(resp.Data.ActualAmount), + Token: resp.Data.Token, + ExpirationTime: resp.Data.ExpirationTime, + }, nil +} + +func (a *BEpusdtAdapter) VerifyCallback(data interface{}) bool { + switch v := data.(type) { + case CallbackData: + return a.client.VerifyCallback(v) + default: + return false + } +} diff --git a/backend/internal/service/setting.go b/backend/internal/service/setting.go new file mode 100644 index 0000000..76c592f --- /dev/null +++ b/backend/internal/service/setting.go @@ -0,0 +1,127 @@ +package service + +import ( + "fmt" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + + "gorm.io/gorm/clause" +) + +type SettingService struct{} + +func NewSettingService() *SettingService { + return &SettingService{} +} + +func (s *SettingService) GetSettings() (map[string]interface{}, error) { + var settings []model.Setting + if err := database.DB.Find(&settings).Error; err != nil { + return nil, err + } + + result := make(map[string]interface{}) + for _, setting := range settings { + if _, ok := result[setting.Category]; !ok { + result[setting.Category] = make(map[string]interface{}) + } + result[setting.Category].(map[string]interface{})[setting.Key] = setting.Value + } + + fmt.Println("========================================") + fmt.Println("GetSettings 返回的数据:") + for cat, data := range result { + fmt.Printf(" 分类: %s\n", cat) + if dataMap, ok := data.(map[string]interface{}); ok { + for k, v := range dataMap { + fmt.Printf(" %s: %v\n", k, v) + } + } + } + fmt.Println("========================================") + + return result, nil +} + +func (s *SettingService) GetSettingsByCategory(category string) (map[string]interface{}, error) { + var settings []model.Setting + if err := database.DB.Where("category = ?", category).Find(&settings).Error; err != nil { + return nil, err + } + + result := make(map[string]interface{}) + for _, setting := range settings { + result[setting.Key] = setting.Value + } + + return result, nil +} + +func (s *SettingService) UpdateSettings(category string, data map[string]interface{}) error { + fmt.Println("========================================") + fmt.Printf("UpdateSettings 被调用: category=%s\n", category) + for key, value := range data { + var strValue string + switch v := value.(type) { + case string: + strValue = v + case bool: + if v { + strValue = "true" + } else { + strValue = "false" + } + case int: + strValue = fmt.Sprintf("%d", v) + case int64: + strValue = fmt.Sprintf("%d", v) + case float64: + strValue = fmt.Sprintf("%.0f", v) + case float32: + strValue = fmt.Sprintf("%.0f", v) + default: + fmt.Printf(" 跳过: %s (类型: %T, 值: %v)\n", key, value, value) + continue + } + + fmt.Printf(" 保存: %s = %s\n", key, strValue) + + setting := model.Setting{ + Category: category, + Key: key, + Value: strValue, + } + + if err := database.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "category", "updated_at"}), + }).Create(&setting).Error; err != nil { + fmt.Printf(" 保存失败: %v\n", err) + return err + } + } + fmt.Println("========================================") + + return nil +} + +func (s *SettingService) GetSetting(category, key string) (string, error) { + var setting model.Setting + if err := database.DB.Where("category = ? AND key = ?", category, key).First(&setting).Error; err != nil { + return "", err + } + return setting.Value, nil +} + +func (s *SettingService) SetSetting(category, key, value string) error { + setting := model.Setting{ + Category: category, + Key: key, + Value: value, + } + + return database.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "category", "updated_at"}), + }).Create(&setting).Error +} diff --git a/backend/internal/service/sms.go b/backend/internal/service/sms.go new file mode 100644 index 0000000..5601c90 --- /dev/null +++ b/backend/internal/service/sms.go @@ -0,0 +1,133 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type SmsService struct{} + +func NewSmsService() *SmsService { + return &SmsService{} +} + +type SmsConfig struct { + Provider string + AccessKey string + SecretKey string + SignName string + TemplateCode string +} + +func (s *SmsService) SendSms(config SmsConfig, phone, code string) error { + switch config.Provider { + case "aliyun": + return s.sendAliyunSms(config, phone, code) + case "tencent": + return s.sendTencentSms(config, phone, code) + case "huawei": + return s.sendHuaweiSms(config, phone, code) + default: + return fmt.Errorf("不支持的短信服务商: %s", config.Provider) + } +} + +func (s *SmsService) sendAliyunSms(config SmsConfig, phone, code string) error { + params := url.Values{} + params.Set("AccessKeyId", config.AccessKey) + params.Set("Action", "SendSms") + params.Set("Format", "JSON") + params.Set("PhoneNumbers", phone) + params.Set("RegionId", "cn-hangzhou") + params.Set("SignName", config.SignName) + params.Set("SignatureMethod", "HMAC-SHA1") + params.Set("SignatureNonce", fmt.Sprintf("%d", getTimeStamp())) + params.Set("SignatureVersion", "1.0") + params.Set("TemplateCode", config.TemplateCode) + params.Set("TemplateParam", fmt.Sprintf(`{"code":"%s"}`, code)) + params.Set("Timestamp", getTimeStampStr()) + params.Set("Version", "2017-05-25") + + signature := s.generateAliyunSignature(params, config.SecretKey) + params.Set("Signature", signature) + + resp, err := http.Get(fmt.Sprintf("https://dysmsapi.aliyuncs.com/?%s", params.Encode())) + if err != nil { + return fmt.Errorf("请求阿里云短信API失败: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取响应失败: %v", err) + } + + var result struct { + Code string `json:"Code"` + Message string `json:"Message"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("解析响应失败: %v", err) + } + + if result.Code != "OK" { + return fmt.Errorf("发送失败: %s", result.Message) + } + + return nil +} + +func (s *SmsService) generateAliyunSignature(params url.Values, secretKey string) string { + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + + encodedParams := make([]string, 0, len(params)) + for _, k := range keys { + encodedParams = append(encodedParams, fmt.Sprintf("%s=%s", specialUrlEncode(k), specialUrlEncode(params.Get(k)))) + } + sortParams := strings.Join(encodedParams, "&") + + stringToSign := fmt.Sprintf("GET&%s&%s", specialUrlEncode("/"), specialUrlEncode(sortParams)) + + signature := hmacSHA1(secretKey+"&", stringToSign) + return signature +} + +func (s *SmsService) sendTencentSms(config SmsConfig, phone, code string) error { + return fmt.Errorf("腾讯云短信服务暂未实现") +} + +func (s *SmsService) sendHuaweiSms(config SmsConfig, phone, code string) error { + return fmt.Errorf("华为云短信服务暂未实现") +} + +func (s *SmsService) SendTestSms(config SmsConfig, phone string) error { + code := "123456" + return s.SendSms(config, phone, code) +} + +func specialUrlEncode(s string) string { + encoded := url.QueryEscape(s) + encoded = strings.ReplaceAll(encoded, "+", "%20") + encoded = strings.ReplaceAll(encoded, "*", "%2A") + encoded = strings.ReplaceAll(encoded, "%7E", "~") + return encoded +} + +func hmacSHA1(key, data string) string { + return data +} + +func getTimeStamp() int64 { + return 0 +} + +func getTimeStampStr() string { + return "" +} diff --git a/backend/internal/service/usage_alert.go b/backend/internal/service/usage_alert.go new file mode 100644 index 0000000..418acb2 --- /dev/null +++ b/backend/internal/service/usage_alert.go @@ -0,0 +1,280 @@ +package service + +import ( + "fmt" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +type UsageAlertService struct{} + +func NewUsageAlertService() *UsageAlertService { + return &UsageAlertService{} +} + +type AlertType string + +const ( + AlertTypeAPICalls AlertType = "api_calls" + AlertTypeStorage AlertType = "storage" + AlertTypePackageExp AlertType = "package_expiry" +) + +type AlertLevel string + +const ( + AlertLevelWarning AlertLevel = "warning" + AlertLevelCritical AlertLevel = "critical" +) + +type UsageAlert struct { + UserID uint `json:"user_id"` + Type AlertType `json:"type"` + Level AlertLevel `json:"level"` + Message string `json:"message"` + Usage float64 `json:"usage"` + Limit float64 `json:"limit"` + Percent float64 `json:"percent"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *UsageAlertService) CheckAndCreateAlerts(userID uint) ([]UsageAlert, error) { + var alerts []UsageAlert + + var user model.User + if err := database.DB.Preload("CurrentPackage").First(&user, userID).Error; err != nil { + return nil, err + } + + if user.CurrentPackageID == nil { + return alerts, nil + } + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", user.CurrentPackageID).First(&permission).Error; err != nil { + return nil, err + } + + if alert := s.checkAPICallsUsage(&user, &permission); alert != nil { + alerts = append(alerts, *alert) + } + + if alert := s.checkStorageUsage(&user, &permission); alert != nil { + alerts = append(alerts, *alert) + } + + if alert := s.checkPackageExpiry(&user); alert != nil { + alerts = append(alerts, *alert) + } + + for _, alert := range alerts { + s.sendNotification(&alert) + } + + return alerts, nil +} + +func (s *UsageAlertService) checkAPICallsUsage(user *model.User, permission *model.PackagePermission) *UsageAlert { + if permission.MaxApiCalls == 0 { + return nil + } + + usagePercent := float64(user.ApiCallsUsed) / float64(permission.MaxApiCalls) * 100 + + var lastAlert model.UsageAlertRecord + hasRecentAlert := database.DB.Where( + "user_id = ? AND type = ? AND created_at > ?", + user.ID, + AlertTypeAPICalls, + time.Now().Add(-24*time.Hour), + ).First(&lastAlert).Error == nil + + if hasRecentAlert { + return nil + } + + if usagePercent >= 90 { + return &UsageAlert{ + UserID: user.ID, + Type: AlertTypeAPICalls, + Level: AlertLevelCritical, + Message: fmt.Sprintf("API调用次数已使用 %.1f%%,即将达到上限", usagePercent), + Usage: float64(user.ApiCallsUsed), + Limit: float64(permission.MaxApiCalls), + Percent: usagePercent, + } + } + + if usagePercent >= 80 { + return &UsageAlert{ + UserID: user.ID, + Type: AlertTypeAPICalls, + Level: AlertLevelWarning, + Message: fmt.Sprintf("API调用次数已使用 %.1f%%,请注意用量", usagePercent), + Usage: float64(user.ApiCallsUsed), + Limit: float64(permission.MaxApiCalls), + Percent: usagePercent, + } + } + + return nil +} + +func (s *UsageAlertService) checkStorageUsage(user *model.User, permission *model.PackagePermission) *UsageAlert { + if permission.MaxStorage == 0 { + return nil + } + + usagePercent := float64(user.StorageUsed) / float64(int64(permission.MaxStorage)*1024*1024) * 100 + + var lastAlert model.UsageAlertRecord + hasRecentAlert := database.DB.Where( + "user_id = ? AND type = ? AND created_at > ?", + user.ID, + AlertTypeStorage, + time.Now().Add(-24*time.Hour), + ).First(&lastAlert).Error == nil + + if hasRecentAlert { + return nil + } + + if usagePercent >= 90 { + return &UsageAlert{ + UserID: user.ID, + Type: AlertTypeStorage, + Level: AlertLevelCritical, + Message: fmt.Sprintf("存储空间已使用 %.1f%%,即将达到上限", usagePercent), + Usage: float64(user.StorageUsed) / 1024 / 1024, + Limit: float64(permission.MaxStorage), + Percent: usagePercent, + } + } + + if usagePercent >= 80 { + return &UsageAlert{ + UserID: user.ID, + Type: AlertTypeStorage, + Level: AlertLevelWarning, + Message: fmt.Sprintf("存储空间已使用 %.1f%%,请注意用量", usagePercent), + Usage: float64(user.StorageUsed) / 1024 / 1024, + Limit: float64(permission.MaxStorage), + Percent: usagePercent, + } + } + + return nil +} + +func (s *UsageAlertService) checkPackageExpiry(user *model.User) *UsageAlert { + var userPackage model.UserPackage + if err := database.DB.Where( + "user_id = ? AND package_id = ? AND status = ?", + user.ID, + user.CurrentPackageID, + "active", + ).First(&userPackage).Error; err != nil { + return nil + } + + if userPackage.ExpiredAt == nil { + return nil + } + + daysUntilExpiry := time.Until(*userPackage.ExpiredAt).Hours() / 24 + + var lastAlert model.UsageAlertRecord + hasRecentAlert := database.DB.Where( + "user_id = ? AND type = ? AND created_at > ?", + user.ID, + AlertTypePackageExp, + time.Now().Add(-24*time.Hour), + ).First(&lastAlert).Error == nil + + if hasRecentAlert { + return nil + } + + if daysUntilExpiry <= 3 { + return &UsageAlert{ + UserID: user.ID, + Type: AlertTypePackageExp, + Level: AlertLevelCritical, + Message: fmt.Sprintf("套餐将在 %.0f 天后过期,请及时续费", daysUntilExpiry), + Usage: daysUntilExpiry, + Limit: 0, + Percent: 0, + } + } + + if daysUntilExpiry <= 7 { + return &UsageAlert{ + UserID: user.ID, + Type: AlertTypePackageExp, + Level: AlertLevelWarning, + Message: fmt.Sprintf("套餐将在 %.0f 天后过期,请及时续费", daysUntilExpiry), + Usage: daysUntilExpiry, + Limit: 0, + Percent: 0, + } + } + + return nil +} + +func (s *UsageAlertService) sendNotification(alert *UsageAlert) { + alertRecord := model.UsageAlertRecord{ + UserID: alert.UserID, + Type: string(alert.Type), + Level: string(alert.Level), + Message: alert.Message, + Usage: alert.Usage, + Limit: alert.Limit, + Percent: alert.Percent, + Status: "sent", + CreatedAt: time.Now(), + } + database.DB.Create(&alertRecord) + + notification := model.Notification{ + UserID: alert.UserID, + Title: "用量告警", + Content: alert.Message, + Type: "usage_alert", + IsRead: false, + CreatedAt: time.Now(), + } + database.DB.Create(¬ification) +} + +func (s *UsageAlertService) CheckAllUsers() error { + var users []model.User + if err := database.DB.Where("current_package_id IS NOT NULL").Find(&users).Error; err != nil { + return err + } + + for _, user := range users { + _, err := s.CheckAndCreateAlerts(user.ID) + if err != nil { + fmt.Printf("检查用户 %d 用量告警失败: %v\n", user.ID, err) + } + } + + return nil +} + +func (s *UsageAlertService) GetUserAlerts(userID uint, limit int) ([]model.UsageAlertRecord, error) { + var alerts []model.UsageAlertRecord + err := database.DB.Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(limit). + Find(&alerts).Error + return alerts, err +} + +func (s *UsageAlertService) MarkAlertAsRead(alertID uint) error { + return database.DB.Model(&model.UsageAlertRecord{}). + Where("id = ?", alertID). + Update("status", "read").Error +} diff --git a/backend/internal/utils/webhook/trigger.go b/backend/internal/utils/webhook/trigger.go new file mode 100644 index 0000000..6facd0a --- /dev/null +++ b/backend/internal/utils/webhook/trigger.go @@ -0,0 +1,77 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +type EventType string + +const ( + EventUserRegistered EventType = "user.registered" + EventUserLogin EventType = "user.login" + EventUserRecharged EventType = "user.recharged" + EventUserExpired EventType = "user.expired" + EventCardUsed EventType = "card.used" + EventCardExpired EventType = "card.expired" + EventAbnormalDetected EventType = "abnormal.detected" +) + +type WebhookPayload struct { + Event EventType `json:"event"` + Timestamp int64 `json:"timestamp"` + Data map[string]interface{} `json:"data"` +} + +func TriggerEvent(appID uint, event EventType, data map[string]interface{}) { + var webhooks []model.WebhookConfig + if err := database.DB.Where("application_id = ? AND status = ?", appID, "active").Find(&webhooks).Error; err != nil { + log.Printf("Failed to fetch webhooks: %v", err) + return + } + + for _, webhook := range webhooks { + var events []string + if err := json.Unmarshal([]byte(webhook.Events), &events); err != nil { + log.Printf("Failed to parse webhook events: %v", err) + continue + } + + if !containsEvent(events, string(event)) { + continue + } + + payload := WebhookPayload{ + Event: event, + Timestamp: time.Now().Unix(), + Data: data, + } + + go sendWebhookNotification(&webhook, payload) + } +} + +func containsEvent(events []string, event string) bool { + for _, e := range events { + if e == event || e == "*" { + return true + } + } + return false +} + +func sendWebhookNotification(webhook *model.WebhookConfig, payload WebhookPayload) { + payloadBytes, err := json.Marshal(payload) + if err != nil { + log.Printf("Failed to marshal webhook payload: %v", err) + return + } + + log.Printf("Sending webhook to %s: %s", webhook.URL, string(payloadBytes)) + fmt.Printf("[Webhook] Sending to %s: Event=%s, AppID=%d\n", webhook.URL, payload.Event, webhook.ApplicationID) +} diff --git a/backend/migrations/add_card_type_recharge_fields.sql b/backend/migrations/add_card_type_recharge_fields.sql new file mode 100644 index 0000000..d1ed74e --- /dev/null +++ b/backend/migrations/add_card_type_recharge_fields.sql @@ -0,0 +1,16 @@ +-- 为卡密类型添加充值类型和时长单位字段 +ALTER TABLE card_types ADD COLUMN recharge_type VARCHAR(20) DEFAULT 'balance'; +ALTER TABLE card_types ADD COLUMN value_unit VARCHAR(20) DEFAULT 'day'; + +-- 更新现有数据:根据应用的计费方式设置充值类型 +UPDATE card_types +SET recharge_type = CASE + WHEN application_id IN (SELECT id FROM applications WHERE billing_type = 'subscription') + THEN 'subscription' + ELSE 'balance' +END; + +-- 为订阅充值类型设置默认时长单位 +UPDATE card_types +SET value_unit = 'day' +WHERE recharge_type = 'subscription' AND (value_unit IS NULL OR value_unit = ''); diff --git a/backend/migrations/add_file_fields_to_cloud.sql b/backend/migrations/add_file_fields_to_cloud.sql new file mode 100644 index 0000000..4a1634d --- /dev/null +++ b/backend/migrations/add_file_fields_to_cloud.sql @@ -0,0 +1,11 @@ +-- 为云端常量表添加文件相关字段 +ALTER TABLE cloud_constants ADD COLUMN file_path VARCHAR(500); +ALTER TABLE cloud_constants ADD COLUMN file_size BIGINT DEFAULT 0; +ALTER TABLE cloud_constants ADD COLUMN mime_type VARCHAR(100); +ALTER TABLE cloud_constants ADD COLUMN original_name VARCHAR(255); + +-- 为云端变量表添加文件相关字段 +ALTER TABLE cloud_variables ADD COLUMN file_path VARCHAR(500); +ALTER TABLE cloud_variables ADD COLUMN file_size BIGINT DEFAULT 0; +ALTER TABLE cloud_variables ADD COLUMN mime_type VARCHAR(100); +ALTER TABLE cloud_variables ADD COLUMN original_name VARCHAR(255); diff --git a/backend/migrations/add_file_fields_to_user_variables.sql b/backend/migrations/add_file_fields_to_user_variables.sql new file mode 100644 index 0000000..5654a3b --- /dev/null +++ b/backend/migrations/add_file_fields_to_user_variables.sql @@ -0,0 +1,5 @@ +-- 为 user_variables 表添加文件相关字段 +ALTER TABLE user_variables ADD COLUMN file_path TEXT; +ALTER TABLE user_variables ADD COLUMN file_size INTEGER DEFAULT 0; +ALTER TABLE user_variables ADD COLUMN mime_type TEXT; +ALTER TABLE user_variables ADD COLUMN original_name TEXT; diff --git a/backend/migrations/migrate_to_balance.sql b/backend/migrations/migrate_to_balance.sql new file mode 100644 index 0000000..b8bb7b9 --- /dev/null +++ b/backend/migrations/migrate_to_balance.sql @@ -0,0 +1,72 @@ +-- 数据库迁移脚本:统一收费模式为余额 +-- 执行前请备份数据库 + +-- 1. 为app_users表添加balance字段(如果不存在) +ALTER TABLE app_users ADD COLUMN IF NOT EXISTS balance DECIMAL(10,2) DEFAULT 0; + +-- 2. 为applications表添加相关字段(如果不存在) +ALTER TABLE applications ADD COLUMN IF NOT EXISTS deduction_cycle VARCHAR(20) DEFAULT 'per_use'; +ALTER TABLE applications ADD COLUMN IF NOT EXISTS deduction_amount DECIMAL(10,2) DEFAULT 1; +ALTER TABLE applications ADD COLUMN IF NOT EXISTS trial_balance DECIMAL(10,2) DEFAULT 0; + +-- 3. 为card_types表修改value字段类型(如果需要) +ALTER TABLE card_types MODIFY COLUMN value DECIMAL(10,2); + +-- 4. 迁移app_users数据:将expiry_at和points转换为balance +-- 注意:这里需要根据实际业务逻辑调整转换规则 + +-- 4.1 对于订阅模式用户,将剩余时间转换为余额 +-- 假设:1小时 = 1余额单位 +UPDATE app_users au +JOIN applications a ON au.application_id = a.id +SET au.balance = + CASE + WHEN au.expiry_at IS NULL THEN 0 + WHEN au.expiry_at = '9999-12-31 23:59:59' THEN -1 + ELSE GREATEST(0, TIMESTAMPDIFF(HOUR, NOW(), au.expiry_at)) + END +WHERE a.billing_type = 'subscription' AND au.balance = 0; + +-- 4.2 对于计时模式用户,将剩余时间转换为余额 +-- 假设:1分钟 = 0.01余额单位 +UPDATE app_users au +JOIN applications a ON au.application_id = a.id +SET au.balance = + CASE + WHEN au.expiry_at IS NULL THEN 0 + WHEN au.expiry_at = '9999-12-31 23:59:59' THEN -1 + ELSE GREATEST(0, TIMESTAMPDIFF(MINUTE, NOW(), au.expiry_at) * 0.01) + END +WHERE a.billing_type = 'timer' AND au.balance = 0; + +-- 4.3 对于点卡模式用户,将points转换为balance +UPDATE app_users au +JOIN applications a ON au.application_id = a.id +SET au.balance = + CASE + WHEN au.points = -1 THEN -1 + ELSE COALESCE(au.points, 0) + END +WHERE a.billing_type = 'points' AND au.balance = 0; + +-- 5. 更新applications表的billing_type值 +-- 将'timer'保持不变,将'point'改为'count' +UPDATE applications SET billing_type = 'count' WHERE billing_type = 'point'; + +-- 6. 删除不再需要的字段(可选,建议保留一段时间以便回滚) +-- ALTER TABLE app_users DROP COLUMN IF EXISTS expiry_at; +-- ALTER TABLE app_users DROP COLUMN IF EXISTS points; +-- ALTER TABLE applications DROP COLUMN IF EXISTS point_deduction_cycle; +-- ALTER TABLE applications DROP COLUMN IF EXISTS points_per_cycle; +-- ALTER TABLE applications DROP COLUMN IF EXISTS point_price; +-- ALTER TABLE applications DROP COLUMN IF EXISTS trial_type; +-- ALTER TABLE applications DROP COLUMN IF EXISTS trial_duration; +-- ALTER TABLE applications DROP COLUMN IF EXISTS trial_points; +-- ALTER TABLE card_types DROP COLUMN IF EXISTS billing_type; + +-- 7. 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_app_users_balance ON app_users(balance); +CREATE INDEX IF NOT EXISTS idx_applications_billing_type ON applications(billing_type); + +-- 迁移完成 +SELECT 'Migration completed successfully!' as status; diff --git a/backend/pkg/crypto/crypto.go b/backend/pkg/crypto/crypto.go new file mode 100644 index 0000000..13289fa --- /dev/null +++ b/backend/pkg/crypto/crypto.go @@ -0,0 +1,229 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" +) + +type EncryptType string + +const ( + EncryptTypeNone EncryptType = "none" + EncryptTypeAES EncryptType = "aes" + EncryptTypeRC4 EncryptType = "rc4" +) + +type CryptoManager struct { + encryptType EncryptType + secretKey string +} + +func NewCryptoManager(encryptType EncryptType, secretKey string) *CryptoManager { + return &CryptoManager{ + encryptType: encryptType, + secretKey: secretKey, + } +} + +func (cm *CryptoManager) GetEncryptType() EncryptType { + return cm.encryptType +} + +func (cm *CryptoManager) GetSecretKey() string { + return cm.secretKey +} + +func (cm *CryptoManager) Encrypt(plaintext string) (string, error) { + if cm.encryptType == EncryptTypeNone || cm.secretKey == "" { + return plaintext, nil + } + + switch cm.encryptType { + case EncryptTypeAES: + return cm.encryptAES(plaintext) + case EncryptTypeRC4: + return cm.encryptRC4(plaintext) + default: + return plaintext, nil + } +} + +func (cm *CryptoManager) Decrypt(ciphertext string) (string, error) { + if cm.encryptType == EncryptTypeNone || cm.secretKey == "" { + return ciphertext, nil + } + + switch cm.encryptType { + case EncryptTypeAES: + return cm.decryptAES(ciphertext) + case EncryptTypeRC4: + return cm.decryptRC4(ciphertext) + default: + return ciphertext, nil + } +} + +func (cm *CryptoManager) encryptAES(plaintext string) (string, error) { + key := []byte(cm.secretKey) + if len(key) != 16 && len(key) != 24 && len(key) != 32 { + key = cm.padKey(key, 32) + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (cm *CryptoManager) decryptAES(ciphertext string) (string, error) { + key := []byte(cm.secretKey) + if len(key) != 16 && len(key) != 24 && len(key) != 32 { + key = cm.padKey(key, 32) + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +func (cm *CryptoManager) encryptRC4(plaintext string) (string, error) { + key := []byte(cm.secretKey) + if len(key) == 0 { + return "", errors.New("secret key is empty") + } + + cipher, err := NewRC4(key) + if err != nil { + return "", err + } + + ciphertext := make([]byte, len(plaintext)) + cipher.XORKeyStream(ciphertext, []byte(plaintext)) + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (cm *CryptoManager) decryptRC4(ciphertext string) (string, error) { + key := []byte(cm.secretKey) + if len(key) == 0 { + return "", errors.New("secret key is empty") + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + cipher, err := NewRC4(key) + if err != nil { + return "", err + } + + plaintext := make([]byte, len(data)) + cipher.XORKeyStream(plaintext, data) + + return string(plaintext), nil +} + +func (cm *CryptoManager) padKey(key []byte, targetLen int) []byte { + if len(key) >= targetLen { + return key[:targetLen] + } + + padded := make([]byte, targetLen) + copy(padded, key) + for i := len(key); i < targetLen; i++ { + padded[i] = key[i%len(key)] + } + return padded +} + +type RC4 struct { + s [256]byte + i, j uint8 +} + +func NewRC4(key []byte) (*RC4, error) { + if len(key) == 0 { + return nil, errors.New("RC4 key cannot be empty") + } + + rc4 := &RC4{} + for i := 0; i < 256; i++ { + rc4.s[i] = byte(i) + } + + j := uint8(0) + for i := 0; i < 256; i++ { + j = j + rc4.s[i] + key[i%len(key)] + rc4.s[i], rc4.s[j] = rc4.s[j], rc4.s[i] + } + + return rc4, nil +} + +func (rc4 *RC4) XORKeyStream(dst, src []byte) { + for i := range src { + rc4.i++ + rc4.j += rc4.s[rc4.i] + rc4.s[rc4.i], rc4.s[rc4.j] = rc4.s[rc4.j], rc4.s[rc4.i] + dst[i] = src[i] ^ rc4.s[(rc4.s[rc4.i]+rc4.s[rc4.j])] + } +} + +func GenerateRandomKey(length int) (string, error) { + key := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(key), nil +} + +func GenerateAESKey() (string, error) { + return GenerateRandomKey(32) +} + +func GenerateRC4Key() (string, error) { + return GenerateRandomKey(16) +} diff --git a/backend/pkg/jwt/jwt.go b/backend/pkg/jwt/jwt.go new file mode 100644 index 0000000..4f3e588 --- /dev/null +++ b/backend/pkg/jwt/jwt.go @@ -0,0 +1,75 @@ +package jwt + +import ( + "errors" + "time" + "verification-platform-backend/internal/config" + + "github.com/golang-jwt/jwt/v5" +) + +// Claims JWT声明 +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// GenerateToken 生成JWT令牌 +func GenerateToken(userID uint, username, role string) (string, error) { + expireHours := config.GetInt("app.jwt_expire_hours") + if expireHours <= 0 { + expireHours = 24 + } + + expiresAt := time.Now().Add(time.Hour * time.Duration(expireHours)) + + claims := Claims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: config.GetString("app.name"), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.GetString("app.jwt_secret"))) +} + +// ParseToken 解析JWT令牌 +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(config.GetString("app.jwt_secret")), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +// RefreshToken 刷新令牌 +func RefreshToken(tokenString string) (string, error) { + claims, err := ParseToken(tokenString) + if err != nil { + return "", err + } + + // 检查令牌是否即将过期(30分钟内) + if time.Until(claims.ExpiresAt.Time) > 30*time.Minute { + return "", errors.New("token is still valid") + } + + // 生成新令牌 + return GenerateToken(claims.UserID, claims.Username, claims.Role) +} diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go new file mode 100644 index 0000000..9848fd6 --- /dev/null +++ b/backend/pkg/logger/logger.go @@ -0,0 +1,15 @@ +package logger + +import ( + "log" + "os" +) + +// Init 初始化日志 +func Init() { + // 设置日志格式 + log.SetFlags(log.LstdFlags | log.Lshortfile) + + // 设置输出 + log.SetOutput(os.Stdout) +} diff --git a/backend/pkg/response/response.go b/backend/pkg/response/response.go new file mode 100644 index 0000000..6bf1566 --- /dev/null +++ b/backend/pkg/response/response.go @@ -0,0 +1,103 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 统一响应结构 +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// PageResponse 分页响应结构 +type PageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +// Success 成功响应 +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 200, + Message: "操作成功", + Data: data, + }) +} + +func SuccessWithMessage(c *gin.Context, message string, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 200, + Message: message, + Data: data, + }) +} + +// Error 错误响应 +func Error(c *gin.Context, httpCode int, message string) { + c.JSON(httpCode, Response{ + Code: httpCode, + Message: message, + }) +} + +// ErrorWithData 带数据的错误响应 +func ErrorWithData(c *gin.Context, httpCode int, message string, data interface{}) { + c.JSON(httpCode, Response{ + Code: httpCode, + Message: message, + Data: data, + }) +} + +// ErrorWithCode 带自定义错误码的错误响应 +func ErrorWithCode(c *gin.Context, httpCode int, code int, message string) { + c.JSON(httpCode, Response{ + Code: code, + Message: message, + }) +} + +// PageSuccess 分页成功响应 +func PageSuccess(c *gin.Context, data interface{}, total int64, page, perPage int) { + c.JSON(http.StatusOK, PageResponse{ + Code: 0, + Message: "操作成功", + Data: data, + Total: total, + Page: page, + PerPage: perPage, + }) +} + +// ParamError 参数错误响应 +func ParamError(c *gin.Context, message string) { + Error(c, http.StatusBadRequest, message) +} + +// Unauthorized 未授权响应 +func Unauthorized(c *gin.Context, message string) { + Error(c, http.StatusUnauthorized, message) +} + +// Forbidden 禁止访问响应 +func Forbidden(c *gin.Context, message string) { + Error(c, http.StatusForbidden, message) +} + +// NotFound 资源未找到响应 +func NotFound(c *gin.Context, message string) { + Error(c, http.StatusNotFound, message) +} + +// InternalError 内部错误响应 +func InternalError(c *gin.Context, message string) { + Error(c, http.StatusInternalServerError, message) +} diff --git a/backend/pkg/utils/utils.go b/backend/pkg/utils/utils.go new file mode 100644 index 0000000..c891078 --- /dev/null +++ b/backend/pkg/utils/utils.go @@ -0,0 +1,42 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "math/big" +) + +// GenerateRandomString 生成随机字符串 +func GenerateRandomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + b[i] = charset[n.Int64()] + } + return string(b) +} + +// GenerateRandomNumber 生成随机数字 +func GenerateRandomNumber(length int) string { + const charset = "0123456789" + b := make([]byte, length) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + b[i] = charset[n.Int64()] + } + return string(b) +} + +// GenerateCardKey 生成卡密 +func GenerateCardKey(prefix string, length int) string { + randomPart := GenerateRandomString(length) + return prefix + "-" + randomPart +} + +// GenerateBase64Token 生成Base64令牌 +func GenerateBase64Token(length int) string { + b := make([]byte, length) + rand.Read(b) + return base64.URLEncoding.EncodeToString(b) +} diff --git a/backend/scripts/check_all_deleted.go b/backend/scripts/check_all_deleted.go new file mode 100644 index 0000000..a99a7bd --- /dev/null +++ b/backend/scripts/check_all_deleted.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + models := []struct { + name string + query interface{} + }{ + {"User", &[]model.User{}}, + {"AppUser", &[]model.AppUser{}}, + {"DynamicCode", &[]model.DynamicCode{}}, + {"Application", &[]model.Application{}}, + {"Card", &[]model.Card{}}, + {"Order", &[]model.Order{}}, + {"RechargeRecord", &[]model.RechargeRecord{}}, + {"ConsumptionRecord", &[]model.ConsumptionRecord{}}, + {"DocCategory", &[]model.DocCategory{}}, + {"Doc", &[]model.Doc{}}, + {"Setting", &[]model.Setting{}}, + } + + for _, m := range models { + var allCount int64 + var activeCount int64 + + database.DB.Unscoped().Model(m.query).Count(&allCount) + database.DB.Model(m.query).Count(&activeCount) + + deletedCount := allCount - activeCount + + if deletedCount > 0 { + fmt.Printf("%s: 总数=%d, 活跃=%d, 已删除=%d\n", m.name, allCount, activeCount, deletedCount) + } + } +} diff --git a/backend/scripts/check_deleted_dynamic_codes.go b/backend/scripts/check_deleted_dynamic_codes.go new file mode 100644 index 0000000..053d81e --- /dev/null +++ b/backend/scripts/check_deleted_dynamic_codes.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + var allDynamicCodes []model.DynamicCode + if err := database.DB.Unscoped().Find(&allDynamicCodes).Error; err != nil { + log.Printf("查询失败: %v", err) + return + } + + log.Println("=== 所有动态代码(包括已删除) ===") + for _, dc := range allDynamicCodes { + deletedAt := "" + if dc.DeletedAt.Valid { + deletedAt = dc.DeletedAt.Time.Format("2006-01-02 15:04:05") + } + fmt.Printf("ID: %d, Name: %s, Key: %s, DeletedAt: %s\n", dc.ID, dc.Name, dc.Key, deletedAt) + } + + var activeDynamicCodes []model.DynamicCode + if err := database.DB.Find(&activeDynamicCodes).Error; err != nil { + log.Printf("查询失败: %v", err) + return + } + + log.Println("\n=== 未删除的动态代码 ===") + for _, dc := range activeDynamicCodes { + fmt.Printf("ID: %d, Name: %s, Key: %s\n", dc.ID, dc.Name, dc.Key) + } +} diff --git a/backend/scripts/check_dynamic_codes.go b/backend/scripts/check_dynamic_codes.go new file mode 100644 index 0000000..43aa08a --- /dev/null +++ b/backend/scripts/check_dynamic_codes.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + var dynamicCodes []model.DynamicCode + if err := database.DB.Preload("Creator").Preload("Application").Find(&dynamicCodes).Error; err != nil { + log.Printf("查询失败: %v", err) + return + } + + log.Println("=== 动态代码列表 ===") + for _, dc := range dynamicCodes { + fmt.Printf("ID: %d\n", dc.ID) + fmt.Printf(" Name: %s\n", dc.Name) + fmt.Printf(" ApplicationID: %d\n", dc.ApplicationID) + fmt.Printf(" UserID: %d\n", dc.UserID) + fmt.Printf(" Application Name: %s\n", dc.Application.Name) + if dc.Creator.ID != 0 { + fmt.Printf(" Creator ID: %d\n", dc.Creator.ID) + fmt.Printf(" Creator Username: %s\n", dc.Creator.Username) + } else { + fmt.Printf(" Creator: (empty)\n") + } + fmt.Printf(" Status: %s\n", dc.Status) + fmt.Println() + } +} diff --git a/backend/scripts/check_i18n_data.go b/backend/scripts/check_i18n_data.go new file mode 100644 index 0000000..6009a91 --- /dev/null +++ b/backend/scripts/check_i18n_data.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + db := database.DB + + var packages []model.Package + if err := db.Find(&packages).Error; err != nil { + log.Fatal("Failed to get packages:", err) + } + + fmt.Println("=== Packages ===") + for _, pkg := range packages { + fmt.Printf("ID: %d, Name: %s, NameEn: %s, DescriptionEn: %s\n", pkg.ID, pkg.Name, pkg.NameEn, pkg.DescriptionEn) + } + + var categories []model.DocCategory + if err := db.Find(&categories).Error; err != nil { + log.Fatal("Failed to get categories:", err) + } + + fmt.Println("\n=== Doc Categories ===") + for _, cat := range categories { + fmt.Printf("ID: %d, Name: %s, NameEn: %s, DescriptionEn: %s\n", cat.ID, cat.Name, cat.NameEn, cat.DescriptionEn) + } + + var docs []model.Doc + if err := db.Find(&docs).Error; err != nil { + log.Fatal("Failed to get docs:", err) + } + + fmt.Println("\n=== Docs ===") + for _, doc := range docs { + fmt.Printf("ID: %d, Title: %s, TitleEn: %s, ContentEn: %d chars\n", doc.ID, doc.Title, doc.TitleEn, len(doc.ContentEn)) + } +} diff --git a/backend/scripts/check_packages.go b/backend/scripts/check_packages.go new file mode 100644 index 0000000..0943ffb --- /dev/null +++ b/backend/scripts/check_packages.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + log.Println("=== 查看套餐数据 ===") + + var packages []model.Package + if err := database.DB.Find(&packages).Error; err != nil { + log.Printf("查询套餐失败: %v\n", err) + return + } + + for _, pkg := range packages { + fmt.Printf("ID: %d, Name: '%s', Price: %.2f, Period: %s, Status: %s\n", + pkg.ID, pkg.Name, pkg.Price, pkg.Period, pkg.Status) + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", pkg.ID).First(&permission).Error; err != nil { + fmt.Printf(" 权限: 未找到\n") + } else { + fmt.Printf(" 权限: MaxApps=%d, MaxStorage=%d, MaxApiCalls=%d\n", + permission.MaxApplications, permission.MaxStorage, permission.MaxApiCalls) + } + } +} diff --git a/backend/scripts/create_cloud_function_doc.go b/backend/scripts/create_cloud_function_doc.go new file mode 100644 index 0000000..198d6b9 --- /dev/null +++ b/backend/scripts/create_cloud_function_doc.go @@ -0,0 +1,977 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + log.Println("=== 查看当前所有文档 ===") + + var docs []model.Doc + if err := database.DB.Find(&docs).Error; err != nil { + log.Printf("查询文档失败: %v", err) + return + } + + for _, doc := range docs { + log.Printf("ID: %d, Slug: %s, Title: %s", doc.ID, doc.Slug, doc.Title) + } + + log.Println("\n=== 删除旧的云端函数文档 ===") + + if err := database.DB.Where("slug = ?", "dynamic-code").Delete(&model.Doc{}).Error; err != nil { + log.Printf("删除文档失败: %v", err) + } + + if err := database.DB.Where("slug = ?", "cloud-function").Delete(&model.Doc{}).Error; err != nil { + log.Printf("删除文档失败: %v", err) + } + + log.Println("\n=== 创建云端函数文档 ===") + + var apiCat model.DocCategory + if err := database.DB.Where("slug = ?", "api").First(&apiCat).Error; err != nil { + log.Printf("未找到API分类: %v", err) + return + } + + cloudFunctionContent := "# 云端函数接口\n\n" + + "云端函数允许开发者为应用创建自定义的业务逻辑,通过JavaScript代码实现灵活的数据处理和业务规则。\n\n" + + "---\n\n" + + "## 概述\n\n" + + "云端函数是基于JavaScript的自定义代码,可以在应用运行时动态执行。开发者可以通过云端函数实现:\n\n" + + "- 动态定价计算\n" + + "- 权限验证逻辑\n" + + "- 业务规则判断\n" + + "- 用户数据处理\n" + + "- 自动化操作指令\n\n" + + "---\n\n" + + "## 获取云端函数列表\n\n" + + "### 接口地址\n\n" + + "`GET /api/v1/app/:appKey/cloud-function`\n\n" + + "### 请求头\n\n" + + "```\n" + + "Authorization: Bearer {token}\n" + + "```\n\n" + + "### 路径参数\n\n" + + "| 参数名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| appKey | string | 是 | 应用密钥 |\n\n" + + "### 响应示例\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": [\n" + + " {\n" + + " \"id\": 1,\n" + + " \"name\": \"加法函数\",\n" + + " \"key\": \"add\",\n" + + " \"description\": \"实现数字加法运算\",\n" + + " \"status\": \"active\",\n" + + " \"created_at\": \"2024-01-01T00:00:00Z\"\n" + + " }\n" + + " ]\n" + + "}\n" + + "```\n\n" + + "### 字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| id | number | 云端函数ID |\n" + + "| name | string | 云端函数名称 |\n" + + "| key | string | 云端函数唯一标识 |\n" + + "| description | string | 描述 |\n" + + "| status | string | 状态(active、inactive) |\n" + + "| created_at | string | 创建时间 |\n\n" + + "---\n\n" + + "## 执行云端函数\n\n" + + "### 接口地址\n\n" + + "`POST /api/v1/app/:appKey/cloud-function/:key/execute`\n\n" + + "### 请求头\n\n" + + "```\n" + + "Authorization: Bearer {token}\n" + + "Content-Type: application/json\n" + + "```\n\n" + + "### 路径参数\n\n" + + "| 参数名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| appKey | string | 是 | 应用密钥 |\n" + + "| key | string | 是 | 云端函数标识 |\n\n" + + "### 请求体\n\n" + + "```json\n" + + "{\n" + + " \"params\": {\n" + + " \"param1\": \"value1\",\n" + + " \"param2\": \"value2\"\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "### 参数说明\n\n" + + "| 参数名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| params | object | 否 | 自定义参数对象,键值对形式传递 |\n" + + "| user_id | number | 否 | 用户ID,提供后会注入当前用户信息 |\n\n" + + "### 响应示例\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": \"执行结果\",\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "### 字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| result | any | 代码执行结果,可以是任意类型 |\n" + + "| execution_time | number | 执行时间(毫秒) |\n\n" + + "---\n\n" + + "## 注入的数据\n\n" + + "### 1. 应用配置数据 (app)\n\n" + + "云端函数可以访问应用的完整配置信息:\n\n" + + "```javascript\n" + + "app.name\n" + + "app.description\n" + + "app.app_key\n" + + "app.status\n" + + "app.billing_type\n" + + "app.point_price\n" + + "app.points_per_cycle\n" + + "app.point_deduction_cycle\n" + + "app.enable_trial\n" + + "app.trial_type\n" + + "app.trial_duration\n" + + "app.trial_points\n" + + "app.enable_free_period\n" + + "app.free_period_type\n" + + "app.free_period_start\n" + + "app.free_period_end\n" + + "app.free_period_weekdays\n" + + "app.free_period_start_time\n" + + "app.free_period_end_time\n" + + "app.max_devices\n" + + "app.bind_type\n" + + "app.multi_open\n" + + "app.multi_open_mode\n" + + "app.max_instances\n" + + "app.login_policy\n" + + "app.max_attempts\n" + + "app.lock_duration\n" + + "app.heartbeat_interval\n" + + "app.heartbeat_timeout\n" + + "app.change_limit\n" + + "app.change_interval\n" + + "app.change_exceed_action\n" + + "app.change_deduct_amount\n" + + "```\n\n" + + "#### 应用配置字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| name | string | 应用名称 |\n" + + "| description | string | 应用描述 |\n" + + "| app_key | string | 应用密钥 |\n" + + "| status | string | 应用状态 |\n" + + "| billing_type | string | 计费方式 |\n" + + "| point_price | number | 点数价格 |\n" + + "| points_per_cycle | number | 每周期扣点数 |\n" + + "| point_deduction_cycle | string | 扣点周期 |\n" + + "| enable_trial | boolean | 是否启用试用 |\n" + + "| trial_type | string | 试用类型 |\n" + + "| trial_duration | number | 试用时长 |\n" + + "| trial_points | number | 试用点数 |\n" + + "| enable_free_period | boolean | 是否启用免费时段 |\n" + + "| free_period_type | string | 免费时段类型 |\n" + + "| free_period_start | string | 免费时段开始 |\n" + + "| free_period_end | string | 免费时段结束 |\n" + + "| free_period_weekdays | string | 免费时段星期 |\n" + + "| free_period_start_time | string | 免费时段开始时间 |\n" + + "| free_period_end_time | string | 免费时段结束时间 |\n" + + "| max_devices | number | 最大设备数 |\n" + + "| bind_type | string | 绑定方式 |\n" + + "| multi_open | boolean | 是否允许多开 |\n" + + "| multi_open_mode | string | 多开模式 |\n" + + "| max_instances | number | 最大实例数 |\n" + + "| login_policy | string | 登录策略 |\n" + + "| max_attempts | number | 最大尝试次数 |\n" + + "| lock_duration | number | 锁定时长 |\n" + + "| heartbeat_interval | number | 心跳间隔 |\n" + + "| heartbeat_timeout | number | 心跳超时 |\n" + + "| change_limit | number | 变更限制 |\n" + + "| change_interval | number | 变更间隔 |\n" + + "| change_exceed_action | string | 超限操作 |\n" + + "| change_deduct_amount | number | 超限扣点数 |\n\n" + + "### 2. 用户信息 (user)\n\n" + + "如果请求中提供了 `user_id`,会自动注入当前用户的信息:\n\n" + + "```javascript\n" + + "user.id\n" + + "user.username\n" + + "user.email\n" + + "user.status\n" + + "user.points\n" + + "user.is_trial_user\n" + + "user.expiry_at\n" + + "user.last_login_at\n" + + "```\n\n" + + "#### 用户信息字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| id | number | 用户ID |\n" + + "| username | string | 用户名 |\n" + + "| email | string | 邮箱 |\n" + + "| status | string | 用户状态 |\n" + + "| points | number | 积分 |\n" + + "| is_trial_user | boolean | 是否试用用户 |\n" + + "| expiry_at | string | 到期时间 |\n" + + "| last_login_at | string | 最后登录时间 |\n\n" + + "### 3. 自定义参数\n\n" + + "通过请求的 `params` 参数传递的自定义数据,可以直接在代码中使用:\n\n" + + "```javascript\n" + + "// 请求参数: {\"params\": {\"a\": 10, \"b\": 20, \"name\": \"张三\"}}\n\n" + + "a + b\n" + + "name.toUpperCase()\n" + + "```\n\n" + + "---\n\n" + + "## JavaScript代码编写规范\n\n" + + "### 1. 参数使用\n\n" + + "云端函数可以直接使用请求参数中传递的变量,无需额外定义:\n\n" + + "```javascript\n" + + "a + b\n" + + "```\n\n" + + "**请求参数**:`{\"params\": {\"a\": 10, \"b\": 20}}`\n\n" + + "**返回结果**:`30`\n\n" + + "---\n\n" + + "### 2. 函数定义与调用\n\n" + + "可以定义函数并在最后一行调用:\n\n" + + "```javascript\n" + + "function calculate(x, y) {\n" + + " return Math.sqrt(x * x + y * y);\n" + + "}\n\n" + + "calculate(x, y)\n" + + "```\n\n" + + "**请求参数**:`{\"params\": {\"x\": 3, \"y\": 4}}`\n\n" + + "**返回结果**:`5`\n\n" + + "---\n\n" + + "### 3. 复杂逻辑\n\n" + + "支持完整的JavaScript语法,包括控制流、循环、对象操作等:\n\n" + + "```javascript\n" + + "function processScore(score) {\n" + + " if (score >= 90) {\n" + + " return \"优秀\";\n" + + " } else if (score >= 60) {\n" + + " return \"及格\";\n" + + " } else {\n" + + " return \"不及格\";\n" + + " }\n" + + "}\n\n" + + "processScore(score)\n" + + "```\n\n" + + "---\n\n" + + "### 4. 返回值规则\n\n" + + "- **最后一个表达式的值即为返回值**\n" + + "- **不要在全局作用域使用return语句**\n" + + "- 可以返回任意类型:数字、字符串、布尔值、对象、数组等\n\n" + + "---\n\n" + + "### 5. 使用注入数据\n\n" + + "#### 使用应用配置\n\n" + + "```javascript\n" + + "function getAppInfo() {\n" + + " return {\n" + + " name: app.name,\n" + + " status: app.status,\n" + + " billing_type: app.billing_type,\n" + + " max_devices: app.max_devices,\n" + + " enable_trial: app.enable_trial\n" + + " };\n" + + "}\n\n" + + "getAppInfo()\n" + + "```\n\n" + + "#### 使用用户信息\n\n" + + "```javascript\n" + + "function getUserInfo() {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " return {\n" + + " username: user.username,\n" + + " points: user.points,\n" + + " is_trial_user: user.is_trial_user,\n" + + " status: user.status\n" + + " };\n" + + "}\n\n" + + "getUserInfo()\n" + + "```\n\n" + + "#### 组合使用\n\n" + + "```javascript\n" + + "function checkUserPermission() {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { error: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " if (app.max_devices > 0) {\n" + + " return {\n" + + " allowed: true,\n" + + " max_devices: app.max_devices,\n" + + " user_status: user.status\n" + + " };\n" + + " }\n" + + " \n" + + " return { allowed: false, reason: \"应用未设置设备限制\" };\n" + + "}\n\n" + + "checkUserPermission()\n" + + "```\n\n" + + "---\n\n" + + "### 6. 常见示例\n\n" + + "#### 简单计算\n\n" + + "```javascript\n" + + "price * quantity\n" + + "```\n\n" + + "#### 字符串拼接\n\n" + + "```javascript\n" + + "name + \"的年龄是\" + age + \"岁\"\n" + + "```\n\n" + + "#### 条件判断\n\n" + + "```javascript\n" + + "score >= 60 ? \"及格\" : \"不及格\"\n" + + "```\n\n" + + "#### 对象返回\n\n" + + "```javascript\n" + + "{\n" + + " sum: a + b,\n" + + " product: a * b,\n" + + " difference: a - b\n" + + "}\n" + + "```\n\n" + + "#### 数组操作\n\n" + + "```javascript\n" + + "function sumArray(numbers) {\n" + + " let total = 0;\n" + + " for (let i = 0; i < numbers.length; i++) {\n" + + " total += numbers[i];\n" + + " }\n" + + " return total;\n" + + "}\n\n" + + "sumArray([1, 2, 3, 4, 5])\n" + + "```\n\n" + + "---\n\n" + + "### 7. 注意事项\n\n" + + "1. **全局作用域不能使用return**:return只能在函数内部使用\n" + + "2. **参数变量直接使用**:无需重新定义\n" + + "3. **最后一行是返回值**:确保最后一行是需要返回的表达式\n" + + "4. **支持ES6语法**:可以使用let、const、箭头函数等\n" + + "5. **内置对象可用**:Math、Date、JSON等JavaScript内置对象都可以使用\n\n" + + "---\n\n" + + "## 操作指令\n\n" + + "云端函数可以返回操作指令,后端会安全地执行这些指令。\n\n" + + "### 1. 加时指令\n\n" + + "为用户延长使用时间。\n\n" + + "#### 指令格式\n\n" + + "```javascript\n" + + "{\n" + + " action: \"extend_time\",\n" + + " user_id: 123,\n" + + " days: 7,\n" + + " reason: \"用户申请延期\"\n" + + "}\n" + + "```\n\n" + + "#### 字段说明\n\n" + + "| 字段名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| action | string | 是 | 固定值:\"extend_time\" |\n" + + "| user_id | number | 是 | 用户ID |\n" + + "| days | number | 是 | 延长天数 |\n" + + "| reason | string | 否 | 延长原因 |\n\n" + + "#### 示例\n\n" + + "```javascript\n" + + "function autoExtendTime(userId, userLevel) {\n" + + " let days = 0;\n" + + " \n" + + " if (userLevel === \"vip\") {\n" + + " days = 30;\n" + + " } else if (userLevel === \"premium\") {\n" + + " days = 15;\n" + + " } else if (userLevel === \"normal\") {\n" + + " days = 7;\n" + + " }\n" + + " \n" + + " return {\n" + + " action: \"extend_time\",\n" + + " user_id: userId,\n" + + " days: days,\n" + + " reason: \"根据用户等级自动加时\"\n" + + " };\n" + + "}\n\n" + + "autoExtendTime(123, \"vip\")\n" + + "```\n\n" + + "---\n\n" + + "### 2. 扣点指令\n\n" + + "扣除用户积分。\n\n" + + "#### 指令格式\n\n" + + "```javascript\n" + + "{\n" + + " action: \"deduct_points\",\n" + + " user_id: 123,\n" + + " points: 100,\n" + + " reason: \"使用功能扣点\"\n" + + "}\n" + + "```\n\n" + + "#### 字段说明\n\n" + + "| 字段名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| action | string | 是 | 固定值:\"deduct_points\" |\n" + + "| user_id | number | 是 | 用户ID |\n" + + "| points | number | 是 | 扣除点数 |\n" + + "| reason | string | 否 | 扣点原因 |\n\n" + + "#### 示例\n\n" + + "```javascript\n" + + "function autoDeductPoints(userId, usage) {\n" + + " if (!user || user.points < usage) {\n" + + " return { error: \"积分不足\" };\n" + + " }\n" + + " \n" + + " return {\n" + + " action: \"deduct_points\",\n" + + " user_id: userId,\n" + + " points: usage,\n" + + " reason: \"功能使用扣点\"\n" + + " };\n" + + "}\n\n" + + "autoDeductPoints(123, 50)\n" + + "```\n\n" + + "---\n\n" + + "## 完整示例\n\n" + + "### 示例1:动态定价\n\n" + + "#### 场景\n" + + "根据用户等级和应用设置,动态计算价格。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/cloud-function/calculate-price/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"basePrice\": 100,\n" + + " \"userType\": \"new\"\n" + + " }\n" + + "}\n" + + "```\n\n" + + "#### 云端函数代码\n\n" + + "```javascript\n" + + "function calculatePrice(basePrice, userType) {\n" + + " let finalPrice = basePrice;\n" + + " \n" + + " if (app.enable_trial && userType === \"new\") {\n" + + " finalPrice = finalPrice * 0.5;\n" + + " }\n" + + " \n" + + " if (userType === \"vip\") {\n" + + " finalPrice = finalPrice * 0.8;\n" + + " }\n" + + " \n" + + " return {\n" + + " original_price: basePrice,\n" + + " user_type: userType,\n" + + " enable_trial: app.enable_trial,\n" + + " final_price: finalPrice,\n" + + " discount: basePrice - finalPrice\n" + + " };\n" + + "}\n\n" + + "calculatePrice(basePrice, userType)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"original_price\": 100,\n" + + " \"user_type\": \"new\",\n" + + " \"enable_trial\": true,\n" + + " \"final_price\": 50,\n" + + " \"discount\": 50\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例2:权限验证\n\n" + + "#### 场景\n" + + "检查用户设备数量是否超过限制。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/cloud-function/check-permission/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"deviceCount\": 2\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "#### 云端函数代码\n\n" + + "```javascript\n" + + "function checkPermission(deviceCount) {\n" + + " if (!user) {\n" + + " return { allowed: false, reason: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { allowed: false, reason: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " if (deviceCount > app.max_devices) {\n" + + " return { \n" + + " allowed: false, \n" + + " reason: \"设备数量超过限制\",\n" + + " current: deviceCount,\n" + + " max: app.max_devices\n" + + " };\n" + + " }\n" + + " \n" + + " return { \n" + + " allowed: true, \n" + + " reason: \"权限验证通过\",\n" + + " user: user.username\n" + + " };\n" + + "}\n\n" + + "checkPermission(deviceCount)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"allowed\": true,\n" + + " \"reason\": \"权限验证通过\",\n" + + " \"user\": \"testuser\"\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例3:每日登录自动加时\n\n" + + "#### 场景\n" + + "试用用户每日登录自动延长1天。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/cloud-function/daily-login/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"userId\": 123\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "#### 云端函数代码\n\n" + + "```javascript\n" + + "function processDailyLogin(userId) {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { error: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " if (user.is_trial_user) {\n" + + " return {\n" + + " action: \"extend_time\",\n" + + " user_id: userId,\n" + + " days: 1,\n" + + " reason: \"试用用户每日登录加时\",\n" + + " timestamp: new Date().toISOString()\n" + + " };\n" + + " }\n" + + " \n" + + " return {\n" + + " message: \"登录成功\",\n" + + " user_points: user.points,\n" + + " app_name: app.name\n" + + " };\n" + + "}\n\n" + + "processDailyLogin(userId)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"action\": \"extend_time\",\n" + + " \"user_id\": 123,\n" + + " \"days\": 1,\n" + + " \"reason\": \"试用用户每日登录加时\",\n" + + " \"timestamp\": \"2026-03-11T00:57:55.123Z\"\n" + + " },\n" + + " \"execution_time\": 2\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例4:功能使用自动扣点\n\n" + + "#### 场景\n" + + "用户使用功能时自动扣除相应积分。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/cloud-function/use-feature/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"userId\": 123,\n" + + " \"featureType\": \"advanced\"\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "#### 云端函数代码\n\n" + + "```javascript\n" + + "function processFeatureUsage(userId, featureType) {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { error: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " let cost = app.points_per_cycle;\n" + + " \n" + + " if (featureType === \"advanced\") {\n" + + " cost = cost * 2;\n" + + " } else if (featureType === \"premium\") {\n" + + " cost = cost * 5;\n" + + " }\n" + + " \n" + + " if (user.points < cost) {\n" + + " return { \n" + + " error: \"积分不足\",\n" + + " required: cost,\n" + + " current: user.points\n" + + " };\n" + + " }\n" + + " \n" + + " return {\n" + + " action: \"deduct_points\",\n" + + " user_id: userId,\n" + + " points: cost,\n" + + " reason: \"使用功能扣点: \" + featureType,\n" + + " feature_type: featureType\n" + + " };\n" + + "}\n\n" + + "processFeatureUsage(userId, featureType)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"action\": \"deduct_points\",\n" + + " \"user_id\": 123,\n" + + " \"points\": 2,\n" + + " \"reason\": \"使用功能扣点: advanced\",\n" + + " \"feature_type\": \"advanced\"\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例5:业务规则获取\n\n" + + "#### 场景\n" + + "获取应用的所有业务规则,用于前端展示。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/cloud-function/get-rules/execute\n" + + "{\n" + + " \"params\": {}\n" + + "}\n" + + "```\n\n" + + "#### 云端函数代码\n\n" + + "```javascript\n" + + "function getBusinessRules() {\n" + + " const rules = [];\n" + + " \n" + + " if (app.enable_trial) {\n" + + " rules.push({\n" + + " type: \"trial\",\n" + + " title: \"试用功能\",\n" + + " description: \"支持试用,试用时长:\" + app.trial_duration + \"天\"\n" + + " });\n" + + " }\n" + + " \n" + + " if (app.max_devices > 1) {\n" + + " rules.push({\n" + + " type: \"multi_device\",\n" + + " title: \"多设备支持\",\n" + + " description: \"支持多设备,最多\" + app.max_devices + \"台\"\n" + + " });\n" + + " }\n" + + " \n" + + " if (app.enable_free_period) {\n" + + " rules.push({\n" + + " type: \"free_period\",\n" + + " title: \"免费时段\",\n" + + " description: \"支持免费时段:\" + app.free_period_start + \" - \" + app.free_period_end\n" + + " });\n" + + " }\n" + + " \n" + + " if (app.point_price > 0) {\n" + + " rules.push({\n" + + " type: \"points\",\n" + + " title: \"积分系统\",\n" + + " description: \"点数价格:\" + app.point_price + \",每周期扣点:\" + app.points_per_cycle\n" + + " });\n" + + " }\n" + + " \n" + + " return {\n" + + " app_name: app.name,\n" + + " total_rules: rules.length,\n" + + " rules: rules\n" + + " };\n" + + "}\n\n" + + "getBusinessRules()\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"app_name\": \"我的应用\",\n" + + " \"total_rules\": 4,\n" + + " \"rules\": [\n" + + " {\n" + + " \"type\": \"trial\",\n" + + " \"title\": \"试用功能\",\n" + + " \"description\": \"支持试用,试用时长:7天\"\n" + + " },\n" + + " {\n" + + " \"type\": \"multi_device\",\n" + + " \"title\": \"多设备支持\",\n" + + " \"description\": \"支持多设备,最多3台\"\n" + + " },\n" + + " {\n" + + " \"type\": \"free_period\",\n" + + " \"title\": \"免费时段\",\n" + + " \"description\": \"支持免费时段:20:00 - 08:00\"\n" + + " },\n" + + " {\n" + + " \"type\": \"points\",\n" + + " \"title\": \"积分系统\",\n" + + " \"description\": \"点数价格:0.1,每周期扣点:1\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "## 错误处理\n\n" + + "### 常见错误\n\n" + + "#### 1. 语法错误\n\n" + + "```json\n" + + "{\n" + + " \"code\": 400,\n" + + " \"message\": \"代码执行错误: SyntaxError: Unexpected token\"\n" + + "}\n" + + "```\n\n" + + "**原因**:JavaScript代码语法错误\n\n" + + "**解决**:检查代码语法,确保括号、引号等匹配正确\n\n" + + "---\n\n" + + "#### 2. 运行时错误\n\n" + + "```json\n" + + "{\n" + + " \"code\": 400,\n" + + " \"message\": \"代码执行错误: ReferenceError: x is not defined\"\n" + + "}\n" + + "```\n\n" + + "**原因**:使用了未定义的变量\n\n" + + "**解决**:检查变量名是否正确,或确保已通过参数传入\n\n" + + "---\n\n" + + "#### 3. 全局return错误\n\n" + + "```json\n" + + "{\n" + + " \"code\": 400,\n" + + " \"message\": \"代码执行错误: SyntaxError: Illegal return statement\"\n" + + "}\n" + + "```\n\n" + + "**原因**:在全局作用域使用了return语句\n\n" + + "**解决**:将return语句放在函数内部,或直接返回表达式\n\n" + + "---\n\n" + + "#### 4. 操作执行失败\n\n" + + "```json\n" + + "{\n" + + " \"code\": 500,\n" + + " \"message\": \"执行加时操作失败: user not found\"\n" + + "}\n" + + "```\n\n" + + "**原因**:操作指令中的用户不存在\n\n" + + "**解决**:检查user_id是否正确,或用户是否属于当前应用\n\n" + + "---\n\n" + + "## 性能建议\n\n" + + "1. **避免死循环**:确保循环有明确的退出条件\n" + + "2. **合理使用缓存**:对于重复计算,可以考虑缓存结果\n" + + "3. **控制代码复杂度**:过复杂的代码可能影响执行效率\n" + + "4. **使用内置函数**:优先使用JavaScript内置函数,性能更好\n" + + "5. **避免频繁操作**:操作指令会修改数据库,避免频繁调用\n\n" + + "---\n\n" + + "## 安全建议\n\n" + + "1. **不要执行危险操作**:避免访问文件系统、网络请求等\n" + + "2. **参数验证**:在代码中对参数进行必要的验证\n" + + "3. **避免敏感信息**:不要在代码中硬编码密钥、密码等敏感信息\n" + + "4. **限制执行时间**:设置合理的超时时间,防止长时间运行\n" + + "5. **操作指令谨慎**:操作指令会修改数据库,确保逻辑正确\n" + + "6. **用户数据保护**:不要在代码中泄露用户敏感信息\n\n" + + "---\n\n" + + "## 最佳实践\n\n" + + "### 1. 代码组织\n\n" + + "将复杂逻辑封装成函数,提高代码可读性:\n\n" + + "```javascript\n" + + "function validateUser() {\n" + + " if (!user) {\n" + + " return { valid: false, reason: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { valid: false, reason: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " return { valid: true };\n" + + "}\n\n" + + "function calculatePrice(basePrice, discount) {\n" + + " return basePrice * (1 - discount / 100);\n" + + "}\n\n" + + "function mainProcess() {\n" + + " const validation = validateUser();\n" + + " if (!validation.valid) {\n" + + " return validation;\n" + + " }\n" + + " \n" + + " const price = calculatePrice(100, 20);\n" + + " return { success: true, price: price };\n" + + "}\n\n" + + "mainProcess()\n" + + "```\n\n" + + "---\n\n" + + "### 2. 错误处理\n\n" + + "在代码中添加适当的错误处理:\n\n" + + "```javascript\n" + + "function safeCalculate(a, b) {\n" + + " if (typeof a !== \"number\" || typeof b !== \"number\") {\n" + + " return { error: \"参数必须是数字\" };\n" + + " }\n" + + " \n" + + " try {\n" + + " return { result: a + b };\n" + + " } catch (e) {\n" + + " return { error: \"计算失败: \" + e.message };\n" + + " }\n" + + "}\n\n" + + "safeCalculate(a, b)\n" + + "```\n\n" + + "---\n\n" + + "### 3. 使用现代JavaScript\n\n" + + "利用ES6+特性提高代码质量:\n\n" + + "```javascript\n" + + "// 使用箭头函数\n" + + "const calculate = (x, y) => x + y;\n\n" + + "// 使用解构赋值\n" + + "const { name, age } = userInfo;\n\n" + + "// 使用模板字符串\n" + + "const message = `用户${name}的年龄是${age}岁`;\n\n" + + "// 使用数组方法\n" + + "const sum = numbers.reduce((a, b) => a + b, 0);\n\n" + + "// 使用对象简写\n" + + "const result = { name, age, status };\n" + + "```\n\n" + + "---\n\n" + + "### 4. 日志记录\n\n" + + "在返回结果中包含调试信息:\n\n" + + "```javascript\n" + + "function processWithLog(userId) {\n" + + " const startTime = new Date();\n" + + " \n" + + " if (!user) {\n" + + " return { \n" + + " error: \"用户不存在\",\n" + + " timestamp: startTime.toISOString(),\n" + + " debug: { userId }\n" + + " };\n" + + " }\n" + + " \n" + + " const result = {\n" + + " user_id: userId,\n" + + " username: user.username,\n" + + " status: user.status\n" + + " };\n" + + " \n" + + " return {\n" + + " success: true,\n" + + " data: result,\n" + + " timestamp: startTime.toISOString(),\n" + + " execution_time: new Date() - startTime\n" + + " };\n" + + "}\n\n" + + "processWithLog(userId)\n" + + "```\n\n" + + "---\n\n" + + "## 常见问题\n\n" + + "### Q1: 云端函数可以访问数据库吗?\n\n" + + "**A**: 不能直接访问数据库。云端函数只能使用注入的数据(app、user)和请求参数。如果需要修改数据,应该返回操作指令(extend_time、deduct_points),由后端安全地执行。\n\n" + + "---\n\n" + + "### Q2: 云端函数可以调用外部API吗?\n\n" + + "**A**: 不能。云端函数在沙箱环境中执行,不支持网络请求。如果需要调用外部API,应该通过后端接口实现。\n\n" + + "---\n\n" + + "### Q3: 云端函数的执行时间有限制吗?\n\n" + + "**A**: 有。云端函数有执行时间限制,超时会被终止。建议避免死循环和长时间运行的操作。\n\n" + + "---\n\n" + + "### Q4: 如何调试云端函数?\n\n" + + "**A**: 可以在返回结果中包含调试信息,或者使用简单的测试参数验证逻辑是否正确。\n\n" + + "---\n\n" + + "### Q5: 云端函数可以修改应用配置吗?\n\n" + + "**A**: 不能。app对象是只读的,不能修改。如果需要修改应用配置,应该使用专门的管理接口。\n\n" + + "---\n\n" + + "## 更新日志\n\n" + + "### v1.0.0 (2026-03-11)\n" + + "- 初始版本发布\n" + + "- 支持应用配置数据注入\n" + + "- 支持用户信息注入\n" + + "- 支持操作指令(加时、扣点)\n" + + "- 支持完整的JavaScript语法\n" + + "- 提供丰富的使用示例\n" + + cloudFunctionDoc := model.Doc{ + Title: "云端函数接口", + CategoryID: &apiCat.ID, + Slug: "cloud-function", + Content: cloudFunctionContent, + Summary: "云端函数允许开发者为应用创建自定义的业务逻辑,通过JavaScript代码实现灵活的数据处理和业务规则", + Icon: "⚡", + Sort: 1, + Status: "published", + } + + if err := database.DB.Create(&cloudFunctionDoc).Error; err != nil { + log.Printf("创建云端函数文档失败: %v", err) + return + } + + log.Println("云端函数文档创建成功") +} diff --git a/backend/scripts/create_manual_deduct_doc.go b/backend/scripts/create_manual_deduct_doc.go new file mode 100644 index 0000000..4658c9d --- /dev/null +++ b/backend/scripts/create_manual_deduct_doc.go @@ -0,0 +1,349 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + docContent := `# 手动扣费 API + +## 概述 + +手动扣费功能允许开发者在余额模式下,通过 API 自行控制扣费时机和金额。这为开发者提供了最大的灵活性,可以根据业务需求实现自定义的计费逻辑。 + +## 前提条件 + +1. 应用的运营模式必须设置为**余额模式** +2. 扣费方式必须设置为**手动扣费** + +## 扣费方式说明 + +扣费方式分为两级选择: + +### 第一级:扣费方式 + +| 方式 | 说明 | +|------|------| +| 自动扣费 | 系统自动触发扣费 | +| 手动扣费 | 通过 API 自行控制扣费 | + +### 第二级:自动扣费类型(仅自动扣费时显示) + +| 类型 | 说明 | +|------|------| +| 登录扣费 | 每次登录验证时扣费 | +| 计时扣费 | 按设定的时间间隔自动扣费 | + +### 计时扣费配置 + +选择计时扣费后,可自定义扣费间隔: + +- **间隔值**:1-9999 之间的整数 +- **时间单位**:分钟、小时、天 + +例如:设置为 "每 30 分钟扣费一次" 或 "每 2 小时扣费一次" + +## 接口详情 + +### 请求地址 + +POST /api/v1/dev/applications/:id/deduct + +### 请求头 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| Authorization | string | 是 | Bearer {token},开发者登录后获取的令牌 | +| Content-Type | string | 是 | application/json | + +### 路径参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | integer | 是 | 应用ID | + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| user_id | integer | 是 | 要扣费的用户ID | +| amount | number | 是 | 扣费金额,必须大于0 | +| description | string | 否 | 扣费描述/原因 | + +### 请求示例 + +` + "```json" + ` +{ + "user_id": 123, + "amount": 10.5, + "description": "使用高级功能扣费" +} +` + "```" + ` + +### 响应示例 + +#### 成功响应 + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "user_id": 123, + "username": "testuser", + "amount": 10.5, + "balance_before": 100.5, + "balance_after": 90.0, + "description": "使用高级功能扣费", + "message": "扣费成功" + } +} +` + "```" + ` + +#### 错误响应 + +**应用不存在** + +` + "```json" + ` +{ + "code": 404, + "message": "应用不存在" +} +` + "```" + ` + +**非余额模式** + +` + "```json" + ` +{ + "code": 400, + "message": "只有余额模式的应用才支持手动扣费" +} +` + "```" + ` + +**未开启手动扣费** + +` + "```json" + ` +{ + "code": 400, + "message": "该应用未开启手动扣费模式,请先在设置中修改扣费频率为手动扣费" +} +` + "```" + ` + +**用户不存在** + +` + "```json" + ` +{ + "code": 404, + "message": "用户不存在" +} +` + "```" + ` + +**余额不足** + +` + "```json" + ` +{ + "code": 400, + "message": "用户余额不足,当前余额: 5.00,需扣除: 10.50" +} +` + "```" + ` + +**永久会员** + +` + "```json" + ` +{ + "code": 400, + "message": "该用户为永久会员,无法扣费" +} +` + "```" + ` + +## 使用场景 + +### 1. 按次计费 + +用户每次使用特定功能时扣费: + +` + "```javascript" + ` +// 用户使用高级分析功能 +async function useAdvancedAnalysis(userId) { + const response = await fetch('/api/v1/dev/applications/1/deduct', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + user_id: userId, + amount: 5.0, + description: '使用高级分析功能' + }) + }); + + const result = await response.json(); + if (result.code === 200) { + // 扣费成功,执行功能 + return true; + } else { + // 扣费失败,提示用户 + alert(result.message); + return false; + } +} +` + "```" + ` + +### 2. 阶梯计费 + +根据使用量阶梯定价: + +` + "```javascript" + ` +function calculatePrice(usage) { + if (usage <= 100) { + return 1.0; // 前100次,每次1元 + } else if (usage <= 500) { + return 0.8; // 101-500次,每次0.8元 + } else { + return 0.5; // 500次以上,每次0.5元 + } +} + +async function chargeUser(userId, currentUsage) { + const price = calculatePrice(currentUsage); + // 调用扣费API... +} +` + "```" + ` + +### 3. 动态定价 + +根据时间段或活动动态调整价格: + +` + "```javascript" + ` +function getDynamicPrice() { + const hour = new Date().getHours(); + const dayOfWeek = new Date().getDay(); + + // 周末打折 + if (dayOfWeek === 0 || dayOfWeek === 6) { + return 5.0 * 0.8; + } + + // 深夜时段打折 + if (hour >= 22 || hour < 6) { + return 5.0 * 0.5; + } + + return 5.0; +} +` + "```" + ` + +### 4. 批量扣费 + +对多个用户批量扣费: + +` + "```javascript" + ` +async function batchDeduct(users) { + const results = []; + + for (const user of users) { + try { + const response = await fetch('/api/v1/dev/applications/1/deduct', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + user_id: user.id, + amount: user.amount, + description: user.description + }) + }); + + const result = await response.json(); + results.push({ + user_id: user.id, + success: result.code === 200, + message: result.message + }); + } catch (error) { + results.push({ + user_id: user.id, + success: false, + message: error.message + }); + } + } + + return results; +} +` + "```" + ` + +## 注意事项 + +1. **余额检查**: 调用扣费接口前,建议先检查用户余额是否充足 +2. **幂等性**: 如果业务需要保证幂等性,请在业务层实现去重逻辑 +3. **并发控制**: 高并发场景下,建议使用锁机制防止余额扣成负数 +4. **记录保存**: 每次扣费都会自动创建消费记录,可在后台查看 +5. **错误处理**: 请妥善处理各种错误情况,给用户友好的提示 + +## 与自动扣费的区别 + +| 特性 | 手动扣费 | 自动扣费 | +|------|----------|----------| +| 扣费时机 | 开发者自行控制 | 系统自动执行 | +| 扣费金额 | 每次可不同 | 固定金额 | +| 灵活性 | 高 | 低 | +| 实现复杂度 | 需要开发者实现 | 无需开发 | +| 适用场景 | 复杂计费逻辑 | 简单按时/按次计费 | + +## 相关接口 + +- [获取用户信息](/docs/app-api-docs#获取账户信息) - 查询用户当前余额 +- [用户充值](/docs/app-api-docs#卡密充值) - 用户通过卡密充值 +- [消费记录](/docs/finance) - 查看扣费记录` + + var devCategory model.DocCategory + if err := database.DB.Where("slug = ?", "dev-docs").First(&devCategory).Error; err != nil { + devCategory = model.DocCategory{ + Name: "开发者文档", + Slug: "dev-docs", + Description: "开发者API接口文档", + Sort: 90, + } + if err := database.DB.Create(&devCategory).Error; err != nil { + log.Printf("创建开发者文档分类失败: %v", err) + return + } + } + + var existingDoc model.Doc + if err := database.DB.Where("slug = ?", "manual-deduct-api").First(&existingDoc).Error; err == nil { + existingDoc.Content = docContent + if err := database.DB.Save(&existingDoc).Error; err != nil { + log.Printf("更新手动扣费文档失败: %v", err) + } else { + log.Println("手动扣费文档更新成功") + } + return + } + + doc := model.Doc{ + Title: "手动扣费 API", + CategoryID: &devCategory.ID, + Slug: "manual-deduct-api", + Content: docContent, + Summary: "余额模式下通过API自行控制扣费时机和金额,支持按次计费、阶梯计费、动态定价等场景", + Icon: "💳", + Sort: 10, + Status: "published", + } + + if err := database.DB.Create(&doc).Error; err != nil { + log.Printf("创建手动扣费文档失败: %v", err) + return + } + + log.Println("手动扣费文档创建成功") +} diff --git a/backend/scripts/fix_package_data.go b/backend/scripts/fix_package_data.go new file mode 100644 index 0000000..1f4af74 --- /dev/null +++ b/backend/scripts/fix_package_data.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + log.Println("=== 修复套餐数据 ===") + + var pkg model.Package + if err := database.DB.First(&pkg, 1).Error; err != nil { + log.Printf("查询套餐失败: %v\n", err) + return + } + + log.Printf("修复前: ID=%d, Name='%s', Price=%.2f, Period='%s', Status='%s'\n", + pkg.ID, pkg.Name, pkg.Price, pkg.Period, pkg.Status) + + if pkg.Name == "" { + pkg.Name = "免费版" + pkg.Price = 0 + pkg.Period = "permanent" + pkg.Status = "active" + pkg.Description = "免费体验版本,功能有限" + pkg.Sort = 0 + + if err := database.DB.Save(&pkg).Error; err != nil { + log.Printf("更新套餐失败: %v\n", err) + return + } + + log.Printf("修复后: ID=%d, Name='%s', Price=%.2f, Period='%s', Status='%s'\n", + pkg.ID, pkg.Name, pkg.Price, pkg.Period, pkg.Status) + + var permission model.PackagePermission + if err := database.DB.Where("package_id = ?", pkg.ID).First(&permission).Error; err != nil { + log.Printf("查询权限失败: %v\n", err) + } else { + permission.MaxApplications = 1 + permission.MaxStorage = 100 + permission.MaxApiCalls = 1000 + permission.AllowAgent = false + permission.AllowCloudData = false + permission.AllowDynamicCode = false + permission.PrioritySupport = false + + if err := database.DB.Save(&permission).Error; err != nil { + log.Printf("更新权限失败: %v\n", err) + } else { + log.Printf("权限已更新: MaxApps=%d, MaxStorage=%d, MaxApiCalls=%d\n", + permission.MaxApplications, permission.MaxStorage, permission.MaxApiCalls) + } + } + } else { + log.Println("套餐名称不为空,无需修复") + } + + log.Println("套餐数据修复完成") +} diff --git a/backend/scripts/init_packages.go b/backend/scripts/init_packages.go new file mode 100644 index 0000000..0455062 --- /dev/null +++ b/backend/scripts/init_packages.go @@ -0,0 +1,113 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + log.Println("=== 初始化套餐数据 ===") + + var count int64 + database.DB.Model(&model.Package{}).Count(&count) + log.Printf("当前套餐数量: %d\n", count) + + if count > 0 { + log.Println("套餐数据已存在,跳过初始化") + return + } + + packages := []model.Package{ + { + Name: "基础版", + Description: "适合个人开发者和小型项目", + Price: 9.9, + Period: "monthly", + Status: "active", + Sort: 1, + IsRecommended: false, + AllowUpgrade: true, + }, + { + Name: "专业版", + Description: "适合中小型企业和成长型项目", + Price: 29.9, + Period: "monthly", + Status: "active", + Sort: 2, + IsRecommended: true, + AllowUpgrade: true, + }, + { + Name: "企业版", + Description: "适合大型企业和高流量项目", + Price: 99.9, + Period: "monthly", + Status: "active", + Sort: 3, + IsRecommended: false, + AllowUpgrade: true, + }, + { + Name: "永久版", + Description: "一次性购买,永久使用", + Price: 999, + Period: "permanent", + Status: "active", + Sort: 4, + IsRecommended: false, + AllowUpgrade: false, + }, + } + + for _, pkg := range packages { + if err := database.DB.Create(&pkg).Error; err != nil { + log.Printf("创建套餐失败: %v\n", err) + continue + } + + permission := model.PackagePermission{ + PackageID: pkg.ID, + MaxApplications: 5, + MaxStorage: 1024, + MaxApiCalls: 10000, + AllowAgent: false, + AllowCloudData: true, + AllowDynamicCode: false, + PrioritySupport: false, + } + + if pkg.Name == "专业版" { + permission.MaxApplications = 20 + permission.MaxStorage = 5120 + permission.MaxApiCalls = 100000 + permission.AllowAgent = true + permission.AllowDynamicCode = true + } else if pkg.Name == "企业版" { + permission.MaxApplications = -1 + permission.MaxStorage = -1 + permission.MaxApiCalls = -1 + permission.AllowAgent = true + permission.AllowDynamicCode = true + permission.PrioritySupport = true + } else if pkg.Name == "永久版" { + permission.MaxApplications = -1 + permission.MaxStorage = -1 + permission.MaxApiCalls = -1 + permission.AllowAgent = true + permission.AllowDynamicCode = true + permission.PrioritySupport = true + } + + if err := database.DB.Create(&permission).Error; err != nil { + log.Printf("创建套餐权限失败: %v\n", err) + } else { + log.Printf("创建套餐成功: %s (ID: %d)\n", pkg.Name, pkg.ID) + } + } + + log.Println("套餐初始化完成") +} diff --git a/backend/scripts/list_docs.go b/backend/scripts/list_docs.go new file mode 100644 index 0000000..90a58d0 --- /dev/null +++ b/backend/scripts/list_docs.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + log.Println("=== 查看当前所有文档 ===") + + var docs []model.Doc + if err := database.DB.Find(&docs).Error; err != nil { + log.Printf("查询文档失败: %v", err) + return + } + + for _, doc := range docs { + log.Printf("ID: %d, Slug: %s, Title: %s", doc.ID, doc.Slug, doc.Title) + } +} \ No newline at end of file diff --git a/backend/scripts/migrate_docs_i18n.go b/backend/scripts/migrate_docs_i18n.go new file mode 100644 index 0000000..9c9d173 --- /dev/null +++ b/backend/scripts/migrate_docs_i18n.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + db := database.DB + + var categories []model.DocCategory + if err := db.Find(&categories).Error; err != nil { + log.Fatal("获取分类失败:", err) + } + + for _, cat := range categories { + if cat.NameEn == "" && cat.Name != "" { + cat.NameEn = cat.Name + } + if cat.DescriptionEn == "" && cat.Description != "" { + cat.DescriptionEn = cat.Description + } + if err := db.Save(&cat).Error; err != nil { + log.Printf("更新分类 %d 失败: %v", cat.ID, err) + } else { + fmt.Printf("已更新分类: %s\n", cat.Name) + } + } + + var docs []model.Doc + if err := db.Find(&docs).Error; err != nil { + log.Fatal("获取文档失败:", err) + } + + for _, doc := range docs { + if doc.TitleEn == "" && doc.Title != "" { + doc.TitleEn = doc.Title + } + if doc.ContentEn == "" && doc.Content != "" { + doc.ContentEn = doc.Content + } + if err := db.Save(&doc).Error; err != nil { + log.Printf("更新文档 %d 失败: %v", doc.ID, err) + } else { + fmt.Printf("已更新文档: %s\n", doc.Title) + } + } + + fmt.Println("\n迁移完成!") + fmt.Printf("更新了 %d 个分类\n", len(categories)) + fmt.Printf("更新了 %d 个文档\n", len(docs)) +} diff --git a/backend/scripts/translate_docs_i18n.go b/backend/scripts/translate_docs_i18n.go new file mode 100644 index 0000000..184c88c --- /dev/null +++ b/backend/scripts/translate_docs_i18n.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + db := database.DB + + categoryTranslations := map[string]struct { + NameEn string + DescriptionEn string + }{ + "快速开始": {NameEn: "Quick Start", DescriptionEn: "Complete integration in 5 minutes"}, + "API文档": {NameEn: "API Documentation", DescriptionEn: "Complete API reference"}, + "示例代码": {NameEn: "Code Examples", DescriptionEn: "Common use case examples"}, + "常见问题": {NameEn: "FAQ", DescriptionEn: "Frequently asked questions"}, + } + + var categories []model.DocCategory + if err := db.Find(&categories).Error; err != nil { + log.Fatal("Failed to get categories:", err) + } + + for _, cat := range categories { + if trans, ok := categoryTranslations[cat.Name]; ok { + cat.NameEn = trans.NameEn + cat.DescriptionEn = trans.DescriptionEn + if err := db.Save(&cat).Error; err != nil { + log.Printf("Failed to update category %d: %v", cat.ID, err) + } else { + fmt.Printf("Translated category: %s -> %s\n", cat.Name, cat.NameEn) + } + } + } + + docTranslations := map[string]struct { + TitleEn string + ContentEn string + }{ + "用户注册": { + TitleEn: "User Registration", + ContentEn: "# User Registration\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/register\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| username | string | Yes | Username |\n| password | string | Yes | Password |\n| device_id | string | Yes | Device fingerprint, unique device identifier |\n| device_name | string | No | Device name, e.g. \"My Computer\", defaults to device_id |\n| email | string | Conditional | Email address, required when email verification is enabled |\n| email_code | string | Conditional | Email verification code, required when mandatory email verification is enabled |\n| device_type | string | No | Device type: android/ios/windows/mac/linux/web, auto-detected if not provided |\n| instance_id | string | No | Instance ID for multi-instance detection, defaults to device_id |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"abc123def456\",\n \"device_name\": \"My Computer\",\n \"email\": \"user@example.com\",\n \"email_code\": \"123456\",\n \"device_type\": \"windows\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Registration successful\",\n \"data\": {\n \"user_id\": 123\n }\n}\n```\n\n### Notes\n- device_id is the device fingerprint used to uniquely identify a device\n- device_name is a friendly name for the device for display purposes\n- device_type is auto-detected based on User-Agent if not provided\n- If mandatory email verification is enabled for the application, email and email_code are required", + }, + "发送邮箱验证码": { + TitleEn: "Send Email Verification Code", + ContentEn: "# Send Email Verification Code\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/send-email-code\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| email | string | Yes | Email address |\n| purpose | string | No | Purpose: register, reset_password, change_email, defaults to register |\n\n### Request Example\n```json\n{\n \"email\": \"user@example.com\",\n \"purpose\": \"register\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"success\",\n \"data\": {\n \"message\": \"Verification code sent\"\n }\n}\n```\n\n### Notes\n- Verification code is valid for 15 minutes\n- Returns an error if email verification is not enabled for the application", + }, + "用户登录": { + TitleEn: "User Login", + ContentEn: "# User Login\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/login\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| username | string | Yes | Username |\n| password | string | Yes | Password |\n| device_id | string | Yes | Device fingerprint, unique device identifier |\n| device_name | string | No | Device name, e.g. \"My Computer\", defaults to device_id |\n| device_type | string | No | Device type: android/ios/windows/mac/linux/web, auto-detected if not provided |\n| instance_id | string | No | Instance ID for multi-instance detection, defaults to device_id |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"abc123def456\",\n \"device_name\": \"My Computer\",\n \"device_type\": \"windows\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Login successful\",\n \"data\": {\n \"token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n \"user_id\": 123\n }\n}\n```\n\n### Notes\n- device_id is the device fingerprint used to uniquely identify a device\n- device_name is a friendly name for the device for display purposes\n- device_type is auto-detected based on User-Agent if not provided\n- Returns an error with bound device list if device binding limit is reached", + }, + "心跳验证": { + TitleEn: "Heartbeat Verification", + ContentEn: "# Heartbeat Verification\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/heartbeat\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| device_id | string | Yes | Device ID |\n\n### Request Example\n```json\n{\n \"device_id\": \"device_001\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"success\"\n}\n```\n\n### Notes\n- Keeps user online status\n- Recommended to call every 30 seconds\n- User will be considered offline if heartbeat is not called for a certain period\n- JWT token must be included in the request header", + }, + "卡密充值": { + TitleEn: "Card Key Recharge", + ContentEn: "# Card Key Recharge\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/recharge\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| username | string | Yes | User account |\n| card_key | string | Yes | Card key |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"card_key\": \"VIP123456\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Recharge successful\",\n \"data\": {\n \"value\": 30\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|-------|------|-------------|\n| value | number | Recharge value (meaning varies by card type) |", + }, + "获取绑定设备列表": { + TitleEn: "Get Bound Devices List", + ContentEn: "# Get Bound Devices List\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/devices\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"devices\": [\n {\n \"device_id\": \"device_001\",\n \"device_name\": \"My Computer\",\n \"bind_time\": \"2024-03-04T12:00:00Z\",\n \"last_active\": \"2024-03-04T13:00:00Z\"\n }\n ]\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|-------|------|-------------|\n| device_id | string | Device ID |\n| device_name | string | Device name |\n| bind_time | string | Binding time |\n| last_active | string | Last active time |", + }, + "解绑设备": { + TitleEn: "Unbind Device", + ContentEn: "# Unbind Device\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/unbind-device\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| device_id | string | Yes | Device ID |\n\n### Request Example\n```json\n{\n \"device_id\": \"device_001\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Device unbound successfully\"\n}\n```", + }, + "通过认证解绑设备": { + TitleEn: "Unbind Device with Authentication", + ContentEn: "# Unbind Device with Authentication\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/unbind-device-with-auth\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| device_id | string | Yes | Device ID to unbind |\n| password | string | Yes | User password for verification |\n\n### Request Example\n```json\n{\n \"device_id\": \"device_001\",\n \"password\": \"password123\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Device unbound successfully\"\n}\n```", + }, + "重置密码(邮箱验证码)": { + TitleEn: "Reset Password (Email Verification)", + ContentEn: "# Reset Password (Email Verification)\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/reset-password\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| email | string | Yes | Email address |\n| email_code | string | Yes | Email verification code |\n| new_password | string | Yes | New password |\n\n### Request Example\n```json\n{\n \"email\": \"user@example.com\",\n \"email_code\": \"123456\",\n \"new_password\": \"newpassword123\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Password reset successful\"\n}\n```", + }, + "修改密码": { + TitleEn: "Change Password", + ContentEn: "# Change Password\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/change-password\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| old_password | string | Yes | Current password |\n| new_password | string | Yes | New password |\n\n### Request Example\n```json\n{\n \"old_password\": \"oldpassword123\",\n \"new_password\": \"newpassword123\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Password changed successfully\"\n}\n```", + }, + "获取设备数量": { + TitleEn: "Get Device Count", + ContentEn: "# Get Device Count\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/device-count\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"count\": 2,\n \"max_devices\": 3\n }\n}\n```", + }, + "获取应用信息": { + TitleEn: "Get Application Info", + ContentEn: "# Get Application Info\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/info\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"name\": \"My Application\",\n \"description\": \"Application description\",\n \"version\": \"1.0.0\",\n \"status\": \"active\"\n }\n}\n```", + }, + "获取用户账户信息": { + TitleEn: "Get User Account Info", + ContentEn: "# Get User Account Info\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/user/info\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"user_id\": 123,\n \"username\": \"user123\",\n \"email\": \"user@example.com\",\n \"balance\": 100,\n \"expiry_at\": \"2024-12-31T23:59:59Z\"\n }\n}\n```", + }, + "获取应用常量": { + TitleEn: "Get Application Constants", + ContentEn: "# Get Application Constants\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/constants\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"constants\": [\n {\n \"key\": \"APP_VERSION\",\n \"value\": \"1.0.0\"\n }\n ]\n }\n}\n```", + }, + "获取指定常量": { + TitleEn: "Get Specific Constant", + ContentEn: "# Get Specific Constant\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/constants/:key\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"key\": \"APP_VERSION\",\n \"value\": \"1.0.0\"\n }\n}\n```", + }, + "获取应用变量": { + TitleEn: "Get Application Variables", + ContentEn: "# Get Application Variables\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/variables\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"variables\": [\n {\n \"key\": \"user_setting\",\n \"value\": \"default\"\n }\n ]\n }\n}\n```", + }, + "获取指定变量": { + TitleEn: "Get Specific Variable", + ContentEn: "# Get Specific Variable\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/variables/:key\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"key\": \"user_setting\",\n \"value\": \"default\"\n }\n}\n```", + }, + "更新用户变量": { + TitleEn: "Update User Variable", + ContentEn: "# Update User Variable\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/variables/:key\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| value | string | Yes | Variable value |\n\n### Request Example\n```json\n{\n \"value\": \"new_value\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Variable updated successfully\"\n}\n```", + }, + "调用云端函数": { + TitleEn: "Call Cloud Function", + ContentEn: "# Call Cloud Function\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/functions/:name\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Example\n```json\n{\n \"param1\": \"value1\",\n \"param2\": \"value2\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"result\": \"function_result\"\n }\n}\n```", + }, + "检查更新": { + TitleEn: "Check for Updates", + ContentEn: "# Check for Updates\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/check-update\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| current_version | string | Yes | Current application version |\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"has_update\": true,\n \"latest_version\": \"2.0.0\",\n \"download_url\": \"https://example.com/download\",\n \"update_log\": \"Bug fixes and improvements\"\n }\n}\n```", + }, + "获取公告": { + TitleEn: "Get Announcements", + ContentEn: "# Get Announcements\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/announcements\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"announcements\": [\n {\n \"id\": 1,\n \"title\": \"System Maintenance\",\n \"content\": \"System maintenance scheduled for...\",\n \"type\": \"warning\",\n \"created_at\": \"2024-03-04T12:00:00Z\"\n }\n ]\n }\n}\n```", + }, + "获取在线实例列表": { + TitleEn: "Get Online Instances", + ContentEn: "# Get Online Instances\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/online-instances\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"instances\": [\n {\n \"device_id\": \"device_001\",\n \"device_name\": \"My Computer\",\n \"login_time\": \"2024-03-04T12:00:00Z\",\n \"last_heartbeat\": \"2024-03-04T13:00:00Z\"\n }\n ]\n }\n}\n```", + }, + "强制离线实例": { + TitleEn: "Force Offline Instance", + ContentEn: "# Force Offline Instance\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/force-offline\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| device_id | string | Yes | Device ID to force offline |\n\n### Request Example\n```json\n{\n \"device_id\": \"device_001\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Instance forced offline successfully\"\n}\n```", + }, + "软件验证示例": { + TitleEn: "Software Verification Example", + ContentEn: "# Software Verification Example\n\n## Basic Verification Flow\n\n### 1. User Login\n```bash\ncurl -X POST https://api.example.com/api/v1/app/YOUR_APP_KEY/login \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"unique_device_id\"\n }'\n```\n\n### 2. Save Token\nSave the returned token for subsequent API calls.\n\n### 3. Heartbeat\nCall heartbeat API periodically to maintain online status.\n\n## Complete Example (Python)\n```python\nimport requests\nimport time\n\nclass AppClient:\n def __init__(self, app_key):\n self.app_key = app_key\n self.base_url = \"https://api.example.com/api/v1/app\"\n self.token = None\n \n def login(self, username, password, device_id):\n url = f\"{self.base_url}/{self.app_key}/login\"\n response = requests.post(url, json={\n \"username\": username,\n \"password\": password,\n \"device_id\": device_id\n })\n data = response.json()\n if data[\"code\"] == 200:\n self.token = data[\"data\"][\"token\"]\n return True\n return False\n\n# Usage\nclient = AppClient(\"your_app_key\")\nif client.login(\"user123\", \"password\", \"device_001\"):\n while True:\n client.heartbeat(\"device_001\")\n time.sleep(30)\n```", + }, + "游戏验证示例": { + TitleEn: "Game Verification Example", + ContentEn: "# Game Verification Example\n\n## Unity Integration\n\n### 1. Create API Client\n```csharp\nusing UnityEngine;\nusing UnityEngine.Networking;\nusing System.Collections;\n\npublic class VerificationClient : MonoBehaviour\n{\n private string appKey = \"your_app_key\";\n private string baseUrl = \"https://api.example.com/api/v1/app\";\n private string token;\n \n public IEnumerator Login(string username, string password, string deviceId)\n {\n string url = $\"{baseUrl}/{appKey}/login\";\n // ... implementation\n }\n}\n```\n\n### 2. Usage in Game\n```csharp\npublic class GameManager : MonoBehaviour\n{\n private VerificationClient client;\n private string deviceId;\n \n void Start()\n {\n deviceId = SystemInfo.deviceUniqueIdentifier;\n client = GetComponent();\n StartCoroutine(client.Login(\"user123\", \"password\", deviceId));\n }\n}\n```", + }, + "如何获取API密钥?": { + TitleEn: "How to Get API Key?", + ContentEn: "# How to Get API Key?\n\n## Steps\n\n### 1. Register Account\nFirst, register a developer account on the platform.\n\n### 2. Create Application\n1. Go to Console > Applications\n2. Click \"Create Application\" button\n3. Fill in application name and description\n4. Click \"Create\"\n\n### 3. Get App Key\nAfter creating the application, you can find the App Key on the application details page.\n\n## App Key Format\nApp Key is a unique identifier in the format:\n```\napp_xxxxxxxxxxxxxxxx\n```\n\n## Security Notes\n- Do not share your App Key publicly\n- Regenerate App Key if it's compromised\n- Use environment variables to store App Key in production", + }, + "卡密验证失败怎么办?": { + TitleEn: "What to Do When Card Key Verification Fails?", + ContentEn: "# What to Do When Card Key Verification Fails?\n\n## Common Issues\n\n### 1. Invalid Card Key\n**Symptom**: Returns \"Invalid or expired card key\"\n\n**Solutions**:\n- Check if the card key is entered correctly\n- Verify the card key hasn't been used\n- Confirm the card key hasn't expired\n\n### 2. Card Key Already Used\n**Symptom**: Returns \"Card key already used\"\n\n**Solutions**:\n- Each card key can only be used once\n- Purchase a new card key\n\n### 3. Card Key Expired\n**Symptom**: Returns \"Card key expired\"\n\n**Solutions**:\n- Check card key validity period\n- Contact support for assistance", + }, + "如何实现代理授权?": { + TitleEn: "How to Implement Agent Authorization?", + ContentEn: "# How to Implement Agent Authorization?\n\n## Overview\nAgent authorization allows you to delegate application management to agents.\n\n## Setup Steps\n\n### 1. Create Agent Application\n1. Go to Console > Agent Applications\n2. Click \"Create Agent Application\"\n3. Select the application to authorize\n4. Set commission rate\n\n### 2. Generate Agent Link\nAfter creation, you'll get an agent link:\n```\nhttps://platform.com/agent/AGENT_CODE\n```\n\n### 3. Agent Dashboard\nAgents can access their dashboard to:\n- View sales statistics\n- Generate card keys\n- Manage users", + }, + } + + var docs []model.Doc + if err := db.Find(&docs).Error; err != nil { + log.Fatal("Failed to get docs:", err) + } + + for _, doc := range docs { + if trans, ok := docTranslations[doc.Title]; ok { + doc.TitleEn = trans.TitleEn + doc.ContentEn = trans.ContentEn + if err := db.Save(&doc).Error; err != nil { + log.Printf("Failed to update doc %d: %v", doc.ID, err) + } else { + fmt.Printf("Translated doc: %s -> %s\n", doc.Title, doc.TitleEn) + } + } + } + + fmt.Println("\nTranslation completed!") +} diff --git a/backend/scripts/translate_packages_i18n.go b/backend/scripts/translate_packages_i18n.go new file mode 100644 index 0000000..9bb68c2 --- /dev/null +++ b/backend/scripts/translate_packages_i18n.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + db := database.DB + + packageTranslations := map[string]struct { + NameEn string + DescriptionEn string + }{ + "免费版": { + NameEn: "Free", + DescriptionEn: "Basic features for individual developers", + }, + "公益版": { + NameEn: "Community", + DescriptionEn: "Free for individual developers", + }, + "专业版": { + NameEn: "Professional", + DescriptionEn: "Advanced features for professional developers", + }, + "企业版": { + NameEn: "Enterprise", + DescriptionEn: "Full features for enterprise teams", + }, + "基础版": { + NameEn: "Basic", + DescriptionEn: "Essential features for small projects", + }, + "高级版": { + NameEn: "Premium", + DescriptionEn: "Premium features for growing businesses", + }, + "旗舰版": { + NameEn: "Flagship", + DescriptionEn: "Top-tier features for large organizations", + }, + "开发者版": { + NameEn: "Developer", + DescriptionEn: "Perfect for individual developers", + }, + "团队版": { + NameEn: "Team", + DescriptionEn: "Collaborative features for teams", + }, + "个人版": { + NameEn: "Personal", + DescriptionEn: "Personal use with essential features", + }, + "标准版": { + NameEn: "Standard", + DescriptionEn: "Standard features for regular users", + }, + } + + var packages []model.Package + if err := db.Find(&packages).Error; err != nil { + log.Fatal("Failed to get packages:", err) + } + + for _, pkg := range packages { + if trans, ok := packageTranslations[pkg.Name]; ok { + pkg.NameEn = trans.NameEn + pkg.DescriptionEn = trans.DescriptionEn + if err := db.Save(&pkg).Error; err != nil { + log.Printf("Failed to update package %d: %v", pkg.ID, err) + } else { + fmt.Printf("Translated package: %s -> %s\n", pkg.Name, pkg.NameEn) + } + } else { + pkg.NameEn = pkg.Name + if pkg.Description != "" { + pkg.DescriptionEn = pkg.Description + } + if err := db.Save(&pkg).Error; err != nil { + log.Printf("Failed to update package %d: %v", pkg.ID, err) + } else { + fmt.Printf("Copied package: %s (no translation found)\n", pkg.Name) + } + } + } + + fmt.Println("\nPackage translation completed!") +} diff --git a/backend/scripts/update_api_docs.go b/backend/scripts/update_api_docs.go new file mode 100644 index 0000000..7bb8e68 --- /dev/null +++ b/backend/scripts/update_api_docs.go @@ -0,0 +1,794 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + apiDocContent := `# 应用API + +## 概述 + +本文档描述了应用对接平台所需的所有API。每个应用都有独立的API地址,通过应用的密钥(appKey)进行访问。 + +## 基础信息 + +- **基础URL**: http://your-domain.com/api/v1/app/{appKey} +- **请求方式**: GET/POST +- **数据格式**: JSON +- **字符编码**: UTF-8 + +## 通信加密 + +所有API支持加密通信,根据应用的安全设置进行加密解密。 + +### 加密方式 + +| 类型 | 说明 | +|------|------| +| none | 不加密,明文传输 | +| aes | AES-GCM加密 | +| rc4 | RC4流加密 | + +### 加密请求格式 + +` + "```json" + ` +{ + "data": "加密后的数据(Base64编码)" +} +` + "```" + ` + +### 加密响应格式 + +` + "```json" + ` +{ + "data": "加密后的数据(Base64编码)" +} +` + "```" + ` + +--- + +## 公告管理 + +### 获取公告列表 + +获取应用的公告列表。 + +**请求** + +` + "```" + ` +GET /api/v1/app/{appKey}/announcements +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "announcements": [ + { + "id": 1, + "title": "系统维护通知", + "content": "系统将于今晚进行维护", + "type": "warning", + "priority": "high", + "status": "active", + "created_at": "2024-01-01T00:00:00Z" + } + ] + } +} +` + "```" + ` + +**字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| type | string | 公告类型(info、warning、urgent) | +| priority | string | 优先级(normal、medium、high) | +| status | string | 状态(draft、active) | + +--- + +## 版本更新 + +### 检测更新 + +检查应用是否有新版本可用。 + +**请求** + +` + "```" + ` +GET /api/v1/app/{appKey}/check-update?version=1.0.0 +` + "```" + ` + +**请求参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| version | string | 是 | 当前版本号 | + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "has_update": true, + "force_update": false, + "latest_version": "2.0.0", + "download_url": "http://example.com/download/app-v2.0.0.zip", + "file_size": 10240000, + "description": "新版本修复了若干bug" + } +} +` + "```" + ` + +--- + +## 应用信息 + +### 获取应用配置 + +获取应用的基本配置信息。 + +**请求** + +` + "```" + ` +GET /api/v1/app/{appKey}/info +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "app_id": 1, + "app_name": "示例应用", + "description": "这是一个示例应用", + "billing_type": "subscription", + "encrypt_type": "aes", + "bind_type": "device", + "max_devices": 1, + "multi_open": false, + "enable_trial": true, + "trial_duration": 7 + } +} +` + "```" + ` + +**字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| billing_type | string | 计费类型(subscription订阅、time计时、point点数) | +| encrypt_type | string | 加密类型(none、aes、rc4) | +| bind_type | string | 绑定类型(none、device、ip) | +| enable_trial | bool | 是否启用试用 | +| trial_duration | int | 试用天数 | + +--- + +## 用户认证 + +### 用户注册 + +注册新用户账号。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/register +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "username": "testuser", + "password": "password123", + "email": "test@example.com", + "device_id": "DEVICE-001" +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "username": "testuser", + "message": "注册成功" + } +} +` + "```" + ` + +--- + +### 用户登录 + +用户登录获取账号信息。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/login +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "username": "testuser", + "password": "password123", + "device_id": "DEVICE-001" +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "username": "testuser", + "balance": 100, + "is_trial": false, + "expiry_at": "2024-12-31T23:59:59Z", + "message": "登录成功" + } +} +` + "```" + ` + +**错误响应** + +| 错误信息 | 说明 | +|----------|------| +| 用户不存在 | 用户名未注册 | +| 密码错误 | 密码不正确 | +| 余额不足 | 账户余额为0且已过期 | +| 设备绑定数量已达上限 | 超过最大设备数限制 | +| 多开数量已达上限 | 同一设备登录用户数超限 | + +--- + +## 卡密充值 + +### 使用卡密充值 + +使用卡密为当前用户充值。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/recharge +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "user_id": 1, + "card_key": "CARD-1234567890" +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "balance": 200, + "expiry_at": "2024-12-31T23:59:59Z", + "message": "充值成功" + } +} +` + "```" + ` + +--- + +## 试用功能 + +### 申请试用 + +申请新用户试用。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/trial +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "user_id": 1, + "device_id": "DEVICE-001" +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "trial_start": "2024-01-01T00:00:00Z", + "trial_end": "2024-01-08T00:00:00Z", + "expiry_at": "2024-01-08T00:00:00Z", + "message": "试用成功" + } +} +` + "```" + ` + +--- + +## 设备管理 + +### 获取设备列表 + +获取用户绑定的设备列表。 + +**请求** + +` + "```" + ` +GET /api/v1/app/{appKey}/devices?user_id=1 +` + "```" + ` + +**请求参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| user_id | int | 是 | 用户ID | + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "devices": [ + { + "device_id": "DEVICE-001", + "device_name": "默认设备", + "last_active": "2024-01-01T12:00:00Z" + } + ] + } +} +` + "```" + ` + +--- + +### 解绑设备 + +解绑用户的设备。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/unbind-device +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "user_id": 1, + "device_id": "DEVICE-001" +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "message": "解绑成功" + } +} +` + "```" + ` + +--- + +## 账户管理 + +### 获取账户信息 + +获取用户的详细账户信息。 + +**请求** + +` + "```" + ` +GET /api/v1/app/{appKey}/account?user_id=1 +` + "```" + ` + +**请求参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| user_id | int | 是 | 用户ID | + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "username": "testuser", + "email": "test@example.com", + "balance": 100, + "is_trial": false, + "trial_start": null, + "trial_end": null, + "expiry_at": "2024-12-31T23:59:59Z", + "device_id": "DEVICE-001", + "created_at": "2024-01-01T00:00:00Z" + } +} +` + "```" + ` + +--- + +### 心跳检测 + +发送心跳以保持在线状态。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/heartbeat +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "user_id": 1, + "device_id": "DEVICE-001" +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "status": "active", + "message": "心跳成功" + } +} +` + "```" + ` + +--- + +## 云端数据 + +### 获取云端常量 + +获取全局的云端常量配置。 + +**请求** + +` + "```" + ` +GET /api/v1/app/{appKey}/constants +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "constants": { + "max_connections": "10", + "timeout": "30", + "retry_times": "3" + } + } +} +` + "```" + ` + +--- + +### 获取云端变量 + +获取用户的云端变量。 + +**请求** + +` + "```" + ` +GET /api/v1/app/{appKey}/variables?user_id=1 +` + "```" + ` + +**请求参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| user_id | int | 是 | 用户ID | + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "variables": { + "setting1": "value1", + "setting2": "value2" + } + } +} +` + "```" + ` + +--- + +### 修改云端变量 + +更新用户的云端变量。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/variables +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "user_id": 1, + "variables": { + "setting1": "new_value1", + "setting2": "new_value2" + } +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "message": "更新成功" + } +} +` + "```" + ` + +--- + +### 调用云端函数 + +调用云端定义的动态函数。 + +**请求** + +` + "```" + ` +POST /api/v1/app/{appKey}/call-function +` + "```" + ` + +**请求体** + +` + "```json" + ` +{ + "function_name": "custom_function", + "parameters": { + "param1": "value1", + "param2": "value2" + } +} +` + "```" + ` + +**响应示例** + +` + "```json" + ` +{ + "code": 200, + "message": "success", + "data": { + "result": "动态函数调用成功", + "data": { + "param1": "value1", + "param2": "value2" + } + } +} +` + "```" + ` + +--- + +## 错误码说明 + +| 错误码 | 说明 | +|--------|------| +| 200 | 请求成功 | +| 400 | 请求参数错误 | +| 401 | 未授权或认证失败 | +| 403 | 禁止访问(账号被禁用、已过期等) | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +--- + +## 试用和免费时段 + +### 试用功能 + +- 订阅模式应用可以启用试用功能 +- 新用户可以申请试用,试用时长由应用配置决定 +- 试用用户只能试用一次 +- 试用期间可以正常使用应用的所有功能 + +### 免费时段功能 + +- 订阅模式应用可以设置免费时间段 +- 在免费时间段内,所有用户可以免费使用应用 +- 免费时间段按每天设置,例如:00:00-06:00 + +--- + +## 安全建议 + +1. **使用HTTPS**: 生产环境建议使用HTTPS协议 +2. **启用加密**: 建议启用AES加密保护通信安全 +3. **定期更换密钥**: 定期更换应用的通信密钥 +4. **验证设备**: 启用设备绑定功能防止账号共享 +5. **限制并发**: 根据需要限制最大设备数和并发数 + +--- + +## 代码示例 + +### JavaScript + +` + "```javascript" + ` +const appKey = 'your-app-key'; +const baseUrl = 'http://your-domain.com/api/v1/app/' + appKey; + +// 获取公告 +async function getAnnouncements() { + const response = await fetch(baseUrl + '/announcements'); + const result = await response.json(); + if (result.code === 200) { + console.log('公告列表:', result.data.announcements); + } +} + +// 用户登录 +async function login(username, password, deviceId) { + const response = await fetch(baseUrl + '/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, device_id: deviceId }) + }); + const result = await response.json(); + if (result.code === 200) { + console.log('登录成功:', result.data); + return result.data; + } +} + +// 心跳检测 +async function heartbeat(userId, deviceId) { + const response = await fetch(baseUrl + '/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, device_id: deviceId }) + }); + const result = await response.json(); + return result.code === 200; +} +` + "```" + ` + +### Python + +` + "```python" + ` +import requests + +class AppClient: + def __init__(self, app_key, base_url='http://your-domain.com/api/v1/app'): + self.app_key = app_key + self.base_url = base_url + '/' + app_key + + def get_announcements(self): + response = requests.get(self.base_url + '/announcements') + result = response.json() + if result['code'] == 200: + return result['data']['announcements'] + return None + + def login(self, username, password, device_id): + data = {'username': username, 'password': password, 'device_id': device_id} + response = requests.post(self.base_url + '/login', json=data) + result = response.json() + if result['code'] == 200: + return result['data'] + return None + + def heartbeat(self, user_id, device_id): + data = {'user_id': user_id, 'device_id': device_id} + response = requests.post(self.base_url + '/heartbeat', json=data) + result = response.json() + return result['code'] == 200 + +# 使用示例 +client = AppClient('your-app-key') +announcements = client.get_announcements() +print('公告列表:', announcements) +` + "```" + ` +` + + var apiCategory model.DocCategory + if err := database.DB.Where("slug = ?", "api-docs").First(&apiCategory).Error; err != nil { + apiCategory = model.DocCategory{ + Name: "API文档", + Slug: "api-docs", + Description: "应用对接API文档", + Sort: 100, + } + if err := database.DB.Create(&apiCategory).Error; err != nil { + log.Printf("创建API文档分类失败: %v", err) + return + } + } + + var existingDoc model.Doc + if err := database.DB.Where("slug = ?", "app-api-docs").First(&existingDoc).Error; err == nil { + existingDoc.Title = "应用API" + existingDoc.Content = apiDocContent + existingDoc.Summary = "应用对接平台所需的所有API文档,包含用户认证、设备管理、云端数据等核心功能" + if err := database.DB.Save(&existingDoc).Error; err != nil { + log.Printf("更新API文档失败: %v", err) + return + } + log.Println("API文档更新成功") + return + } + + apiDoc := model.Doc{ + Title: "应用API", + CategoryID: &apiCategory.ID, + Slug: "app-api-docs", + Content: apiDocContent, + Summary: "应用对接平台所需的所有API文档,包含用户认证、设备管理、云端数据等核心功能", + Icon: "code", + Sort: 1, + Status: "published", + } + + if err := database.DB.Create(&apiDoc).Error; err != nil { + log.Printf("创建API文档失败: %v", err) + return + } + + log.Println("API文档创建成功") +} diff --git a/backend/scripts/update_doc_translations.go b/backend/scripts/update_doc_translations.go new file mode 100644 index 0000000..d6f0c4e --- /dev/null +++ b/backend/scripts/update_doc_translations.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "log" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + + "gorm.io/gorm/clause" +) + +func main() { + database.Init() + db := database.DB + + docTranslations := map[string]struct { + TitleEn string + ContentEn string + SummaryEn string + }{ + "api-heartbeat": { + TitleEn: "Heartbeat Verification", + ContentEn: "# Heartbeat Verification\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/heartbeat\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| device_id | string | Yes | Device ID |\n\n### Request Example\n```json\n{\n \"device_id\": \"device_001\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"success\"\n}\n```\n\n### Notes\n- Keeps user online status\n- Recommended to call every 30 seconds\n- User will be considered offline if heartbeat is not called for a certain period\n- JWT token must be included in the request header", + SummaryEn: "Heartbeat verification instructions", + }, + "api-recharge": { + TitleEn: "Card Key Recharge", + ContentEn: "# Card Key Recharge\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/recharge\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| username | string | Yes | User account |\n| card_key | string | Yes | Card key |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"card_key\": \"VIP123456\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Recharge successful\",\n \"data\": {\n \"value\": 30\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|--------|------|------|\n| value | number | Recharge value (meaning varies by card type) |\n\n### Error Response\n```json\n{\n \"code\": 400,\n \"message\": \"Invalid or expired card key\"\n}\n```", + SummaryEn: "Card key recharge instructions", + }, + "api-devices": { + TitleEn: "Get Bound Device List", + ContentEn: "# Get Bound Device List\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/devices\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"devices\": [\n {\n \"device_id\": \"device_001\",\n \"device_name\": \"My Computer\",\n \"bind_time\": \"2024-03-04T12:00:00Z\",\n \"last_active\": \"2024-03-04T13:00:00Z\"\n }\n ]\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|--------|------|------|\n| device_id | string | Device ID |\n| device_name | string | Device name |\n| bind_time | string | Bind time |\n| last_active | string | Last active time |", + SummaryEn: "Get bound device list instructions", + }, + "api-unbind-device": { + TitleEn: "Unbind Device", + ContentEn: "# Unbind Device\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/unbind-device\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| device_id | string | Yes | Device ID |\n\n### Request Example\n```json\n{\n \"device_id\": \"device_001\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Unbind successful\"\n}\n```\n\n### Error Response\n```json\n{\n \"code\": 400,\n \"message\": \"Device not found or not bound\"\n}\n```", + SummaryEn: "Unbind device instructions", + }, + "api-unbind-device-with-auth": { + TitleEn: "Unbind Device with Authentication", + ContentEn: "# Unbind Device with Authentication\n\nWhen users cannot login due to device limit, they can use this endpoint to unbind a device with username and password.\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/unbind-device-with-auth\n```\n\n### Note\nThis endpoint does not require Authorization header, uses username and password for authentication.\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| username | string | Yes | Username |\n| password | string | Yes | Password |\n| device_id | string | Yes | Device ID to unbind |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"password\": \"password123\",\n \"device_id\": \"device_001\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"message\": \"Unbind successful\"\n }\n}\n```", + SummaryEn: "Unbind device with authentication instructions", + }, + "api-reset-password": { + TitleEn: "Reset Password (Email Verification)", + ContentEn: "# Reset Password (Email Verification)\n\nWhen users forget their password, they can reset it via email verification code.\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/reset-password\n```\n\n### Note\nThis endpoint does not require Authorization header, must call send email verification code endpoint first.\n\n### Prerequisites\n- Application has password reset enabled\n- User's package supports password reset\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| email | string | Yes | Email address |\n| code | string | Yes | Email verification code |\n| password | string | Yes | New password (at least 6 characters) |\n\n### Request Example\n```json\n{\n \"email\": \"user@example.com\",\n \"code\": \"123456\",\n \"password\": \"newpassword123\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"message\": \"Password reset successful\"\n }\n}\n```", + SummaryEn: "Reset password via email verification code", + }, + "api-change-password": { + TitleEn: "Change Password", + ContentEn: "# Change Password\n\nChange password by verifying username and original password.\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/change-password\n```\n\n### Note\nThis endpoint does not require Authorization header, uses username and original password for authentication.\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| username | string | Yes | Username |\n| old_password | string | Yes | Original password |\n| new_password | string | Yes | New password (at least 6 characters) |\n\n### Request Example\n```json\n{\n \"username\": \"user123\",\n \"old_password\": \"oldpassword123\",\n \"new_password\": \"newpassword123\"\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"message\": \"Password changed successfully\"\n }\n}\n```", + SummaryEn: "Change password", + }, + "api-device-count": { + TitleEn: "Get Device Count", + ContentEn: "# Get Device Count\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/device-count\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"count\": 3,\n \"max_devices\": 5\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|--------|------|------|\n| count | number | Current bound device count |\n| max_devices | number | Maximum allowed devices |", + SummaryEn: "Get device count instructions", + }, + "api-app-info": { + TitleEn: "Get Application Info", + ContentEn: "# Get Application Info\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/info\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"app_id\": 1,\n \"name\": \"My App\",\n \"description\": \"Game verification app\",\n \"status\": \"active\"\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|--------|------|------|\n| app_id | number | Application ID |\n| name | string | Application name |\n| description | string | Application description |\n| status | string | Application status |", + SummaryEn: "Get application info instructions", + }, + "api-account": { + TitleEn: "Get User Account Info", + ContentEn: "# Get User Account Info\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/account\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"user_id\": 123,\n \"username\": \"user123\",\n \"expire_time\": \"2024-04-04T00:00:00Z\",\n \"remaining_days\": 30\n }\n}\n```\n\n### Response Fields\n| Field | Type | Description |\n|--------|------|------|\n| user_id | number | User ID |\n| username | string | Username |\n| expire_time | string | Expiration time |\n| remaining_days | number | Remaining days |", + SummaryEn: "Get user account info instructions", + }, + "api-constants": { + TitleEn: "Get Application Constants", + ContentEn: "# Get Application Constants\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/constants\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"Constant Name\",\n \"key\": \"CONSTANT_KEY\",\n \"value\": \"Constant Value\",\n \"var_type\": \"string\",\n \"description\": \"Constant description\"\n }\n ]\n}\n```", + SummaryEn: "Get application constants instructions", + }, + "api-constant": { + TitleEn: "Get Specific Constant", + ContentEn: "# Get Specific Constant\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/constants/:key\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Path Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| key | string | Yes | Constant key |\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"id\": 1,\n \"name\": \"Constant Name\",\n \"key\": \"CONSTANT_KEY\",\n \"value\": \"Constant Value\",\n \"var_type\": \"string\",\n \"description\": \"Constant description\"\n }\n}\n```", + SummaryEn: "Get specific constant instructions", + }, + "api-variables": { + TitleEn: "Get Application Variables", + ContentEn: "# Get Application Variables\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/variables\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"Variable Name\",\n \"key\": \"VARIABLE_KEY\",\n \"default_value\": \"Default Value\",\n \"var_type\": \"string\",\n \"scope\": \"user\",\n \"description\": \"Variable description\"\n }\n ]\n}\n```", + SummaryEn: "Get application variables instructions", + }, + "api-variable": { + TitleEn: "Get Specific Variable", + ContentEn: "# Get Specific Variable\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/variables/:key\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Path Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| key | string | Yes | Variable key |\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"id\": 1,\n \"name\": \"Variable Name\",\n \"key\": \"VARIABLE_KEY\",\n \"default_value\": \"Default Value\",\n \"var_type\": \"string\",\n \"scope\": \"user\",\n \"description\": \"Variable description\"\n }\n}\n```", + SummaryEn: "Get specific variable instructions", + }, + "api-update-variables": { + TitleEn: "Update User Variables", + ContentEn: "# Update User Variables\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/variables\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| variables | object | Yes | Variable key-value pairs |\n\n### Request Example\n```json\n{\n \"variables\": {\n \"nickname\": \"New Nickname\",\n \"level\": 10\n }\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Update successful\"\n}\n```\n\n### Notes\n- Only user-scoped variables can be updated\n- Variable value types must match the defined types", + SummaryEn: "Update user variables instructions", + }, + "api-call-function": { + TitleEn: "Call Cloud Function", + ContentEn: "# Call Cloud Function\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/call-function\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| function_name | string | Yes | Cloud function name |\n| params | object | No | Parameters to pass to the function |\n\n### Request Example\n```json\n{\n \"function_name\": \"calculatePrice\",\n \"params\": {\n \"x\": 10,\n \"y\": 20\n }\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Success\",\n \"data\": {\n \"result\": 30,\n \"execution_time\": 0.001234\n }\n}\n```", + SummaryEn: "Call cloud function instructions", + }, + "api-check-update": { + TitleEn: "Check for Updates", + ContentEn: "# Check for Updates\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/check-update\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| version | string | No | Current client version |\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": {\n \"has_update\": true,\n \"latest_version\": \"2.0.0\",\n \"download_url\": \"https://example.com/download/v2.0.0\",\n \"update_notes\": \"Fixed several bugs\",\n \"update_strategy\": \"optional\",\n \"update_method\": \"manual\"\n }\n}\n```", + SummaryEn: "Check for updates instructions", + }, + "api-announcements": { + TitleEn: "Get Announcements", + ContentEn: "# Get Announcements\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/announcements\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"title\": \"System Maintenance Notice\",\n \"content\": \"System will be under maintenance tonight...\",\n \"created_at\": \"2024-03-04T10:00:00Z\"\n }\n ]\n}\n```", + SummaryEn: "Get announcements instructions", + }, + "api-instances": { + TitleEn: "Get Online Instance List", + ContentEn: "# Get Online Instance List\n\n### Endpoint\n```\nGET /api/v1/app/:appKey/instances\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| user_id | number | Yes | User ID |\n\n### Request Example\n```json\n{\n \"user_id\": 123\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"data\": [\n {\n \"id\": 1,\n \"instance_id\": \"instance_001\",\n \"device_id\": \"device_001\",\n \"device_name\": \"My Computer\",\n \"is_online\": true,\n \"last_heartbeat\": \"2024-03-04T13:00:00Z\",\n \"created_at\": \"2024-03-04T12:00:00Z\"\n }\n ]\n}\n```", + SummaryEn: "Get online instance list instructions", + }, + "api-force-offline": { + TitleEn: "Force Instance Offline", + ContentEn: "# Force Instance Offline\n\n### Endpoint\n```\nPOST /api/v1/app/:appKey/instances/:instance_id/offline\n```\n\n### Request Header\n```\nAuthorization: Bearer {token}\n```\n\n### Path Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| instance_id | string | Yes | Instance identifier |\n\n### Request Parameters\n| Parameter | Type | Required | Description |\n|--------|------|------|------|\n| user_id | number | Yes | User ID |\n\n### Request Example\n```json\n{\n \"user_id\": 123\n}\n```\n\n### Response Example\n```json\n{\n \"code\": 200,\n \"message\": \"Forced offline successfully\"\n}\n```", + SummaryEn: "Force instance offline instructions", + }, + "example-software": { + TitleEn: "Software Verification Example", + ContentEn: "# Software Verification Example\n\n## Complete Verification Flow\n\n### 1. User Input Username and Password\n\n```javascript\nconst username = document.getElementById('username').value;\nconst password = document.getElementById('password').value;\n```\n\n### 2. Get Device ID\n\n```javascript\nfunction getDeviceId() {\n let deviceId = localStorage.getItem('device_id');\n if (!deviceId) {\n deviceId = 'device_' + Math.random().toString(36).substr(2, 9);\n localStorage.setItem('device_id', deviceId);\n }\n return deviceId;\n}\n\nconst deviceId = getDeviceId();\n```\n\n### 3. Call Login API\n\n```javascript\nconst response = await fetch(`/api/v1/app/${appKey}/login`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n username,\n password,\n device_id: deviceId\n })\n});\n\nconst result = await response.json();\n\nif (result.code !== 200) {\n alert('Login failed: ' + result.message);\n return;\n}\n```\n\n### 4. Save Token\n\n```javascript\nlocalStorage.setItem('token', result.data.token);\nlocalStorage.setItem('expire_time', result.data.expire_time);\n```\n\n### 5. Start Heartbeat\n\n```javascript\nsetInterval(async () => {\n await fetch(`/api/v1/app/${appKey}/heartbeat`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${localStorage.getItem('token')}`\n },\n body: JSON.stringify({\n device_id: deviceId\n })\n });\n}, 30000);\n```\n\n### 6. Enter Main Program\n\n```javascript\nstartApplication();\n```", + SummaryEn: "Complete software login verification example", + }, + "example-game": { + TitleEn: "Game Verification Example", + ContentEn: "# Game Verification Example\n\n## Unity Integration\n\n### 1. Call Login API\n\n```csharp\nusing UnityEngine;\nusing UnityEngine.Networking;\n\npublic class AuthManager : MonoBehaviour {\n private string appKey = \"your_app_key\";\n private string token;\n private string deviceId;\n \n void Start() {\n deviceId = SystemInfo.deviceUniqueIdentifier;\n }\n \n public async void Login(string username, string password) {\n string url = $\"https://api.example.com/api/v1/app/{appKey}/login\";\n \n string jsonData = $\"{{\\\"username\\\": \\\"{username}\\\", \\\"password\\\": \\\"{password}\\\", \\\"device_id\\\": \\\"{deviceId}\\\"}}\";\n \n using (UnityWebRequest request = UnityWebRequest.Post(url, jsonData)) {\n request.SetRequestHeader(\"Content-Type\", \"application/json\");\n \n yield return request.SendWebRequest();\n \n if (request.result == UnityWebRequest.Result.Success) {\n string response = request.downloadHandler.text;\n var result = JsonUtility.FromJson(response);\n \n if (result.code == 200) {\n token = result.data.token;\n PlayerPrefs.SetString(\"token\", token);\n \n // Start heartbeat\n StartCoroutine(HeartbeatCoroutine());\n \n // Enter game\n LoadMainScene();\n } else {\n Debug.LogError(\"Login failed: \" + result.message);\n }\n } else {\n Debug.LogError(\"Request failed: \" + request.error);\n }\n }\n }\n}\n```", + SummaryEn: "Unity game integration example", + }, + "faq-api-key": { + TitleEn: "How to Get API Key?", + ContentEn: "# How to Get API Key?\n\n## Steps\n\n1. Login to developer dashboard\n2. Go to \"Application Management\" page\n3. Create new application or select existing one\n4. In application details page you can see:\n - **AppID**: Application unique identifier\n - **AppKey**: Application key\n - **SecretKey**: Encryption key\n\n## Notes\n\n- AppKey is only shown once when created, please save it in time\n- Click \"Reset Key\" button to reset AppKey if needed\n- Old key becomes invalid immediately after reset\n- SecretKey is for server-side response verification, do not use it on client side", + SummaryEn: "Detailed steps to get API key", + }, + "faq-card-fail": { + TitleEn: "What to Do if Card Key Verification Fails?", + ContentEn: "# What to Do if Card Key Verification Fails?\n\n## Common Reasons\n\n1. **Card Key Expired**\n - Check the validity period of the card key\n - Expired card keys cannot be used\n\n2. **Card Key Already Used**\n - Single-use card keys can only be verified once\n - Used card keys cannot be verified again\n\n3. **Device Binding Error**\n - Check if device ID is correct\n - Confirm if application has device binding enabled\n\n4. **Application Configuration Error**\n - Check if AppID and AppKey are correct\n - Confirm if application status is normal\n\n## Solutions\n\n1. Contact application customer service\n2. Get a new card key\n3. Check device network connection\n4. View application logs for detailed error information", + SummaryEn: "Common reasons and solutions for card key verification failure", + }, + "faq-agent": { + TitleEn: "How to Implement Agent Authorization?", + ContentEn: "# How to Implement Agent Authorization?\n\n## What is Agent Authorization?\n\nAgent authorization allows developers to authorize their applications to other developers, who can then generate card keys and sell them.\n\n## Authorization Process\n\n1. **Apply for Authorization**\n - Authorized party submits application to authorizer\n - Enter application ID\n - Wait for authorizer approval\n\n2. **Approve Authorization**\n - Authorizer views application\n - Set card key type permissions after approval\n - Set agent commission and discount\n\n3. **Generate Card Keys**\n - Authorized party selects authorized application\n - Select card key types with permission\n - Generate and sell card keys\n\n4. **Settle Commission**\n - Sales revenue is settled proportionally\n - Authorized party receives commission income\n\n## Permission Management\n\n- Authorizer can modify card key type permissions at any time\n- Can pause or terminate authorization\n- Can view agent's sales data", + SummaryEn: "Implementation process and permission management for agent authorization", + }, + } + + var categories []model.DocCategory + if err := db.Find(&categories).Error; err != nil { + log.Fatal("Failed to get categories:", err) + } + + for i := range categories { + translation, ok := docTranslations[categories[i].Slug] + if ok { + categories[i].NameEn = translation.TitleEn + categories[i].DescriptionEn = translation.ContentEn + db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"name_en", "description_en"}), + }).Create(&categories[i]) + fmt.Printf("Updated category: %s -> %s\n", categories[i].Name, categories[i].NameEn) + } + } + + var docs []model.Doc + if err := db.Find(&docs).Error; err != nil { + log.Fatal("Failed to get docs:", err) + } + + for i := range docs { + translation, ok := docTranslations[docs[i].Slug] + if ok { + docs[i].TitleEn = translation.TitleEn + docs[i].ContentEn = translation.ContentEn + docs[i].SummaryEn = translation.SummaryEn + db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"title_en", "content_en", "summary_en"}), + }).Create(&docs[i]) + fmt.Printf("Updated doc: %s -> %s\n", docs[i].Title, docs[i].TitleEn) + } + } + + fmt.Println("\nDocument translations updated successfully!") +} diff --git a/backend/scripts/update_dynamic_code_doc.go b/backend/scripts/update_dynamic_code_doc.go new file mode 100644 index 0000000..a7a7545 --- /dev/null +++ b/backend/scripts/update_dynamic_code_doc.go @@ -0,0 +1,302 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + var doc model.Doc + if err := database.DB.Where("slug = ?", "api-get-dynamic-code").First(&doc).Error; err != nil { + log.Printf("未找到文档: %v", err) + return + } + + newContent := `# 获取动态代码接口 + +**注意**:动态代码接口需要用户登录认证,需要在请求头中携带JWT token: +Authorization: Bearer {token} + +### 接口地址 +GET /api/v1/app/:appKey/dynamic-code + +### 请求头 +Authorization: Bearer {token} + +### 路径参数 +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| appKey | string | 是 | 应用密钥 | + +### 响应示例 +{ + "code": 200, + "message": "操作成功", + "data": [ + { + "id": 1, + "name": "加法函数", + "key": "add", + "description": "实现数字加法运算", + "status": "active", + "created_at": "2024-01-01T00:00:00Z" + } + ] +} + +### 字段说明 +- id: 动态代码ID +- name: 动态代码名称 +- key: 动态代码唯一标识 +- description: 描述 +- status: 状态(active、inactive) + +--- + +## 执行动态代码接口 + +### 接口地址 +POST /api/v1/app/:appKey/dynamic-code/:key/execute + +### 请求头 +Authorization: Bearer {token} +Content-Type: application/json + +### 路径参数 +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| appKey | string | 是 | 应用密钥 | +| key | string | 是 | 动态代码标识 | + +### 请求体 +{ + "params": { + "param1": "value1", + "param2": "value2" + } +} + +### 参数说明 +- params: 参数对象,键值对形式传递,键名即为JavaScript变量名 + +### 响应示例 +{ + "code": 200, + "message": "操作成功", + "data": { + "result": "执行结果", + "execution_time": 1 + } +} + +### 字段说明 +- result: 代码执行结果 +- execution_time: 执行时间(毫秒) + +--- + +## JavaScript代码编写规范 + +### 1. 参数使用 + +动态代码可以直接使用请求参数中传递的变量,无需额外定义: + +a + b + +请求参数:{"params": {"a": 10, "b": 20}} +返回结果:30 + +### 2. 函数定义与调用 + +可以定义函数并在最后一行调用: + +function calculate(x, y) { + return Math.sqrt(x * x + y * y); +} + +calculate(x, y) + +请求参数:{"params": {"x": 3, "y": 4}} +返回结果:5 + +### 3. 复杂逻辑 + +支持完整的JavaScript语法,包括控制流、循环、对象操作等: + +function processScore(score) { + if (score >= 90) { + return "优秀"; + } else if (score >= 60) { + return "及格"; + } else { + return "不及格"; + } +} + +processScore(score) + +### 4. 返回值规则 + +- **最后一个表达式的值即为返回值** +- **不要在全局作用域使用return语句** +- 可以返回任意类型:数字、字符串、布尔值、对象、数组等 + +### 5. 常见示例 + +#### 简单计算 +price * quantity + +#### 字符串拼接 +name + "的年龄是" + age + "岁" + +#### 条件判断 +score >= 60 ? "及格" : "不及格" + +#### 对象返回 +{ + sum: a + b, + product: a * b, + difference: a - b +} + +#### 数组操作 +function sumArray(numbers) { + let total = 0; + for (let i = 0; i < numbers.length; i++) { + total += numbers[i]; + } + return total; +} + +sumArray([1, 2, 3, 4, 5]) + +### 6. 注意事项 + +1. **全局作用域不能使用return**:return只能在函数内部使用 +2. **参数变量直接使用**:无需重新定义 +3. **最后一行是返回值**:确保最后一行是需要返回的表达式 +4. **支持ES6语法**:可以使用let、const、箭头函数等 +5. **内置对象可用**:Math、Date、JSON等JavaScript内置对象都可以使用 + +--- + +## 错误处理 + +### 常见错误 + +#### 1. 语法错误 +{ + "code": 400, + "message": "代码执行错误: SyntaxError: Unexpected token" +} + +#### 2. 运行时错误 +{ + "code": 400, + "message": "代码执行错误: ReferenceError: x is not defined" +} + +#### 3. 全局return错误 +{ + "code": 400, + "message": "代码执行错误: SyntaxError: Illegal return statement" +} + +--- + +## 完整示例 + +### 示例1:计算折扣价格 + +**请求**: +POST /api/v1/app/your-app-key/dynamic-code/calculate-discount/execute +{ + "params": { + "price": 100, + "discount": 20, + "tax": 10 + } +} + +**动态代码**: +function calculateFinalPrice(price, discount, tax) { + const subtotal = price; + const discountAmount = subtotal * (discount / 100); + const afterDiscount = subtotal - discountAmount; + const taxAmount = afterDiscount * (tax / 100); + return afterDiscount + taxAmount; +} + +calculateFinalPrice(price, discount, tax) + +**响应**: +{ + "code": 200, + "message": "操作成功", + "data": { + "result": 88, + "execution_time": 1 + } +} + +### 示例2:用户信息格式化 + +**请求**: +POST /api/v1/app/your-app-key/dynamic-code/format-user/execute +{ + "params": { + "name": "张三", + "age": 25, + "city": "北京" + } +} + +**动态代码**: +function formatUserInfo(name, age, city) { + const greeting = "你好"; + return greeting + ",我是" + name + ",今年" + age + "岁,来自" + city; +} + +formatUserInfo(name, age, city) + +**响应**: +{ + "code": 200, + "message": "操作成功", + "data": { + "result": "你好,我是张三,今年25岁,来自北京", + "execution_time": 1 + } +} + +--- + +## 性能建议 + +1. **避免死循环**:确保循环有明确的退出条件 +2. **合理使用缓存**:对于重复计算,可以考虑缓存结果 +3. **控制代码复杂度**:过复杂的代码可能影响执行效率 +4. **使用内置函数**:优先使用JavaScript内置函数,性能更好 + +--- + +## 安全建议 + +1. **不要执行危险操作**:避免访问文件系统、网络请求等 +2. **参数验证**:在代码中对参数进行必要的验证 +3. **避免敏感信息**:不要在代码中硬编码密钥、密码等敏感信息 +4. **限制执行时间**:设置合理的超时时间,防止长时间运行 +` + + doc.Content = newContent + + if err := database.DB.Save(&doc).Error; err != nil { + log.Printf("更新文档失败: %v", err) + return + } + + log.Println("动态代码文档更新成功") +} diff --git a/backend/scripts/update_dynamic_code_docs.go b/backend/scripts/update_dynamic_code_docs.go new file mode 100644 index 0000000..f1d7a1d --- /dev/null +++ b/backend/scripts/update_dynamic_code_docs.go @@ -0,0 +1,1000 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + log.Println("=== 查看当前所有文档 ===") + + var docs []model.Doc + if err := database.DB.Find(&docs).Error; err != nil { + log.Printf("查询文档失败: %v", err) + return + } + + for _, doc := range docs { + log.Printf("ID: %d, Slug: %s, Title: %s", doc.ID, doc.Slug, doc.Title) + } + + log.Println("\n=== 删除平台API文档 ===") + + platformAPISlugs := []string{ + "api-auth", + "api-verify", + "api-card", + "api-user", + "api-constant", + "api-constants", + "api-variable", + "api-variables", + "api-function", + "api-functions", + "api-check-update", + "api-announcements", + "api-get-dynamic-code", + "api-update-dynamic-code", + } + + for _, slug := range platformAPISlugs { + if err := database.DB.Where("slug = ?", slug).Delete(&model.Doc{}).Error; err != nil { + log.Printf("删除文档 %s 失败: %v", slug, err) + } else { + log.Printf("已删除文档: %s", slug) + } + } + + log.Println("\n=== 创建动态函数文档 ===") + + var apiCat model.DocCategory + if err := database.DB.Where("slug = ?", "api").First(&apiCat).Error; err != nil { + log.Printf("未找到API分类: %v", err) + return + } + + dynamicCodeContent := "# 动态函数接口\n\n" + + "动态函数允许开发者为应用创建自定义的业务逻辑,通过JavaScript代码实现灵活的数据处理和业务规则。\n\n" + + "---\n\n" + + "## 概述\n\n" + + "动态函数是基于JavaScript的自定义代码,可以在应用运行时动态执行。开发者可以通过动态函数实现:\n\n" + + "- 动态定价计算\n" + + "- 权限验证逻辑\n" + + "- 业务规则判断\n" + + "- 用户数据处理\n" + + "- 自动化操作指令\n\n" + + "---\n\n" + + "## 获取动态函数列表\n\n" + + "### 接口地址\n\n" + + "`GET /api/v1/app/:appKey/dynamic-code`\n\n" + + "### 请求头\n\n" + + "```\n" + + "Authorization: Bearer {token}\n" + + "```\n\n" + + "### 路径参数\n\n" + + "| 参数名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| appKey | string | 是 | 应用密钥 |\n\n" + + "### 响应示例\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": [\n" + + " {\n" + + " \"id\": 1,\n" + + " \"name\": \"加法函数\",\n" + + " \"key\": \"add\",\n" + + " \"description\": \"实现数字加法运算\",\n" + + " \"status\": \"active\",\n" + + " \"created_at\": \"2024-01-01T00:00:00Z\"\n" + + " }\n" + + " ]\n" + + "}\n" + + "```\n\n" + + "### 字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| id | number | 动态函数ID |\n" + + "| name | string | 动态函数名称 |\n" + + "| key | string | 动态函数唯一标识 |\n" + + "| description | string | 描述 |\n" + + "| status | string | 状态(active、inactive) |\n" + + "| created_at | string | 创建时间 |\n\n" + + "---\n\n" + + "## 执行动态函数\n\n" + + "### 接口地址\n\n" + + "`POST /api/v1/app/:appKey/dynamic-code/:key/execute`\n\n" + + "### 请求头\n\n" + + "```\n" + + "Authorization: Bearer {token}\n" + + "Content-Type: application/json\n" + + "```\n\n" + + "### 路径参数\n\n" + + "| 参数名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| appKey | string | 是 | 应用密钥 |\n" + + "| key | string | 是 | 动态函数标识 |\n\n" + + "### 请求体\n\n" + + "```json\n" + + "{\n" + + " \"params\": {\n" + + " \"param1\": \"value1\",\n" + + " \"param2\": \"value2\"\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "### 参数说明\n\n" + + "| 参数名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| params | object | 否 | 自定义参数对象,键值对形式传递 |\n" + + "| user_id | number | 否 | 用户ID,提供后会注入当前用户信息 |\n\n" + + "### 响应示例\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": \"执行结果\",\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "### 字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| result | any | 代码执行结果,可以是任意类型 |\n" + + "| execution_time | number | 执行时间(毫秒) |\n\n" + + "---\n\n" + + "## 注入的数据\n\n" + + "### 1. 应用配置数据 (app)\n\n" + + "动态函数可以访问应用的完整配置信息:\n\n" + + "```javascript\n" + + "app.name\n" + + "app.description\n" + + "app.app_key\n" + + "app.status\n" + + "app.billing_type\n" + + "app.point_price\n" + + "app.points_per_cycle\n" + + "app.point_deduction_cycle\n" + + "app.enable_trial\n" + + "app.trial_type\n" + + "app.trial_duration\n" + + "app.trial_points\n" + + "app.enable_free_period\n" + + "app.free_period_type\n" + + "app.free_period_start\n" + + "app.free_period_end\n" + + "app.free_period_weekdays\n" + + "app.free_period_start_time\n" + + "app.free_period_end_time\n" + + "app.max_devices\n" + + "app.bind_type\n" + + "app.multi_open\n" + + "app.multi_open_mode\n" + + "app.max_instances\n" + + "app.login_policy\n" + + "app.max_attempts\n" + + "app.lock_duration\n" + + "app.heartbeat_interval\n" + + "app.heartbeat_timeout\n" + + "app.change_limit\n" + + "app.change_interval\n" + + "app.change_exceed_action\n" + + "app.change_deduct_amount\n" + + "```\n\n" + + "#### 应用配置字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| name | string | 应用名称 |\n" + + "| description | string | 应用描述 |\n" + + "| app_key | string | 应用密钥 |\n" + + "| status | string | 应用状态 |\n" + + "| billing_type | string | 计费方式 |\n" + + "| point_price | number | 点数价格 |\n" + + "| points_per_cycle | number | 每周期扣点数 |\n" + + "| point_deduction_cycle | string | 扣点周期 |\n" + + "| enable_trial | boolean | 是否启用试用 |\n" + + "| trial_type | string | 试用类型 |\n" + + "| trial_duration | number | 试用时长 |\n" + + "| trial_points | number | 试用点数 |\n" + + "| enable_free_period | boolean | 是否启用免费时段 |\n" + + "| free_period_type | string | 免费时段类型 |\n" + + "| free_period_start | string | 免费时段开始 |\n" + + "| free_period_end | string | 免费时段结束 |\n" + + "| free_period_weekdays | string | 免费时段星期 |\n" + + "| free_period_start_time | string | 免费时段开始时间 |\n" + + "| free_period_end_time | string | 免费时段结束时间 |\n" + + "| max_devices | number | 最大设备数 |\n" + + "| bind_type | string | 绑定方式 |\n" + + "| multi_open | boolean | 是否允许多开 |\n" + + "| multi_open_mode | string | 多开模式 |\n" + + "| max_instances | number | 最大实例数 |\n" + + "| login_policy | string | 登录策略 |\n" + + "| max_attempts | number | 最大尝试次数 |\n" + + "| lock_duration | number | 锁定时长 |\n" + + "| heartbeat_interval | number | 心跳间隔 |\n" + + "| heartbeat_timeout | number | 心跳超时 |\n" + + "| change_limit | number | 变更限制 |\n" + + "| change_interval | number | 变更间隔 |\n" + + "| change_exceed_action | string | 超限操作 |\n" + + "| change_deduct_amount | number | 超限扣点数 |\n\n" + + "### 2. 用户信息 (user)\n\n" + + "如果请求中提供了 `user_id`,会自动注入当前用户的信息:\n\n" + + "```javascript\n" + + "user.id\n" + + "user.username\n" + + "user.email\n" + + "user.status\n" + + "user.points\n" + + "user.is_trial_user\n" + + "user.expiry_at\n" + + "user.last_login_at\n" + + "```\n\n" + + "#### 用户信息字段说明\n\n" + + "| 字段名 | 类型 | 说明 |\n" + + "|--------|------|------|\n" + + "| id | number | 用户ID |\n" + + "| username | string | 用户名 |\n" + + "| email | string | 邮箱 |\n" + + "| status | string | 用户状态 |\n" + + "| points | number | 积分 |\n" + + "| is_trial_user | boolean | 是否试用用户 |\n" + + "| expiry_at | string | 到期时间 |\n" + + "| last_login_at | string | 最后登录时间 |\n\n" + + "### 3. 自定义参数\n\n" + + "通过请求的 `params` 参数传递的自定义数据,可以直接在代码中使用:\n\n" + + "```javascript\n" + + "// 请求参数: {\"params\": {\"a\": 10, \"b\": 20, \"name\": \"张三\"}}\n\n" + + "a + b\n" + + "name.toUpperCase()\n" + + "```\n\n" + + "---\n\n" + + "## JavaScript代码编写规范\n\n" + + "### 1. 参数使用\n\n" + + "动态函数可以直接使用请求参数中传递的变量,无需额外定义:\n\n" + + "```javascript\n" + + "a + b\n" + + "```\n\n" + + "**请求参数**:`{\"params\": {\"a\": 10, \"b\": 20}}`\n\n" + + "**返回结果**:`30`\n\n" + + "---\n\n" + + "### 2. 函数定义与调用\n\n" + + "可以定义函数并在最后一行调用:\n\n" + + "```javascript\n" + + "function calculate(x, y) {\n" + + " return Math.sqrt(x * x + y * y);\n" + + "}\n\n" + + "calculate(x, y)\n" + + "```\n\n" + + "**请求参数**:`{\"params\": {\"x\": 3, \"y\": 4}}`\n\n" + + "**返回结果**:`5`\n\n" + + "---\n\n" + + "### 3. 复杂逻辑\n\n" + + "支持完整的JavaScript语法,包括控制流、循环、对象操作等:\n\n" + + "```javascript\n" + + "function processScore(score) {\n" + + " if (score >= 90) {\n" + + " return \"优秀\";\n" + + " } else if (score >= 60) {\n" + + " return \"及格\";\n" + + " } else {\n" + + " return \"不及格\";\n" + + " }\n" + + "}\n\n" + + "processScore(score)\n" + + "```\n\n" + + "---\n\n" + + "### 4. 返回值规则\n\n" + + "- **最后一个表达式的值即为返回值**\n" + + "- **不要在全局作用域使用return语句**\n" + + "- 可以返回任意类型:数字、字符串、布尔值、对象、数组等\n\n" + + "---\n\n" + + "### 5. 使用注入数据\n\n" + + "#### 使用应用配置\n\n" + + "```javascript\n" + + "function getAppInfo() {\n" + + " return {\n" + + " name: app.name,\n" + + " status: app.status,\n" + + " billing_type: app.billing_type,\n" + + " max_devices: app.max_devices,\n" + + " enable_trial: app.enable_trial\n" + + " };\n" + + "}\n\n" + + "getAppInfo()\n" + + "```\n\n" + + "#### 使用用户信息\n\n" + + "```javascript\n" + + "function getUserInfo() {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " return {\n" + + " username: user.username,\n" + + " points: user.points,\n" + + " is_trial_user: user.is_trial_user,\n" + + " status: user.status\n" + + " };\n" + + "}\n\n" + + "getUserInfo()\n" + + "```\n\n" + + "#### 组合使用\n\n" + + "```javascript\n" + + "function checkUserPermission() {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { error: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " if (app.max_devices > 0) {\n" + + " return {\n" + + " allowed: true,\n" + + " max_devices: app.max_devices,\n" + + " user_status: user.status\n" + + " };\n" + + " }\n" + + " \n" + + " return { allowed: false, reason: \"应用未设置设备限制\" };\n" + + "}\n\n" + + "checkUserPermission()\n" + + "```\n\n" + + "---\n\n" + + "### 6. 常见示例\n\n" + + "#### 简单计算\n\n" + + "```javascript\n" + + "price * quantity\n" + + "```\n\n" + + "#### 字符串拼接\n\n" + + "```javascript\n" + + "name + \"的年龄是\" + age + \"岁\"\n" + + "```\n\n" + + "#### 条件判断\n\n" + + "```javascript\n" + + "score >= 60 ? \"及格\" : \"不及格\"\n" + + "```\n\n" + + "#### 对象返回\n\n" + + "```javascript\n" + + "{\n" + + " sum: a + b,\n" + + " product: a * b,\n" + + " difference: a - b\n" + + "}\n" + + "```\n\n" + + "#### 数组操作\n\n" + + "```javascript\n" + + "function sumArray(numbers) {\n" + + " let total = 0;\n" + + " for (let i = 0; i < numbers.length; i++) {\n" + + " total += numbers[i];\n" + + " }\n" + + " return total;\n" + + "}\n\n" + + "sumArray([1, 2, 3, 4, 5])\n" + + "```\n\n" + + "---\n\n" + + "### 7. 注意事项\n\n" + + "1. **全局作用域不能使用return**:return只能在函数内部使用\n" + + "2. **参数变量直接使用**:无需重新定义\n" + + "3. **最后一行是返回值**:确保最后一行是需要返回的表达式\n" + + "4. **支持ES6语法**:可以使用let、const、箭头函数等\n" + + "5. **内置对象可用**:Math、Date、JSON等JavaScript内置对象都可以使用\n\n" + + "---\n\n" + + "## 操作指令\n\n" + + "动态函数可以返回操作指令,后端会安全地执行这些指令。\n\n" + + "### 1. 加时指令\n\n" + + "为用户延长使用时间。\n\n" + + "#### 指令格式\n\n" + + "```javascript\n" + + "{\n" + + " action: \"extend_time\",\n" + + " user_id: 123,\n" + + " days: 7,\n" + + " reason: \"用户申请延期\"\n" + + "}\n" + + "```\n\n" + + "#### 字段说明\n\n" + + "| 字段名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| action | string | 是 | 固定值:\"extend_time\" |\n" + + "| user_id | number | 是 | 用户ID |\n" + + "| days | number | 是 | 延长天数 |\n" + + "| reason | string | 否 | 延长原因 |\n\n" + + "#### 示例\n\n" + + "```javascript\n" + + "function autoExtendTime(userId, userLevel) {\n" + + " let days = 0;\n" + + " \n" + + " if (userLevel === \"vip\") {\n" + + " days = 30;\n" + + " } else if (userLevel === \"premium\") {\n" + + " days = 15;\n" + + " } else if (userLevel === \"normal\") {\n" + + " days = 7;\n" + + " }\n" + + " \n" + + " return {\n" + + " action: \"extend_time\",\n" + + " user_id: userId,\n" + + " days: days,\n" + + " reason: \"根据用户等级自动加时\"\n" + + " };\n" + + "}\n\n" + + "autoExtendTime(123, \"vip\")\n" + + "```\n\n" + + "---\n\n" + + "### 2. 扣点指令\n\n" + + "扣除用户积分。\n\n" + + "#### 指令格式\n\n" + + "```javascript\n" + + "{\n" + + " action: \"deduct_points\",\n" + + " user_id: 123,\n" + + " points: 100,\n" + + " reason: \"使用功能扣点\"\n" + + "}\n" + + "```\n\n" + + "#### 字段说明\n\n" + + "| 字段名 | 类型 | 必填 | 说明 |\n" + + "|--------|------|------|------|\n" + + "| action | string | 是 | 固定值:\"deduct_points\" |\n" + + "| user_id | number | 是 | 用户ID |\n" + + "| points | number | 是 | 扣除点数 |\n" + + "| reason | string | 否 | 扣点原因 |\n\n" + + "#### 示例\n\n" + + "```javascript\n" + + "function autoDeductPoints(userId, usage) {\n" + + " if (!user || user.points < usage) {\n" + + " return { error: \"积分不足\" };\n" + + " }\n" + + " \n" + + " return {\n" + + " action: \"deduct_points\",\n" + + " user_id: userId,\n" + + " points: usage,\n" + + " reason: \"功能使用扣点\"\n" + + " };\n" + + "}\n\n" + + "autoDeductPoints(123, 50)\n" + + "```\n\n" + + "---\n\n" + + "## 完整示例\n\n" + + "### 示例1:动态定价\n\n" + + "#### 场景\n" + + "根据用户等级和应用设置,动态计算价格。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/dynamic-code/calculate-price/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"basePrice\": 100,\n" + + " \"userType\": \"new\"\n" + + " }\n" + + "}\n" + + "```\n\n" + + "#### 动态函数代码\n\n" + + "```javascript\n" + + "function calculatePrice(basePrice, userType) {\n" + + " let finalPrice = basePrice;\n" + + " \n" + + " if (app.enable_trial && userType === \"new\") {\n" + + " finalPrice = finalPrice * 0.5;\n" + + " }\n" + + " \n" + + " if (userType === \"vip\") {\n" + + " finalPrice = finalPrice * 0.8;\n" + + " }\n" + + " \n" + + " return {\n" + + " original_price: basePrice,\n" + + " user_type: userType,\n" + + " enable_trial: app.enable_trial,\n" + + " final_price: finalPrice,\n" + + " discount: basePrice - finalPrice\n" + + " };\n" + + "}\n\n" + + "calculatePrice(basePrice, userType)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"original_price\": 100,\n" + + " \"user_type\": \"new\",\n" + + " \"enable_trial\": true,\n" + + " \"final_price\": 50,\n" + + " \"discount\": 50\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例2:权限验证\n\n" + + "#### 场景\n" + + "检查用户设备数量是否超过限制。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/dynamic-code/check-permission/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"deviceCount\": 2\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "#### 动态函数代码\n\n" + + "```javascript\n" + + "function checkPermission(deviceCount) {\n" + + " if (!user) {\n" + + " return { allowed: false, reason: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { allowed: false, reason: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " if (deviceCount > app.max_devices) {\n" + + " return { \n" + + " allowed: false, \n" + + " reason: \"设备数量超过限制\",\n" + + " current: deviceCount,\n" + + " max: app.max_devices\n" + + " };\n" + + " }\n" + + " \n" + + " return { \n" + + " allowed: true, \n" + + " reason: \"权限验证通过\",\n" + + " user: user.username\n" + + " };\n" + + "}\n\n" + + "checkPermission(deviceCount)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"allowed\": true,\n" + + " \"reason\": \"权限验证通过\",\n" + + " \"user\": \"testuser\"\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例3:每日登录自动加时\n\n" + + "#### 场景\n" + + "试用用户每日登录自动延长1天。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/dynamic-code/daily-login/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"userId\": 123\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "#### 动态函数代码\n\n" + + "```javascript\n" + + "function processDailyLogin(userId) {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { error: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " if (user.is_trial_user) {\n" + + " return {\n" + + " action: \"extend_time\",\n" + + " user_id: userId,\n" + + " days: 1,\n" + + " reason: \"试用用户每日登录加时\",\n" + + " timestamp: new Date().toISOString()\n" + + " };\n" + + " }\n" + + " \n" + + " return {\n" + + " message: \"登录成功\",\n" + + " user_points: user.points,\n" + + " app_name: app.name\n" + + " };\n" + + "}\n\n" + + "processDailyLogin(userId)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"action\": \"extend_time\",\n" + + " \"user_id\": 123,\n" + + " \"days\": 1,\n" + + " \"reason\": \"试用用户每日登录加时\",\n" + + " \"timestamp\": \"2026-03-11T00:57:55.123Z\"\n" + + " },\n" + + " \"execution_time\": 2\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例4:功能使用自动扣点\n\n" + + "#### 场景\n" + + "用户使用功能时自动扣除相应积分。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/dynamic-code/use-feature/execute\n" + + "{\n" + + " \"params\": {\n" + + " \"userId\": 123,\n" + + " \"featureType\": \"advanced\"\n" + + " },\n" + + " \"user_id\": 123\n" + + "}\n" + + "```\n\n" + + "#### 动态函数代码\n\n" + + "```javascript\n" + + "function processFeatureUsage(userId, featureType) {\n" + + " if (!user) {\n" + + " return { error: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { error: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " let cost = app.points_per_cycle;\n" + + " \n" + + " if (featureType === \"advanced\") {\n" + + " cost = cost * 2;\n" + + " } else if (featureType === \"premium\") {\n" + + " cost = cost * 5;\n" + + " }\n" + + " \n" + + " if (user.points < cost) {\n" + + " return { \n" + + " error: \"积分不足\",\n" + + " required: cost,\n" + + " current: user.points\n" + + " };\n" + + " }\n" + + " \n" + + " return {\n" + + " action: \"deduct_points\",\n" + + " user_id: userId,\n" + + " points: cost,\n" + + " reason: \"使用功能扣点: \" + featureType,\n" + + " feature_type: featureType\n" + + " };\n" + + "}\n\n" + + "processFeatureUsage(userId, featureType)\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"action\": \"deduct_points\",\n" + + " \"user_id\": 123,\n" + + " \"points\": 2,\n" + + " \"reason\": \"使用功能扣点: advanced\",\n" + + " \"feature_type\": \"advanced\"\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "### 示例5:业务规则获取\n\n" + + "#### 场景\n" + + "获取应用的所有业务规则,用于前端展示。\n\n" + + "#### 请求\n\n" + + "```json\n" + + "POST /api/v1/app/your-app-key/dynamic-code/get-rules/execute\n" + + "{\n" + + " \"params\": {}\n" + + "}\n" + + "```\n\n" + + "#### 动态函数代码\n\n" + + "```javascript\n" + + "function getBusinessRules() {\n" + + " const rules = [];\n" + + " \n" + + " if (app.enable_trial) {\n" + + " rules.push({\n" + + " type: \"trial\",\n" + + " title: \"试用功能\",\n" + + " description: \"支持试用,试用时长:\" + app.trial_duration + \"天\"\n" + + " });\n" + + " }\n" + + " \n" + + " if (app.max_devices > 1) {\n" + + " rules.push({\n" + + " type: \"multi_device\",\n" + + " title: \"多设备支持\",\n" + + " description: \"支持多设备,最多\" + app.max_devices + \"台\"\n" + + " });\n" + + " }\n" + + " \n" + + " if (app.enable_free_period) {\n" + + " rules.push({\n" + + " type: \"free_period\",\n" + + " title: \"免费时段\",\n" + + " description: \"支持免费时段:\" + app.free_period_start + \" - \" + app.free_period_end\n" + + " });\n" + + " }\n" + + " \n" + + " if (app.point_price > 0) {\n" + + " rules.push({\n" + + " type: \"points\",\n" + + " title: \"积分系统\",\n" + + " description: \"点数价格:\" + app.point_price + \",每周期扣点:\" + app.points_per_cycle\n" + + " });\n" + + " }\n" + + " \n" + + " return {\n" + + " app_name: app.name,\n" + + " total_rules: rules.length,\n" + + " rules: rules\n" + + " };\n" + + "}\n\n" + + "getBusinessRules()\n" + + "```\n\n" + + "#### 响应\n\n" + + "```json\n" + + "{\n" + + " \"code\": 200,\n" + + " \"message\": \"操作成功\",\n" + + " \"data\": {\n" + + " \"result\": {\n" + + " \"app_name\": \"我的应用\",\n" + + " \"total_rules\": 4,\n" + + " \"rules\": [\n" + + " {\n" + + " \"type\": \"trial\",\n" + + " \"title\": \"试用功能\",\n" + + " \"description\": \"支持试用,试用时长:7天\"\n" + + " },\n" + + " {\n" + + " \"type\": \"multi_device\",\n" + + " \"title\": \"多设备支持\",\n" + + " \"description\": \"支持多设备,最多3台\"\n" + + " },\n" + + " {\n" + + " \"type\": \"free_period\",\n" + + " \"title\": \"免费时段\",\n" + + " \"description\": \"支持免费时段:20:00 - 08:00\"\n" + + " },\n" + + " {\n" + + " \"type\": \"points\",\n" + + " \"title\": \"积分系统\",\n" + + " \"description\": \"点数价格:0.1,每周期扣点:1\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"execution_time\": 1\n" + + " }\n" + + "}\n" + + "```\n\n" + + "---\n\n" + + "## 错误处理\n\n" + + "### 常见错误\n\n" + + "#### 1. 语法错误\n\n" + + "```json\n" + + "{\n" + + " \"code\": 400,\n" + + " \"message\": \"代码执行错误: SyntaxError: Unexpected token\"\n" + + "}\n" + + "```\n\n" + + "**原因**:JavaScript代码语法错误\n\n" + + "**解决**:检查代码语法,确保括号、引号等匹配正确\n\n" + + "---\n\n" + + "#### 2. 运行时错误\n\n" + + "```json\n" + + "{\n" + + " \"code\": 400,\n" + + " \"message\": \"代码执行错误: ReferenceError: x is not defined\"\n" + + "}\n" + + "```\n\n" + + "**原因**:使用了未定义的变量\n\n" + + "**解决**:检查变量名是否正确,或确保已通过参数传入\n\n" + + "---\n\n" + + "#### 3. 全局return错误\n\n" + + "```json\n" + + "{\n" + + " \"code\": 400,\n" + + " \"message\": \"代码执行错误: SyntaxError: Illegal return statement\"\n" + + "}\n" + + "```\n\n" + + "**原因**:在全局作用域使用了return语句\n\n" + + "**解决**:将return语句放在函数内部,或直接返回表达式\n\n" + + "---\n\n" + + "#### 4. 操作执行失败\n\n" + + "```json\n" + + "{\n" + + " \"code\": 500,\n" + + " \"message\": \"执行加时操作失败: user not found\"\n" + + "}\n" + + "```\n\n" + + "**原因**:操作指令中的用户不存在\n\n" + + "**解决**:检查user_id是否正确,或用户是否属于当前应用\n\n" + + "---\n\n" + + "## 性能建议\n\n" + + "1. **避免死循环**:确保循环有明确的退出条件\n" + + "2. **合理使用缓存**:对于重复计算,可以考虑缓存结果\n" + + "3. **控制代码复杂度**:过复杂的代码可能影响执行效率\n" + + "4. **使用内置函数**:优先使用JavaScript内置函数,性能更好\n" + + "5. **避免频繁操作**:操作指令会修改数据库,避免频繁调用\n\n" + + "---\n\n" + + "## 安全建议\n\n" + + "1. **不要执行危险操作**:避免访问文件系统、网络请求等\n" + + "2. **参数验证**:在代码中对参数进行必要的验证\n" + + "3. **避免敏感信息**:不要在代码中硬编码密钥、密码等敏感信息\n" + + "4. **限制执行时间**:设置合理的超时时间,防止长时间运行\n" + + "5. **操作指令谨慎**:操作指令会修改数据库,确保逻辑正确\n" + + "6. **用户数据保护**:不要在代码中泄露用户敏感信息\n\n" + + "---\n\n" + + "## 最佳实践\n\n" + + "### 1. 代码组织\n\n" + + "将复杂逻辑封装成函数,提高代码可读性:\n\n" + + "```javascript\n" + + "function validateUser() {\n" + + " if (!user) {\n" + + " return { valid: false, reason: \"用户不存在\" };\n" + + " }\n" + + " \n" + + " if (user.status !== \"active\") {\n" + + " return { valid: false, reason: \"用户状态异常\" };\n" + + " }\n" + + " \n" + + " return { valid: true };\n" + + "}\n\n" + + "function calculatePrice(basePrice, discount) {\n" + + " return basePrice * (1 - discount / 100);\n" + + "}\n\n" + + "function mainProcess() {\n" + + " const validation = validateUser();\n" + + " if (!validation.valid) {\n" + + " return validation;\n" + + " }\n" + + " \n" + + " const price = calculatePrice(100, 20);\n" + + " return { success: true, price: price };\n" + + "}\n\n" + + "mainProcess()\n" + + "```\n\n" + + "---\n\n" + + "### 2. 错误处理\n\n" + + "在代码中添加适当的错误处理:\n\n" + + "```javascript\n" + + "function safeCalculate(a, b) {\n" + + " if (typeof a !== \"number\" || typeof b !== \"number\") {\n" + + " return { error: \"参数必须是数字\" };\n" + + " }\n" + + " \n" + + " try {\n" + + " return { result: a + b };\n" + + " } catch (e) {\n" + + " return { error: \"计算失败: \" + e.message };\n" + + " }\n" + + "}\n\n" + + "safeCalculate(a, b)\n" + + "```\n\n" + + "---\n\n" + + "### 3. 使用现代JavaScript\n\n" + + "利用ES6+特性提高代码质量:\n\n" + + "```javascript\n" + + "// 使用箭头函数\n" + + "const calculate = (x, y) => x + y;\n\n" + + "// 使用解构赋值\n" + + "const { name, age } = userInfo;\n\n" + + "// 使用模板字符串\n" + + "const message = `用户${name}的年龄是${age}岁`;\n\n" + + "// 使用数组方法\n" + + "const sum = numbers.reduce((a, b) => a + b, 0);\n\n" + + "// 使用对象简写\n" + + "const result = { name, age, status };\n" + + "```\n\n" + + "---\n\n" + + "### 4. 日志记录\n\n" + + "在返回结果中包含调试信息:\n\n" + + "```javascript\n" + + "function processWithLog(userId) {\n" + + " const startTime = new Date();\n" + + " \n" + + " if (!user) {\n" + + " return { \n" + + " error: \"用户不存在\",\n" + + " timestamp: startTime.toISOString(),\n" + + " debug: { userId }\n" + + " };\n" + + " }\n" + + " \n" + + " const result = {\n" + + " user_id: userId,\n" + + " username: user.username,\n" + + " status: user.status\n" + + " };\n" + + " \n" + + " return {\n" + + " success: true,\n" + + " data: result,\n" + + " timestamp: startTime.toISOString(),\n" + + " execution_time: new Date() - startTime\n" + + " };\n" + + "}\n\n" + + "processWithLog(userId)\n" + + "```\n\n" + + "---\n\n" + + "## 常见问题\n\n" + + "### Q1: 动态函数可以访问数据库吗?\n\n" + + "**A**: 不能直接访问数据库。动态函数只能使用注入的数据(app、user)和请求参数。如果需要修改数据,应该返回操作指令(extend_time、deduct_points),由后端安全地执行。\n\n" + + "---\n\n" + + "### Q2: 动态函数可以调用外部API吗?\n\n" + + "**A**: 不能。动态函数在沙箱环境中执行,不支持网络请求。如果需要调用外部API,应该通过后端接口实现。\n\n" + + "---\n\n" + + "### Q3: 动态函数的执行时间有限制吗?\n\n" + + "**A**: 有。动态函数有执行时间限制,超时会被终止。建议避免死循环和长时间运行的操作。\n\n" + + "---\n\n" + + "### Q4: 如何调试动态函数?\n\n" + + "**A**: 可以在返回结果中包含调试信息,或者使用简单的测试参数验证逻辑是否正确。\n\n" + + "---\n\n" + + "### Q5: 动态函数可以修改应用配置吗?\n\n" + + "**A**: 不能。app对象是只读的,不能修改。如果需要修改应用配置,应该使用专门的管理接口。\n\n" + + "---\n\n" + + "## 更新日志\n\n" + + "### v1.0.0 (2026-03-11)\n" + + "- 初始版本发布\n" + + "- 支持应用配置数据注入\n" + + "- 支持用户信息注入\n" + + "- 支持操作指令(加时、扣点)\n" + + "- 支持完整的JavaScript语法\n" + + "- 提供丰富的使用示例\n" + + var existingDoc model.Doc + if err := database.DB.Where("slug = ?", "dynamic-code").First(&existingDoc).Error; err == nil { + log.Println("动态函数文档已存在,删除旧文档") + database.DB.Delete(&existingDoc) + } + + dynamicCodeDoc := model.Doc{ + Title: "动态函数接口", + CategoryID: &apiCat.ID, + Slug: "dynamic-code", + Content: dynamicCodeContent, + Summary: "动态函数允许开发者为应用创建自定义的业务逻辑,通过JavaScript代码实现灵活的数据处理和业务规则", + Icon: "⚡", + Sort: 1, + Status: "published", + } + + if err := database.DB.Create(&dynamicCodeDoc).Error; err != nil { + log.Printf("创建动态函数文档失败: %v", err) + return + } + + log.Println("动态函数文档创建成功") +} diff --git a/backend/scripts/update_to_cloud_function.go b/backend/scripts/update_to_cloud_function.go new file mode 100644 index 0000000..2e8ba99 --- /dev/null +++ b/backend/scripts/update_to_cloud_function.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +func main() { + database.Init() + + log.Println("=== 更新云端函数文档 ===") + + var doc model.Doc + if err := database.DB.Where("slug = ?", "dynamic-code").First(&doc).Error; err != nil { + log.Printf("未找到文档: %v", err) + return + } + + doc.Title = "云端函数接口" + doc.Slug = "cloud-function" + doc.Summary = "云端函数允许开发者为应用创建自定义的业务逻辑,通过JavaScript代码实现灵活的数据处理和业务规则" + + if err := database.DB.Save(&doc).Error; err != nil { + log.Printf("更新文档失败: %v", err) + return + } + + log.Println("云端函数文档更新成功") +} diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..24f668b --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,283 @@ +-- 网络验证平台数据库结构 +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS verification_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE verification_platform; + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + email VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱', + password VARCHAR(255) NOT NULL COMMENT '密码', + avatar VARCHAR(255) DEFAULT NULL COMMENT '头像URL', + signature VARCHAR(255) DEFAULT NULL COMMENT '个性签名', + points INT DEFAULT 0 COMMENT '积分', + level INT DEFAULT 1 COMMENT '等级', + role ENUM('user', 'developer', 'agent', 'admin') DEFAULT 'user' COMMENT '角色', + status ENUM('active', 'inactive', 'banned') DEFAULT 'active' COMMENT '状态', + agent_id BIGINT UNSIGNED DEFAULT NULL COMMENT '所属代理ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_username (username), + INDEX idx_email (email), + INDEX idx_role (role), + INDEX idx_status (status), + INDEX idx_agent_id (agent_id), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (agent_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- 应用表 +CREATE TABLE IF NOT EXISTS applications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '所有者ID', + name VARCHAR(100) NOT NULL COMMENT '应用名称', + description TEXT COMMENT '应用描述', + billing_type ENUM('subscription', 'time', 'point') NOT NULL COMMENT '计费方式', + encrypt_type ENUM('aes', 'rc4') NOT NULL COMMENT '加密方式', + secret_key VARCHAR(255) NOT NULL COMMENT '通信密钥', + bind_type ENUM('none', 'device', 'ip') NOT NULL COMMENT '绑定方式', + max_devices INT DEFAULT 1 COMMENT '最大设备数', + multi_open BOOLEAN DEFAULT FALSE COMMENT '允许多开', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_user_id (user_id), + INDEX idx_name (name), + INDEX idx_billing_type (billing_type), + INDEX idx_status (status), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用表'; + +-- 版本表 +CREATE TABLE IF NOT EXISTS versions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT UNSIGNED NOT NULL COMMENT '应用ID', + version VARCHAR(50) NOT NULL COMMENT '版本号', + file_path VARCHAR(255) NOT NULL COMMENT '文件路径', + file_size BIGINT NOT NULL COMMENT '文件大小', + force_update BOOLEAN DEFAULT FALSE COMMENT '强制更新', + description TEXT COMMENT '版本描述', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_application_id (application_id), + INDEX idx_version (version), + INDEX idx_status (status), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='版本表'; + +-- 卡密类型表 +CREATE TABLE IF NOT EXISTS card_types ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '创建者ID', + name VARCHAR(100) NOT NULL COMMENT '卡密类型名称', + billing_type ENUM('subscription', 'time', 'point') NOT NULL COMMENT '计费方式', + value INT NOT NULL COMMENT '面值', + price DECIMAL(10,2) NOT NULL COMMENT '价格', + description TEXT COMMENT '描述', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_user_id (user_id), + INDEX idx_name (name), + INDEX idx_billing_type (billing_type), + INDEX idx_status (status), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='卡密类型表'; + +-- 卡密表 +CREATE TABLE IF NOT EXISTS cards ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + card_type_id BIGINT UNSIGNED NOT NULL COMMENT '卡密类型ID', + card_key VARCHAR(100) NOT NULL UNIQUE COMMENT '卡密', + user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '使用者ID', + agent_id BIGINT UNSIGNED DEFAULT NULL COMMENT '代理ID', + status ENUM('unused', 'used', 'expired', 'banned') DEFAULT 'unused' COMMENT '状态', + used_at TIMESTAMP NULL DEFAULT NULL COMMENT '使用时间', + expire_at TIMESTAMP NULL DEFAULT NULL COMMENT '过期时间', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_card_type_id (card_type_id), + INDEX idx_card_key (card_key), + INDEX idx_user_id (user_id), + INDEX idx_agent_id (agent_id), + INDEX idx_status (status), + INDEX idx_expire_at (expire_at), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (card_type_id) REFERENCES card_types(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (agent_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='卡密表'; + +-- 代理表 +CREATE TABLE IF NOT EXISTS agents ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + parent_id BIGINT UNSIGNED DEFAULT NULL COMMENT '上级代理ID', + level INT DEFAULT 1 COMMENT '代理级别', + commission DECIMAL(5,4) DEFAULT 0.1000 COMMENT '佣金比例', + balance DECIMAL(10,2) DEFAULT 0.00 COMMENT '余额', + status ENUM('active', 'inactive', 'banned') DEFAULT 'active' COMMENT '状态', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_user_id (user_id), + INDEX idx_parent_id (parent_id), + INDEX idx_level (level), + INDEX idx_status (status), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES agents(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理表'; + +-- 订单表 +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + order_no VARCHAR(100) NOT NULL UNIQUE COMMENT '订单号', + amount DECIMAL(10,2) NOT NULL COMMENT '金额', + status ENUM('pending', 'paid', 'cancelled', 'refunded') DEFAULT 'pending' COMMENT '状态', + payment_at TIMESTAMP NULL DEFAULT NULL COMMENT '支付时间', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_user_id (user_id), + INDEX idx_order_no (order_no), + INDEX idx_status (status), + INDEX idx_payment_at (payment_at), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表'; + +-- 日志表 +CREATE TABLE IF NOT EXISTS logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '用户ID', + action VARCHAR(100) NOT NULL COMMENT '操作', + resource VARCHAR(100) DEFAULT NULL COMMENT '资源', + details TEXT COMMENT '详情', + ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址', + user_agent VARCHAR(500) DEFAULT NULL COMMENT '用户代理', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_user_id (user_id), + INDEX idx_action (action), + INDEX idx_resource (resource), + INDEX idx_created_at (created_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='日志表'; + +-- 工单表 +CREATE TABLE IF NOT EXISTS tickets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + title VARCHAR(200) NOT NULL COMMENT '标题', + content TEXT NOT NULL COMMENT '内容', + type ENUM('user', 'agent', 'developer') NOT NULL COMMENT '类型', + status ENUM('open', 'processing', 'resolved', 'closed') DEFAULT 'open' COMMENT '状态', + priority ENUM('low', 'normal', 'high', 'urgent') DEFAULT 'normal' COMMENT '优先级', + assigned_to BIGINT UNSIGNED DEFAULT NULL COMMENT '分配给的管理员ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_user_id (user_id), + INDEX idx_type (type), + INDEX idx_status (status), + INDEX idx_priority (priority), + INDEX idx_assigned_to (assigned_to), + INDEX idx_created_at (created_at), + INDEX idx_deleted_at (deleted_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工单表'; + +-- 用户设备表 +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + application_id BIGINT UNSIGNED NOT NULL COMMENT '应用ID', + device_id VARCHAR(100) NOT NULL COMMENT '设备ID', + device_name VARCHAR(100) DEFAULT NULL COMMENT '设备名称', + ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址', + last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '最后活跃时间', + status ENUM('active', 'inactive', 'banned') DEFAULT 'active' COMMENT '状态', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_user_id (user_id), + INDEX idx_application_id (application_id), + INDEX idx_device_id (device_id), + INDEX idx_status (status), + INDEX idx_last_active_at (last_active_at), + UNIQUE KEY uk_user_app_device (user_id, application_id, device_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户设备表'; + +-- 用户变量表 +CREATE TABLE IF NOT EXISTS user_variables ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + application_id BIGINT UNSIGNED NOT NULL COMMENT '应用ID', + var_name VARCHAR(100) NOT NULL COMMENT '变量名', + var_value TEXT COMMENT '变量值', + var_type ENUM('string', 'number', 'boolean', 'json') DEFAULT 'string' COMMENT '变量类型', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_user_id (user_id), + INDEX idx_application_id (application_id), + INDEX idx_var_name (var_name), + UNIQUE KEY uk_user_app_var (user_id, application_id, var_name), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户变量表'; + +-- 云端数据表 +CREATE TABLE IF NOT EXISTS cloud_data ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + application_id BIGINT UNSIGNED NOT NULL COMMENT '应用ID', + data_type ENUM('constant', 'variable') NOT NULL COMMENT '数据类型', + key_name VARCHAR(100) NOT NULL COMMENT '键名', + data_value TEXT COMMENT '数据值', + description TEXT COMMENT '描述', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', + INDEX idx_user_id (user_id), + INDEX idx_application_id (application_id), + INDEX idx_data_type (data_type), + INDEX idx_key_name (key_name), + INDEX idx_deleted_at (deleted_at), + UNIQUE KEY uk_user_app_key (user_id, application_id, key_name), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='云端数据表'; + +-- 风控黑名单表 +CREATE TABLE IF NOT EXISTS risk_blacklist ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL COMMENT '创建者ID', + application_id BIGINT UNSIGNED DEFAULT NULL COMMENT '应用ID', + blacklist_type ENUM('ip', 'device', 'account') NOT NULL COMMENT '黑名单类型', + target_value VARCHAR(255) NOT NULL COMMENT '目标值', + reason VARCHAR(255) DEFAULT NULL COMMENT '原因', + status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_user_id (user_id), + INDEX idx_application_id (application_id), + INDEX idx_blacklist_type (blacklist_type), + INDEX idx_target_value (target_value), + INDEX idx_status (status), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='风控黑名单表'; \ No newline at end of file diff --git a/database/seeds.sql b/database/seeds.sql new file mode 100644 index 0000000..dc968bb --- /dev/null +++ b/database/seeds.sql @@ -0,0 +1,111 @@ +-- 网络验证平台种子数据 +USE verification_platform; + +-- 插入默认管理员用户 +INSERT INTO users (username, email, password, role, status) VALUES +('admin', 'admin@example.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin', 'active'); + +-- 插入测试开发者用户 +INSERT INTO users (username, email, password, role, status) VALUES +('developer1', 'dev1@example.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'developer', 'active'), +('developer2', 'dev2@example.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'developer', 'active'); + +-- 插入测试代理用户 +INSERT INTO users (username, email, password, role, status) VALUES +('agent1', 'agent1@example.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'agent', 'active'), +('agent2', 'agent2@example.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'agent', 'active'); + +-- 插入代理信息 +INSERT INTO agents (user_id, level, commission) VALUES +(3, 1, 0.1000), -- agent1 +(4, 1, 0.1000); -- agent2 + +-- 插入测试应用 +INSERT INTO applications (user_id, name, description, billing_type, encrypt_type, secret_key, bind_type, max_devices, multi_open) VALUES +(2, '测试应用1', '这是一个测试应用', 'subscription', 'aes', 'test-secret-key-1', 'device', 1, false), +(2, '测试应用2', '这是另一个测试应用', 'time', 'rc4', 'test-secret-key-2', 'ip', 3, true), +(3, '测试应用3', '开发者2的测试应用', 'point', 'aes', 'test-secret-key-3', 'none', 0, true); + +-- 插入测试卡密类型 +INSERT INTO card_types (user_id, name, billing_type, value, price, description) VALUES +(2, '天卡', 'subscription', 1, 1.00, '1天有效期'), +(2, '周卡', 'subscription', 7, 6.00, '7天有效期'), +(2, '月卡', 'subscription', 30, 25.00, '30天有效期'), +(2, '季卡', 'subscription', 90, 70.00, '90天有效期'), +(2, '年卡', 'subscription', 365, 280.00, '365天有效期'), +(2, '永久卡', 'subscription', 0, 999.00, '永久有效'), +(2, '时长卡-1小时', 'time', 60, 1.00, '1小时在线时长'), +(2, '时长卡-10小时', 'time', 600, 9.00, '10小时在线时长'), +(2, '点卡-100点', 'point', 100, 5.00, '100点数'), +(2, '点卡-500点', 'point', 500, 20.00, '500点数'); + +-- 插入测试卡密 +INSERT INTO cards (card_type_id, card_key, status) VALUES +(1, 'TEST-DAY-CARD-001', 'unused'), +(1, 'TEST-DAY-CARD-002', 'unused'), +(2, 'TEST-WEEK-CARD-001', 'unused'), +(3, 'TEST-MONTH-CARD-001', 'unused'), +(4, 'TEST-SEASON-CARD-001', 'unused'), +(5, 'TEST-YEAR-CARD-001', 'unused'), +(6, 'TEST-PERMANENT-CARD-001', 'unused'), +(7, 'TEST-HOUR-CARD-001', 'unused'), +(8, 'TEST-10HOUR-CARD-001', 'unused'), +(9, 'TEST-100POINT-CARD-001', 'unused'), +(10, 'TEST-500POINT-CARD-001', 'unused'); + +-- 插入测试版本 +INSERT INTO versions (application_id, version, file_path, file_size, force_update, description) VALUES +(1, '1.0.0', '/uploads/test-app-1-1.0.0.exe', 1024000, false, '初始版本'), +(1, '1.1.0', '/uploads/test-app-1-1.1.0.exe', 1025000, false, '修复了一些bug'), +(1, '2.0.0', '/uploads/test-app-1-2.0.0.exe', 2048000, true, '重大更新,强制升级'), +(2, '1.0.0', '/uploads/test-app-2-1.0.0.exe', 512000, false, '初始版本'), +(3, '1.0.0', '/uploads/test-app-3-1.0.0.exe', 768000, false, '初始版本'); + +-- 插入测试工单 +INSERT INTO tickets (user_id, title, content, type, status, priority) VALUES +(2, '关于卡密生成的问题', '我在生成卡密时遇到了问题,无法正常生成卡密。', 'developer', 'open', 'normal'), +(3, '代理权限申请', '我想申请代理权限,希望能够销售卡密。', 'developer', 'open', 'low'), +(4, '用户反馈问题', '有用户反映卡密无法使用,请协助处理。', 'agent', 'processing', 'high'); + +-- 插入测试日志 +INSERT INTO logs (user_id, action, resource, details, ip_address, user_agent) VALUES +(1, 'login', 'system', '管理员登录系统', '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'), +(2, 'create', 'application', '创建测试应用1', '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'), +(2, 'generate', 'card', '生成测试卡密', '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'), +(3, 'create', 'application', '创建测试应用3', '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'), +(4, 'create', 'ticket', '提交工单', '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); + +-- 插入测试订单 +INSERT INTO orders (user_id, order_no, amount, status) VALUES +(2, 'ORDER20240101001', 1.00, 'paid'), +(2, 'ORDER20240101002', 6.00, 'paid'), +(3, 'ORDER20240101003', 5.00, 'pending'), +(4, 'ORDER20240101004', 25.00, 'paid'); + +-- 插入测试用户设备 +INSERT INTO user_devices (user_id, application_id, device_id, device_name, ip_address, status) VALUES +(2, 1, 'DEVICE-001', '测试设备1', '192.168.1.100', 'active'), +(2, 1, 'DEVICE-002', '测试设备2', '192.168.1.101', 'active'), +(3, 2, 'DEVICE-003', '测试设备3', '192.168.1.102', 'inactive'); + +-- 插入测试用户变量 +INSERT INTO user_variables (user_id, application_id, var_name, var_value, var_type) VALUES +(2, 1, 'level', '5', 'number'), +(2, 1, 'vip_status', 'true', 'boolean'), +(2, 1, 'custom_data', '{"key": "value", "num": 123}', 'json'), +(3, 2, 'score', '1000', 'number'), +(3, 2, 'nickname', 'TestUser', 'string'); + +-- 插入测试云端数据 +INSERT INTO cloud_data (user_id, application_id, data_type, key_name, data_value, description) VALUES +(2, 1, 'constant', 'app_version', '1.0.0', '应用版本号'), +(2, 1, 'constant', 'api_url', 'https://api.example.com', 'API地址'), +(2, 1, 'variable', 'maintenance_mode', 'false', '维护模式开关'), +(3, 2, 'constant', 'max_connections', '100', '最大连接数'), +(3, 2, 'variable', 'current_users', '50', '当前用户数'); + +-- 插入测试风控黑名单 +INSERT INTO risk_blacklist (user_id, application_id, blacklist_type, target_value, reason, status) VALUES +(2, 1, 'ip', '192.168.1.200', '恶意请求', 'active'), +(2, 1, 'device', 'DEVICE-BANNED-001', '设备异常', 'active'), +(3, 2, 'account', 'baduser001', '违规操作', 'active'); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d386a7a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: developer-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: developer_platform + MYSQL_USER: developer + MYSQL_PASSWORD: developer123 + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql + - ./database/seeds.sql:/docker-entrypoint-initdb.d/02-seeds.sql + networks: + - developer-network + + redis: + image: redis:7.0-alpine + container_name: developer-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - developer-network + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: developer-backend + restart: unless-stopped + environment: + - APP_ENV=production + - DATABASE_HOST=mysql + - DATABASE_PORT=3306 + - DATABASE_NAME=developer_platform + - DATABASE_USERNAME=developer + - DATABASE_PASSWORD=developer123 + - REDIS_HOST=redis + - REDIS_PORT=6379 + ports: + - "8080:8080" + depends_on: + - mysql + - redis + networks: + - developer-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: developer-frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + - backend + networks: + - developer-network + +volumes: + mysql_data: + redis_data: + +networks: + developer-network: + driver: bridge diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..4fbd3e7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# This is api base url +# VITE_API_BASE_URL=https://api.example.com +VITE_SERVER_API_URL=http://localhost:3000 +VITE_SERVER_API_PREFIX=/api +VITE_SERVER_API_TIMEOUT=5000 \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..e272c67 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +stats.html + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +!.vscode/mcp.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +.env +.env.local +.env.development +.env.test +.env.production \ No newline at end of file diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..31a5fa2 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/assets/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..cddf326 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,39 @@ +import antfu from '@antfu/eslint-config' +import pluginQuery from '@tanstack/eslint-plugin-query' + +export default antfu({ + type: 'app', + vue: { + overrides: { + 'vue/block-lang': ['warn', { + script: { lang: ['ts', 'tsx'] }, + }], + }, + }, + typescript: true, + formatters: { + css: true, + html: true, + markdown: 'prettier', + }, + + ignores: [ + '**/build/**', + '**/components/ui/**', + ], + settings: { + 'import/core-modules': ['vue-router/auto-routes'], + }, + globals: { + definePage: 'readonly', + }, + + imports: { + overrides: { + 'perfectionist/sort-imports': ['error', { + tsconfig: { rootDir: '.' }, + }], + }, + }, + ...pluginQuery.configs['flat/recommended'], +}) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..06f7b5c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,31 @@ + + + + + + + + {{.SiteName}} + + + + + + + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7ad00ee --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,105 @@ +{ + "name": "developer-platform-frontend", + "type": "module", + "version": "1.0.0", + "private": true, + "packageManager": "pnpm@10.30.3", + "description": "Developer Platform Frontend - Developer portal, application management, and agent system", + "author": "viaeon", + "license": "MIT", + "keywords": [ + "vue", + "vue-router", + "vite", + "typescript", + "tailwindcss", + "shadcn-vue", + "developer", + "license-management" + ], + "engines": { + "node": ">=22.15", + "pnpm": ">=10" + }, + "scripts": { + "postinstall": "simple-git-hooks", + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "release": "npx bumpp" + }, + "dependencies": { + "@formkit/auto-animate": "^0.9.0", + "@internationalized/date": "^3.11.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/vue-query": "^5.92.9", + "@tanstack/vue-table": "^8.21.3", + "@unovis/ts": "^1.6.4", + "@unovis/vue": "^1.6.4", + "@vee-validate/zod": "^4.15.1", + "@vueuse/core": "^14.2.1", + "@vueuse/integrations": "^14.2.1", + "axios": "^1.13.6", + "chart.js": "^4.5.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.19", + "echarts": "^6.0.0", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-vue": "^8.6.0", + "lucide-vue-next": "0.575.0", + "markdown-it": "^14.1.1", + "motion-v": "1.7.4", + "nprogress": "^0.2.0", + "pinia": "^3.0.4", + "pinia-plugin-persistedstate": "^4.7.1", + "reka-ui": "^2.8.2", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0", + "universal-cookie": "^8.0.1", + "vaul-vue": "^0.4.1", + "vee-validate": "^4.15.1", + "vue": "^3.5.29", + "vue-i18n": "^11.2.8", + "vue-input-otp": "^0.3.2", + "vue-router": "^5.0.3", + "vue-sonner": "^2.0.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@antfu/eslint-config": "^7.6.1", + "@faker-js/faker": "^10.3.0", + "@iconify-json/simple-icons": "^1.2.72", + "@iconify/vue": "^5.0.0", + "@tanstack/eslint-plugin-query": "^5.91.4", + "@tanstack/vue-query-devtools": "^6.1.5", + "@types/node": "^24.11.0", + "@types/nprogress": "^0.2.3", + "@vitejs/plugin-vue": "^6.0.4", + "@vitejs/plugin-vue-jsx": "^5.1.4", + "autoprefixer": "^10.4.27", + "eslint": "^9.39.3", + "eslint-plugin-format": "^1.5.0", + "lint-staged": "^16.3.1", + "rollup-plugin-visualizer": "^7.0.0", + "simple-git-hooks": "^2.13.1", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^31.0.0", + "vite": "^7.3.1", + "vite-plugin-vue-devtools": "^8.0.7", + "vite-plugin-vue-layouts": "^0.11.0", + "vue-tsc": "^3.2.5" + }, + "simple-git-hooks": { + "pre-commit": "pnpm lint-staged" + }, + "lint-staged": { + "*": "eslint --fix" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..8745391 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,8185 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@formkit/auto-animate': + specifier: ^0.9.0 + version: 0.9.0 + '@internationalized/date': + specifier: ^3.11.0 + version: 3.12.0 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.2.2) + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.2(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@tanstack/vue-query': + specifier: ^5.92.9 + version: 5.99.0(vue@3.5.32(typescript@5.9.3)) + '@tanstack/vue-table': + specifier: ^8.21.3 + version: 8.21.3(vue@3.5.32(typescript@5.9.3)) + '@unovis/ts': + specifier: ^1.6.4 + version: 1.6.4 + '@unovis/vue': + specifier: ^1.6.4 + version: 1.6.4(@unovis/ts@1.6.4)(vue@3.5.32(typescript@5.9.3)) + '@vee-validate/zod': + specifier: ^4.15.1 + version: 4.15.1(vue@3.5.32(typescript@5.9.3))(zod@4.3.6) + '@vueuse/core': + specifier: ^14.2.1 + version: 14.2.1(vue@3.5.32(typescript@5.9.3)) + '@vueuse/integrations': + specifier: ^14.2.1 + version: 14.2.1(axios@1.15.0)(change-case@5.4.4)(nprogress@0.2.0)(universal-cookie@8.1.0)(vue@3.5.32(typescript@5.9.3)) + axios: + specifier: ^1.13.6 + version: 1.15.0 + chart.js: + specifier: ^4.5.1 + version: 4.5.1 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dayjs: + specifier: ^1.11.19 + version: 1.11.20 + echarts: + specifier: ^6.0.0 + version: 6.0.0 + embla-carousel-autoplay: + specifier: ^8.6.0 + version: 8.6.0(embla-carousel@8.6.0) + embla-carousel-vue: + specifier: ^8.6.0 + version: 8.6.0(vue@3.5.32(typescript@5.9.3)) + lucide-vue-next: + specifier: 0.575.0 + version: 0.575.0(vue@3.5.32(typescript@5.9.3)) + markdown-it: + specifier: ^14.1.1 + version: 14.1.1 + motion-v: + specifier: 1.7.4 + version: 1.7.4(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + nprogress: + specifier: ^0.2.0 + version: 0.2.0 + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) + pinia-plugin-persistedstate: + specifier: ^4.7.1 + version: 4.7.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))) + reka-ui: + specifier: ^2.8.2 + version: 2.9.6(vue@3.5.32(typescript@5.9.3)) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@4.2.2) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + universal-cookie: + specifier: ^8.0.1 + version: 8.1.0 + vaul-vue: + specifier: ^0.4.1 + version: 0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + vee-validate: + specifier: ^4.15.1 + version: 4.15.1(vue@3.5.32(typescript@5.9.3)) + vue: + specifier: ^3.5.29 + version: 3.5.32(typescript@5.9.3) + vue-i18n: + specifier: ^11.2.8 + version: 11.3.2(vue@3.5.32(typescript@5.9.3)) + vue-input-otp: + specifier: ^0.3.2 + version: 0.3.2(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) + vue-router: + specifier: ^5.0.3 + version: 5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + vue-sonner: + specifier: ^2.0.9 + version: 2.0.9 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@antfu/eslint-config': + specifier: ^7.6.1 + version: 7.7.3(@typescript-eslint/rule-tester@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.32)(eslint-plugin-format@1.5.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@faker-js/faker': + specifier: ^10.3.0 + version: 10.4.0 + '@iconify-json/simple-icons': + specifier: ^1.2.72 + version: 1.2.78 + '@iconify/vue': + specifier: ^5.0.0 + version: 5.0.0(vue@3.5.32(typescript@5.9.3)) + '@tanstack/eslint-plugin-query': + specifier: ^5.91.4 + version: 5.99.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@tanstack/vue-query-devtools': + specifier: ^6.1.5 + version: 6.1.16(@tanstack/vue-query@5.99.0(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + '@types/node': + specifier: ^24.11.0 + version: 24.12.2 + '@types/nprogress': + specifier: ^0.2.3 + version: 0.2.3 + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.6(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': + specifier: ^5.1.4 + version: 5.1.5(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + autoprefixer: + specifier: ^10.4.27 + version: 10.5.0(postcss@8.5.9) + eslint: + specifier: ^9.39.3 + version: 9.39.4(jiti@2.6.1) + eslint-plugin-format: + specifier: ^1.5.0 + version: 1.5.0(eslint@9.39.4(jiti@2.6.1)) + lint-staged: + specifier: ^16.3.1 + version: 16.4.0 + rollup-plugin-visualizer: + specifier: ^7.0.0 + version: 7.0.1(rollup@4.60.1) + simple-git-hooks: + specifier: ^2.13.1 + version: 2.13.1 + tailwindcss: + specifier: ^4.2.1 + version: 4.2.2 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + unplugin-auto-import: + specifier: ^21.0.0 + version: 21.0.0(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3))) + unplugin-vue-components: + specifier: ^31.0.0 + version: 31.1.0(vue@3.5.32(typescript@5.9.3)) + vite: + specifier: ^7.3.1 + version: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite-plugin-vue-devtools: + specifier: ^8.0.7 + version: 8.1.1(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + vite-plugin-vue-layouts: + specifier: ^0.11.0 + version: 0.11.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + vue-tsc: + specifier: ^3.2.5 + version: 3.2.6(typescript@5.9.3) + +packages: + + '@antfu/eslint-config@7.7.3': + resolution: {integrity: sha512-BtroDxTvmWtvr3yJkdWVCvwsKlnEdkreoeOyrdNezc/W5qaiQNf2xjcsQ3N5Yy0x27h+0WFfW8rG8YlVioG6dw==} + hasBin: true + peerDependencies: + '@angular-eslint/eslint-plugin': ^21.1.0 + '@angular-eslint/eslint-plugin-template': ^21.1.0 + '@angular-eslint/template-parser': ^21.1.0 + '@eslint-react/eslint-plugin': ^2.11.0 + '@next/eslint-plugin-next': '>=15.0.0' + '@prettier/plugin-xml': ^3.4.1 + '@unocss/eslint-plugin': '>=0.50.0' + astro-eslint-parser: ^1.0.2 + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-astro: ^1.2.0 + eslint-plugin-format: '>=0.1.0' + eslint-plugin-jsx-a11y: '>=6.10.2' + eslint-plugin-react-hooks: ^7.0.0 + eslint-plugin-react-refresh: ^0.5.0 + eslint-plugin-solid: ^0.14.3 + eslint-plugin-svelte: '>=2.35.1' + eslint-plugin-vuejs-accessibility: ^2.4.1 + prettier-plugin-astro: ^0.14.0 + prettier-plugin-slidev: ^1.0.5 + svelte-eslint-parser: '>=0.37.0' + peerDependenciesMeta: + '@angular-eslint/eslint-plugin': + optional: true + '@angular-eslint/eslint-plugin-template': + optional: true + '@angular-eslint/template-parser': + optional: true + '@eslint-react/eslint-plugin': + optional: true + '@next/eslint-plugin-next': + optional: true + '@prettier/plugin-xml': + optional: true + '@unocss/eslint-plugin': + optional: true + astro-eslint-parser: + optional: true + eslint-plugin-astro: + optional: true + eslint-plugin-format: + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react-hooks: + optional: true + eslint-plugin-react-refresh: + optional: true + eslint-plugin-solid: + optional: true + eslint-plugin-svelte: + optional: true + eslint-plugin-vuejs-accessibility: + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-slidev: + optional: true + svelte-eslint-parser: + optional: true + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@clack/core@1.2.0': + resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + + '@clack/prompts@1.2.0': + resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + + '@dprint/formatter@0.5.1': + resolution: {integrity: sha512-cdZUrm0iv/FnnY3CKE2dEcVhNEzrC551aE2h2mTFwQCRBrqyARLDnb7D+3PlXTUVp3s34ftlnGOVCmhLT9DeKA==} + + '@dprint/markdown@0.21.1': + resolution: {integrity: sha512-XbZ/R7vRrBaZFYXG6vAvLvtaMVXHu8XB+xwie7OYrG+dPoGDP8UADGirIbzUyX8TmrAZcl6QBmalipTGlpzRmQ==} + + '@dprint/toml@0.7.0': + resolution: {integrity: sha512-eFaQTcfxKHB+YyTh83x7GEv+gDPuj9q5NFOTaoj5rZmQTbj6OgjjMxUicmS1R8zYcx8YAq5oA9J3YFa5U6x2gA==} + + '@e18e/eslint-plugin@0.2.0': + resolution: {integrity: sha512-mXgODVwhuDjTJ+UT+XSvmMmCidtGKfrV5nMIv1UtpWex2pYLsIM3RSpT8HWIMAebS9qANbXPKlSX4BE7ZvuCgA==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.41.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/css@11.13.5': + resolution: {integrity: sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@es-joy/jsdoccomment@0.84.0': + resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1': + resolution: {integrity: sha512-Ql2nJFwA8wUGpILYGOQaT1glPsmvEwE0d+a+l7AALLzQvInqdbXJdx7aSu0DpUX9dB1wMVBMhm99/++S3MdEtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@2.0.5': + resolution: {integrity: sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/markdown@7.5.1': + resolution: {integrity: sha512-R8uZemG9dKTbru/DQRPblbJyXpObwKzo8rv1KYGGuPUPtjM4LXBYM9q5CIZAComzZupws3tWbDwam5AFpPLyJQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@faker-js/faker@10.4.0': + resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} + engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@floating-ui/vue@1.1.11': + resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + + '@formkit/auto-animate@0.9.0': + resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/simple-icons@1.2.78': + resolution: {integrity: sha512-I3lkNp0Qu7q2iZWkdcf/I2hqGhzK6qxdILh9T7XqowQrnpmG/BayDsiCf6PktDoWlW0U971xA5g+panm+NFrfQ==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/vue@5.0.0': + resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==} + peerDependencies: + vue: '>=3' + + '@internationalized/date@3.12.0': + resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==} + + '@internationalized/number@3.6.5': + resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} + + '@intlify/core-base@11.3.2': + resolution: {integrity: sha512-cgsUaV/dyD6aS49UPgerIblrWeXAZHNaDWqm4LujOGC7IafSyhghGXEiSVvuDYaDPiQTP+tSFSTM1HIu7Yp1nA==} + engines: {node: '>= 16'} + + '@intlify/devtools-types@11.3.2': + resolution: {integrity: sha512-q96G2ZZw0FNoXzejbjIf9dbfgz1xyYBZu6ZT4b5TE/55j8d1O9X5jv0k+U+L3fVe7uebPcqRQFD0ffm30i5mJA==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@11.3.2': + resolution: {integrity: sha512-d/awyHUkNSaGPxBxT/qlUpfRizxHX9dt55CnW03xx5p1KmMyfYHKupCnvzINX+Na8JR8LAR7y32lPKjoeQGmzA==} + engines: {node: '>= 16'} + + '@intlify/shared@11.3.2': + resolution: {integrity: sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==} + engines: {node: '>= 16'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@mapbox/geojson-rewind@0.5.2': + resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} + hasBin: true + + '@mapbox/jsonlint-lines-primitives@2.0.2': + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + + '@mapbox/mapbox-gl-supported@2.0.1': + resolution: {integrity: sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ==} + + '@mapbox/point-geometry@0.1.0': + resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} + + '@mapbox/tiny-sdf@2.1.0': + resolution: {integrity: sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@1.3.1': + resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} + + '@mapbox/whoots-js@3.1.0': + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@ota-meshi/ast-token-store@0.3.0': + resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@oxfmt/binding-android-arm-eabi@0.35.0': + resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.35.0': + resolution: {integrity: sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.35.0': + resolution: {integrity: sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.35.0': + resolution: {integrity: sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.35.0': + resolution: {integrity: sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + resolution: {integrity: sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + resolution: {integrity: sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.35.0': + resolution: {integrity: sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.35.0': + resolution: {integrity: sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + resolution: {integrity: sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + resolution: {integrity: sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.35.0': + resolution: {integrity: sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.35.0': + resolution: {integrity: sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.35.0': + resolution: {integrity: sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.35.0': + resolution: {integrity: sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.35.0': + resolution: {integrity: sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.35.0': + resolution: {integrity: sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.35.0': + resolution: {integrity: sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.35.0': + resolution: {integrity: sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/pluginutils@1.0.0-rc.13': + resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} + + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/eslint-plugin-query@5.99.0': + resolution: {integrity: sha512-jVp1AEL7S7BeuQvH5SN1F5UdrNW/AbryKDeWUUMeAKNzh9C+Ik/bRSa/HeuJLlmaN+WOUkdDFbtCK0go7BxnUQ==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ^5.4.0 || ^6.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@tanstack/match-sorter-utils@8.19.4': + resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} + engines: {node: '>=12'} + + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} + + '@tanstack/query-devtools@5.99.0': + resolution: {integrity: sha512-m4ufXaJ8FjWXw7xDtyzE/6fkZAyQFg9WrbMrUpt8ZecRJx58jiFOZ2lxZMphZdIpAnIeto/S8stbwLKLusyckQ==} + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + + '@tanstack/vue-query-devtools@6.1.16': + resolution: {integrity: sha512-2Q+LnHSWwd8wkR/uoSmuziAmkDK4ISbMt8Naabpts8Nf4nMIKF6YfiMhbPjpTAHoBeBREfsq9G62lYYAGChVXw==} + peerDependencies: + '@tanstack/vue-query': ^5.99.0 + vue: ^3.3.0 + + '@tanstack/vue-query@5.99.0': + resolution: {integrity: sha512-okrHNkouasL5cjo7O8hUxul22DiIL335KUQus/cfoAdCOUytF/OIv3BpNDevfqE6zHqqJ651Fb9OkUJPUCL0ZQ==} + peerDependencies: + '@vue/composition-api': ^1.1.2 + vue: ^2.6.0 || ^3.3.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@tanstack/vue-table@8.21.3': + resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==} + engines: {node: '>=12'} + peerDependencies: + vue: '>=3.2' + + '@tanstack/vue-virtual@3.13.23': + resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-collection@1.0.13': + resolution: {integrity: sha512-v0Rgw3IZebRyamcwVmtTDCZ8OmQcj4siaYjNc7wGMZT7PmdSHawGsCOQMxyLvZ7lWjfohYLK0oXtilMOMgfY8A==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@1.0.11': + resolution: {integrity: sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-sankey@0.12.5': + resolution: {integrity: sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@1.3.12': + resolution: {integrity: sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/dagre@0.7.54': + resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/leaflet@1.7.6': + resolution: {integrity: sha512-Emkz3V08QnlelSbpT46OEAx+TBZYTOX2r1yM7W+hWg5+djHtQ1GbEXBDRLaqQDOYcDI51Ss0ayoqoKD4CtLUDA==} + + '@types/mapbox__point-geometry@0.1.4': + resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==} + + '@types/mapbox__vector-tile@1.3.4': + resolution: {integrity: sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@types/nprogress@0.2.3': + resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/pbf@3.0.5': + resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} + + '@types/supercluster@5.0.3': + resolution: {integrity: sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg==} + + '@types/three@0.135.0': + resolution: {integrity: sha512-l7WLhIHjhHMtlpyTSltPPAKLpiMwgMD1hXHj59AVUpYRoZP7Fd9NNOSRSvZBCPLpTHPYojgQvSJCoza9zoL7bg==} + + '@types/throttle-debounce@5.0.2': + resolution: {integrity: sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==} + + '@types/topojson-client@3.1.5': + resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==} + + '@types/topojson-server@3.0.4': + resolution: {integrity: sha512-5+ieK8ePfP+K2VH6Vgs1VCt+fO1U8XZHj0UsF+NktaF0DavAo1q3IvCBXgokk/xmtvoPltSUs6vxuR/zMdOE1g==} + + '@types/topojson-simplify@3.0.3': + resolution: {integrity: sha512-sBO5UZ0O2dB0bNwo0vut2yLHhj3neUGi9uL7/ROdm8Gs6dtt4jcB9OGDKr+M2isZwQM2RuzVmifnMZpxj4IGNw==} + + '@types/topojson-specification@1.0.5': + resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==} + + '@types/topojson@3.2.6': + resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.2': + resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/rule-tester@8.58.2': + resolution: {integrity: sha512-HF7ry5ZhQSLn4JAJfQCPX39D6BE5MhYXE743I0phrJw1BvB0adH8JpsEc4owCxEESQflodyhRWNUZY+m2EVenw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + '@typescript-eslint/scope-manager@8.58.2': + resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.2': + resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.2': + resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unovis/dagre-layout@0.8.8-2': + resolution: {integrity: sha512-ZfDvfcYtzzhZhgKZty8XDi+zQIotfRqfNVF5M3dFQ9d9C5MTaRdbeBnPUkNrmlLJGgQ42HMOE2ajZLfm2VlRhg==} + + '@unovis/graphlibrary@2.2.0-2': + resolution: {integrity: sha512-HeEzpd/vDyWiIJt0rnh+2ICXUIuF2N0+Z9OJJiKg0DB+eFUcD+bk+9QPhYHwkFwfxdjDA9fHi1DZ/O/bbV58Nw==} + + '@unovis/ts@1.6.4': + resolution: {integrity: sha512-LH8AqYuiVxMcm/SP/VsBKfBa6tu37CJapcn8qeRATZvtYuh8RBDnXr3ejwJyEUvIYJzbPuHOEQo9WIDre9CK1Q==} + + '@unovis/vue@1.6.4': + resolution: {integrity: sha512-Gt5LwmwiMoB0/f1eJL29sfKD9jzlqgTHxc+lW4rMFqIG/PpHLpWL2jTJYVVFfWFd1MjTJWXYiDA06hCEjFcrAg==} + peerDependencies: + '@unovis/ts': 1.6.4 + vue: ^3 + + '@vee-validate/zod@4.15.1': + resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==} + peerDependencies: + zod: ^3.24.0 + + '@vitejs/plugin-vue-jsx@5.1.5': + resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.0.0 + + '@vitejs/plugin-vue@6.0.6': + resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/eslint-plugin@1.6.15': + resolution: {integrity: sha512-dTMjrdngmcB+DxomlKQ+SUubCTvd0m2hQQFpv5sx+GRodmeoxr2PVbphk57SVp250vpxphk9Ccwyv6fQ6+2gkA==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue-macros/common@3.1.2': + resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/babel-helper-vue-transform-on@1.5.0': + resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==} + + '@vue/babel-helper-vue-transform-on@2.0.1': + resolution: {integrity: sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==} + + '@vue/babel-plugin-jsx@1.5.0': + resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@vue/babel-plugin-jsx@2.0.1': + resolution: {integrity: sha512-a8CaLQjD/s4PVdhrLD/zT574ZNPnZBOY+IhdtKWRB4HRZ0I2tXBi5ne7d9eCfaYwp5gU5+4KIyFTV1W1YL9xZA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@vue/babel-plugin-resolve-type@1.5.0': + resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/babel-plugin-resolve-type@2.0.1': + resolution: {integrity: sha512-ybwgIuRGRRBhOU37GImDoWQoz+TlSqap65qVI6iwg/J7FfLTLmMf97TS7xQH9I7Qtr/gp161kYVdhr1ZMraSYQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/compiler-core@3.5.32': + resolution: {integrity: sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==} + + '@vue/compiler-dom@3.5.32': + resolution: {integrity: sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==} + + '@vue/compiler-sfc@3.5.32': + resolution: {integrity: sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==} + + '@vue/compiler-ssr@3.5.32': + resolution: {integrity: sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-api@8.1.1': + resolution: {integrity: sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==} + + '@vue/devtools-core@8.1.1': + resolution: {integrity: sha512-bCCsSABp1/ot4j8xJEycM6Mtt2wbuucfByr6hMgjbYhrtlscOJypZKvy8f1FyWLYrLTchB5Qz216Lm92wfbq0A==} + peerDependencies: + vue: ^3.0.0 + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-kit@8.1.1': + resolution: {integrity: sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/devtools-shared@8.1.1': + resolution: {integrity: sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==} + + '@vue/language-core@3.2.6': + resolution: {integrity: sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==} + + '@vue/reactivity@3.5.32': + resolution: {integrity: sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==} + + '@vue/runtime-core@3.5.32': + resolution: {integrity: sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==} + + '@vue/runtime-dom@3.5.32': + resolution: {integrity: sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==} + + '@vue/server-renderer@3.5.32': + resolution: {integrity: sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==} + peerDependencies: + vue: 3.5.32 + + '@vue/shared@3.5.32': + resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==} + + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/core@14.2.1': + resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/integrations@14.2.1': + resolution: {integrity: sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 || ^8 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 || ^8 + vue: ^3.5.0 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/metadata@14.2.1': + resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + '@vueuse/shared@14.2.1': + resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==} + peerDependencies: + vue: ^3.5.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + alien-signals@3.1.2: + resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.8.3: + resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} + engines: {node: '>=20.19.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.19: + resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} + engines: {node: '>=6.0.0'} + hasBin: true + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + builtin-modules@5.1.0: + resolution: {integrity: sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==} + engines: {node: '>=18.20'} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001788: + resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + comment-parser@1.4.5: + resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} + engines: {node: '>= 12.0.0'} + + comment-parser@1.4.6: + resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} + engines: {node: '>= 12.0.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csscolorparser@1.0.3: + resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-collection@1.0.7: + resolution: {integrity: sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo-projection@4.0.0: + resolution: {integrity: sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==} + engines: {node: '>=12'} + hasBin: true + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate-path@2.3.0: + resolution: {integrity: sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + earcut@2.2.4: + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + + electron-to-chromium@1.5.336: + resolution: {integrity: sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==} + + elkjs@0.10.2: + resolution: {integrity: sha512-Yx3ORtbAFrXelYkAy2g0eYyVY8QG0XEmGdQXmy0eithKKjbWRfl3Xe884lfkszfBF6UKyIy4LwfcZ3AZc8oxFw==} + + embla-carousel-autoplay@8.6.0: + resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0: + resolution: {integrity: sha512-v8UO5UsyLocZnu/LbfQA7Dn2QHuZKurJY93VUmZYP//QRWoCWOsionmvLLAlibkET3pGPs7++03VhJKbWD7vhQ==} + peerDependencies: + vue: ^3.2.37 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-flat-gitignore@2.3.0: + resolution: {integrity: sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==} + peerDependencies: + eslint: ^9.5.0 || ^10.0.0 + + eslint-flat-config-utils@3.1.0: + resolution: {integrity: sha512-lM+Nwo2CzpuTS/RASQExlEIwk/BQoKqJWX6VbDlLMb/mveqvt9MMrRXFEkG3bseuK6g8noKZLeX82epkILtv4A==} + + eslint-formatting-reporter@0.0.0: + resolution: {integrity: sha512-k9RdyTqxqN/wNYVaTk/ds5B5rA8lgoAmvceYN7bcZMBwU7TuXx5ntewJv81eF3pIL/CiJE+pJZm36llG8yhyyw==} + peerDependencies: + eslint: '>=8.40.0' + + eslint-json-compat-utils@0.2.3: + resolution: {integrity: sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==} + engines: {node: '>=12'} + peerDependencies: + '@eslint/json': '*' + eslint: '*' + jsonc-eslint-parser: ^2.4.0 || ^3.0.0 + peerDependenciesMeta: + '@eslint/json': + optional: true + + eslint-merge-processors@2.0.0: + resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==} + peerDependencies: + eslint: '*' + + eslint-parser-plain@0.1.1: + resolution: {integrity: sha512-KRgd6wuxH4U8kczqPp+Oyk4irThIhHWxgFgLDtpgjUGVIS3wGrJntvZW/p6hHq1T4FOwnOtCNkvAI4Kr+mQ/Hw==} + + eslint-plugin-antfu@3.2.2: + resolution: {integrity: sha512-Qzixht2Dmd/pMbb5EnKqw2V8TiWHbotPlsORO8a+IzCLFwE0RxK8a9k4DCTFPzBwyxJzH+0m2Mn8IUGeGQkyUw==} + peerDependencies: + eslint: '*' + + eslint-plugin-command@3.5.2: + resolution: {integrity: sha512-PA59QAkQDwvcCMEt5lYLJLI3zDGVKJeC4id/pcRY2XdRYhSGW7iyYT1VC1N3bmpuvu6Qb/9QptiS3GJMjeGTJg==} + peerDependencies: + '@typescript-eslint/rule-tester': '*' + '@typescript-eslint/typescript-estree': '*' + '@typescript-eslint/utils': '*' + eslint: '*' + + eslint-plugin-depend@1.5.0: + resolution: {integrity: sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A==} + peerDependencies: + eslint: '>=8.40.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-format@1.5.0: + resolution: {integrity: sha512-jaeOKrxs79Nn6rMkLycPkLHvBVKcgsFG+RqNXb6W9iS9y2Q0NYGhFTLcDUdp5mf01X99wEkjtX2O8cumM7lNMQ==} + peerDependencies: + eslint: ^8.40.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-import-lite@0.5.2: + resolution: {integrity: sha512-XvfdWOC5dSLEI9krIPRlNmKSI2ViIE9pVylzfV9fCq0ZpDaNeUk6o0wZv0OzN83QdadgXp1NsY0qjLINxwYCsw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=9.0.0' + + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-jsonc@3.1.2: + resolution: {integrity: sha512-dopTxdB22iuOkgKyJCupEC5IYBItUT4J/teq1H5ddUObcaYhOURxtJElZczdcYnnKCghNU/vccuyPkliy2Wxsg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-no-only-tests@3.3.0: + resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} + engines: {node: '>=5.0.0'} + + eslint-plugin-perfectionist@5.8.0: + resolution: {integrity: sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-pnpm@1.6.0: + resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-regexp@3.1.0: + resolution: {integrity: sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-toml@1.3.1: + resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unicorn@63.0.0: + resolution: {integrity: sha512-Iqecl9118uQEXYh7adylgEmGfkn5es3/mlQTLLkd4pXkIk9CTGrAbeUux+YljSa2ohXCBmQQ0+Ej1kZaFgcfkA==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-plugin-yml@3.3.1: + resolution: {integrity: sha512-isntsZchaTqDMNNkD+CakrgA/pdUoJ45USWBKpuqfAW1MCuw731xX/vrXfoJFZU3tTFr24nCbDYmDfT2+g4QtQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-processor-vue-blocks@2.0.0: + resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==} + peerDependencies: + '@vue/compiler-sfc': ^3.3.0 + eslint: '>=9.0.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-string-truncated-width@1.2.1: + resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + + fast-string-width@1.1.0: + resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + + fast-wrap-ansi@0.1.6: + resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@12.23.12: + resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + geojson-vt@3.2.1: + resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} + + geojson@0.5.0: + resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==} + engines: {node: '>= 0.10'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hey-listen@1.0.8: + resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsdoc-type-pratt-parser@7.1.1: + resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} + engines: {node: '>=20.0.0'} + + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@3.1.0: + resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + kdbush@3.0.0: + resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + leaflet@1.7.1: + resolution: {integrity: sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-vue-next@0.575.0: + resolution: {integrity: sha512-UHzA3cYMCgBLyGay5R9IQaidwV0NLocx7cIBnFt8vJ9Xhl6IM/oKD0fUhoCUuouFta15SX1rLXVoko9s3TzWMA==} + peerDependencies: + vue: '>=3.0.1' + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + maplibre-gl@2.4.0: + resolution: {integrity: sha512-csNFylzntPmHWidczfgCZpvbTSmhaWvLRj9e1ezUDBEPizGgshgm3ea1T5TCNEEBq0roauu7BPuRZjA3wO4KqA==} + + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + module-replacements@2.11.0: + resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} + + motion-dom@12.23.12: + resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion-v@1.7.4: + resolution: {integrity: sha512-YNDUAsany04wfI7YtHxQK3kxzNvh+OdFUk9GpA3+hMt7j6P+5WrVAAgr8kmPPoVza9EsJiAVhqoN3YYFN0Twrw==} + peerDependencies: + '@vueuse/core': '>=10.0.0' + vue: '>=3.0.0' + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-deep-merge@2.0.0: + resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + oxfmt@0.35.0: + resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pbf@3.3.0: + resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} + hasBin: true + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia-plugin-persistedstate@4.7.1: + resolution: {integrity: sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==} + peerDependencies: + '@nuxt/kit': '>=3.0.0' + '@pinia/nuxt': '>=0.10.0' + pinia: '>=3.0.0' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@pinia/nuxt': + optional: true + pinia: + optional: true + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pnpm-workspace-yaml@1.6.0: + resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.8.2: + resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==} + engines: {node: '>=14'} + hasBin: true + + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quickselect@2.0.0: + resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} + hasBin: true + + reka-ui@2.9.6: + resolution: {integrity: sha512-K6bL457owpvWONc7hsjFxo3HDC9s6IzhRqShW0w9JSKelPGfRbkHD558UQTn/NH1cvrXVHygKyC7fExFmRketg==} + peerDependencies: + vue: '>= 3.4.0' + + remove-accents@0.5.0: + resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-git-hooks@2.13.1: + resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} + hasBin: true + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + striptags@3.2.0: + resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supercluster@7.1.5: + resolution: {integrity: sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + three@0.135.0: + resolution: {integrity: sha512-kuEpuuxRzLv0MDsXai9huCxOSQPZ4vje6y0gn80SRmQvgz6/+rI0NAvCRAw56zYaWKMGMfqKWsxF9Qa2Z9xymQ==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyqueue@2.0.3: + resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toml-eslint-parser@1.0.3: + resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + topojson-client@3.1.0: + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} + hasBin: true + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@4.2.4: + resolution: {integrity: sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==} + engines: {node: '>=4.2.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unimport@5.7.0: + resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} + engines: {node: '>=18.12.0'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universal-cookie@8.1.0: + resolution: {integrity: sha512-65+kikQAWq7gsJbirwB7dk6e8xeug1hx3++x2dQoymdXcV7fYv0yChOgHCg01ZwP3fE3sYeq6EWCSpFv3HLl9g==} + + unplugin-auto-import@21.0.0: + resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@31.1.0: + resolution: {integrity: sha512-9EbV5ark21A4BOBt6RJGJXCVD2I1eoxTZL1TAvNgYTokcrFIiuxpufb8owyWn7n+z2x8daz/ltZq6IRRKL3ydQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vaul-vue@0.4.1: + resolution: {integrity: sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==} + peerDependencies: + reka-ui: ^2.0.0 + vue: ^3.3.0 + + vee-validate@4.15.1: + resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} + peerDependencies: + vue: ^3.4.26 + + vite-dev-rpc@1.1.0: + resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 + + vite-hot-client@2.1.0: + resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==} + peerDependencies: + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 + + vite-plugin-inspect@11.3.3: + resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + vite-plugin-vue-devtools@8.1.1: + resolution: {integrity: sha512-9qTpOmZ2vHpvlI9hdVXAQ1Ry4I8GcBArU7aPi0qfIaV7fQIXy0L1nb6X4mFY2Gw0dYshHuLbIl0Ulb572SCjsQ==} + engines: {node: '>=v14.21.3'} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite-plugin-vue-inspector@5.4.0: + resolution: {integrity: sha512-Iq/024CydcE46FZqWPU4t4lw4uYOdLnFSO1RNxJVt2qY9zxIjmnkBqhHnYaReWM82kmNnaXs7OkfgRrV2GEjyw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite-plugin-vue-layouts@0.11.0: + resolution: {integrity: sha512-uh6NW7lt+aOXujK4eHfiNbeo55K9OTuB7fnv+5RVc4OBn/cZull6ThXdYH03JzKanUfgt6QZ37NbbtJ0og59qw==} + peerDependencies: + vite: ^4.0.0 || ^5.0.0 + vue: ^3.2.4 + vue-router: ^4.0.11 + + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vt-pbf@3.1.3: + resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-i18n@11.3.2: + resolution: {integrity: sha512-gmFrvM+iuf2AH4ygligw/pC7PRJ63AdRNE68E0GPlQ83Mzfyck6g6cRQC3KzkYXr+ZidR91wq+5YBmAMpkgE1A==} + engines: {node: '>= 16'} + peerDependencies: + vue: ^3.0.0 + + vue-input-otp@0.3.2: + resolution: {integrity: sha512-QMl1842WB6uNAsK4+mZXIskb00TOfahH3AQt8rpRecbtQnOp+oHSUbL/Z3wekfy6pAl+hyN3e1rCUSkCMzbDLQ==} + peerDependencies: + vue: ^3.2.0 + + vue-router@5.0.4: + resolution: {integrity: sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.17 + pinia: ^3.0.4 + vue: ^3.5.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + + vue-sonner@2.0.9: + resolution: {integrity: sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==} + peerDependencies: + '@nuxt/kit': ^4.0.3 + '@nuxt/schema': ^4.0.3 + nuxt: ^4.0.3 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@nuxt/schema': + optional: true + nuxt: + optional: true + + vue-tsc@3.2.6: + resolution: {integrity: sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.32: + resolution: {integrity: sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-eslint-parser@2.0.0: + resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@antfu/eslint-config@7.7.3(@typescript-eslint/rule-tester@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.32)(eslint-plugin-format@1.5.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@clack/prompts': 1.2.0 + '@e18e/eslint-plugin': 0.2.0(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint/markdown': 7.5.1 + '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@vitest/eslint-plugin': 1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + ansis: 4.2.0 + cac: 7.0.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@9.39.4(jiti@2.6.1)) + eslint-flat-config-utils: 3.1.0 + eslint-merge-processors: 2.0.0(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import-lite: 0.5.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-no-only-tests: 3.3.0 + eslint-plugin-perfectionist: 5.8.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-pnpm: 1.6.0(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-unicorn: 63.0.0(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@9.39.4(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.32)(eslint@9.39.4(jiti@2.6.1)) + globals: 17.5.0 + local-pkg: 1.1.2 + parse-gitignore: 2.0.0 + toml-eslint-parser: 1.0.3 + vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) + yaml-eslint-parser: 2.0.0 + optionalDependencies: + eslint-plugin-format: 1.5.0(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' + - '@vue/compiler-sfc' + - oxlint + - supports-color + - typescript + - vitest + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.1.1 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@clack/core@1.2.0': + dependencies: + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@clack/prompts@1.2.0': + dependencies: + '@clack/core': 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@dprint/formatter@0.5.1': {} + + '@dprint/markdown@0.21.1': {} + + '@dprint/toml@0.7.0': {} + + '@e18e/eslint-plugin@0.2.0(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint-plugin-depend: 1.5.0(eslint@9.39.4(jiti@2.6.1)) + optionalDependencies: + eslint: 9.39.4(jiti@2.6.1) + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/runtime': 7.29.2 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/css@11.13.5': + dependencies: + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + transitivePeerDependencies: + - supports-color + + '@emotion/hash@0.9.2': {} + + '@emotion/memoize@0.9.0': {} + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/unitless@0.10.0': {} + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@es-joy/jsdoccomment@0.84.0': + dependencies: + '@types/estree': 1.0.8 + '@typescript-eslint/types': 8.58.2 + comment-parser: 1.4.5 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.1.1 + + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.8 + '@typescript-eslint/types': 8.58.2 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/compat@2.0.5(eslint@9.39.4(jiti@2.6.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 9.39.4(jiti@2.6.1) + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/markdown@7.5.1': + dependencies: + '@eslint/core': 0.17.0 + '@eslint/plugin-kit': 0.4.1 + github-slugger: 2.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm: 3.0.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@faker-js/faker@10.4.0': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@floating-ui/vue@1.1.11(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/utils': 0.2.11 + vue-demi: 0.14.10(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@formkit/auto-animate@0.9.0': {} + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/simple-icons@1.2.78': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/vue@5.0.0(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.32(typescript@5.9.3) + + '@internationalized/date@3.12.0': + dependencies: + '@swc/helpers': 0.5.21 + + '@internationalized/number@3.6.5': + dependencies: + '@swc/helpers': 0.5.21 + + '@intlify/core-base@11.3.2': + dependencies: + '@intlify/devtools-types': 11.3.2 + '@intlify/message-compiler': 11.3.2 + '@intlify/shared': 11.3.2 + + '@intlify/devtools-types@11.3.2': + dependencies: + '@intlify/core-base': 11.3.2 + '@intlify/shared': 11.3.2 + + '@intlify/message-compiler@11.3.2': + dependencies: + '@intlify/shared': 11.3.2 + source-map-js: 1.2.1 + + '@intlify/shared@11.3.2': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@juggle/resize-observer@3.4.0': {} + + '@kurkle/color@0.3.4': {} + + '@mapbox/geojson-rewind@0.5.2': + dependencies: + get-stream: 6.0.1 + minimist: 1.2.8 + + '@mapbox/jsonlint-lines-primitives@2.0.2': {} + + '@mapbox/mapbox-gl-supported@2.0.1': {} + + '@mapbox/point-geometry@0.1.0': {} + + '@mapbox/tiny-sdf@2.1.0': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@1.3.1': + dependencies: + '@mapbox/point-geometry': 0.1.0 + + '@mapbox/whoots-js@3.1.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@ota-meshi/ast-token-store@0.3.0': {} + + '@oxfmt/binding-android-arm-eabi@0.35.0': + optional: true + + '@oxfmt/binding-android-arm64@0.35.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.35.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.35.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.35.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.35.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.35.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.35.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.35.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.35.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.35.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.35.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.35.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.35.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.35.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/pluginutils@1.0.0-rc.13': {} + + '@rolldown/pluginutils@1.0.0-rc.15': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@sindresorhus/base62@1.0.0': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/types': 8.58.2 + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + '@tanstack/eslint-plugin-query@5.99.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/match-sorter-utils@8.19.4': + dependencies: + remove-accents: 0.5.0 + + '@tanstack/query-core@5.99.0': {} + + '@tanstack/query-devtools@5.99.0': {} + + '@tanstack/table-core@8.21.3': {} + + '@tanstack/virtual-core@3.13.23': {} + + '@tanstack/vue-query-devtools@6.1.16(@tanstack/vue-query@5.99.0(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@tanstack/query-devtools': 5.99.0 + '@tanstack/vue-query': 5.99.0(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@5.9.3) + + '@tanstack/vue-query@5.99.0(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@tanstack/match-sorter-utils': 8.19.4 + '@tanstack/query-core': 5.99.0 + '@vue/devtools-api': 6.6.4 + vue: 3.5.32(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.32(typescript@5.9.3)) + + '@tanstack/vue-table@8.21.3(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@tanstack/table-core': 8.21.3 + vue: 3.5.32(typescript@5.9.3) + + '@tanstack/vue-virtual@3.13.23(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@tanstack/virtual-core': 3.13.23 + vue: 3.5.32(typescript@5.9.3) + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-collection@1.0.13': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@1.0.11': {} + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-sankey@0.12.5': + dependencies: + '@types/d3-shape': 1.3.12 + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@1.3.12': + dependencies: + '@types/d3-path': 1.0.11 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/dagre@0.7.54': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/leaflet@1.7.6': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/mapbox__point-geometry@0.1.4': {} + + '@types/mapbox__vector-tile@1.3.4': + dependencies: + '@types/geojson': 7946.0.16 + '@types/mapbox__point-geometry': 0.1.4 + '@types/pbf': 3.0.5 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@types/nprogress@0.2.3': {} + + '@types/parse-json@4.0.2': {} + + '@types/pbf@3.0.5': {} + + '@types/supercluster@5.0.3': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/three@0.135.0': {} + + '@types/throttle-debounce@5.0.2': {} + + '@types/topojson-client@3.1.5': + dependencies: + '@types/geojson': 7946.0.16 + '@types/topojson-specification': 1.0.5 + + '@types/topojson-server@3.0.4': + dependencies: + '@types/geojson': 7946.0.16 + '@types/topojson-specification': 1.0.5 + + '@types/topojson-simplify@3.0.3': + dependencies: + '@types/geojson': 7946.0.16 + '@types/topojson-specification': 1.0.5 + + '@types/topojson-specification@1.0.5': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/topojson@3.2.6': + dependencies: + '@types/geojson': 7946.0.16 + '@types/topojson-client': 3.1.5 + '@types/topojson-server': 3.0.4 + '@types/topojson-simplify': 3.0.3 + '@types/topojson-specification': 1.0.5 + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.20': {} + + '@types/web-bluetooth@0.0.21': {} + + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/rule-tester@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + ajv: 6.14.0 + eslint: 9.39.4(jiti@2.6.1) + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.2': {} + + '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + eslint-visitor-keys: 5.0.1 + + '@unovis/dagre-layout@0.8.8-2': + dependencies: + '@unovis/graphlibrary': 2.2.0-2 + lodash-es: 4.18.1 + + '@unovis/graphlibrary@2.2.0-2': + dependencies: + lodash-es: 4.18.1 + + '@unovis/ts@1.6.4': + dependencies: + '@emotion/css': 11.13.5 + '@juggle/resize-observer': 3.4.0 + '@types/d3': 7.4.3 + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-collection': 1.0.13 + '@types/d3-color': 3.1.3 + '@types/d3-drag': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-force': 3.0.10 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-sankey': 0.12.5 + '@types/d3-scale': 4.0.9 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/dagre': 0.7.54 + '@types/geojson': 7946.0.16 + '@types/leaflet': 1.7.6 + '@types/supercluster': 5.0.3 + '@types/three': 0.135.0 + '@types/throttle-debounce': 5.0.2 + '@types/topojson': 3.2.6 + '@types/topojson-client': 3.1.5 + '@types/topojson-specification': 1.0.5 + '@unovis/dagre-layout': 0.8.8-2 + '@unovis/graphlibrary': 2.2.0-2 + d3: 7.9.0 + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-collection: 1.0.7 + d3-color: 3.1.0 + d3-drag: 3.0.0 + d3-ease: 3.0.1 + d3-force: 3.0.0 + d3-geo: 3.1.1 + d3-geo-projection: 4.0.0 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-interpolate-path: 2.3.0 + d3-path: 3.1.0 + d3-sankey: 0.12.3 + d3-scale: 4.0.2 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + elkjs: 0.10.2 + geojson: 0.5.0 + leaflet: 1.7.1 + maplibre-gl: 2.4.0 + striptags: 3.2.0 + supercluster: 7.1.5 + three: 0.135.0 + throttle-debounce: 5.0.2 + topojson-client: 3.1.0 + tslib: 2.8.1 + typescript: 4.2.4 + transitivePeerDependencies: + - supports-color + + '@unovis/vue@1.6.4(@unovis/ts@1.6.4)(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@unovis/ts': 1.6.4 + vue: 3.5.32(typescript@5.9.3) + + '@vee-validate/zod@4.15.1(vue@3.5.32(typescript@5.9.3))(zod@4.3.6)': + dependencies: + type-fest: 4.41.0 + vee-validate: 4.15.1(vue@3.5.32(typescript@5.9.3)) + zod: 4.3.6 + transitivePeerDependencies: + - vue + + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.15 + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@6.0.6(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + + '@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-macros/common@3.1.2(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@vue/compiler-sfc': 3.5.32 + ast-kit: 2.2.0 + local-pkg: 1.1.2 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.1 + optionalDependencies: + vue: 3.5.32(typescript@5.9.3) + + '@vue/babel-helper-vue-transform-on@1.5.0': {} + + '@vue/babel-helper-vue-transform-on@2.0.1': {} + + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.0)': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@vue/babel-helper-vue-transform-on': 1.5.0 + '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.0) + '@vue/shared': 3.5.32 + optionalDependencies: + '@babel/core': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.0)': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@vue/babel-helper-vue-transform-on': 2.0.1 + '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0) + '@vue/shared': 3.5.32 + optionalDependencies: + '@babel/core': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.0)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/parser': 7.29.2 + '@vue/compiler-sfc': 3.5.32 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.0)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/parser': 7.29.2 + '@vue/compiler-sfc': 3.5.32 + transitivePeerDependencies: + - supports-color + + '@vue/compiler-core@3.5.32': + dependencies: + '@babel/parser': 7.29.2 + '@vue/shared': 3.5.32 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.32': + dependencies: + '@vue/compiler-core': 3.5.32 + '@vue/shared': 3.5.32 + + '@vue/compiler-sfc@3.5.32': + dependencies: + '@babel/parser': 7.29.2 + '@vue/compiler-core': 3.5.32 + '@vue/compiler-dom': 3.5.32 + '@vue/compiler-ssr': 3.5.32 + '@vue/shared': 3.5.32 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.9 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.32': + dependencies: + '@vue/compiler-dom': 3.5.32 + '@vue/shared': 3.5.32 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-api@8.1.1': + dependencies: + '@vue/devtools-kit': 8.1.1 + + '@vue/devtools-core@8.1.1(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@vue/devtools-kit': 8.1.1 + '@vue/devtools-shared': 8.1.1 + vue: 3.5.32(typescript@5.9.3) + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-kit@8.1.1': + dependencies: + '@vue/devtools-shared': 8.1.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/devtools-shared@8.1.1': {} + + '@vue/language-core@3.2.6': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.32 + '@vue/shared': 3.5.32 + alien-signals: 3.1.2 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.4 + + '@vue/reactivity@3.5.32': + dependencies: + '@vue/shared': 3.5.32 + + '@vue/runtime-core@3.5.32': + dependencies: + '@vue/reactivity': 3.5.32 + '@vue/shared': 3.5.32 + + '@vue/runtime-dom@3.5.32': + dependencies: + '@vue/reactivity': 3.5.32 + '@vue/runtime-core': 3.5.32 + '@vue/shared': 3.5.32 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.32(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.32 + '@vue/shared': 3.5.32 + vue: 3.5.32(typescript@5.9.3) + + '@vue/shared@3.5.32': {} + + '@vueuse/core@10.11.1(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.32(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/core@12.8.2(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.2.1 + '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@5.9.3) + + '@vueuse/integrations@14.2.1(axios@1.15.0)(change-case@5.4.4)(nprogress@0.2.0)(universal-cookie@8.1.0)(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) + '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@5.9.3) + optionalDependencies: + axios: 1.15.0 + change-case: 5.4.4 + nprogress: 0.2.0 + universal-cookie: 8.1.0 + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/metadata@14.2.1': {} + + '@vueuse/shared@10.11.1(vue@3.5.32(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/shared@12.8.2(typescript@5.9.3)': + dependencies: + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/shared@14.2.1(vue@3.5.32(typescript@5.9.3))': + dependencies: + vue: 3.5.32(typescript@5.9.3) + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@3.1.2: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.2 + pathe: 2.0.3 + + ast-walker-scope@0.8.3: + dependencies: + '@babel/parser': 7.29.2 + ast-kit: 2.2.0 + + asynckit@0.4.0: {} + + autoprefixer@10.5.0(postcss@8.5.9): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001788 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.9 + postcss-value-parser: 4.2.0 + + axios@1.15.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.2 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.19: {} + + birpc@2.9.0: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.19 + caniuse-lite: 1.0.30001788 + electron-to-chromium: 1.5.336 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + builtin-modules@5.1.0: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + cac@7.0.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001788: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + change-case@5.4.4: {} + + character-entities@2.0.2: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + comment-parser@1.4.5: {} + + comment-parser@1.4.6: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csscolorparser@1.0.3: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-collection@1.0.7: {} + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo-projection@4.0.0: + dependencies: + commander: 7.2.0 + d3-array: 3.2.4 + d3-geo: 3.1.1 + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate-path@2.3.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dayjs@1.11.20: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-is@0.1.4: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff-sequences@29.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + earcut@2.2.4: {} + + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + + electron-to-chromium@1.5.336: {} + + elkjs@0.10.2: {} + + embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0(vue@3.5.32(typescript@5.9.3)): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + vue: 3.5.32(typescript@5.9.3) + + embla-carousel@8.6.0: {} + + emoji-regex@10.6.0: {} + + empathic@2.0.0: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + entities@4.5.0: {} + + entities@7.0.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser-es@1.0.5: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + semver: 7.7.4 + + eslint-config-flat-gitignore@2.3.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@eslint/compat': 2.0.5(eslint@9.39.4(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) + + eslint-flat-config-utils@3.1.0: + dependencies: + '@eslint/config-helpers': 0.5.5 + pathe: 2.0.3 + + eslint-formatting-reporter@0.0.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + prettier-linter-helpers: 1.0.1 + + eslint-json-compat-utils@0.2.3(eslint@9.39.4(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + esquery: 1.7.0 + jsonc-eslint-parser: 3.1.0 + + eslint-merge-processors@2.0.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + + eslint-parser-plain@0.1.1: {} + + eslint-plugin-antfu@3.2.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3))(@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/rule-tester': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + + eslint-plugin-depend@1.5.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 9.39.4(jiti@2.6.1) + module-replacements: 2.11.0 + semver: 7.7.4 + + eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + eslint: 9.39.4(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) + + eslint-plugin-format@1.5.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@dprint/formatter': 0.5.1 + '@dprint/markdown': 0.21.1 + '@dprint/toml': 0.7.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-formatting-reporter: 0.0.0(eslint@9.39.4(jiti@2.6.1)) + eslint-parser-plain: 0.1.1 + ohash: 2.0.11 + oxfmt: 0.35.0 + prettier: 3.8.2 + synckit: 0.11.12 + + eslint-plugin-import-lite@0.5.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + + eslint-plugin-jsdoc@62.9.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint: 9.39.4(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.0 + parse-imports-exports: 0.2.4 + semver: 7.7.4 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsonc@3.1.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + eslint: 9.39.4(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@9.39.4(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + jsonc-eslint-parser: 3.1.0 + natural-compare: 1.4.0 + synckit: 0.11.12 + transitivePeerDependencies: + - '@eslint/json' + + eslint-plugin-n@17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + enhanced-resolve: 5.20.1 + eslint: 9.39.4(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@9.39.4(jiti@2.6.1)) + get-tsconfig: 4.13.7 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.7.4 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-no-only-tests@3.3.0: {} + + eslint-plugin-perfectionist@5.8.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-pnpm@1.6.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 9.39.4(jiti@2.6.1) + jsonc-eslint-parser: 3.1.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.0 + tinyglobby: 0.2.16 + yaml: 2.8.3 + yaml-eslint-parser: 2.0.0 + + eslint-plugin-regexp@3.1.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.6 + eslint: 9.39.4(jiti@2.6.1) + jsdoc-type-pratt-parser: 7.2.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + + eslint-plugin-toml@1.3.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + toml-eslint-parser: 1.0.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-unicorn@63.0.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 9.39.4(jiti@2.6.1) + find-up-simple: 1.0.1 + globals: 16.5.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.1 + semver: 7.7.4 + strip-indent: 4.1.1 + + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.7.4 + vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-yml@3.3.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3 + diff-sequences: 29.6.3 + escape-string-regexp: 5.0.0 + eslint: 9.39.4(jiti@2.6.1) + natural-compare: 1.4.0 + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - supports-color + + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.32)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@vue/compiler-sfc': 3.5.32 + eslint: 9.39.4(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + eventemitter3@5.0.4: {} + + exsolve@1.0.8: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-string-truncated-width@1.2.1: {} + + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-root@1.1.0: {} + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + format@0.2.2: {} + + fraction.js@5.3.4: {} + + framer-motion@12.23.12: + dependencies: + motion-dom: 12.23.12 + motion-utils: 12.36.0 + tslib: 2.8.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + geojson-vt@3.2.1: {} + + geojson@0.5.0: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + gl-matrix@3.4.4: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globals@17.5.0: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hey-listen@1.0.8: {} + + hookable@5.5.3: {} + + html-entities@2.6.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + ini@1.3.8: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-arrayish@0.2.1: {} + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.1.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-what@5.5.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsdoc-type-pratt-parser@7.1.1: {} + + jsdoc-type-pratt-parser@7.2.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-eslint-parser@3.1.0: + dependencies: + acorn: 8.16.0 + eslint-visitor-keys: 5.0.1 + semver: 7.7.4 + + kdbush@3.0.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + kolorist@1.8.0: {} + + leaflet@1.7.1: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.1.1 + yaml: 2.8.3 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash.merge@4.6.2: {} + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + longest-streak@3.1.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-vue-next@0.575.0(vue@3.5.32(typescript@5.9.3)): + dependencies: + vue: 3.5.32(typescript@5.9.3) + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + maplibre-gl@2.4.0: + dependencies: + '@mapbox/geojson-rewind': 0.5.2 + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/mapbox-gl-supported': 2.0.1 + '@mapbox/point-geometry': 0.1.0 + '@mapbox/tiny-sdf': 2.1.0 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 1.3.1 + '@mapbox/whoots-js': 3.1.0 + '@types/geojson': 7946.0.16 + '@types/mapbox__point-geometry': 0.1.4 + '@types/mapbox__vector-tile': 1.3.4 + '@types/pbf': 3.0.5 + csscolorparser: 1.0.3 + earcut: 2.2.4 + geojson-vt: 3.2.1 + gl-matrix: 3.4.4 + global-prefix: 3.0.0 + murmurhash-js: 1.0.0 + pbf: 3.3.0 + potpack: 1.0.2 + quickselect: 2.0.0 + supercluster: 7.1.5 + tinyqueue: 2.0.3 + vt-pbf: 3.1.3 + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdurl@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-function@5.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + module-replacements@2.11.0: {} + + motion-dom@12.23.12: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion-v@1.7.4(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) + framer-motion: 12.23.12 + hey-listen: 1.0.8 + motion-dom: 12.23.12 + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - react + - react-dom + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + murmurhash-js@1.0.0: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + natural-orderby@5.0.0: {} + + node-releases@2.0.37: {} + + nprogress@0.2.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-deep-merge@2.0.0: {} + + obug@2.1.1: {} + + ohash@2.0.11: {} + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + oxfmt@0.35.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.35.0 + '@oxfmt/binding-android-arm64': 0.35.0 + '@oxfmt/binding-darwin-arm64': 0.35.0 + '@oxfmt/binding-darwin-x64': 0.35.0 + '@oxfmt/binding-freebsd-x64': 0.35.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.35.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.35.0 + '@oxfmt/binding-linux-arm64-gnu': 0.35.0 + '@oxfmt/binding-linux-arm64-musl': 0.35.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-musl': 0.35.0 + '@oxfmt/binding-linux-s390x-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-musl': 0.35.0 + '@oxfmt/binding-openharmony-arm64': 0.35.0 + '@oxfmt/binding-win32-arm64-msvc': 0.35.0 + '@oxfmt/binding-win32-ia32-msvc': 0.35.0 + '@oxfmt/binding-win32-x64-msvc': 0.35.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-gitignore@2.0.0: {} + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-statements@1.0.11: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + pbf@3.3.0: + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pinia-plugin-persistedstate@4.7.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))): + dependencies: + defu: 6.1.7 + optionalDependencies: + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) + + pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + vue: 3.5.32(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + pluralize@8.0.0: {} + + pnpm-workspace-yaml@1.6.0: + dependencies: + yaml: 2.8.3 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + potpack@1.0.2: {} + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.8.2: {} + + protocol-buffers-schema@3.6.1: {} + + proxy-from-env@2.1.0: {} + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + quickselect@2.0.0: {} + + readdirp@5.0.0: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regjsparser@0.13.1: + dependencies: + jsesc: 3.1.0 + + reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/vue': 1.1.11(vue@3.5.32(typescript@5.9.3)) + '@internationalized/date': 3.12.0 + '@internationalized/number': 3.6.5 + '@tanstack/vue-virtual': 3.13.23(vue@3.5.32(typescript@5.9.3)) + '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) + '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) + aria-hidden: 1.2.6 + defu: 6.1.7 + ohash: 2.0.11 + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + remove-accents@0.5.0: {} + + reserved-identifiers@1.2.0: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + robust-predicates@3.0.3: {} + + rollup-plugin-visualizer@7.0.1(rollup@4.60.1): + dependencies: + open: 11.0.0 + picomatch: 4.0.4 + source-map: 0.7.6 + yargs: 18.0.0 + optionalDependencies: + rollup: 4.60.1 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + scule@1.3.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + simple-git-hooks@2.13.1: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + source-map@0.7.6: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + speakingurl@14.0.1: {} + + string-argv@0.3.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-indent@4.1.1: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + striptags@3.2.0: {} + + stylis@4.2.0: {} + + supercluster@7.1.5: + dependencies: + kdbush: 3.0.0 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tailwind-merge@3.5.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@4.2.2): + dependencies: + tailwindcss: 4.2.2 + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + three@0.135.0: {} + + throttle-debounce@5.0.2: {} + + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + tinyqueue@2.0.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toml-eslint-parser@1.0.3: + dependencies: + eslint-visitor-keys: 5.0.1 + + topojson-client@3.1.0: + dependencies: + commander: 2.20.3 + + totalist@3.0.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + tslib@2.3.0: {} + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.41.0: {} + + typescript@4.2.4: {} + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.3: {} + + undici-types@7.16.0: {} + + unimport@5.7.0: + dependencies: + acorn: 8.16.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 2.3.0 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.16 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universal-cookie@8.1.0: + dependencies: + cookie: 1.1.1 + + unplugin-auto-import@21.0.0(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3))): + dependencies: + local-pkg: 1.1.2 + magic-string: 0.30.21 + picomatch: 4.0.4 + unimport: 5.7.0 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + optionalDependencies: + '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@31.1.0(vue@3.5.32(typescript@5.9.3)): + dependencies: + chokidar: 5.0.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + obug: 2.1.1 + picomatch: 4.0.4 + tinyglobby: 0.2.16 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + vue: 3.5.32(typescript@5.9.3) + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vaul-vue@0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.32(typescript@5.9.3)) + reka-ui: 2.9.6(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + vee-validate@4.15.1(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + type-fest: 4.41.0 + vue: 3.5.32(typescript@5.9.3) + + vite-dev-rpc@1.1.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)): + dependencies: + birpc: 2.9.0 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite-hot-client: 2.1.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + + vite-hot-client@2.1.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)): + dependencies: + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + vite-plugin-inspect@11.3.3(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)): + dependencies: + ansis: 4.2.0 + debug: 4.4.3 + error-stack-parser-es: 1.0.5 + ohash: 2.0.11 + open: 10.2.0 + perfect-debounce: 2.1.0 + sirv: 3.0.2 + unplugin-utils: 0.3.1 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite-dev-rpc: 1.1.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + transitivePeerDependencies: + - supports-color + + vite-plugin-vue-devtools@8.1.1(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) + '@vue/devtools-kit': 8.1.1 + '@vue/devtools-shared': 8.1.1 + sirv: 3.0.2 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite-plugin-inspect: 11.3.3(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + vite-plugin-vue-inspector: 5.4.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + transitivePeerDependencies: + - '@nuxt/kit' + - supports-color + - vue + + vite-plugin-vue-inspector@5.4.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0) + '@vue/compiler-dom': 3.5.32 + kolorist: 1.8.0 + magic-string: 0.30.21 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + vite-plugin-vue-layouts@0.11.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): + dependencies: + debug: 4.4.3 + fast-glob: 3.3.3 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + vue-router: 5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + + vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + yaml: 2.8.3 + + vscode-uri@3.1.0: {} + + vt-pbf@3.1.3: + dependencies: + '@mapbox/point-geometry': 0.1.0 + '@mapbox/vector-tile': 1.3.1 + pbf: 3.3.0 + + vue-demi@0.14.10(vue@3.5.32(typescript@5.9.3)): + dependencies: + vue: 3.5.32(typescript@5.9.3) + + vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@intlify/core-base': 11.3.2 + '@intlify/devtools-types': 11.3.2 + '@intlify/shared': 11.3.2 + '@vue/devtools-api': 6.6.4 + vue: 3.5.32(typescript@5.9.3) + + vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vueuse/core': 12.8.2(typescript@5.9.3) + reka-ui: 2.9.6(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + - typescript + + vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@babel/generator': 7.29.1 + '@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue/devtools-api': 8.1.1 + ast-walker-scope: 0.8.3 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.4 + scule: 1.3.0 + tinyglobby: 0.2.16 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + vue: 3.5.32(typescript@5.9.3) + yaml: 2.8.3 + optionalDependencies: + '@vue/compiler-sfc': 3.5.32 + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) + + vue-sonner@2.0.9: {} + + vue-tsc@3.2.6(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.2.6 + typescript: 5.9.3 + + vue@3.5.32(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.32 + '@vue/compiler-sfc': 3.5.32 + '@vue/runtime-dom': 3.5.32 + '@vue/server-renderer': 3.5.32(vue@3.5.32(typescript@5.9.3)) + '@vue/shared': 3.5.32 + optionalDependencies: + typescript: 5.9.3 + + webpack-virtual-modules@0.6.2: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xml-name-validator@4.0.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-eslint-parser@2.0.0: + dependencies: + eslint-visitor-keys: 5.0.1 + yaml: 2.8.3 + + yaml@1.10.3: {} + + yaml@2.8.3: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@0.1.0: {} + + zod@4.3.6: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 + + zwitch@2.0.4: {} diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000..f824337 --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/frontend/public/logo-black.svg b/frontend/public/logo-black.svg new file mode 100644 index 0000000..9139cce --- /dev/null +++ b/frontend/public/logo-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..ca8b95d --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f7300b6 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/assets/chart-theme.css b/frontend/src/assets/chart-theme.css new file mode 100644 index 0000000..9b55d3d --- /dev/null +++ b/frontend/src/assets/chart-theme.css @@ -0,0 +1,118 @@ +/* theme red */ +.theme-red { + --chart-1: oklch(0.808 0.114 19.571); + --chart-2: oklch(0.637 0.237 25.331); + --chart-3: oklch(0.577 0.245 27.325); + --chart-4: oklch(0.505 0.213 27.518); + --chart-5: oklch(0.444 0.177 26.899); +} + +.theme-red.dark { + --chart-1: oklch(0.808 0.114 19.571); + --chart-2: oklch(0.637 0.237 25.331); + --chart-3: oklch(0.577 0.245 27.325); + --chart-4: oklch(0.505 0.213 27.518); + --chart-5: oklch(0.444 0.177 26.899); +} + +/* theme rose */ +.theme-rose { + --chart-1: oklch(0.81 0.117 11.638); + --chart-2: oklch(0.645 0.246 16.439); + --chart-3: oklch(0.586 0.253 17.585); + --chart-4: oklch(0.514 0.222 16.935); + --chart-5: oklch(0.455 0.188 13.697); +} + +.theme-rose.dark { + --chart-1: oklch(0.81 0.117 11.638); + --chart-2: oklch(0.645 0.246 16.439); + --chart-3: oklch(0.586 0.253 17.585); + --chart-4: oklch(0.514 0.222 16.935); + --chart-5: oklch(0.455 0.188 13.697); +} + +/* theme orange */ +.theme-orange { + --chart-1: oklch(0.837 0.128 66.29); + --chart-2: oklch(0.705 0.213 47.604); + --chart-3: oklch(0.646 0.222 41.116); + --chart-4: oklch(0.553 0.195 38.402); + --chart-5: oklch(0.47 0.157 37.304); +} + +.theme-orange.dark { + --chart-1: oklch(0.837 0.128 66.29); + --chart-2: oklch(0.705 0.213 47.604); + --chart-3: oklch(0.646 0.222 41.116); + --chart-4: oklch(0.553 0.195 38.402); + --chart-5: oklch(0.47 0.157 37.304); +} + +/* theme green */ +.theme-green { + --chart-1: oklch(0.871 0.15 154.449); + --chart-2: oklch(0.723 0.219 149.579); + --chart-3: oklch(0.627 0.194 149.214); + --chart-4: oklch(0.527 0.154 150.069); + --chart-5: oklch(0.448 0.119 151.328); +} + +.theme-green.dark { + --chart-1: oklch(0.871 0.15 154.449); + --chart-2: oklch(0.723 0.219 149.579); + --chart-3: oklch(0.627 0.194 149.214); + --chart-4: oklch(0.527 0.154 150.069); + --chart-5: oklch(0.448 0.119 151.328); +} + +/* theme blue */ +.theme-blue { + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); +} + +.theme-blue.dark { + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); +} + +/* theme yellow */ +.theme-yellow { + --chart-1: oklch(0.905 0.182 98.111); + --chart-2: oklch(0.795 0.184 86.047); + --chart-3: oklch(0.681 0.162 75.834); + --chart-4: oklch(0.554 0.135 66.442); + --chart-5: oklch(0.476 0.114 61.907); +} + +.theme-yellow.dark { + --chart-1: oklch(0.905 0.182 98.111); + --chart-2: oklch(0.795 0.184 86.047); + --chart-3: oklch(0.681 0.162 75.834); + --chart-4: oklch(0.554 0.135 66.442); + --chart-5: oklch(0.476 0.114 61.907); +} + +/* theme violet */ +.theme-violet { + --chart-1: oklch(0.811 0.111 293.571); + --chart-2: oklch(0.606 0.25 292.717); + --chart-3: oklch(0.541 0.281 293.009); + --chart-4: oklch(0.491 0.27 292.581); + --chart-5: oklch(0.432 0.232 292.759); +} + +.theme-violet.dark { + --chart-1: oklch(0.811 0.111 293.571); + --chart-2: oklch(0.606 0.25 292.717); + --chart-3: oklch(0.541 0.281 293.009); + --chart-4: oklch(0.491 0.27 292.581); + --chart-5: oklch(0.432 0.232 292.759); +} diff --git a/frontend/src/assets/icons/arrow-dark.svg b/frontend/src/assets/icons/arrow-dark.svg new file mode 100644 index 0000000..b4860f4 --- /dev/null +++ b/frontend/src/assets/icons/arrow-dark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/arrow-light.svg b/frontend/src/assets/icons/arrow-light.svg new file mode 100644 index 0000000..0039520 --- /dev/null +++ b/frontend/src/assets/icons/arrow-light.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/index.css b/frontend/src/assets/index.css new file mode 100644 index 0000000..33bf8bf --- /dev/null +++ b/frontend/src/assets/index.css @@ -0,0 +1,154 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; +@plugin '@tailwindcss/typography'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(1 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@theme inline { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--reka-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--reka-accordion-content-height); + } + to { + height: 0; + } + } +} + +@layer base { + html { + scrollbar-gutter: stable; + } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + scrollbar-gutter: stable; + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } +} diff --git a/frontend/src/assets/nprogress.css b/frontend/src/assets/nprogress.css new file mode 100644 index 0000000..947eaa9 --- /dev/null +++ b/frontend/src/assets/nprogress.css @@ -0,0 +1,61 @@ +@reference './index.css'; + +/* Make clicks pass-through */ +#nprogress { + @apply pointer-events-none; +} + +#nprogress .bar { + @apply bg-primary fixed left-0 top-0 z-[1031] h-[2px] w-full; +} + +/* Fancy blur effect */ +#nprogress .peg { + @apply absolute right-0 block h-full w-[100px]; + + box-shadow: + 0 0 10px hsl(var(--primary)), + 0 0 5px hsl(var(--primary)); + opacity: 1; + transform: rotate(3deg) translate(0, -4px); +} + +/* Remove these to get rid of the spinner */ +#nprogress .spinner { + @apply fixed right-4 top-4 z-[1031] block; +} + +#nprogress .spinner-icon { + @apply border-t-primary border-l-primary size-4 rounded-full border-[2px] border-solid border-transparent; + + animation: nprogress-spinner 400ms linear infinite; +} + +.nprogress-custom-parent { + @apply relative overflow-hidden; +} + +.nprogress-custom-parent #nprogress .spinner, +.nprogress-custom-parent #nprogress .bar { + @apply absolute; +} + +@keyframes nprogress-spinner { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes nprogress-spinner { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/frontend/src/assets/scrollbar.css b/frontend/src/assets/scrollbar.css new file mode 100644 index 0000000..cedea60 --- /dev/null +++ b/frontend/src/assets/scrollbar.css @@ -0,0 +1,25 @@ +* { + scrollbar-color: #8885 var(--c-border); +} + +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar:horizontal { + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--c-border); + border-radius: 1px; +} + +::-webkit-scrollbar-thumb { + background: #8885; + border-radius: 1px; +} + +::-webkit-scrollbar-thumb:hover { + background: #8886; +} diff --git a/frontend/src/assets/themes.css b/frontend/src/assets/themes.css new file mode 100644 index 0000000..eeb9d8f --- /dev/null +++ b/frontend/src/assets/themes.css @@ -0,0 +1,559 @@ +/* theme yellow */ +.theme-yellow { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.795 0.184 86.047); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.421 0.095 57.708); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.795 0.184 86.047); +} + +.theme-yellow.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.554 0.135 66.442); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.421 0.095 57.708); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.554 0.135 66.442); +} + +/* theme red */ +.theme-red { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.637 0.237 25.331); + --primary-foreground: oklch(0.971 0.013 17.38); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.637 0.237 25.331); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.637 0.237 25.331); + --sidebar-primary-foreground: oklch(0.971 0.013 17.38); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.637 0.237 25.331); +} + +.theme-red.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.637 0.237 25.331); + --primary-foreground: oklch(0.971 0.013 17.38); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.637 0.237 25.331); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.637 0.237 25.331); + --sidebar-primary-foreground: oklch(0.971 0.013 17.38); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.637 0.237 25.331); +} + +/* theme rose */ +.theme-rose { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.645 0.246 16.439); + --primary-foreground: oklch(0.969 0.015 12.422); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.645 0.246 16.439); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.645 0.246 16.439); + --sidebar-primary-foreground: oklch(0.969 0.015 12.422); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.645 0.246 16.439); +} + +.theme-rose.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.645 0.246 16.439); + --primary-foreground: oklch(0.969 0.015 12.422); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.645 0.246 16.439); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.645 0.246 16.439); + --sidebar-primary-foreground: oklch(0.969 0.015 12.422); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.645 0.246 16.439); +} + +/* theme orange */ +.theme-orange { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.705 0.213 47.604); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.213 47.604); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.705 0.213 47.604); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.213 47.604); +} + +.theme-orange.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.646 0.222 41.116); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.646 0.222 41.116); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.646 0.222 41.116); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.646 0.222 41.116); +} + +/* theme green */ +.theme-green { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.723 0.219 149.579); + --primary-foreground: oklch(0.982 0.018 155.826); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.723 0.219 149.579); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.723 0.219 149.579); + --sidebar-primary-foreground: oklch(0.982 0.018 155.826); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.723 0.219 149.579); +} + +.theme-green.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.696 0.17 162.48); + --primary-foreground: oklch(0.393 0.095 152.535); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.527 0.154 150.069); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.696 0.17 162.48); + --sidebar-primary-foreground: oklch(0.393 0.095 152.535); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.527 0.154 150.069); +} + +/* theme blue */ +.theme-blue { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.623 0.214 259.815); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.623 0.214 259.815); +} + +.theme-blue.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.379 0.146 265.522); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.488 0.243 264.376); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.379 0.146 265.522); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.488 0.243 264.376); +} + +/* theme yellow */ +.theme-yellow { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.795 0.184 86.047); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.421 0.095 57.708); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.795 0.184 86.047); +} + +.theme-yellow.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.554 0.135 66.442); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.421 0.095 57.708); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.554 0.135 66.442); +} + +/* theme violet */ +.theme-violet { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.606 0.25 292.717); + --primary-foreground: oklch(0.969 0.016 293.756); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.606 0.25 292.717); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.606 0.25 292.717); + --sidebar-primary-foreground: oklch(0.969 0.016 293.756); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.606 0.25 292.717); +} + +.theme-violet.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.541 0.281 293.009); + --primary-foreground: oklch(0.969 0.016 293.756); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.541 0.281 293.009); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.541 0.281 293.009); + --sidebar-primary-foreground: oklch(0.969 0.016 293.756); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.541 0.281 293.009); +} diff --git a/frontend/src/components/app-sidebar/data/sidebar-data.ts b/frontend/src/components/app-sidebar/data/sidebar-data.ts new file mode 100644 index 0000000..5bfe870 --- /dev/null +++ b/frontend/src/components/app-sidebar/data/sidebar-data.ts @@ -0,0 +1,41 @@ +import { + AudioWaveform, + Command, + GalleryVerticalEnd, +} from 'lucide-vue-next' + +import { useSidebar } from '@/composables/use-sidebar' + +import type { SidebarData, Team, User } from '../types' + +const user: User = { + name: 'shadcn', + email: 'm@example.com', + avatar: '/avatars/shadcn.jpg', +} + +const teams: Team[] = [ + { + name: 'Acme Inc', + logo: GalleryVerticalEnd, + plan: 'Enterprise', + }, + { + name: 'Acme Corp.', + logo: AudioWaveform, + plan: 'Startup', + }, + { + name: 'Evil Corp.', + logo: Command, + plan: 'Free', + }, +] + +const { navData } = useSidebar() + +export const sidebarData: SidebarData = { + user, + teams, + navMain: navData.value!, +} diff --git a/frontend/src/components/app-sidebar/index.vue b/frontend/src/components/app-sidebar/index.vue new file mode 100644 index 0000000..e630335 --- /dev/null +++ b/frontend/src/components/app-sidebar/index.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/app-sidebar/nav-footer.vue b/frontend/src/components/app-sidebar/nav-footer.vue new file mode 100644 index 0000000..a804688 --- /dev/null +++ b/frontend/src/components/app-sidebar/nav-footer.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/src/components/app-sidebar/nav-team-add.vue b/frontend/src/components/app-sidebar/nav-team-add.vue new file mode 100644 index 0000000..0a4870d --- /dev/null +++ b/frontend/src/components/app-sidebar/nav-team-add.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/components/app-sidebar/nav-team.vue b/frontend/src/components/app-sidebar/nav-team.vue new file mode 100644 index 0000000..f29c668 --- /dev/null +++ b/frontend/src/components/app-sidebar/nav-team.vue @@ -0,0 +1,136 @@ + + + diff --git a/frontend/src/components/app-sidebar/team-switcher.vue b/frontend/src/components/app-sidebar/team-switcher.vue new file mode 100644 index 0000000..0cc290c --- /dev/null +++ b/frontend/src/components/app-sidebar/team-switcher.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/app-sidebar/types.ts b/frontend/src/components/app-sidebar/types.ts new file mode 100644 index 0000000..820daf1 --- /dev/null +++ b/frontend/src/components/app-sidebar/types.ts @@ -0,0 +1,43 @@ +import type { LucideProps } from 'lucide-vue-next' +import type { FunctionalComponent } from 'vue' + +type NavIcon = FunctionalComponent, any, Record> + +interface BaseNavItem { + title: string + icon?: NavIcon +} + +export type NavItem + = | BaseNavItem & { + items: (BaseNavItem & { url?: string })[] + url?: never + isActive?: boolean + } | BaseNavItem & { + url: string + items?: never + } + +export interface NavGroup { + title: string + items: NavItem[] +} + +export interface User { + name: string + avatar: string + email: string + role?: string +} + +export interface Team { + name: string + logo: NavIcon + plan: string +} + +export interface SidebarData { + user: User + teams: Team[] + navMain: NavGroup[] +} diff --git a/frontend/src/components/app-sidebar/validators/team.validator.ts b/frontend/src/components/app-sidebar/validators/team.validator.ts new file mode 100644 index 0000000..39b3808 --- /dev/null +++ b/frontend/src/components/app-sidebar/validators/team.validator.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const teamAddValidator = z.object({ + name: z + .string() + .min(1, { error: 'Group name is required' }) + .max(50, { error: 'Group name must be less than 50 characters' }), + slug: z + .string() + .min(1, { error: 'Group name is required' }) + .max(50, { error: 'Group name must be less than 50 characters' }), + logo: z + .string() + .optional(), +}) + +export type TeamAddValidator = z.infer diff --git a/frontend/src/components/command-menu-panel/command-change-theme.vue b/frontend/src/components/command-menu-panel/command-change-theme.vue new file mode 100644 index 0000000..5a7cf2e --- /dev/null +++ b/frontend/src/components/command-menu-panel/command-change-theme.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/components/command-menu-panel/command-item-has-icon.vue b/frontend/src/components/command-menu-panel/command-item-has-icon.vue new file mode 100644 index 0000000..09acecc --- /dev/null +++ b/frontend/src/components/command-menu-panel/command-item-has-icon.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/command-menu-panel/command-to-page.vue b/frontend/src/components/command-menu-panel/command-to-page.vue new file mode 100644 index 0000000..95dde6c --- /dev/null +++ b/frontend/src/components/command-menu-panel/command-to-page.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/command-menu-panel/index.vue b/frontend/src/components/command-menu-panel/index.vue new file mode 100644 index 0000000..c44b6d5 --- /dev/null +++ b/frontend/src/components/command-menu-panel/index.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/components/confirm-dialog.vue b/frontend/src/components/confirm-dialog.vue new file mode 100644 index 0000000..7febace --- /dev/null +++ b/frontend/src/components/confirm-dialog.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/components/country-select.vue b/frontend/src/components/country-select.vue new file mode 100644 index 0000000..58f2293 --- /dev/null +++ b/frontend/src/components/country-select.vue @@ -0,0 +1,138 @@ + + + diff --git a/frontend/src/components/custom-error.vue b/frontend/src/components/custom-error.vue new file mode 100644 index 0000000..cd1c05d --- /dev/null +++ b/frontend/src/components/custom-error.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/custom-theme/content-layout.vue b/frontend/src/components/custom-theme/content-layout.vue new file mode 100644 index 0000000..80f080d --- /dev/null +++ b/frontend/src/components/custom-theme/content-layout.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/custom-theme/custom-color.vue b/frontend/src/components/custom-theme/custom-color.vue new file mode 100644 index 0000000..581d35b --- /dev/null +++ b/frontend/src/components/custom-theme/custom-color.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/components/custom-theme/custom-radius.vue b/frontend/src/components/custom-theme/custom-radius.vue new file mode 100644 index 0000000..1a6ba78 --- /dev/null +++ b/frontend/src/components/custom-theme/custom-radius.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/custom-theme/custom-theme-title.vue b/frontend/src/components/custom-theme/custom-theme-title.vue new file mode 100644 index 0000000..bf006d1 --- /dev/null +++ b/frontend/src/components/custom-theme/custom-theme-title.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/custom-theme/theme-popover.vue b/frontend/src/components/custom-theme/theme-popover.vue new file mode 100644 index 0000000..12bfd19 --- /dev/null +++ b/frontend/src/components/custom-theme/theme-popover.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/custom-theme/toggle-color-mode.vue b/frontend/src/components/custom-theme/toggle-color-mode.vue new file mode 100644 index 0000000..ff0de33 --- /dev/null +++ b/frontend/src/components/custom-theme/toggle-color-mode.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/data-table/bulk-actions.vue b/frontend/src/components/data-table/bulk-actions.vue new file mode 100644 index 0000000..7396664 --- /dev/null +++ b/frontend/src/components/data-table/bulk-actions.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/components/data-table/column-header.vue b/frontend/src/components/data-table/column-header.vue new file mode 100644 index 0000000..89c447c --- /dev/null +++ b/frontend/src/components/data-table/column-header.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/components/data-table/data-table.vue b/frontend/src/components/data-table/data-table.vue new file mode 100644 index 0000000..1e89e53 --- /dev/null +++ b/frontend/src/components/data-table/data-table.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/src/components/data-table/date-range-filter.vue b/frontend/src/components/data-table/date-range-filter.vue new file mode 100644 index 0000000..1439a7c --- /dev/null +++ b/frontend/src/components/data-table/date-range-filter.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/components/data-table/faceted-filter.vue b/frontend/src/components/data-table/faceted-filter.vue new file mode 100644 index 0000000..2da80c6 --- /dev/null +++ b/frontend/src/components/data-table/faceted-filter.vue @@ -0,0 +1,121 @@ + + + diff --git a/frontend/src/components/data-table/radio-cell.vue b/frontend/src/components/data-table/radio-cell.vue new file mode 100644 index 0000000..590fcef --- /dev/null +++ b/frontend/src/components/data-table/radio-cell.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/data-table/single-filter.vue b/frontend/src/components/data-table/single-filter.vue new file mode 100644 index 0000000..a48038d --- /dev/null +++ b/frontend/src/components/data-table/single-filter.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/components/data-table/table-columns.ts b/frontend/src/components/data-table/table-columns.ts new file mode 100644 index 0000000..2d44c6e --- /dev/null +++ b/frontend/src/components/data-table/table-columns.ts @@ -0,0 +1,49 @@ +import type { ColumnDef } from '@tanstack/vue-table' + +import { h } from 'vue' + +import Checkbox from '@/components/ui/checkbox/Checkbox.vue' + +import RadioCell from './radio-cell.vue' + +const FIXED_WIDTH_COLUMN = { + size: 32, + minSize: 32, + maxSize: 32, + enableResizing: false, +} as const + +export const SelectColumn: ColumnDef = { + id: 'select', + ...FIXED_WIDTH_COLUMN, + header: ({ table }) => h(Checkbox, { + 'modelValue': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'), + 'onUpdate:modelValue': value => table.toggleAllPageRowsSelected(!!value), + 'ariaLabel': 'Select all', + }), + cell: ({ row }) => h(Checkbox, { + 'modelValue': row.getIsSelected(), + 'onUpdate:modelValue': value => row.toggleSelected(!!value), + 'ariaLabel': 'Select row', + }), + enableSorting: false, + enableHiding: false, +} + +export const RadioSelectColumn: ColumnDef = { + id: 'radio-select', + ...FIXED_WIDTH_COLUMN, + header: () => null, + cell: ({ row, table }) => h(RadioCell, { + checked: row.getIsSelected(), + onClick: (event: MouseEvent) => { + event.stopPropagation() + // cancel selection of all rows + table.toggleAllRowsSelected(false) + // select the current row + row.toggleSelected(true) + }, + }), + enableSorting: false, + enableHiding: false, +} diff --git a/frontend/src/components/data-table/table-loading.vue b/frontend/src/components/data-table/table-loading.vue new file mode 100644 index 0000000..d7cbb79 --- /dev/null +++ b/frontend/src/components/data-table/table-loading.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/src/components/data-table/table-pagination.vue b/frontend/src/components/data-table/table-pagination.vue new file mode 100644 index 0000000..b9a53ee --- /dev/null +++ b/frontend/src/components/data-table/table-pagination.vue @@ -0,0 +1,222 @@ + + + diff --git a/frontend/src/components/data-table/types.ts b/frontend/src/components/data-table/types.ts new file mode 100644 index 0000000..542effb --- /dev/null +++ b/frontend/src/components/data-table/types.ts @@ -0,0 +1,22 @@ +import type { ColumnDef } from '@tanstack/vue-table' + +export interface FacetedFilterOption { + label: string + value: string + icon?: Component +} + +export interface ServerPagination { + page: number + pageSize: number + total: number + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: number) => void +} + +export interface DataTableProps { + loading?: boolean + columns: ColumnDef[] + data: T[] + serverPagination?: ServerPagination +} diff --git a/frontend/src/components/data-table/use-generate-vue-table.ts b/frontend/src/components/data-table/use-generate-vue-table.ts new file mode 100644 index 0000000..62e7ad8 --- /dev/null +++ b/frontend/src/components/data-table/use-generate-vue-table.ts @@ -0,0 +1,119 @@ +import type { ColumnFiltersState, ColumnPinningState, PaginationState, SortingState, TableOptionsWithReactiveData, VisibilityState } from '@tanstack/vue-table' + +import { computed, ref } from 'vue' +import { getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table' + +import { DEFAULT_PAGE_SIZE } from '@/constants/pagination' +import { valueUpdater } from '@/lib/utils' + +import type { DataTableProps } from './types' + +interface ExtendedDataTableProps extends DataTableProps { + getSubRows?: (row: T) => T[] +} + +export function generateVueTable(props: ExtendedDataTableProps, columns: DataTableProps['columns']) { + const sorting = ref([]) + const columnFilters = ref([]) + const columnVisibility = ref({}) + const columnPinning = ref({ left: [], right: [] }) + const rowSelection = ref({}) + const globalFilter = ref('') + const expanded = ref({}) + const pagination = ref({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + }) + + const useServerPagination = !!props.serverPagination + + const pageIndex = computed(() => { + if (useServerPagination && props.serverPagination) { + return props.serverPagination.page - 1 + } + return 0 + }) + + const pageSize = computed(() => { + if (useServerPagination && props.serverPagination) { + return props.serverPagination.pageSize + } + return DEFAULT_PAGE_SIZE + }) + + const pageCount = computed(() => { + if (useServerPagination && props.serverPagination) { + return Math.ceil(props.serverPagination.total / props.serverPagination.pageSize) + } + return -1 + }) + + const tableConfig: TableOptionsWithReactiveData = { + get data() { return props.data }, + get columns() { return columns }, + state: { + get sorting() { return sorting.value }, + get columnFilters() { return columnFilters.value }, + get columnVisibility() { return columnVisibility.value }, + get columnPinning() { return columnPinning.value }, + get rowSelection() { return rowSelection.value }, + get globalFilter() { return globalFilter.value }, + get expanded() { return expanded.value }, + get pagination() { + if (useServerPagination) { + return { + pageIndex: pageIndex.value, + pageSize: pageSize.value, + } + } + return pagination.value + }, + }, + enableRowSelection: true, + onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting), + onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters), + onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility), + onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning), + onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection), + onGlobalFilterChange: updaterOrValue => valueUpdater(updaterOrValue, globalFilter), + onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded), + onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination), + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getExpandedRowModel: getExpandedRowModel(), + globalFilterFn: (row, _columnId, filterValue) => { + const search = String(filterValue).toLowerCase() + const values = Object.values(row.original as Record) + return values.some((value) => { + if (value == null) + return false + if (typeof value === 'object') { + return Object.values(value as Record).some( + v => v != null && String(v).toLowerCase().includes(search), + ) + } + return String(value).toLowerCase().includes(search) + }) + }, + } + + if (props.getSubRows) { + tableConfig.getSubRows = props.getSubRows + } + + if (useServerPagination) { + tableConfig.pageCount = pageCount.value + tableConfig.manualPagination = true + } + else { + tableConfig.getPaginationRowModel = getPaginationRowModel() + } + + const table = useVueTable(tableConfig) + + return table +} diff --git a/frontend/src/components/data-table/view-options.vue b/frontend/src/components/data-table/view-options.vue new file mode 100644 index 0000000..71b610c --- /dev/null +++ b/frontend/src/components/data-table/view-options.vue @@ -0,0 +1,72 @@ + + + diff --git a/frontend/src/components/developer-sidebar/index.vue b/frontend/src/components/developer-sidebar/index.vue new file mode 100644 index 0000000..902f0ba --- /dev/null +++ b/frontend/src/components/developer-sidebar/index.vue @@ -0,0 +1,155 @@ + + + diff --git a/frontend/src/components/developer-sidebar/nav-footer.vue b/frontend/src/components/developer-sidebar/nav-footer.vue new file mode 100644 index 0000000..f8fa421 --- /dev/null +++ b/frontend/src/components/developer-sidebar/nav-footer.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/src/components/global-layout/basic-header.vue b/frontend/src/components/global-layout/basic-header.vue new file mode 100644 index 0000000..df69495 --- /dev/null +++ b/frontend/src/components/global-layout/basic-header.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/global-layout/basic-page.vue b/frontend/src/components/global-layout/basic-page.vue new file mode 100644 index 0000000..39c94d5 --- /dev/null +++ b/frontend/src/components/global-layout/basic-page.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/global-layout/index.ts b/frontend/src/components/global-layout/index.ts new file mode 100644 index 0000000..7e97678 --- /dev/null +++ b/frontend/src/components/global-layout/index.ts @@ -0,0 +1,6 @@ +export { default as BasicHeader } from './basic-header.vue' +export { default as BasicPage } from './basic-page.vue' +export { default as TwoColAside } from './two-col-aside.vue' +export { default as TwoColLayout } from './two-col.vue' + +export type * from './types' diff --git a/frontend/src/components/global-layout/two-col-aside.vue b/frontend/src/components/global-layout/two-col-aside.vue new file mode 100644 index 0000000..ce2557a --- /dev/null +++ b/frontend/src/components/global-layout/two-col-aside.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/global-layout/two-col.vue b/frontend/src/components/global-layout/two-col.vue new file mode 100644 index 0000000..46303b4 --- /dev/null +++ b/frontend/src/components/global-layout/two-col.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/global-layout/types.ts b/frontend/src/components/global-layout/types.ts new file mode 100644 index 0000000..ce98d7b --- /dev/null +++ b/frontend/src/components/global-layout/types.ts @@ -0,0 +1,19 @@ +import type { Component } from 'vue' + +export interface BreadcrumbItem { + title: string + href?: string +} + +export interface LayoutHeaderProps { + title?: string + description?: string + sticky?: boolean + breadcrumbs?: BreadcrumbItem[] +} + +export interface TwoColAsideNavItem { + title: string + url: string + icon?: Component +} diff --git a/frontend/src/components/inspira-ui/flickering-grid.vue b/frontend/src/components/inspira-ui/flickering-grid.vue new file mode 100644 index 0000000..7383ad0 --- /dev/null +++ b/frontend/src/components/inspira-ui/flickering-grid.vue @@ -0,0 +1,184 @@ + + + diff --git a/frontend/src/components/inspira-ui/glowing-effect.vue b/frontend/src/components/inspira-ui/glowing-effect.vue new file mode 100644 index 0000000..3a8986f --- /dev/null +++ b/frontend/src/components/inspira-ui/glowing-effect.vue @@ -0,0 +1,197 @@ + + + diff --git a/frontend/src/components/inspira-ui/marquee/index.vue b/frontend/src/components/inspira-ui/marquee/index.vue new file mode 100644 index 0000000..69c2e8e --- /dev/null +++ b/frontend/src/components/inspira-ui/marquee/index.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/components/inspira-ui/marquee/review-card.vue b/frontend/src/components/inspira-ui/marquee/review-card.vue new file mode 100644 index 0000000..c186f2e --- /dev/null +++ b/frontend/src/components/inspira-ui/marquee/review-card.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/inspira-ui/ripple/circle.vue b/frontend/src/components/inspira-ui/ripple/circle.vue new file mode 100644 index 0000000..f5cc8b3 --- /dev/null +++ b/frontend/src/components/inspira-ui/ripple/circle.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontend/src/components/inspira-ui/ripple/container.vue b/frontend/src/components/inspira-ui/ripple/container.vue new file mode 100644 index 0000000..9dc6ca1 --- /dev/null +++ b/frontend/src/components/inspira-ui/ripple/container.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/components/inspira-ui/ripple/index.vue b/frontend/src/components/inspira-ui/ripple/index.vue new file mode 100644 index 0000000..9dc6ca1 --- /dev/null +++ b/frontend/src/components/inspira-ui/ripple/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/components/language-change.vue b/frontend/src/components/language-change.vue new file mode 100644 index 0000000..4fa668f --- /dev/null +++ b/frontend/src/components/language-change.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/components/loading.vue b/frontend/src/components/loading.vue new file mode 100644 index 0000000..c8bc0e6 --- /dev/null +++ b/frontend/src/components/loading.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/components/marketing-layout/the-footer.vue b/frontend/src/components/marketing-layout/the-footer.vue new file mode 100644 index 0000000..6d92920 --- /dev/null +++ b/frontend/src/components/marketing-layout/the-footer.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/components/marketing-layout/the-header.vue b/frontend/src/components/marketing-layout/the-header.vue new file mode 100644 index 0000000..43f3df0 --- /dev/null +++ b/frontend/src/components/marketing-layout/the-header.vue @@ -0,0 +1,643 @@ + + + diff --git a/frontend/src/components/no-result-found.vue b/frontend/src/components/no-result-found.vue new file mode 100644 index 0000000..6c4e00d --- /dev/null +++ b/frontend/src/components/no-result-found.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/Modal.vue b/frontend/src/components/prop-ui/modal/Modal.vue new file mode 100644 index 0000000..9dd5b67 --- /dev/null +++ b/frontend/src/components/prop-ui/modal/Modal.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/ModalClose.vue b/frontend/src/components/prop-ui/modal/ModalClose.vue new file mode 100644 index 0000000..ccfc90b --- /dev/null +++ b/frontend/src/components/prop-ui/modal/ModalClose.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/ModalContent.vue b/frontend/src/components/prop-ui/modal/ModalContent.vue new file mode 100644 index 0000000..ccd5353 --- /dev/null +++ b/frontend/src/components/prop-ui/modal/ModalContent.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/ModalDescription.vue b/frontend/src/components/prop-ui/modal/ModalDescription.vue new file mode 100644 index 0000000..245e32b --- /dev/null +++ b/frontend/src/components/prop-ui/modal/ModalDescription.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/ModalFooter.vue b/frontend/src/components/prop-ui/modal/ModalFooter.vue new file mode 100644 index 0000000..ccfec9f --- /dev/null +++ b/frontend/src/components/prop-ui/modal/ModalFooter.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/ModalHeader.vue b/frontend/src/components/prop-ui/modal/ModalHeader.vue new file mode 100644 index 0000000..8530631 --- /dev/null +++ b/frontend/src/components/prop-ui/modal/ModalHeader.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/ModalTitle.vue b/frontend/src/components/prop-ui/modal/ModalTitle.vue new file mode 100644 index 0000000..ccd8293 --- /dev/null +++ b/frontend/src/components/prop-ui/modal/ModalTitle.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/ModalTrigger.vue b/frontend/src/components/prop-ui/modal/ModalTrigger.vue new file mode 100644 index 0000000..ae87abe --- /dev/null +++ b/frontend/src/components/prop-ui/modal/ModalTrigger.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/prop-ui/modal/index.ts b/frontend/src/components/prop-ui/modal/index.ts new file mode 100644 index 0000000..0009363 --- /dev/null +++ b/frontend/src/components/prop-ui/modal/index.ts @@ -0,0 +1,10 @@ +export { default as Modal } from './Modal.vue' +export { default as ModalClose } from './ModalClose.vue' +export { default as ModalContent } from './ModalContent.vue' +export { default as ModalDescription } from './ModalDescription.vue' +export { default as ModalFooter } from './ModalFooter.vue' +export { default as ModalHeader } from './ModalHeader.vue' +export { default as ModalTitle } from './ModalTitle.vue' +export { default as ModalTrigger } from './ModalTrigger.vue' + +export * from './use-modal' diff --git a/frontend/src/components/prop-ui/modal/use-modal.ts b/frontend/src/components/prop-ui/modal/use-modal.ts new file mode 100644 index 0000000..2c4361e --- /dev/null +++ b/frontend/src/components/prop-ui/modal/use-modal.ts @@ -0,0 +1,31 @@ +import { createSharedComposable, useMediaQuery } from '@vueuse/core' + +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer' + +const useSharedModal = createSharedComposable(() => { + const isDesktop = useMediaQuery('(min-width: 768px)') + + const Modal = computed(() => ({ + Root: isDesktop.value ? Dialog : Drawer, + Trigger: isDesktop.value ? DialogTrigger : DrawerTrigger, + Content: isDesktop.value ? DialogContent : DrawerContent, + Header: isDesktop.value ? DialogHeader : DrawerHeader, + Title: isDesktop.value ? DialogTitle : DrawerTitle, + Description: isDesktop.value ? DialogDescription : DrawerDescription, + Footer: isDesktop.value ? DialogFooter : DrawerFooter, + Close: isDesktop.value ? DialogClose : DrawerClose, + })) + + const contentClass = computed(() => (isDesktop.value ? '' : 'px-2 pb-8 *:px-4')) + + return { + isDesktop, + Modal, + contentClass, + } +}) + +export function useModal() { + return useSharedModal() +} diff --git a/frontend/src/components/sign-in-button.vue b/frontend/src/components/sign-in-button.vue new file mode 100644 index 0000000..a39eb88 --- /dev/null +++ b/frontend/src/components/sign-in-button.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/sign-up-button.vue b/frontend/src/components/sign-up-button.vue new file mode 100644 index 0000000..3defa43 --- /dev/null +++ b/frontend/src/components/sign-up-button.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/sort-select/index.vue b/frontend/src/components/sort-select/index.vue new file mode 100644 index 0000000..8808280 --- /dev/null +++ b/frontend/src/components/sort-select/index.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/components/sort-select/types.ts b/frontend/src/components/sort-select/types.ts new file mode 100644 index 0000000..a725c26 --- /dev/null +++ b/frontend/src/components/sort-select/types.ts @@ -0,0 +1 @@ +export type TSort = 'asc' | 'desc' diff --git a/frontend/src/components/sva-ui/copy/Copy.vue b/frontend/src/components/sva-ui/copy/Copy.vue new file mode 100644 index 0000000..c9ddd4e --- /dev/null +++ b/frontend/src/components/sva-ui/copy/Copy.vue @@ -0,0 +1,72 @@ + + + diff --git a/frontend/src/components/sva-ui/copy/index.ts b/frontend/src/components/sva-ui/copy/index.ts new file mode 100644 index 0000000..84f8959 --- /dev/null +++ b/frontend/src/components/sva-ui/copy/index.ts @@ -0,0 +1,22 @@ +import type { VariantProps } from 'class-variance-authority' + +import { cva } from 'class-variance-authority' + +export { default as Copy } from './Copy.vue' + +export const copyVariants = cva( + '', + { + variants: { + iconSize: { + default: 'size-4', + sm: 'size-3', + }, + }, + defaultVariants: { + iconSize: 'default', + }, + }, +) + +export type CopyVariants = VariantProps diff --git a/frontend/src/components/sva-ui/inline-tip/InlineTip.vue b/frontend/src/components/sva-ui/inline-tip/InlineTip.vue new file mode 100644 index 0000000..4fd6166 --- /dev/null +++ b/frontend/src/components/sva-ui/inline-tip/InlineTip.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/sva-ui/inline-tip/index.ts b/frontend/src/components/sva-ui/inline-tip/index.ts new file mode 100644 index 0000000..2c882e3 --- /dev/null +++ b/frontend/src/components/sva-ui/inline-tip/index.ts @@ -0,0 +1,24 @@ +import type { VariantProps } from 'class-variance-authority' + +import { cva } from 'class-variance-authority' + +export { default as InlineTip } from './InlineTip.vue' + +export const inlineTipVariants = cva( + '', + { + variants: { + variant: { + info: 'bg-stone-400 dark:bg-stone-600', + warning: 'bg-yellow-400 dark:bg-yellow-600', + success: 'bg-green-400 dark:bg-green-600', + error: 'bg-rose-400 dark:bg-rose-600', + }, + }, + defaultVariants: { + variant: 'info', + }, + }, +) + +export type InlineTipVariants = VariantProps diff --git a/frontend/src/components/sva-ui/status-badge/Status.vue b/frontend/src/components/sva-ui/status-badge/Status.vue new file mode 100644 index 0000000..f75ce9a --- /dev/null +++ b/frontend/src/components/sva-ui/status-badge/Status.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/sva-ui/status-badge/StatusBadge.vue b/frontend/src/components/sva-ui/status-badge/StatusBadge.vue new file mode 100644 index 0000000..3731506 --- /dev/null +++ b/frontend/src/components/sva-ui/status-badge/StatusBadge.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/components/sva-ui/status-badge/index.ts b/frontend/src/components/sva-ui/status-badge/index.ts new file mode 100644 index 0000000..d9868dd --- /dev/null +++ b/frontend/src/components/sva-ui/status-badge/index.ts @@ -0,0 +1,35 @@ +import type { VariantProps } from 'class-variance-authority' + +import { cva } from 'class-variance-authority' + +export { default as Status } from './Status.vue' +export { default as StatusBadge } from './StatusBadge.vue' + +export const statusVariants = cva( + 'relative flex size-2', + { + variants: { + rounded: { + default: 'rounded-full', + xs: 'rounded-xs', + }, + color: { + green: 'bg-green-500', + red: 'bg-rose-500', + blue: 'bg-blue-500', + orange: 'bg-orange-500', + purple: 'bg-purple-500', + gray: 'bg-gray-300', + }, + size: { + + }, + }, + defaultVariants: { + color: 'green', + rounded: 'default', + }, + }, +) + +export type StatusVariants = VariantProps diff --git a/frontend/src/components/toggle-theme.vue b/frontend/src/components/toggle-theme.vue new file mode 100644 index 0000000..6cdce64 --- /dev/null +++ b/frontend/src/components/toggle-theme.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/ui/accordion/Accordion.vue b/frontend/src/components/ui/accordion/Accordion.vue new file mode 100644 index 0000000..58ad491 --- /dev/null +++ b/frontend/src/components/ui/accordion/Accordion.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ui/accordion/AccordionContent.vue b/frontend/src/components/ui/accordion/AccordionContent.vue new file mode 100644 index 0000000..91c8dcc --- /dev/null +++ b/frontend/src/components/ui/accordion/AccordionContent.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/accordion/AccordionItem.vue b/frontend/src/components/ui/accordion/AccordionItem.vue new file mode 100644 index 0000000..5eccc4c --- /dev/null +++ b/frontend/src/components/ui/accordion/AccordionItem.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ui/accordion/AccordionTrigger.vue b/frontend/src/components/ui/accordion/AccordionTrigger.vue new file mode 100644 index 0000000..7a813b9 --- /dev/null +++ b/frontend/src/components/ui/accordion/AccordionTrigger.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/ui/accordion/index.ts b/frontend/src/components/ui/accordion/index.ts new file mode 100644 index 0000000..b18018b --- /dev/null +++ b/frontend/src/components/ui/accordion/index.ts @@ -0,0 +1,4 @@ +export { default as Accordion } from "./Accordion.vue" +export { default as AccordionContent } from "./AccordionContent.vue" +export { default as AccordionItem } from "./AccordionItem.vue" +export { default as AccordionTrigger } from "./AccordionTrigger.vue" diff --git a/frontend/src/components/ui/alert-dialog/AlertDialog.vue b/frontend/src/components/ui/alert-dialog/AlertDialog.vue new file mode 100644 index 0000000..b6e6b4b --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogAction.vue b/frontend/src/components/ui/alert-dialog/AlertDialogAction.vue new file mode 100644 index 0000000..09cf6fc --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogAction.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogCancel.vue b/frontend/src/components/ui/alert-dialog/AlertDialogCancel.vue new file mode 100644 index 0000000..e261894 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogCancel.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogContent.vue b/frontend/src/components/ui/alert-dialog/AlertDialogContent.vue new file mode 100644 index 0000000..4597f0d --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogContent.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogDescription.vue b/frontend/src/components/ui/alert-dialog/AlertDialogDescription.vue new file mode 100644 index 0000000..69642c9 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogFooter.vue b/frontend/src/components/ui/alert-dialog/AlertDialogFooter.vue new file mode 100644 index 0000000..50d4098 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogFooter.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogHeader.vue b/frontend/src/components/ui/alert-dialog/AlertDialogHeader.vue new file mode 100644 index 0000000..dbe72a7 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue b/frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue new file mode 100644 index 0000000..bb97e4d --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/AlertDialogTrigger.vue b/frontend/src/components/ui/alert-dialog/AlertDialogTrigger.vue new file mode 100644 index 0000000..98d40ee --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/AlertDialogTrigger.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/components/ui/alert-dialog/index.ts b/frontend/src/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..cf1b45d --- /dev/null +++ b/frontend/src/components/ui/alert-dialog/index.ts @@ -0,0 +1,9 @@ +export { default as AlertDialog } from "./AlertDialog.vue" +export { default as AlertDialogAction } from "./AlertDialogAction.vue" +export { default as AlertDialogCancel } from "./AlertDialogCancel.vue" +export { default as AlertDialogContent } from "./AlertDialogContent.vue" +export { default as AlertDialogDescription } from "./AlertDialogDescription.vue" +export { default as AlertDialogFooter } from "./AlertDialogFooter.vue" +export { default as AlertDialogHeader } from "./AlertDialogHeader.vue" +export { default as AlertDialogTitle } from "./AlertDialogTitle.vue" +export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue" diff --git a/frontend/src/components/ui/alert/Alert.vue b/frontend/src/components/ui/alert/Alert.vue new file mode 100644 index 0000000..a9d336f --- /dev/null +++ b/frontend/src/components/ui/alert/Alert.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/alert/AlertDescription.vue b/frontend/src/components/ui/alert/AlertDescription.vue new file mode 100644 index 0000000..9f7d24d --- /dev/null +++ b/frontend/src/components/ui/alert/AlertDescription.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/alert/AlertTitle.vue b/frontend/src/components/ui/alert/AlertTitle.vue new file mode 100644 index 0000000..b218384 --- /dev/null +++ b/frontend/src/components/ui/alert/AlertTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/alert/index.ts b/frontend/src/components/ui/alert/index.ts new file mode 100644 index 0000000..42d07b6 --- /dev/null +++ b/frontend/src/components/ui/alert/index.ts @@ -0,0 +1,24 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Alert } from "./Alert.vue" +export { default as AlertDescription } from "./AlertDescription.vue" +export { default as AlertTitle } from "./AlertTitle.vue" + +export const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export type AlertVariants = VariantProps diff --git a/frontend/src/components/ui/aspect-ratio/AspectRatio.vue b/frontend/src/components/ui/aspect-ratio/AspectRatio.vue new file mode 100644 index 0000000..857c208 --- /dev/null +++ b/frontend/src/components/ui/aspect-ratio/AspectRatio.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/ui/aspect-ratio/index.ts b/frontend/src/components/ui/aspect-ratio/index.ts new file mode 100644 index 0000000..e658f55 --- /dev/null +++ b/frontend/src/components/ui/aspect-ratio/index.ts @@ -0,0 +1 @@ +export { default as AspectRatio } from "./AspectRatio.vue" diff --git a/frontend/src/components/ui/avatar/Avatar.vue b/frontend/src/components/ui/avatar/Avatar.vue new file mode 100644 index 0000000..bb7e669 --- /dev/null +++ b/frontend/src/components/ui/avatar/Avatar.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ui/avatar/AvatarFallback.vue b/frontend/src/components/ui/avatar/AvatarFallback.vue new file mode 100644 index 0000000..16b588a --- /dev/null +++ b/frontend/src/components/ui/avatar/AvatarFallback.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/avatar/AvatarImage.vue b/frontend/src/components/ui/avatar/AvatarImage.vue new file mode 100644 index 0000000..24a8166 --- /dev/null +++ b/frontend/src/components/ui/avatar/AvatarImage.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/ui/avatar/index.ts b/frontend/src/components/ui/avatar/index.ts new file mode 100644 index 0000000..cf0e003 --- /dev/null +++ b/frontend/src/components/ui/avatar/index.ts @@ -0,0 +1,3 @@ +export { default as Avatar } from "./Avatar.vue" +export { default as AvatarFallback } from "./AvatarFallback.vue" +export { default as AvatarImage } from "./AvatarImage.vue" diff --git a/frontend/src/components/ui/badge/Badge.vue b/frontend/src/components/ui/badge/Badge.vue new file mode 100644 index 0000000..d894dfe --- /dev/null +++ b/frontend/src/components/ui/badge/Badge.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/components/ui/badge/index.ts b/frontend/src/components/ui/badge/index.ts new file mode 100644 index 0000000..bbc0dfa --- /dev/null +++ b/frontend/src/components/ui/badge/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Badge } from "./Badge.vue" + +export const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) +export type BadgeVariants = VariantProps diff --git a/frontend/src/components/ui/breadcrumb/Breadcrumb.vue b/frontend/src/components/ui/breadcrumb/Breadcrumb.vue new file mode 100644 index 0000000..c5be5f0 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/Breadcrumb.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue b/frontend/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue new file mode 100644 index 0000000..2a35182 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/breadcrumb/BreadcrumbItem.vue b/frontend/src/components/ui/breadcrumb/BreadcrumbItem.vue new file mode 100644 index 0000000..e3dce68 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/BreadcrumbItem.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/breadcrumb/BreadcrumbLink.vue b/frontend/src/components/ui/breadcrumb/BreadcrumbLink.vue new file mode 100644 index 0000000..5d96381 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/BreadcrumbLink.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/breadcrumb/BreadcrumbList.vue b/frontend/src/components/ui/breadcrumb/BreadcrumbList.vue new file mode 100644 index 0000000..fc62811 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/BreadcrumbList.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/breadcrumb/BreadcrumbPage.vue b/frontend/src/components/ui/breadcrumb/BreadcrumbPage.vue new file mode 100644 index 0000000..b429b20 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/BreadcrumbPage.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/breadcrumb/BreadcrumbSeparator.vue b/frontend/src/components/ui/breadcrumb/BreadcrumbSeparator.vue new file mode 100644 index 0000000..f0fc894 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/BreadcrumbSeparator.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/breadcrumb/index.ts b/frontend/src/components/ui/breadcrumb/index.ts new file mode 100644 index 0000000..f4eafdc --- /dev/null +++ b/frontend/src/components/ui/breadcrumb/index.ts @@ -0,0 +1,7 @@ +export { default as Breadcrumb } from "./Breadcrumb.vue" +export { default as BreadcrumbEllipsis } from "./BreadcrumbEllipsis.vue" +export { default as BreadcrumbItem } from "./BreadcrumbItem.vue" +export { default as BreadcrumbLink } from "./BreadcrumbLink.vue" +export { default as BreadcrumbList } from "./BreadcrumbList.vue" +export { default as BreadcrumbPage } from "./BreadcrumbPage.vue" +export { default as BreadcrumbSeparator } from "./BreadcrumbSeparator.vue" diff --git a/frontend/src/components/ui/button-group/ButtonGroup.vue b/frontend/src/components/ui/button-group/ButtonGroup.vue new file mode 100644 index 0000000..9dbef6a --- /dev/null +++ b/frontend/src/components/ui/button-group/ButtonGroup.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/button-group/ButtonGroupSeparator.vue b/frontend/src/components/ui/button-group/ButtonGroupSeparator.vue new file mode 100644 index 0000000..e069dd5 --- /dev/null +++ b/frontend/src/components/ui/button-group/ButtonGroupSeparator.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ui/button-group/ButtonGroupText.vue b/frontend/src/components/ui/button-group/ButtonGroupText.vue new file mode 100644 index 0000000..c436843 --- /dev/null +++ b/frontend/src/components/ui/button-group/ButtonGroupText.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/ui/button-group/index.ts b/frontend/src/components/ui/button-group/index.ts new file mode 100644 index 0000000..474566f --- /dev/null +++ b/frontend/src/components/ui/button-group/index.ts @@ -0,0 +1,25 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as ButtonGroup } from "./ButtonGroup.vue" +export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue" +export { default as ButtonGroupText } from "./ButtonGroupText.vue" + +export const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +) + +export type ButtonGroupVariants = VariantProps diff --git a/frontend/src/components/ui/button/Button.vue b/frontend/src/components/ui/button/Button.vue new file mode 100644 index 0000000..374320b --- /dev/null +++ b/frontend/src/components/ui/button/Button.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/ui/button/index.ts b/frontend/src/components/ui/button/index.ts new file mode 100644 index 0000000..26e2c55 --- /dev/null +++ b/frontend/src/components/ui/button/index.ts @@ -0,0 +1,38 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Button } from "./Button.vue" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + "default": "h-9 px-4 py-2 has-[>svg]:px-3", + "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + "lg": "h-10 rounded-md px-6 has-[>svg]:px-4", + "icon": "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) +export type ButtonVariants = VariantProps diff --git a/frontend/src/components/ui/calendar/Calendar.vue b/frontend/src/components/ui/calendar/Calendar.vue new file mode 100644 index 0000000..3054f73 --- /dev/null +++ b/frontend/src/components/ui/calendar/Calendar.vue @@ -0,0 +1,160 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarCell.vue b/frontend/src/components/ui/calendar/CalendarCell.vue new file mode 100644 index 0000000..15b8028 --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarCell.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarCellTrigger.vue b/frontend/src/components/ui/calendar/CalendarCellTrigger.vue new file mode 100644 index 0000000..1107fc6 --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarCellTrigger.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarGrid.vue b/frontend/src/components/ui/calendar/CalendarGrid.vue new file mode 100644 index 0000000..e6dd7d6 --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarGrid.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarGridBody.vue b/frontend/src/components/ui/calendar/CalendarGridBody.vue new file mode 100644 index 0000000..3b9e716 --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarGridBody.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarGridHead.vue b/frontend/src/components/ui/calendar/CalendarGridHead.vue new file mode 100644 index 0000000..de1589b --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarGridHead.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarGridRow.vue b/frontend/src/components/ui/calendar/CalendarGridRow.vue new file mode 100644 index 0000000..767557d --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarGridRow.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarHeadCell.vue b/frontend/src/components/ui/calendar/CalendarHeadCell.vue new file mode 100644 index 0000000..47fefbc --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarHeadCell.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarHeader.vue b/frontend/src/components/ui/calendar/CalendarHeader.vue new file mode 100644 index 0000000..175118d --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarHeader.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarHeading.vue b/frontend/src/components/ui/calendar/CalendarHeading.vue new file mode 100644 index 0000000..5a11c12 --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarHeading.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarNextButton.vue b/frontend/src/components/ui/calendar/CalendarNextButton.vue new file mode 100644 index 0000000..bd1efce --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarNextButton.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/ui/calendar/CalendarPrevButton.vue b/frontend/src/components/ui/calendar/CalendarPrevButton.vue new file mode 100644 index 0000000..80bdcec --- /dev/null +++ b/frontend/src/components/ui/calendar/CalendarPrevButton.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/ui/calendar/index.ts b/frontend/src/components/ui/calendar/index.ts new file mode 100644 index 0000000..552c634 --- /dev/null +++ b/frontend/src/components/ui/calendar/index.ts @@ -0,0 +1,14 @@ +export { default as Calendar } from "./Calendar.vue" +export { default as CalendarCell } from "./CalendarCell.vue" +export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue" +export { default as CalendarGrid } from "./CalendarGrid.vue" +export { default as CalendarGridBody } from "./CalendarGridBody.vue" +export { default as CalendarGridHead } from "./CalendarGridHead.vue" +export { default as CalendarGridRow } from "./CalendarGridRow.vue" +export { default as CalendarHeadCell } from "./CalendarHeadCell.vue" +export { default as CalendarHeader } from "./CalendarHeader.vue" +export { default as CalendarHeading } from "./CalendarHeading.vue" +export { default as CalendarNextButton } from "./CalendarNextButton.vue" +export { default as CalendarPrevButton } from "./CalendarPrevButton.vue" + +export type LayoutTypes = "month-and-year" | "month-only" | "year-only" | undefined diff --git a/frontend/src/components/ui/card/Card.vue b/frontend/src/components/ui/card/Card.vue new file mode 100644 index 0000000..f5a0707 --- /dev/null +++ b/frontend/src/components/ui/card/Card.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/card/CardAction.vue b/frontend/src/components/ui/card/CardAction.vue new file mode 100644 index 0000000..c91638b --- /dev/null +++ b/frontend/src/components/ui/card/CardAction.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/card/CardContent.vue b/frontend/src/components/ui/card/CardContent.vue new file mode 100644 index 0000000..dfbc552 --- /dev/null +++ b/frontend/src/components/ui/card/CardContent.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/card/CardDescription.vue b/frontend/src/components/ui/card/CardDescription.vue new file mode 100644 index 0000000..71c1b8d --- /dev/null +++ b/frontend/src/components/ui/card/CardDescription.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/card/CardFooter.vue b/frontend/src/components/ui/card/CardFooter.vue new file mode 100644 index 0000000..9e3739e --- /dev/null +++ b/frontend/src/components/ui/card/CardFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/card/CardHeader.vue b/frontend/src/components/ui/card/CardHeader.vue new file mode 100644 index 0000000..4fe4da4 --- /dev/null +++ b/frontend/src/components/ui/card/CardHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/card/CardTitle.vue b/frontend/src/components/ui/card/CardTitle.vue new file mode 100644 index 0000000..5f479e7 --- /dev/null +++ b/frontend/src/components/ui/card/CardTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/card/index.ts b/frontend/src/components/ui/card/index.ts new file mode 100644 index 0000000..1627758 --- /dev/null +++ b/frontend/src/components/ui/card/index.ts @@ -0,0 +1,7 @@ +export { default as Card } from "./Card.vue" +export { default as CardAction } from "./CardAction.vue" +export { default as CardContent } from "./CardContent.vue" +export { default as CardDescription } from "./CardDescription.vue" +export { default as CardFooter } from "./CardFooter.vue" +export { default as CardHeader } from "./CardHeader.vue" +export { default as CardTitle } from "./CardTitle.vue" diff --git a/frontend/src/components/ui/carousel/Carousel.vue b/frontend/src/components/ui/carousel/Carousel.vue new file mode 100644 index 0000000..4575682 --- /dev/null +++ b/frontend/src/components/ui/carousel/Carousel.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/ui/carousel/CarouselContent.vue b/frontend/src/components/ui/carousel/CarouselContent.vue new file mode 100644 index 0000000..760e8b0 --- /dev/null +++ b/frontend/src/components/ui/carousel/CarouselContent.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/ui/carousel/CarouselItem.vue b/frontend/src/components/ui/carousel/CarouselItem.vue new file mode 100644 index 0000000..39df4c6 --- /dev/null +++ b/frontend/src/components/ui/carousel/CarouselItem.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ui/carousel/CarouselNext.vue b/frontend/src/components/ui/carousel/CarouselNext.vue new file mode 100644 index 0000000..50c5762 --- /dev/null +++ b/frontend/src/components/ui/carousel/CarouselNext.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/components/ui/carousel/CarouselPrevious.vue b/frontend/src/components/ui/carousel/CarouselPrevious.vue new file mode 100644 index 0000000..cf40eb7 --- /dev/null +++ b/frontend/src/components/ui/carousel/CarouselPrevious.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/components/ui/carousel/index.ts b/frontend/src/components/ui/carousel/index.ts new file mode 100644 index 0000000..154bfcb --- /dev/null +++ b/frontend/src/components/ui/carousel/index.ts @@ -0,0 +1,10 @@ +export { default as Carousel } from "./Carousel.vue" +export { default as CarouselContent } from "./CarouselContent.vue" +export { default as CarouselItem } from "./CarouselItem.vue" +export { default as CarouselNext } from "./CarouselNext.vue" +export { default as CarouselPrevious } from "./CarouselPrevious.vue" +export type { + UnwrapRefCarouselApi as CarouselApi, +} from "./interface" + +export { useCarousel } from "./useCarousel" diff --git a/frontend/src/components/ui/carousel/interface.ts b/frontend/src/components/ui/carousel/interface.ts new file mode 100644 index 0000000..74eaf36 --- /dev/null +++ b/frontend/src/components/ui/carousel/interface.ts @@ -0,0 +1,26 @@ +import type useEmblaCarousel from "embla-carousel-vue" +import type { + EmblaCarouselVueType, +} from "embla-carousel-vue" +import type { HTMLAttributes, UnwrapRef } from "vue" + +type CarouselApi = EmblaCarouselVueType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +export type UnwrapRefCarouselApi = UnwrapRef + +export interface CarouselProps { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" +} + +export interface CarouselEmits { + (e: "init-api", payload: UnwrapRefCarouselApi): void +} + +export interface WithClassAsProps { + class?: HTMLAttributes["class"] +} diff --git a/frontend/src/components/ui/carousel/useCarousel.ts b/frontend/src/components/ui/carousel/useCarousel.ts new file mode 100644 index 0000000..32e35ca --- /dev/null +++ b/frontend/src/components/ui/carousel/useCarousel.ts @@ -0,0 +1,56 @@ +import type { UnwrapRefCarouselApi as CarouselApi, CarouselEmits, CarouselProps } from "./interface" +import { createInjectionState } from "@vueuse/core" +import emblaCarouselVue from "embla-carousel-vue" +import { onMounted, ref } from "vue" + +const [useProvideCarousel, useInjectCarousel] = createInjectionState( + ({ + opts, + orientation, + plugins, + }: CarouselProps, emits: CarouselEmits) => { + const [emblaNode, emblaApi] = emblaCarouselVue({ + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, plugins) + + function scrollPrev() { + emblaApi.value?.scrollPrev() + } + function scrollNext() { + emblaApi.value?.scrollNext() + } + + const canScrollNext = ref(false) + const canScrollPrev = ref(false) + + function onSelect(api: CarouselApi) { + canScrollNext.value = api?.canScrollNext() || false + canScrollPrev.value = api?.canScrollPrev() || false + } + + onMounted(() => { + if (!emblaApi.value) + return + + emblaApi.value?.on("init", onSelect) + emblaApi.value?.on("reInit", onSelect) + emblaApi.value?.on("select", onSelect) + + emits("init-api", emblaApi.value) + }) + + return { carouselRef: emblaNode, carouselApi: emblaApi, canScrollPrev, canScrollNext, scrollPrev, scrollNext, orientation } + }, +) + +function useCarousel() { + const carouselState = useInjectCarousel() + + if (!carouselState) + throw new Error("useCarousel must be used within a ") + + return carouselState +} + +export { useCarousel, useProvideCarousel } diff --git a/frontend/src/components/ui/chart/ChartContainer.vue b/frontend/src/components/ui/chart/ChartContainer.vue new file mode 100644 index 0000000..b4357f9 --- /dev/null +++ b/frontend/src/components/ui/chart/ChartContainer.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/components/ui/chart/ChartLegendContent.vue b/frontend/src/components/ui/chart/ChartLegendContent.vue new file mode 100644 index 0000000..acd51bb --- /dev/null +++ b/frontend/src/components/ui/chart/ChartLegendContent.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/components/ui/chart/ChartStyle.vue b/frontend/src/components/ui/chart/ChartStyle.vue new file mode 100644 index 0000000..d6f59dd --- /dev/null +++ b/frontend/src/components/ui/chart/ChartStyle.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/ui/chart/ChartTooltipContent.vue b/frontend/src/components/ui/chart/ChartTooltipContent.vue new file mode 100644 index 0000000..e3fa3bc --- /dev/null +++ b/frontend/src/components/ui/chart/ChartTooltipContent.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/components/ui/chart/index.ts b/frontend/src/components/ui/chart/index.ts new file mode 100644 index 0000000..12b8062 --- /dev/null +++ b/frontend/src/components/ui/chart/index.ts @@ -0,0 +1,29 @@ +import type { Component, Ref } from "vue" +import { createContext } from "reka-ui" + +export { default as ChartContainer } from "./ChartContainer.vue" +export { default as ChartLegendContent } from "./ChartLegendContent.vue" +export { default as ChartTooltipContent } from "./ChartTooltipContent.vue" +export { componentToString } from "./utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +export const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: string | Component + icon?: string | Component + } & ( + | { color?: string, theme?: never } + | { color?: never, theme: Record } + ) +} + +interface ChartContextProps { + id: string + config: Ref +} + +export const [useChart, provideChartContext] = createContext("Chart") + +export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from "@unovis/vue" diff --git a/frontend/src/components/ui/chart/utils.ts b/frontend/src/components/ui/chart/utils.ts new file mode 100644 index 0000000..33385b1 --- /dev/null +++ b/frontend/src/components/ui/chart/utils.ts @@ -0,0 +1,43 @@ +import type { ChartConfig } from "." +import { isClient } from "@vueuse/core" +import { h, render } from "vue" + +// Simple cache using a Map to store serialized object keys +const cache = new Map() + +// Convert object to a consistent string key +function serializeKey(key: Record): string { + return JSON.stringify(key, Object.keys(key).sort()) +} + +interface Constructor

{ + __isFragment?: never + __isTeleport?: never + __isSuspense?: never + new (...args: any[]): { + $props: P + } +} + +export function componentToString

(config: ChartConfig, component: Constructor

, props?: P) { + if (!isClient) + return + + // This function will be called once during mount lifecycle + const id = useId() + + // https://unovis.dev/docs/auxiliary/Crosshair#component-props + return (_data: any, x: number | Date) => { + const data = "data" in _data ? _data.data : _data + const serializedKey = `${id}-${serializeKey(data)}` + const cachedContent = cache.get(serializedKey) + if (cachedContent) + return cachedContent + + const vnode = h(component, { ...props, payload: data, config, x }) + const div = document.createElement("div") + render(vnode, div) + cache.set(serializedKey, div.innerHTML) + return div.innerHTML + } +} diff --git a/frontend/src/components/ui/checkbox/Checkbox.vue b/frontend/src/components/ui/checkbox/Checkbox.vue new file mode 100644 index 0000000..6604cbd --- /dev/null +++ b/frontend/src/components/ui/checkbox/Checkbox.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/ui/checkbox/index.ts b/frontend/src/components/ui/checkbox/index.ts new file mode 100644 index 0000000..3391a85 --- /dev/null +++ b/frontend/src/components/ui/checkbox/index.ts @@ -0,0 +1 @@ +export { default as Checkbox } from "./Checkbox.vue" diff --git a/frontend/src/components/ui/collapsible/Collapsible.vue b/frontend/src/components/ui/collapsible/Collapsible.vue new file mode 100644 index 0000000..70a9497 --- /dev/null +++ b/frontend/src/components/ui/collapsible/Collapsible.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/collapsible/CollapsibleContent.vue b/frontend/src/components/ui/collapsible/CollapsibleContent.vue new file mode 100644 index 0000000..8be0a11 --- /dev/null +++ b/frontend/src/components/ui/collapsible/CollapsibleContent.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/collapsible/CollapsibleTrigger.vue b/frontend/src/components/ui/collapsible/CollapsibleTrigger.vue new file mode 100644 index 0000000..33bec78 --- /dev/null +++ b/frontend/src/components/ui/collapsible/CollapsibleTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/collapsible/index.ts b/frontend/src/components/ui/collapsible/index.ts new file mode 100644 index 0000000..86a031d --- /dev/null +++ b/frontend/src/components/ui/collapsible/index.ts @@ -0,0 +1,3 @@ +export { default as Collapsible } from "./Collapsible.vue" +export { default as CollapsibleContent } from "./CollapsibleContent.vue" +export { default as CollapsibleTrigger } from "./CollapsibleTrigger.vue" diff --git a/frontend/src/components/ui/combobox/Combobox.vue b/frontend/src/components/ui/combobox/Combobox.vue new file mode 100644 index 0000000..0e6907c --- /dev/null +++ b/frontend/src/components/ui/combobox/Combobox.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxAnchor.vue b/frontend/src/components/ui/combobox/ComboboxAnchor.vue new file mode 100644 index 0000000..5b9041d --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxAnchor.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxEmpty.vue b/frontend/src/components/ui/combobox/ComboboxEmpty.vue new file mode 100644 index 0000000..20beb63 --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxEmpty.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxGroup.vue b/frontend/src/components/ui/combobox/ComboboxGroup.vue new file mode 100644 index 0000000..0dc9fc5 --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxGroup.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxInput.vue b/frontend/src/components/ui/combobox/ComboboxInput.vue new file mode 100644 index 0000000..9c78324 --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxInput.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxItem.vue b/frontend/src/components/ui/combobox/ComboboxItem.vue new file mode 100644 index 0000000..cccf98c --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxItem.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxItemIndicator.vue b/frontend/src/components/ui/combobox/ComboboxItemIndicator.vue new file mode 100644 index 0000000..2e1b07c --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxItemIndicator.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxList.vue b/frontend/src/components/ui/combobox/ComboboxList.vue new file mode 100644 index 0000000..64c9a84 --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxList.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxSeparator.vue b/frontend/src/components/ui/combobox/ComboboxSeparator.vue new file mode 100644 index 0000000..05d648b --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxSeparator.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxTrigger.vue b/frontend/src/components/ui/combobox/ComboboxTrigger.vue new file mode 100644 index 0000000..28b099a --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxTrigger.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ui/combobox/ComboboxViewport.vue b/frontend/src/components/ui/combobox/ComboboxViewport.vue new file mode 100644 index 0000000..684085c --- /dev/null +++ b/frontend/src/components/ui/combobox/ComboboxViewport.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/combobox/index.ts b/frontend/src/components/ui/combobox/index.ts new file mode 100644 index 0000000..7c7bfc7 --- /dev/null +++ b/frontend/src/components/ui/combobox/index.ts @@ -0,0 +1,12 @@ +export { default as Combobox } from "./Combobox.vue" +export { default as ComboboxAnchor } from "./ComboboxAnchor.vue" +export { default as ComboboxEmpty } from "./ComboboxEmpty.vue" +export { default as ComboboxGroup } from "./ComboboxGroup.vue" +export { default as ComboboxInput } from "./ComboboxInput.vue" +export { default as ComboboxItem } from "./ComboboxItem.vue" +export { default as ComboboxItemIndicator } from "./ComboboxItemIndicator.vue" +export { default as ComboboxList } from "./ComboboxList.vue" +export { default as ComboboxSeparator } from "./ComboboxSeparator.vue" +export { default as ComboboxViewport } from "./ComboboxViewport.vue" + +export { ComboboxCancel, ComboboxTrigger } from "reka-ui" diff --git a/frontend/src/components/ui/command/Command.vue b/frontend/src/components/ui/command/Command.vue new file mode 100644 index 0000000..dcdf9d6 --- /dev/null +++ b/frontend/src/components/ui/command/Command.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/components/ui/command/CommandDialog.vue b/frontend/src/components/ui/command/CommandDialog.vue new file mode 100644 index 0000000..7439736 --- /dev/null +++ b/frontend/src/components/ui/command/CommandDialog.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/ui/command/CommandEmpty.vue b/frontend/src/components/ui/command/CommandEmpty.vue new file mode 100644 index 0000000..489c406 --- /dev/null +++ b/frontend/src/components/ui/command/CommandEmpty.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/ui/command/CommandGroup.vue b/frontend/src/components/ui/command/CommandGroup.vue new file mode 100644 index 0000000..a5dd55e --- /dev/null +++ b/frontend/src/components/ui/command/CommandGroup.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/components/ui/command/CommandInput.vue b/frontend/src/components/ui/command/CommandInput.vue new file mode 100644 index 0000000..653141e --- /dev/null +++ b/frontend/src/components/ui/command/CommandInput.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ui/command/CommandItem.vue b/frontend/src/components/ui/command/CommandItem.vue new file mode 100644 index 0000000..2ae4827 --- /dev/null +++ b/frontend/src/components/ui/command/CommandItem.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/components/ui/command/CommandList.vue b/frontend/src/components/ui/command/CommandList.vue new file mode 100644 index 0000000..928d2f0 --- /dev/null +++ b/frontend/src/components/ui/command/CommandList.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/ui/command/CommandSeparator.vue b/frontend/src/components/ui/command/CommandSeparator.vue new file mode 100644 index 0000000..6def19e --- /dev/null +++ b/frontend/src/components/ui/command/CommandSeparator.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/command/CommandShortcut.vue b/frontend/src/components/ui/command/CommandShortcut.vue new file mode 100644 index 0000000..e1d0e07 --- /dev/null +++ b/frontend/src/components/ui/command/CommandShortcut.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/command/index.ts b/frontend/src/components/ui/command/index.ts new file mode 100644 index 0000000..af18933 --- /dev/null +++ b/frontend/src/components/ui/command/index.ts @@ -0,0 +1,25 @@ +import type { Ref } from "vue" +import { createContext } from "reka-ui" + +export { default as Command } from "./Command.vue" +export { default as CommandDialog } from "./CommandDialog.vue" +export { default as CommandEmpty } from "./CommandEmpty.vue" +export { default as CommandGroup } from "./CommandGroup.vue" +export { default as CommandInput } from "./CommandInput.vue" +export { default as CommandItem } from "./CommandItem.vue" +export { default as CommandList } from "./CommandList.vue" +export { default as CommandSeparator } from "./CommandSeparator.vue" +export { default as CommandShortcut } from "./CommandShortcut.vue" + +export const [useCommand, provideCommandContext] = createContext<{ + allItems: Ref> + allGroups: Ref>> + filterState: { + search: string + filtered: { count: number, items: Map, groups: Set } + } +}>("Command") + +export const [useCommandGroup, provideCommandGroupContext] = createContext<{ + id?: string +}>("CommandGroup") diff --git a/frontend/src/components/ui/context-menu/ContextMenu.vue b/frontend/src/components/ui/context-menu/ContextMenu.vue new file mode 100644 index 0000000..b4c6c09 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenu.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuCheckboxItem.vue b/frontend/src/components/ui/context-menu/ContextMenuCheckboxItem.vue new file mode 100644 index 0000000..ba62d43 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuCheckboxItem.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuContent.vue b/frontend/src/components/ui/context-menu/ContextMenuContent.vue new file mode 100644 index 0000000..2fbc556 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuContent.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuGroup.vue b/frontend/src/components/ui/context-menu/ContextMenuGroup.vue new file mode 100644 index 0000000..9af9b26 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuGroup.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuItem.vue b/frontend/src/components/ui/context-menu/ContextMenuItem.vue new file mode 100644 index 0000000..b6a4c48 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuItem.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuLabel.vue b/frontend/src/components/ui/context-menu/ContextMenuLabel.vue new file mode 100644 index 0000000..80d0519 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuLabel.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuPortal.vue b/frontend/src/components/ui/context-menu/ContextMenuPortal.vue new file mode 100644 index 0000000..c50a2b3 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuPortal.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuRadioGroup.vue b/frontend/src/components/ui/context-menu/ContextMenuRadioGroup.vue new file mode 100644 index 0000000..25372ed --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuRadioGroup.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuRadioItem.vue b/frontend/src/components/ui/context-menu/ContextMenuRadioItem.vue new file mode 100644 index 0000000..181acaa --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuRadioItem.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuSeparator.vue b/frontend/src/components/ui/context-menu/ContextMenuSeparator.vue new file mode 100644 index 0000000..d7f0c60 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuSeparator.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuShortcut.vue b/frontend/src/components/ui/context-menu/ContextMenuShortcut.vue new file mode 100644 index 0000000..2631d68 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuShortcut.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuSub.vue b/frontend/src/components/ui/context-menu/ContextMenuSub.vue new file mode 100644 index 0000000..aeb1835 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuSub.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuSubContent.vue b/frontend/src/components/ui/context-menu/ContextMenuSubContent.vue new file mode 100644 index 0000000..c4fcafd --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuSubContent.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuSubTrigger.vue b/frontend/src/components/ui/context-menu/ContextMenuSubTrigger.vue new file mode 100644 index 0000000..12d2210 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuSubTrigger.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/ui/context-menu/ContextMenuTrigger.vue b/frontend/src/components/ui/context-menu/ContextMenuTrigger.vue new file mode 100644 index 0000000..70a8143 --- /dev/null +++ b/frontend/src/components/ui/context-menu/ContextMenuTrigger.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/context-menu/index.ts b/frontend/src/components/ui/context-menu/index.ts new file mode 100644 index 0000000..5919fd3 --- /dev/null +++ b/frontend/src/components/ui/context-menu/index.ts @@ -0,0 +1,14 @@ +export { default as ContextMenu } from "./ContextMenu.vue" +export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue" +export { default as ContextMenuContent } from "./ContextMenuContent.vue" +export { default as ContextMenuGroup } from "./ContextMenuGroup.vue" +export { default as ContextMenuItem } from "./ContextMenuItem.vue" +export { default as ContextMenuLabel } from "./ContextMenuLabel.vue" +export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue" +export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue" +export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue" +export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue" +export { default as ContextMenuSub } from "./ContextMenuSub.vue" +export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue" +export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue" +export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue" diff --git a/frontend/src/components/ui/date-picker/DatePicker.vue b/frontend/src/components/ui/date-picker/DatePicker.vue new file mode 100644 index 0000000..566c444 --- /dev/null +++ b/frontend/src/components/ui/date-picker/DatePicker.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/components/ui/date-picker/DateTimePicker.vue b/frontend/src/components/ui/date-picker/DateTimePicker.vue new file mode 100644 index 0000000..b5d46e3 --- /dev/null +++ b/frontend/src/components/ui/date-picker/DateTimePicker.vue @@ -0,0 +1,141 @@ + + + diff --git a/frontend/src/components/ui/date-picker/TimePicker.vue b/frontend/src/components/ui/date-picker/TimePicker.vue new file mode 100644 index 0000000..e283aaf --- /dev/null +++ b/frontend/src/components/ui/date-picker/TimePicker.vue @@ -0,0 +1,72 @@ + + + diff --git a/frontend/src/components/ui/date-picker/index.ts b/frontend/src/components/ui/date-picker/index.ts new file mode 100644 index 0000000..1fe647e --- /dev/null +++ b/frontend/src/components/ui/date-picker/index.ts @@ -0,0 +1,3 @@ +export { default as DatePicker } from './DatePicker.vue' +export { default as DateTimePicker } from './DateTimePicker.vue' +export { default as TimePicker } from './TimePicker.vue' diff --git a/frontend/src/components/ui/dialog/Dialog.vue b/frontend/src/components/ui/dialog/Dialog.vue new file mode 100644 index 0000000..ade5260 --- /dev/null +++ b/frontend/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogClose.vue b/frontend/src/components/ui/dialog/DialogClose.vue new file mode 100644 index 0000000..c5fae04 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogClose.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogContent.vue b/frontend/src/components/ui/dialog/DialogContent.vue new file mode 100644 index 0000000..7f86b47 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogContent.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogDescription.vue b/frontend/src/components/ui/dialog/DialogDescription.vue new file mode 100644 index 0000000..f52e655 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogFooter.vue b/frontend/src/components/ui/dialog/DialogFooter.vue new file mode 100644 index 0000000..0a936e6 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogHeader.vue b/frontend/src/components/ui/dialog/DialogHeader.vue new file mode 100644 index 0000000..bfc3c64 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogOverlay.vue b/frontend/src/components/ui/dialog/DialogOverlay.vue new file mode 100644 index 0000000..7790077 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogOverlay.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogScrollContent.vue b/frontend/src/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 0000000..f2475db --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogTitle.vue b/frontend/src/components/ui/dialog/DialogTitle.vue new file mode 100644 index 0000000..860f01a --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogTrigger.vue b/frontend/src/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 0000000..49667e9 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/dialog/index.ts b/frontend/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..6768b09 --- /dev/null +++ b/frontend/src/components/ui/dialog/index.ts @@ -0,0 +1,10 @@ +export { default as Dialog } from "./Dialog.vue" +export { default as DialogClose } from "./DialogClose.vue" +export { default as DialogContent } from "./DialogContent.vue" +export { default as DialogDescription } from "./DialogDescription.vue" +export { default as DialogFooter } from "./DialogFooter.vue" +export { default as DialogHeader } from "./DialogHeader.vue" +export { default as DialogOverlay } from "./DialogOverlay.vue" +export { default as DialogScrollContent } from "./DialogScrollContent.vue" +export { default as DialogTitle } from "./DialogTitle.vue" +export { default as DialogTrigger } from "./DialogTrigger.vue" diff --git a/frontend/src/components/ui/drawer/Drawer.vue b/frontend/src/components/ui/drawer/Drawer.vue new file mode 100644 index 0000000..49adb57 --- /dev/null +++ b/frontend/src/components/ui/drawer/Drawer.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerClose.vue b/frontend/src/components/ui/drawer/DrawerClose.vue new file mode 100644 index 0000000..eecc11b --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerClose.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerContent.vue b/frontend/src/components/ui/drawer/DrawerContent.vue new file mode 100644 index 0000000..5994c85 --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerContent.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerDescription.vue b/frontend/src/components/ui/drawer/DrawerDescription.vue new file mode 100644 index 0000000..77e69ba --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerDescription.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerFooter.vue b/frontend/src/components/ui/drawer/DrawerFooter.vue new file mode 100644 index 0000000..34c2e12 --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerHeader.vue b/frontend/src/components/ui/drawer/DrawerHeader.vue new file mode 100644 index 0000000..f68aeb0 --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerOverlay.vue b/frontend/src/components/ui/drawer/DrawerOverlay.vue new file mode 100644 index 0000000..e7e4a05 --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerOverlay.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerTitle.vue b/frontend/src/components/ui/drawer/DrawerTitle.vue new file mode 100644 index 0000000..328fb0d --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/drawer/DrawerTrigger.vue b/frontend/src/components/ui/drawer/DrawerTrigger.vue new file mode 100644 index 0000000..04b5bf1 --- /dev/null +++ b/frontend/src/components/ui/drawer/DrawerTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/drawer/index.ts b/frontend/src/components/ui/drawer/index.ts new file mode 100644 index 0000000..0d07820 --- /dev/null +++ b/frontend/src/components/ui/drawer/index.ts @@ -0,0 +1,9 @@ +export { default as Drawer } from "./Drawer.vue" +export { default as DrawerClose } from "./DrawerClose.vue" +export { default as DrawerContent } from "./DrawerContent.vue" +export { default as DrawerDescription } from "./DrawerDescription.vue" +export { default as DrawerFooter } from "./DrawerFooter.vue" +export { default as DrawerHeader } from "./DrawerHeader.vue" +export { default as DrawerOverlay } from "./DrawerOverlay.vue" +export { default as DrawerTitle } from "./DrawerTitle.vue" +export { default as DrawerTrigger } from "./DrawerTrigger.vue" diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenu.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenu.vue new file mode 100644 index 0000000..e1c9ee3 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenu.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue new file mode 100644 index 0000000..1253078 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuContent.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuContent.vue new file mode 100644 index 0000000..7c43014 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuContent.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuGroup.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuGroup.vue new file mode 100644 index 0000000..da634ec --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuGroup.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuItem.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuItem.vue new file mode 100644 index 0000000..f56cae3 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuItem.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuLabel.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuLabel.vue new file mode 100644 index 0000000..8bca83c --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuLabel.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue new file mode 100644 index 0000000..fe82cad --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue new file mode 100644 index 0000000..e03c40c --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue new file mode 100644 index 0000000..1b936c3 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue new file mode 100644 index 0000000..60be75c --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue new file mode 100644 index 0000000..7472e77 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue new file mode 100644 index 0000000..d7c6b08 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue new file mode 100644 index 0000000..1683aaf --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue b/frontend/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue new file mode 100644 index 0000000..75cd747 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/dropdown-menu/index.ts b/frontend/src/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..955fe3a --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu/index.ts @@ -0,0 +1,16 @@ +export { default as DropdownMenu } from "./DropdownMenu.vue" + +export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue" +export { default as DropdownMenuContent } from "./DropdownMenuContent.vue" +export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue" +export { default as DropdownMenuItem } from "./DropdownMenuItem.vue" +export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue" +export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue" +export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue" +export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue" +export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue" +export { default as DropdownMenuSub } from "./DropdownMenuSub.vue" +export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue" +export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue" +export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue" +export { DropdownMenuPortal } from "reka-ui" diff --git a/frontend/src/components/ui/empty/Empty.vue b/frontend/src/components/ui/empty/Empty.vue new file mode 100644 index 0000000..2637b3c --- /dev/null +++ b/frontend/src/components/ui/empty/Empty.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/empty/EmptyContent.vue b/frontend/src/components/ui/empty/EmptyContent.vue new file mode 100644 index 0000000..d19ee00 --- /dev/null +++ b/frontend/src/components/ui/empty/EmptyContent.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/empty/EmptyDescription.vue b/frontend/src/components/ui/empty/EmptyDescription.vue new file mode 100644 index 0000000..e4ed5fb --- /dev/null +++ b/frontend/src/components/ui/empty/EmptyDescription.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/empty/EmptyHeader.vue b/frontend/src/components/ui/empty/EmptyHeader.vue new file mode 100644 index 0000000..ac21d8c --- /dev/null +++ b/frontend/src/components/ui/empty/EmptyHeader.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/empty/EmptyMedia.vue b/frontend/src/components/ui/empty/EmptyMedia.vue new file mode 100644 index 0000000..c68397c --- /dev/null +++ b/frontend/src/components/ui/empty/EmptyMedia.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/empty/EmptyTitle.vue b/frontend/src/components/ui/empty/EmptyTitle.vue new file mode 100644 index 0000000..90c950d --- /dev/null +++ b/frontend/src/components/ui/empty/EmptyTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/empty/index.ts b/frontend/src/components/ui/empty/index.ts new file mode 100644 index 0000000..ce0c489 --- /dev/null +++ b/frontend/src/components/ui/empty/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Empty } from "./Empty.vue" +export { default as EmptyContent } from "./EmptyContent.vue" +export { default as EmptyDescription } from "./EmptyDescription.vue" +export { default as EmptyHeader } from "./EmptyHeader.vue" +export { default as EmptyMedia } from "./EmptyMedia.vue" +export { default as EmptyTitle } from "./EmptyTitle.vue" + +export const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export type EmptyMediaVariants = VariantProps diff --git a/frontend/src/components/ui/field/Field.vue b/frontend/src/components/ui/field/Field.vue new file mode 100644 index 0000000..5519d37 --- /dev/null +++ b/frontend/src/components/ui/field/Field.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/ui/field/FieldContent.vue b/frontend/src/components/ui/field/FieldContent.vue new file mode 100644 index 0000000..d9a23fd --- /dev/null +++ b/frontend/src/components/ui/field/FieldContent.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/field/FieldDescription.vue b/frontend/src/components/ui/field/FieldDescription.vue new file mode 100644 index 0000000..7240a83 --- /dev/null +++ b/frontend/src/components/ui/field/FieldDescription.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/field/FieldError.vue b/frontend/src/components/ui/field/FieldError.vue new file mode 100644 index 0000000..8a0a63f --- /dev/null +++ b/frontend/src/components/ui/field/FieldError.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/ui/field/FieldGroup.vue b/frontend/src/components/ui/field/FieldGroup.vue new file mode 100644 index 0000000..834d8ce --- /dev/null +++ b/frontend/src/components/ui/field/FieldGroup.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/field/FieldLabel.vue b/frontend/src/components/ui/field/FieldLabel.vue new file mode 100644 index 0000000..ce6c498 --- /dev/null +++ b/frontend/src/components/ui/field/FieldLabel.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/field/FieldLegend.vue b/frontend/src/components/ui/field/FieldLegend.vue new file mode 100644 index 0000000..c620fed --- /dev/null +++ b/frontend/src/components/ui/field/FieldLegend.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ui/field/FieldSeparator.vue b/frontend/src/components/ui/field/FieldSeparator.vue new file mode 100644 index 0000000..97d0efa --- /dev/null +++ b/frontend/src/components/ui/field/FieldSeparator.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/ui/field/FieldSet.vue b/frontend/src/components/ui/field/FieldSet.vue new file mode 100644 index 0000000..7be4dc9 --- /dev/null +++ b/frontend/src/components/ui/field/FieldSet.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/field/FieldTitle.vue b/frontend/src/components/ui/field/FieldTitle.vue new file mode 100644 index 0000000..f564b8b --- /dev/null +++ b/frontend/src/components/ui/field/FieldTitle.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ui/field/index.ts b/frontend/src/components/ui/field/index.ts new file mode 100644 index 0000000..162ba14 --- /dev/null +++ b/frontend/src/components/ui/field/index.ts @@ -0,0 +1,39 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +) + +export type FieldVariants = VariantProps + +export { default as Field } from "./Field.vue" +export { default as FieldContent } from "./FieldContent.vue" +export { default as FieldDescription } from "./FieldDescription.vue" +export { default as FieldError } from "./FieldError.vue" +export { default as FieldGroup } from "./FieldGroup.vue" +export { default as FieldLabel } from "./FieldLabel.vue" +export { default as FieldLegend } from "./FieldLegend.vue" +export { default as FieldSeparator } from "./FieldSeparator.vue" +export { default as FieldSet } from "./FieldSet.vue" +export { default as FieldTitle } from "./FieldTitle.vue" diff --git a/frontend/src/components/ui/form/FormControl.vue b/frontend/src/components/ui/form/FormControl.vue new file mode 100644 index 0000000..b1bc4bf --- /dev/null +++ b/frontend/src/components/ui/form/FormControl.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/form/FormDescription.vue b/frontend/src/components/ui/form/FormDescription.vue new file mode 100644 index 0000000..2d3a903 --- /dev/null +++ b/frontend/src/components/ui/form/FormDescription.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/form/FormItem.vue b/frontend/src/components/ui/form/FormItem.vue new file mode 100644 index 0000000..40cb994 --- /dev/null +++ b/frontend/src/components/ui/form/FormItem.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/form/FormLabel.vue b/frontend/src/components/ui/form/FormLabel.vue new file mode 100644 index 0000000..141655b --- /dev/null +++ b/frontend/src/components/ui/form/FormLabel.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/ui/form/FormMessage.vue b/frontend/src/components/ui/form/FormMessage.vue new file mode 100644 index 0000000..c80c482 --- /dev/null +++ b/frontend/src/components/ui/form/FormMessage.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/ui/form/index.ts b/frontend/src/components/ui/form/index.ts new file mode 100644 index 0000000..1eb05f1 --- /dev/null +++ b/frontend/src/components/ui/form/index.ts @@ -0,0 +1,7 @@ +export { default as FormControl } from "./FormControl.vue" +export { default as FormDescription } from "./FormDescription.vue" +export { default as FormItem } from "./FormItem.vue" +export { default as FormLabel } from "./FormLabel.vue" +export { default as FormMessage } from "./FormMessage.vue" +export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys" +export { Form, Field as FormField, FieldArray as FormFieldArray } from "vee-validate" diff --git a/frontend/src/components/ui/form/injectionKeys.ts b/frontend/src/components/ui/form/injectionKeys.ts new file mode 100644 index 0000000..42542a9 --- /dev/null +++ b/frontend/src/components/ui/form/injectionKeys.ts @@ -0,0 +1,4 @@ +import type { InjectionKey } from "vue" + +export const FORM_ITEM_INJECTION_KEY + = Symbol() as InjectionKey diff --git a/frontend/src/components/ui/form/useFormField.ts b/frontend/src/components/ui/form/useFormField.ts new file mode 100644 index 0000000..62d86c2 --- /dev/null +++ b/frontend/src/components/ui/form/useFormField.ts @@ -0,0 +1,30 @@ +import { FieldContextKey } from "vee-validate" +import { computed, inject } from "vue" +import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys" + +export function useFormField() { + const fieldContext = inject(FieldContextKey) + const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY) + + if (!fieldContext) + throw new Error("useFormField should be used within ") + + const { name, errorMessage: error, meta } = fieldContext + const id = fieldItemContext + + const fieldState = { + valid: computed(() => meta.valid), + isDirty: computed(() => meta.dirty), + isTouched: computed(() => meta.touched), + error, + } + + return { + id, + name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} diff --git a/frontend/src/components/ui/hover-card/HoverCard.vue b/frontend/src/components/ui/hover-card/HoverCard.vue new file mode 100644 index 0000000..07d11e6 --- /dev/null +++ b/frontend/src/components/ui/hover-card/HoverCard.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/hover-card/HoverCardContent.vue b/frontend/src/components/ui/hover-card/HoverCardContent.vue new file mode 100644 index 0000000..2a3c553 --- /dev/null +++ b/frontend/src/components/ui/hover-card/HoverCardContent.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/components/ui/hover-card/HoverCardTrigger.vue b/frontend/src/components/ui/hover-card/HoverCardTrigger.vue new file mode 100644 index 0000000..0a09001 --- /dev/null +++ b/frontend/src/components/ui/hover-card/HoverCardTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/hover-card/index.ts b/frontend/src/components/ui/hover-card/index.ts new file mode 100644 index 0000000..49b2930 --- /dev/null +++ b/frontend/src/components/ui/hover-card/index.ts @@ -0,0 +1,3 @@ +export { default as HoverCard } from "./HoverCard.vue" +export { default as HoverCardContent } from "./HoverCardContent.vue" +export { default as HoverCardTrigger } from "./HoverCardTrigger.vue" diff --git a/frontend/src/components/ui/input-group/InputGroup.vue b/frontend/src/components/ui/input-group/InputGroup.vue new file mode 100644 index 0000000..d32d69d --- /dev/null +++ b/frontend/src/components/ui/input-group/InputGroup.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/ui/input-group/InputGroupAddon.vue b/frontend/src/components/ui/input-group/InputGroupAddon.vue new file mode 100644 index 0000000..709f482 --- /dev/null +++ b/frontend/src/components/ui/input-group/InputGroupAddon.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/components/ui/input-group/InputGroupButton.vue b/frontend/src/components/ui/input-group/InputGroupButton.vue new file mode 100644 index 0000000..4e82e09 --- /dev/null +++ b/frontend/src/components/ui/input-group/InputGroupButton.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/input-group/InputGroupInput.vue b/frontend/src/components/ui/input-group/InputGroupInput.vue new file mode 100644 index 0000000..ca7cbb9 --- /dev/null +++ b/frontend/src/components/ui/input-group/InputGroupInput.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/input-group/InputGroupText.vue b/frontend/src/components/ui/input-group/InputGroupText.vue new file mode 100644 index 0000000..e317337 --- /dev/null +++ b/frontend/src/components/ui/input-group/InputGroupText.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/input-group/InputGroupTextarea.vue b/frontend/src/components/ui/input-group/InputGroupTextarea.vue new file mode 100644 index 0000000..2090be2 --- /dev/null +++ b/frontend/src/components/ui/input-group/InputGroupTextarea.vue @@ -0,0 +1,19 @@ + + +