diff --git a/Makefile b/Makefile index 9cda2f5ed..8279d17a4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: build clean ui -VERSION=1.3.6 +VERSION=1.4.0 BIN=answer DIR_SRC=./cmd/answer DOCKER_CMD=docker @@ -8,7 +8,7 @@ DOCKER_CMD=docker GO_ENV=CGO_ENABLED=0 GO111MODULE=on Revision=$(shell git rev-parse --short HEAD 2>/dev/null || echo "") GO_FLAGS=-ldflags="-X github.com/apache/incubator-answer/cmd.Version=$(VERSION) -X 'github.com/apache/incubator-answer/cmd.Revision=$(Revision)' -X 'github.com/apache/incubator-answer/cmd.Time=`date +%s`' -extldflags -static" -GO=$(GO_ENV) $(shell which go) +GO=$(GO_ENV) "$(shell which go)" build: generate @$(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC) @@ -23,6 +23,8 @@ universal: generate generate: @$(GO) get github.com/google/wire/cmd/wire@v0.5.0 @$(GO) get github.com/golang/mock/mockgen@v1.6.0 + @$(GO) get github.com/swaggo/swag/cmd/swag@v1.16.3 + @$(GO) install github.com/swaggo/swag/cmd/swag@v1.16.3 @$(GO) install github.com/google/wire/cmd/wire@v0.5.0 @$(GO) install github.com/golang/mock/mockgen@v1.6.0 @$(GO) generate ./... diff --git a/README.md b/README.md index 0894a413e..7d7538050 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache ### Running with docker ```bash -docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.3.6 +docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.4.0 ``` For more information, see [Installation](https://answer.apache.org/docs/installation). diff --git a/charts/templates/pvc.yaml b/charts/templates/pvc.yaml index 5dfab8fca..640fb9fe0 100644 --- a/charts/templates/pvc.yaml +++ b/charts/templates/pvc.yaml @@ -20,7 +20,7 @@ kind: PersistentVolumeClaim apiVersion: v1 metadata: name: {{ include "answer.fullname" . }}-claim - {{- with .Values.persistence.annotations }} + {{- with .Values.persistence.annotations }} annotations: {{ toYaml . | indent 4 }} {{- end }} @@ -39,4 +39,10 @@ spec: resources: requests: storage: {{ .Values.persistence.size | quote }} + {{- with .Values.persistence.dataSource }} + dataSource: + name: {{ .name }} + kind: {{ .kind | default "VolumeSnapshot" }} + apiGroup: {{ .apiGroup | default "snapshot.storage.k8s.io" }} + {{- end }} {{- end }} \ No newline at end of file diff --git a/charts/values.yaml b/charts/values.yaml index 8bb36c46f..d932db848 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -68,7 +68,7 @@ env: # Configure extra containers extraContainers: [] - # - name: cloudsql-proxy + # - name: cloudsql-proxy # image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.2 # command: # - /cloud-sql-proxy @@ -91,6 +91,12 @@ persistence: accessMode: ReadWriteOnce size: 5Gi annotations: {} + # To restore a PVC from a VolumeSnapshot, set the dataSource; + # the kind and apiGroup are optional and default to the shown values + dataSource: {} + # name: my-volume-snapshot + # kind: VolumeSnapshot + # apiGroup: snapshot.storage.k8s.io imagePullSecrets: [] nameOverride: "" diff --git a/cmd/answer/main.go b/cmd/answer/main.go index ae1147090..fb91f5942 100644 --- a/cmd/answer/main.go +++ b/cmd/answer/main.go @@ -17,12 +17,19 @@ * under the License. */ +//go:generate go run github.com/swaggo/swag/cmd/swag init -g ./cmd/answer/main.go -d ../../ -o ../../docs + package main import ( answercmd "github.com/apache/incubator-answer/cmd" ) +// main godoc +// @title "apache answer" +// @description = "apache answer api" +// @version = "v0.0.1" +// @BasePath = "/" // @securityDefinitions.apikey ApiKeyAuth // @in header // @name Authorization diff --git a/cmd/command.go b/cmd/command.go index b8d9c5611..65710e6b4 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -263,12 +263,17 @@ To run answer, use: } field := &cli.ConfigField{} - for _, f := range configFields { - switch f { + fmt.Println(configFields) + if len(configFields) > 0 { + switch configFields[0] { case "allow_password_login": field.AllowPasswordLogin = true + case "deactivate_plugin": + if len(configFields) > 1 { + field.DeactivatePluginSlugName = configFields[1] + } default: - fmt.Printf("field %s not support\n", f) + fmt.Printf("field %s not support\n", configFields[0]) } } err = cli.SetDefaultConfig(c.Data.Database, c.Data.Cache, field) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4928a122b..d18355e3a 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -40,6 +40,9 @@ import ( "github.com/apache/incubator-answer/internal/repo/activity_common" "github.com/apache/incubator-answer/internal/repo/answer" "github.com/apache/incubator-answer/internal/repo/auth" + "github.com/apache/incubator-answer/internal/repo/badge" + "github.com/apache/incubator-answer/internal/repo/badge_award" + "github.com/apache/incubator-answer/internal/repo/badge_group" "github.com/apache/incubator-answer/internal/repo/captcha" "github.com/apache/incubator-answer/internal/repo/collection" "github.com/apache/incubator-answer/internal/repo/comment" @@ -71,6 +74,7 @@ import ( "github.com/apache/incubator-answer/internal/service/activity_queue" "github.com/apache/incubator-answer/internal/service/answer_common" auth2 "github.com/apache/incubator-answer/internal/service/auth" + badge2 "github.com/apache/incubator-answer/internal/service/badge" collection2 "github.com/apache/incubator-answer/internal/service/collection" "github.com/apache/incubator-answer/internal/service/collection_common" comment2 "github.com/apache/incubator-answer/internal/service/comment" @@ -78,6 +82,7 @@ import ( config2 "github.com/apache/incubator-answer/internal/service/config" "github.com/apache/incubator-answer/internal/service/content" "github.com/apache/incubator-answer/internal/service/dashboard" + "github.com/apache/incubator-answer/internal/service/event_queue" export2 "github.com/apache/incubator-answer/internal/service/export" "github.com/apache/incubator-answer/internal/service/follow" meta2 "github.com/apache/incubator-answer/internal/service/meta" @@ -172,7 +177,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, metaRepo := meta.NewMetaRepo(dataData) metaCommonService := metacommon.NewMetaCommonService(metaRepo) questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, activityQueueService, revisionRepo, dataData) - userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon) + eventQueueService := event_queue.NewEventQueueService() + userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventQueueService) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService) @@ -181,7 +187,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, objService := object_info.NewObjService(answerRepo, questionRepo, commentCommonRepo, tagCommonRepo, tagCommonService) notificationQueueService := notice_queue.NewNotificationQueueService() externalNotificationQueueService := notice_queue.NewNewQuestionNotificationQueueService() - commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, notificationQueueService, externalNotificationQueueService, activityQueueService) + commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, notificationQueueService, externalNotificationQueueService, activityQueueService, eventQueueService) rolePowerRelRepo := role.NewRolePowerRelRepo(dataData) rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService) rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService) @@ -189,20 +195,20 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, rateLimitMiddleware := middleware.NewRateLimitMiddleware(limitRepo) commentController := controller.NewCommentController(commentService, rankService, captchaService, rateLimitMiddleware) reportRepo := report.NewReportRepo(dataData, uniqueIDRepo) + tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, activityQueueService) answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, notificationQueueService) answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService) externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalNotificationQueueService, userExternalLoginRepo, siteInfoCommonService) reviewRepo := review.NewReviewRepo(dataData) reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalNotificationQueueService, tagCommonService, questionCommon, notificationQueueService, siteInfoCommonService) - questionService := content.NewQuestionService(questionRepo, answerRepo, tagCommonService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService, reviewService, configService) - answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService, reviewService) + questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService, reviewService, configService, eventQueueService) + answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService, reviewService, eventQueueService) reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService) - reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService) + reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventQueueService) reportController := controller.NewReportController(reportService, rankService, captchaService) contentVoteRepo := activity.NewVoteRepo(dataData, activityRepo, userRankRepo, notificationQueueService) - voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService) + voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService, eventQueueService) voteController := controller.NewVoteController(voteService, rankService, captchaService) - tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, activityQueueService) tagController := controller.NewTagController(tagService, tagCommonService, rankService) followFollowRepo := activity.NewFollowRepo(dataData, uniqueIDRepo, activityRepo) followService := follow.NewFollowService(followFollowRepo, followRepo, tagCommonRepo) @@ -232,7 +238,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService) notificationRepo := notification2.NewNotificationRepo(dataData) notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, notificationQueueService, userExternalLoginRepo, siteInfoCommonService) - notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService) + badgeRepo := badge.NewBadgeRepo(dataData, uniqueIDRepo) + notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService, badgeRepo) notificationController := controller.NewNotificationController(notificationService, rankService) dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configService, siteInfoCommonService, serviceConf, reviewService, revisionRepo, dataData) dashboardController := controller.NewDashboardController(dashboardService) @@ -251,23 +258,32 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, permissionController := controller.NewPermissionController(rankService) userPluginController := controller.NewUserPluginController(pluginCommonService) reviewController := controller.NewReviewController(reviewService, rankService, captchaService) - metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo) + metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, eventQueueService) metaController := controller.NewMetaController(metaService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController) + badgeGroupRepo := badge_group.NewBadgeGroupRepo(dataData, uniqueIDRepo) + badgeAwardRepo := badge_award.NewBadgeAwardRepo(dataData, uniqueIDRepo) + eventRuleRepo := badge.NewEventRuleRepo(dataData) + badgeAwardService := badge2.NewBadgeAwardService(badgeAwardRepo, badgeRepo, userCommon, objService, notificationQueueService) + badgeEventService := badge2.NewBadgeEventService(dataData, eventQueueService, badgeRepo, eventRuleRepo, badgeAwardService) + badgeService := badge2.NewBadgeService(badgeRepo, badgeGroupRepo, badgeAwardRepo, badgeEventService) + badgeController := controller.NewBadgeController(badgeService, badgeAwardService) + controller_adminBadgeController := controller_admin.NewBadgeController(badgeService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) avatarMiddleware := middleware.NewAvatarMiddleware(serviceConf, uploaderService) shortIDMiddleware := middleware.NewShortIDMiddleware(siteInfoCommonService) templateRenderController := templaterender.NewTemplateRenderController(questionService, userService, tagService, answerService, commentService, siteInfoCommonService, questionRepo) - templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService) + templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService, eventQueueService, userService) templateRouter := router.NewTemplateRouter(templateController, templateRenderController, siteInfoController, authUserMiddleware) connectorController := controller.NewConnectorController(siteInfoCommonService, emailService, userExternalLoginService) userCenterLoginService := user_external_login2.NewUserCenterLoginService(userRepo, userCommon, userExternalLoginRepo, userActiveActivityRepo, siteInfoCommonService) userCenterController := controller.NewUserCenterController(userCenterLoginService, siteInfoCommonService) captchaController := controller.NewCaptchaController() embedController := controller.NewEmbedController() - pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController, captchaController, embedController) + renderController := controller.NewRenderController() + pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController, captchaController, embedController, renderController) ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, shortIDMiddleware, templateRouter, pluginAPIRouter, uiConf) scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService) application := newApplication(serverConf, ginEngine, scheduledTaskManager) diff --git a/dev/i18n/zh_CN.yaml b/dev/i18n/zh_CN.yaml deleted file mode 100644 index 6613bfc22..000000000 --- a/dev/i18n/zh_CN.yaml +++ /dev/null @@ -1,2035 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -# The following fields are used for back-end -backend: - base: - success: - other: 成功。 - unknown: - other: 未知错误。 - request_format_error: - other: 请求格式错误。 - unauthorized_error: - other: 未授权。 - database_error: - other: 数据服务器错误。 - forbidden_error: - other: 禁止访问。 - duplicate_request_error: - other: 重复提交。 - action: - report: - other: 举报 - edit: - other: 编辑 - delete: - other: 删除 - close: - other: 关闭 - reopen: - other: 重新打开 - forbidden_error: - other: 禁止访问。 - pin: - other: 置顶 - hide: - other: 列表隐藏 - unpin: - other: 取消置顶 - show: - other: 列表显示 - invite_someone_to_answer: - other: 编辑 - undelete: - other: 撤消删除 - role: - name: - user: - other: 用户 - admin: - other: 管理员 - moderator: - other: 版主 - description: - user: - other: 默认没有特殊权限。 - admin: - other: 拥有管理网站的全部权限。 - moderator: - other: 拥有除访问后台管理以外的所有权限。 - privilege: - level_1: - description: - other: 级别 1(少量声望要求,适合私有团队、群组) - level_2: - description: - other: 级别 2(低声望要求,适合初启动的社区) - level_3: - description: - other: 级别 3(高声望要求,适合成熟的社区) - level_custom: - description: - other: 自定义等级 - rank_question_add_label: - other: 提问 - rank_answer_add_label: - other: 写答案 - rank_comment_add_label: - other: 写评论 - rank_report_add_label: - other: 举报 - rank_comment_vote_up_label: - other: 点赞评论 - rank_link_url_limit_label: - other: 每次发布超过 2 个链接 - rank_question_vote_up_label: - other: 点赞问题 - rank_answer_vote_up_label: - other: 点赞答案 - rank_question_vote_down_label: - other: 点踩问题 - rank_answer_vote_down_label: - other: 点踩答案 - rank_invite_someone_to_answer_label: - other: 邀请回答 - rank_tag_add_label: - other: 创建新标签 - rank_tag_edit_label: - other: 编辑标签描述(需要审核) - rank_question_edit_label: - other: 编辑别人的问题(需要审核) - rank_answer_edit_label: - other: 编辑别人的答案(需要审核) - rank_question_edit_without_review_label: - other: 编辑别人的问题无需审核 - rank_answer_edit_without_review_label: - other: 编辑别人的答案无需审核 - rank_question_audit_label: - other: 审核问题编辑 - rank_answer_audit_label: - other: 审核回答编辑 - rank_tag_audit_label: - other: 审核标签编辑 - rank_tag_edit_without_review_label: - other: 编辑标签描述无需审核 - rank_tag_synonym_label: - other: 管理标签同义词 - email: - other: 邮箱 - e_mail: - other: 邮箱 - password: - other: 密码 - pass: - other: 密码 - original_text: - other: 本帖 - email_or_password_wrong_error: - other: 邮箱和密码不匹配。 - error: - common: - invalid_url: - other: 无效的 URL。 - password: - space_invalid: - other: 密码不得含有空格。 - admin: - cannot_update_their_password: - other: 你无法修改自己的密码。 - cannot_edit_their_profile: - other: 您不能修改您的个人资料。 - cannot_modify_self_status: - other: 你无法修改自己的状态。 - email_or_password_wrong: - other: 邮箱和密码不匹配。 - answer: - not_found: - other: 没有找到答案。 - cannot_deleted: - other: 没有删除权限。 - cannot_update: - other: 没有更新权限。 - question_closed_cannot_add: - other: 问题已关闭,无法添加。 - comment: - edit_without_permission: - other: 不允许编辑评论。 - not_found: - other: 评论未找到。 - cannot_edit_after_deadline: - other: 评论时间太久,无法修改。 - email: - duplicate: - other: 邮箱已存在。 - need_to_be_verified: - other: 邮箱需要验证。 - verify_url_expired: - other: 邮箱验证的网址已过期,请重新发送邮件。 - illegal_email_domain_error: - other: 此邮箱不在允许注册的邮箱域中。请使用其他邮箱尝试。 - lang: - not_found: - other: 语言文件未找到。 - object: - captcha_verification_failed: - other: 验证码错误。 - disallow_follow: - other: 你不能关注。 - disallow_vote: - other: 你不能投票。 - disallow_vote_your_self: - other: 你不能为自己的帖子投票。 - not_found: - other: 对象未找到。 - verification_failed: - other: 验证失败。 - email_or_password_incorrect: - other: 邮箱和密码不匹配。 - old_password_verification_failed: - other: 旧密码验证失败。 - new_password_same_as_previous_setting: - other: 新密码和旧密码相同。 - already_deleted: - other: 该帖子已被删除。 - meta: - object_not_found: - other: Meta 对象未找到 - question: - already_deleted: - other: 该帖子已被删除。 - under_review: - other: 您的帖子正在等待审核。它将在它获得批准后可见。 - not_found: - other: 问题未找到。 - cannot_deleted: - other: 没有删除权限。 - cannot_close: - other: 没有关闭权限。 - cannot_update: - other: 没有更新权限。 - rank: - fail_to_meet_the_condition: - other: 声望值未达到要求。 - vote_fail_to_meet_the_condition: - other: 感谢投票。你至少需要 {{.Rank}} 声望才能投票。 - no_enough_rank_to_operate: - other: 你至少需要 {{.Rank}} 声望才能执行此操作。 - report: - handle_failed: - other: 报告处理失败。 - not_found: - other: 报告未找到。 - tag: - already_exist: - other: 标签已存在。 - not_found: - other: 标签未找到。 - recommend_tag_not_found: - other: 推荐标签不存在。 - recommend_tag_enter: - other: 请选择至少一个必选标签。 - not_contain_synonym_tags: - other: 不应包含同义词标签。 - cannot_update: - other: 没有更新权限。 - is_used_cannot_delete: - other: 你不能删除这个正在使用的标签。 - cannot_set_synonym_as_itself: - other: 你不能将当前标签设为自己的同义词。 - smtp: - config_from_name_cannot_be_email: - other: 发件人名称不能是邮箱地址。 - theme: - not_found: - other: 主题未找到。 - revision: - review_underway: - other: 目前无法编辑,有一个版本在审阅队列中。 - no_permission: - other: 无权限修改。 - user: - external_login_missing_user_id: - other: 第三方平台没有提供唯一的 UserID,所以你不能登录,请联系网站管理员。 - external_login_unbinding_forbidden: - other: 请在移除此登录之前为你的账户设置登录密码。 - email_or_password_wrong: - other: - other: 邮箱和密码不匹配。 - not_found: - other: 用户未找到。 - suspended: - other: 用户已被封禁。 - username_invalid: - other: 用户名无效。 - username_duplicate: - other: 用户名已被使用。 - set_avatar: - other: 头像设置错误。 - cannot_update_your_role: - other: 你不能修改自己的角色。 - not_allowed_registration: - other: 该网站暂未开放注册。 - not_allowed_login_via_password: - other: 该网站暂不支持密码登录。 - access_denied: - other: 拒绝访问 - page_access_denied: - other: 您没有权限访问此页面。 - add_bulk_users_format_error: - other: "发生错误,{{.Field}} 格式错误,在 '{{.Content}}' 行数 {{.Line}}. {{.ExtraMessage}}" - add_bulk_users_amount_error: - other: "一次性添加的用户数量应在 1-{{.MaxAmount}} 之间。" - config: - read_config_failed: - other: 读取配置失败 - database: - connection_failed: - other: 数据库连接失败 - create_table_failed: - other: 创建表失败 - install: - create_config_failed: - other: 无法创建 config.yaml 文件。 - upload: - unsupported_file_format: - other: 不支持的文件格式。 - site_info: - config_not_found: - other: 未找到网站的该配置信息。 - reason: - spam: - name: - other: 垃圾信息 - desc: - other: 这个帖子是一个广告,或是破坏性行为。它对当前的主题无帮助或无关。 - rude_or_abusive: - name: - other: 粗鲁或辱骂的 - desc: - other: - - 一个有理智的人都会认为这种内容不适合进行尊重性的讨论。 - - 论坛 - a_duplicate: - name: - other: 重复内容 - desc: - other: 该问题有人问过,而且已经有了答案。 - placeholder: - other: 输入已有的问题链接 - not_a_answer: - name: - other: 不是答案 - desc: - other: - - 这张贴作为答案,但它不会试图回答 - - 这可能是一个编辑、一个评论、另一个问题。 - - 或全部删除。 - no_longer_needed: - name: - other: 不再需要 - desc: - other: 该评论已过时,对话性质或与此帖子无关。 - something: - name: - other: 其他原因 - desc: - other: 此帖子需要工作人员注意,因为是上述所列以外的其他理由。 - placeholder: - other: 让我们具体知道你关心的什么 - community_specific: - name: - other: 社区特定原因 - desc: - other: 该问题不符合社区准则。 - not_clarity: - name: - other: 需要细节或澄清 - desc: - other: 该问题目前涵盖多个问题。它应该侧重在一个问题上。 - looks_ok: - name: - other: 看起来没问题 - desc: - other: 这个帖子是好的,不是低质量。 - needs_edit: - name: - other: 需要编辑,我已做了修改。 - desc: - other: 改进和纠正你自己帖子中的问题。 - needs_close: - name: - other: 需要关闭 - desc: - other: 关闭的问题不能回答,但仍然可以编辑、投票和评论。 - needs_delete: - name: - other: 需要删除 - desc: - other: 该帖子将被删除。 - question: - close: - duplicate: - name: - other: 垃圾信息 - desc: - other: 此问题以前就有人问过,而且已经有了答案。 - guideline: - name: - other: 社区特定原因 - desc: - other: 该问题不符合社区准则。 - multiple: - name: - other: 需要细节或澄清 - desc: - other: - - 该问题目前涵盖多个问题。它应该侧重在一个问题上。 - - 只关注一个问题。 - other: - name: - other: 其他原因 - desc: - other: 该帖子存在上面没有列出的另一个原因。 - operation_type: - asked: - other: 提问于 - answered: - other: 回答于 - modified: - other: 修改于 - deleted_title: - other: 删除的问题 - notification: - action: - update_question: - other: 更新了问题 - answer_the_question: - other: 回答了问题 - update_answer: - other: 更新了答案 - accept_answer: - other: 采纳了答案 - comment_question: - other: 评论了问题 - comment_answer: - other: 评论了答案 - reply_to_you: - other: 回复了你 - mention_you: - other: 提到了你 - your_question_is_closed: - other: 你的问题已被关闭 - your_question_was_deleted: - other: 你的问题已被删除 - your_answer_was_deleted: - other: 你的答案已被删除 - your_comment_was_deleted: - other: 你的评论已被删除 - up_voted_question: - other: 点赞问题 - down_voted_question: - other: 点踩问题 - up_voted_answer: - other: 点赞答案 - down_voted_answer: - other: 点踩回答 - up_voted_comment: - other: 点赞评论 - invited_you_to_answer: - other: 邀请你回答 - email_tpl: - change_email: - title: - other: "[{{.SiteName}}] 确认你的新邮箱地址" - body: - other: "请点击以下链接确认你在 {{.SiteName}} 上的新邮箱地址:
\n{{.ChangeEmailUrl}}

\n\n如果你没有请求此更改,请忽略此邮件。\n" - new_answer: - title: - other: "[{{.SiteName}}] {{.DisplayName}} 回答了你的问题" - body: - other: "{{.QuestionTitle}}

\n\n{{.DisplayName}}:
\n
{{.AnswerSummary}}

\n在 {{.SiteName}} 上查看

\n\n--
\n取消订阅" - invited_you_to_answer: - title: - other: "[{{.SiteName}}] {{.DisplayName}} 邀请您回答问题" - body: - other: "{{.QuestionTitle}}

\n\n{{.DisplayName}}:
\n
我想你可能知道答案。

\n在 {{.SiteName}} 上查看

\n\n--
\n取消订阅" - new_comment: - title: - other: "[{{.SiteName}}] {{.DisplayName}} 评论了你的帖子" - body: - other: "{{.QuestionTitle}}

\n\n{{.DisplayName}}:
\n
{{.CommentSummary}}

\n在 {{.SiteName}} 上查看

\n\n--
\n取消订阅" - new_question: - title: - other: "[{{.SiteName}}] 新问题: {{.QuestionTitle}}" - body: - other: "{{.QuestionTitle}}
\n{{.Tags}}

\n\n--
\n取消订阅" - pass_reset: - title: - other: "[{{.SiteName }}] 重置密码" - body: - other: "有人要求在 [{{.SiteName}}] 上重置你的密码。

\n\n如果这不是你的操作,请安心忽略此电子邮件。

\n\n请点击以下链接设置一个新密码:
\n{{.PassResetUrl}}\n" - register: - title: - other: "[{{.SiteName}}] 确认你的新账户" - body: - other: "欢迎加入 {{.SiteName}}!

\n\n请点击以下链接确认并激活你的新账户:
\n{{.RegisterUrl}}

\n\n如果上面的链接不能点击,请将其复制并粘贴到你的浏览器地址栏中。\n" - test: - title: - other: "[{{.SiteName}}] 测试邮件" - body: - other: "这是一封测试邮件。" - action_activity_type: - upvote: - other: 点赞 - upvoted: - other: 点赞 - downvote: - other: 点踩 - downvoted: - other: 点踩 - accept: - other: 采纳 - accepted: - other: 已采纳 - edit: - other: 编辑 - review: - queued_post: - other: 排队的帖子 - flagged_post: - other: 举报的帖子 - suggested_post_edit: - other: 建议的编辑 - reaction: - tooltip: - other: "{{ .Names }} 以及另外 {{ .Count }} 个..." -plugin: - s3_cdn: - backend: - info: - name: - other: S3 存储 CDN - description: - other: 上传文件到S3存储 - config: - endpoint: - title: - other: Endpoint - description: - other: S3存储的Endpoint - bucket_name: - title: - other: Bucket名称 - description: - other: S3存储的Bucket名称 - object_key_prefix: - title: - other: 对象Key前缀 - description: - other: 对象键的前缀,如'answer/data/',以'/'结尾 - access_key_id: - title: - other: AccessKeyID - description: - other: S3存储的AccessKeyID - access_key_secret: - title: - other: AccessKeySecret - description: - other: S3存储的AccessKeySecret - access_token: - title: - other: AccessToken - description: - other: 访问 S3 所需的 AccessToken - visit_url_prefix: - title: - other: 访问URL前缀 - description: - other: 上传静态文件CDN最终访问地址的前缀,以 '/' 结尾 https://static.example.com/xxx/ - max_file_size: - title: - other: 文件最大大小(MB) - description: - other: 限制上传文件的最大大小,单位MB,默认为10MB - region: - title: - other: 区域(Region) - description: - other: S3存储区域 - disable_ssl: - title: - other: 禁用SSL - description: - other: 我们建议您使用SSL访问S3存储。如果您想禁用SSL,请选中此选项。 - err: - mis_storage_config: - other: 错误的存储配置导致上传失败 - file_not_found: - other: 文件未找到 - unsupported_file_type: - other: 不支持的文件类型 - over_file_size_limit: - other: 超过文件大小限制 - upload_file_failed: - other: 上传文件失败 - aliyunoss_cdn: - backend: - info: - name: - other: 阿里云CDN OSS存储 - description: - other: 上传文件到阿里云CDN OSS存储 - config: - endpoint: - title: - other: Endpoint - description: - other: 阿里云OSS存储的Endpoint - bucket_name: - title: - other: Bucket名称 - description: - other: 阿里云OSS存储的Bucket名称 - object_key_prefix: - title: - other: 对象Key前缀 - description: - other: 对象键的前缀,如'answer/data/',以'/'结尾 - access_key_id: - title: - other: AccessKeyID - description: - other: 阿里云OSS存储的AccessKeyID - access_key_secret: - title: - other: AccessKeySecret - description: - other: 阿里云OSS存储的AccessKeySecret - visit_url_prefix: - title: - other: 访问URL前缀 - description: - other: CDN最终访问地址的前缀,以 '/' 结尾 https://example.com/xxx/ - max_file_size: - title: - other: 最大文件大小(MB) - description: - other: 限制上传文件的最大大小,单位为MB,默认为 10MB - err: - mis_storage_config: - other: 错误的存储配置导致上传失败 - file_not_found: - other: 文件未找到 - unsupported_file_type: - other: 不支持的文件类型 - over_file_size_limit: - other: 超过文件大小限制 - upload_file_failed: - other: 上传文件失败 -# The following fields are used for interface presentation(Front-end) -ui: - how_to_format: - title: 如何排版 - desc: >- - - pagination: - prev: 上一页 - next: 下一页 - page_title: - question: 问题 - questions: 问题 - tag: 标签 - tags: 标签 - tag_wiki: 标签维基 - create_tag: 创建标签 - edit_tag: 编辑标签 - ask_a_question: 提问题 - edit_question: 编辑问题 - edit_answer: 编辑回答 - search: 搜索 - posts_containing: 帖子包含 - settings: 设置 - notifications: 通知 - login: 登录 - sign_up: 注册 - account_recovery: 账号恢复 - account_activation: 账号激活 - confirm_email: 确认电子邮件 - account_suspended: 账号已被封禁 - admin: 后台管理 - change_email: 修改邮箱 - install: Answer 安装 - upgrade: Answer 升级 - maintenance: 网站维护 - users: 用户 - oauth_callback: 处理中 - http_404: HTTP 错误 404 - http_50X: HTTP 错误 500 - http_403: HTTP 错误 403 - logout: 退出 - notifications: - title: 通知 - inbox: 收件箱 - achievement: 成就 - new_alerts: 新通知 - all_read: 全部标记为已读 - show_more: 显示更多 - someone: 有人 - inbox_type: - all: 全部 - posts: 帖子 - invites: 邀请 - votes: 投票 - suspended: - title: 你的账号账号已被封禁 - until_time: "你的账号被封禁直到 {{ time }}。" - forever: 你的账号已被永久封禁。 - end: 你违反了我们的社区准则。 - contact_us: 联系我们 - editor: - blockquote: - text: 引用 - bold: - text: 粗体 - chart: - text: 图表 - flow_chart: 流程图 - sequence_diagram: 时序图 - class_diagram: 类图 - state_diagram: 状态图 - entity_relationship_diagram: 实体关系图 - user_defined_diagram: 用户自定义图表 - gantt_chart: 甘特图 - pie_chart: 饼图 - code: - text: 代码块 - add_code: 添加代码块 - form: - fields: - code: - label: 代码块 - msg: - empty: 代码块不能为空 - language: - label: 语言 - placeholder: 自动识别 - btn_cancel: 取消 - btn_confirm: 添加 - formula: - text: 公式 - options: - inline: 行内公式 - block: 块级公式 - heading: - text: 标题 - options: - h1: 标题 1 - h2: 标题 2 - h3: 标题 3 - h4: 标题 4 - h5: 标题 5 - h6: 标题 6 - help: - text: 帮助 - hr: - text: 水平线 - image: - text: 图片 - add_image: 添加图片 - tab_image: 上传图片 - form_image: - fields: - file: - label: 图像文件 - btn: 选择图片 - msg: - empty: 请选择图片文件。 - only_image: 只能上传图片文件。 - max_size: 文件大小不能超过 4 MB。 - desc: - label: 描述 - tab_url: 图片地址 - form_url: - fields: - url: - label: 图片地址 - msg: - empty: 图片地址不能为空 - name: - label: 描述 - btn_cancel: 取消 - btn_confirm: 添加 - uploading: 上传中 - indent: - text: 缩进 - outdent: - text: 减少缩进 - italic: - text: 斜体 - link: - text: 超链接 - add_link: 添加超链接 - form: - fields: - url: - label: 链接 - msg: - empty: 链接不能为空。 - name: - label: 描述 - btn_cancel: 取消 - btn_confirm: 添加 - ordered_list: - text: 有序列表 - unordered_list: - text: 无序列表 - table: - text: 表格 - heading: 表头 - cell: 单元格 - close_modal: - title: 关闭原因是... - btn_cancel: 取消 - btn_submit: 提交 - remark: - empty: 不能为空。 - msg: - empty: 请选择一个原因。 - report_modal: - flag_title: 我举报这篇帖子的原因是... - close_title: 我关闭这篇帖子的原因是... - review_question_title: 审查问题 - review_answer_title: 审查回答 - review_comment_title: 审查评论 - btn_cancel: 取消 - btn_submit: 提交 - remark: - empty: 不能为空 - msg: - empty: 请选择一个原因。 - not_a_url: URL 格式不正确。 - url_not_match: URL 来源与当前网站不匹配。 - tag_modal: - title: 创建新标签 - form: - fields: - display_name: - label: 显示名称 - msg: - empty: 显示名称不能为空。 - range: 显示名称不能超过 35 个字符。 - slug_name: - label: URL 固定链接 - desc: URL 固定链接不能超过 35 个字符。 - msg: - empty: URL 固定链接不能为空。 - range: URL 固定链接不能超过 35 个字符。 - character: URL 固定链接包含非法字符。 - desc: - label: 描述 - revision: - label: 编辑历史 - edit_summary: - label: 编辑备注 - placeholder: >- - 简单描述更改原因(更正拼写、修复语法、改进格式) - btn_cancel: 取消 - btn_submit: 提交 - btn_post: 发布新标签 - tag_info: - created_at: 创建于 - edited_at: 编辑于 - history: 历史 - synonyms: - title: 同义词 - text: 以下标签将被重置到 - empty: 此标签目前没有同义词。 - btn_add: 添加同义词 - btn_edit: 编辑 - btn_save: 保存 - synonyms_text: 以下标签将被重置到 - delete: - title: 删除标签 - tip_with_posts: >- -

我们不允许 删除带有帖子的标签

请先从帖子中移除此标签。

- tip_with_synonyms: >- -

我们不允许 删除带有同义词的标签

请先从此标签中删除同义词。

- tip: 确定要删除吗? - close: 关闭 - edit_tag: - title: 编辑标签 - default_reason: 编辑标签 - default_first_reason: 添加标签 - btn_save_edits: 保存更改 - btn_cancel: 取消 - dates: - long_date: MM 月 DD 日 - long_date_with_year: "YYYY 年 MM 月 DD 日" - long_date_with_time: "YYYY 年 MM 月 DD 日 HH:mm" - now: 刚刚 - x_seconds_ago: "{{count}} 秒前" - x_minutes_ago: "{{count}} 分钟前" - x_hours_ago: "{{count}} 小时前" - hour: 小时 - day: 天 - hours: 小时 - days: 日 - reaction: - heart: 爱心 - smile: 微笑 - frown: 愁 - btn_label: 添加或删除回应。 - undo_emoji: 撤销 {{ emoji }} 回应 - react_emoji: 用 {{ emoji }} 回应 - unreact_emoji: 撤销 {{ emoji }} - comment: - btn_add_comment: 添加评论 - reply_to: 回复 - btn_reply: 回复 - btn_edit: 编辑 - btn_delete: 删除 - btn_flag: 举报 - btn_save_edits: 保存更改 - btn_cancel: 取消 - show_more: "{{count}} 条剩余评论" - tip_question: >- - 使用评论提问更多信息或者提出改进意见。避免在评论里回答问题。 - tip_answer: >- - 使用评论对回答者进行回复,或者通知回答者你已更新了问题的内容。如果要补充或者完善问题的内容,请在原问题中更改。 - tip_vote: 它给帖子添加了一些有用的内容 - edit_answer: - title: 编辑回答 - default_reason: 编辑回答 - default_first_reason: 添加答案 - form: - fields: - revision: - label: 编辑历史 - answer: - label: 回答内容 - feedback: - characters: 内容长度至少 6 个字符 - edit_summary: - label: 编辑摘要 - placeholder: >- - 简单描述更改原因(更正拼写、修复语法、改进格式) - btn_save_edits: 保存更改 - btn_cancel: 取消 - tags: - title: 标签 - sort_buttons: - popular: 热门 - name: 名称 - newest: 最新 - button_follow: 关注 - button_following: 已关注 - tag_label: 个问题 - search_placeholder: 通过标签名称过滤 - no_desc: 此标签无描述。 - more: 更多 - ask: - title: 新增问题 - edit_title: 编辑问题 - default_reason: 编辑问题 - default_first_reason: 新增问题 - similar_questions: 相似问题 - form: - fields: - revision: - label: 修订版本 - title: - label: 标题 - placeholder: 请详细描述你的问题,想象你在问一个人 - msg: - empty: 标题不能为空。 - range: 标题最多 150 个字符 - body: - label: 内容 - msg: - empty: 内容不能为空。 - tags: - label: 标签 - msg: - empty: 必须选择一个标签 - answer: - label: 回答内容 - msg: - empty: 回答内容不能为空 - edit_summary: - label: 编辑备注 - placeholder: >- - 简单描述更改原因(更正拼写、修复语法、改进格式) - btn_post_question: 提交问题 - btn_save_edits: 保存更改 - answer_question: 回答自己的问题 - post_question&answer: 提交问题和回答 - tag_selector: - add_btn: 添加标签 - create_btn: 创建新标签 - search_tag: 搜索标签 - hint: "描述您的问题是关于什么,至少需要一个标签。" - no_result: 没有匹配的标签 - tag_required_text: 必选标签(至少一个) - header: - nav: - question: 问题 - tag: 标签 - user: 用户 - profile: 用户主页 - setting: 账号设置 - logout: 退出 - admin: 后台管理 - review: 审查 - bookmark: 收藏夹 - moderation: 管理 - search: - placeholder: 搜索 - footer: - build_on: >- - 由 <1>Apache Answer 提供动力 - 驱动问答社区的开源软件。
用爱制造 © {{cc}}. - upload_img: - name: 更改 - loading: 加载中... - pic_auth_code: - title: 验证码 - placeholder: 输入图片中的文字 - msg: - empty: 验证码不能为空。 - inactive: - first: >- - 就差一步!我们发送了一封激活邮件到 {{mail}}。请按照邮件中的说明激活你的账户。 - info: "如果没有收到,请检查你的垃圾邮件文件夹。" - another: >- - 我们向你的邮箱 {{mail}} 发送了另一封激活电子邮件。可能需要几分钟才能到达;请务必检查您的垃圾邮件箱。 - btn_name: 重新发送激活邮件 - change_btn_name: 更改邮箱 - msg: - empty: 不能为空。 - resend_email: - url_label: 确定要重新发送激活邮件吗? - url_text: 你也可以将上面的激活链接给该用户。 - login: - login_to_continue: 登录以继续 - info_sign: 没有账户?<1>注册 - info_login: 已经有账户?<1>登录 - agreements: 登录即表示您同意<1>隐私政策和<3>服务条款。 - forgot_pass: 忘记密码? - name: - label: 名字 - msg: - empty: 名字不能为空 - range: 名字应该在 4 到 30 个字符之间 - character: '只能由 "a-z"、"A-Z"、"0-9"、" - . _" 组成' - email: - label: 邮箱 - msg: - empty: 邮箱不能为空 - password: - label: 密码 - msg: - empty: 密码不能为空 - different: 两次输入密码不一致 - account_forgot: - page_title: 忘记密码 - btn_name: 发送恢复邮件 - send_success: >- - 如果存在邮箱为 {{mail}} 账户,你将很快收到一封重置密码的说明邮件。 - email: - label: 邮箱 - msg: - empty: 邮箱不能为空 - change_email: - btn_cancel: 取消 - btn_update: 更新电子邮件地址 - send_success: >- - 如果存在邮箱为 {{mail}} 的账户,你将很快收到一封重置密码的说明邮件。 - email: - label: 新的电子邮件地址 - msg: - empty: 邮箱不能为空。 - oauth: - connect: 连接到 {{ auth_name }} - remove: 移除 {{ auth_name }} - oauth_bind_email: - subtitle: 向你的账户添加恢复邮件地址。 - btn_update: 更新电子邮件地址 - email: - label: 邮箱 - msg: - empty: 邮箱不能为空。 - modal_title: 邮箱已经存在。 - modal_content: 该电子邮件地址已经注册。你确定要连接到已有账户吗? - modal_cancel: 更改邮箱 - modal_confirm: 连接到已有账户 - password_reset: - page_title: 密码重置 - btn_name: 重置我的密码 - reset_success: >- - 你已经成功更改密码;你将被重定向到登录页面。 - link_invalid: >- - 抱歉,此密码重置链接已失效。也许是你已经重置过密码了? - to_login: 前往登录页面 - password: - label: 密码 - msg: - empty: 密码不能为空。 - length: 密码长度在8-32个字符之间 - different: 两次输入密码不一致 - password_confirm: - label: 确认新密码 - settings: - page_title: 设置 - goto_modify: 前往修改 - nav: - profile: 我的资料 - notification: 通知 - account: 账号 - interface: 界面 - profile: - heading: 个人资料 - btn_name: 保存 - display_name: - label: 显示名称 - msg: 昵称不能为空。 - msg_range: 显示名称不能超过 30 个字符。 - username: - label: 用户名 - caption: 用户可以通过 "@用户名" 来提及你。 - msg: 用户名不能为空 - msg_range: 用户名不能超过 30 个字符。 - character: '只能由 "a-z"、"A-Z"、"0-9"、" - . _" 组成' - avatar: - label: 头像 - gravatar: Gravatar - gravatar_text: 你可以更改图像在 - custom: 自定义 - custom_text: 你可以上传你的图片。 - default: 系统 - msg: 请上传头像 - bio: - label: 关于我 - website: - label: 网站 - placeholder: "https://example.com" - msg: 网址格式不正确 - location: - label: 位置 - placeholder: "城市,国家" - notification: - heading: 邮件通知 - turn_on: 开启 - inbox: - label: 收件箱通知 - description: 你的提问有新的回答,评论,邀请回答和其他。 - all_new_question: - label: 所有新问题 - description: 获取所有新问题的通知。每周最多有50个问题。 - all_new_question_for_following_tags: - label: 所有关注标签的新问题 - description: 获取关注的标签下新问题通知。 - account: - heading: 账号 - change_email_btn: 更改邮箱 - change_pass_btn: 更改密码 - change_email_info: >- - 邮件已发送。请根据指引完成验证。 - email: - label: 电子邮件地址 - new_email: - label: 新的电子邮件地址 - msg: 新邮箱不能为空。 - pass: - label: 当前密码 - msg: 密码不能为空。 - password_title: 密码 - current_pass: - label: 当前密码 - msg: - empty: 当前密码不能为空 - length: 密码长度必须在 8 至 32 之间 - different: 两次输入的密码不匹配 - new_pass: - label: 新密码 - pass_confirm: - label: 确认新密码 - interface: - heading: 界面 - lang: - label: 界面语言 - text: 设置用户界面语言,在刷新页面后生效。 - my_logins: - title: 我的登录 - label: 使用这些账户登录或注册本网站。 - modal_title: 移除登录 - modal_content: 你确定要从账户里移除该登录? - modal_confirm_btn: 移除 - remove_success: 移除成功 - toast: - update: 更新成功 - update_password: 密码更新成功。 - flag_success: 感谢标记。 - forbidden_operate_self: 禁止对自己执行操作 - review: 您的修订将在审阅通过后显示。 - sent_success: 发送成功 - related_question: - title: 相关问题 - answers: 个回答 - invite_to_answer: - title: 受邀人 - desc: 邀请你认为可能知道答案的人。 - invite: 邀请回答 - add: 添加人员 - search: 搜索人员 - question_detail: - action: 操作 - Asked: 提问于 - asked: 提问于 - update: 修改于 - edit: 编辑于 - commented: 评论 - Views: 阅读次数 - Follow: 关注此问题 - Following: 已关注 - follow_tip: 关注此问题以接收通知 - answered: 回答于 - closed_in: 关闭于 - show_exist: 查看类似问题。 - useful: 有用的 - question_useful: 它是有用和明确的 - question_un_useful: 它不明确或没用的 - question_bookmark: 收藏该问题 - answer_useful: 这是有用的 - answer_un_useful: 它是没有用的 - answers: - title: 个回答 - score: 评分 - newest: 最新 - oldest: 最旧 - btn_accept: 采纳 - btn_accepted: 已被采纳 - write_answer: - title: 你的回答 - edit_answer: 编辑我的回答 - btn_name: 提交你的回答 - add_another_answer: 添加另一个回答 - confirm_title: 继续回答 - continue: 继续 - confirm_info: >- -

你确定要提交一个新的回答吗?

作为替代,你可以通过编辑来完善和改进之前的回答。

- empty: 回答内容不能为空。 - characters: 内容长度至少 6 个字符。 - tips: - header_1: 感谢你的回答 - li1_1: 请务必确定在 回答问题。提供详细信息并分享你的研究。 - li1_2: 用参考资料或个人经历来支持你所做的任何陈述。 - header_2: 但是 请避免... - li2_1: 请求帮助,寻求澄清,或答复其他答案。 - reopen: - confirm_btn: 重新打开 - title: 重新打开这个帖子 - content: 确定要重新打开吗? - list: - confirm_btn: 列表显示 - title: 列表中显示这个帖子 - content: 确定要列表中显示这个帖子吗? - unlist: - confirm_btn: 列表隐藏 - title: 从列表中隐藏这个帖子 - content: 确定要从列表中隐藏这个帖子吗? - pin: - title: 置顶该帖子 - content: 你确定要全局置顶吗?这个帖子将出现在所有帖子列表的顶部。 - confirm_btn: 置顶 - delete: - title: 删除 - question: >- - 我们不建议 删除有回答的帖子。因为这样做会使得后来的读者无法从该帖子中获得帮助。

如果删除过多有回答的帖子,你的账号将会被禁止提问。你确定要删除吗? - answer_accepted: >- -

我们不建议删除被采纳的回答。因为这样做会使得后来的读者无法从该帖子中获得帮助。

如果删除过多被采纳的回答,你的账号将会被禁止回答任何提问。你确定要删除吗? - other: 你确定要删除? - tip_answer_deleted: 该回答已被删除 - undelete_title: 撤销删除本帖 - undelete_desc: 你确定你要撤销删除吗? - btns: - confirm: 确认 - cancel: 取消 - edit: 编辑 - save: 保存 - delete: 删除 - undelete: 撤消删除 - list: 列表显示 - unlist: 列表隐藏 - unlisted: 已隐藏 - login: 登录 - signup: 注册 - logout: 退出 - verify: 验证 - add_question: 我要提问 - approve: 批准 - reject: 拒绝 - skip: 跳过 - discard_draft: 丢弃草稿 - pinned: 已置顶 - all: 全部 - question: 问题 - answer: 回答 - comment: 评论 - refresh: 刷新 - resend: 重新发送 - deactivate: 取消激活 - active: 激活 - suspend: 封禁 - unsuspend: 解禁 - close: 关闭 - reopen: 重新打开 - ok: 确定 - light: 浅色 - dark: 深色 - system_setting: 跟随系统 - default: 默认 - reset: 重置 - tag: 标签 - post_lowercase: 帖子 - filter: 筛选 - ignore: 忽略 - submit: 提交 - normal: 正常 - closed: 已关闭 - deleted: 已删除 - pending: 等待处理 - more: 更多 - search: - title: 搜索结果 - keywords: 关键词 - options: 选项 - follow: 关注 - following: 已关注 - counts: "{{count}} 个结果" - more: 更多 - sort_btns: - relevance: 相关性 - newest: 最新的 - active: 活跃的 - score: 评分 - more: 更多 - tips: - title: 高级搜索提示 - tag: "<1>[tag] 在指定标签中搜索" - user: "<1>user:username 根据作者搜索" - answer: "<1>answers:0 搜索未回答的问题" - score: "<1>score:3 评分 3+ 的帖子" - question: "<1>is:question 搜索问题" - is_answer: "<1>is:answer 搜索回答" - empty: 找不到任何相关的内容。
请尝试其他关键字,或者减少查找内容的长度。 - share: - name: 分享 - copy: 复制链接 - via: 分享到... - copied: 已复制 - facebook: 分享到 Facebook - twitter: 分享到 Twitter - cannot_vote_for_self: 你不能给自己的帖子投票。 - modal_confirm: - title: 发生错误... - account_result: - success: 你的账号已通过验证,即将返回首页。 - link: 返回首页 - invalid: >- - 抱歉,此验证链接已失效。也许你的账号已经激活了? - confirm_new_email: 你的电子邮箱已更新 - confirm_new_email_invalid: >- - 抱歉,此验证链接已失效。也许是你的邮箱已经成功更改了? - unsubscribe: - page_title: 退订 - success_title: 退订成功 - success_desc: 您已成功退订,并且将不会再收到我们的邮件。 - link: 更改设置 - question: - following_tags: 已关注的标签 - edit: 编辑 - save: 保存 - follow_tag_tip: 关注标签来筛选你的问题列表。 - hot_questions: 热门问题 - all_questions: 全部问题 - x_questions: "{{ count }} 个问题" - x_answers: "{{ count }} 个回答" - questions: 问题 - answers: 回答 - newest: 最新 - active: 活跃 - hot: 热门 - score: 评分 - unanswered: 未回答 - modified: 更新于 - answered: 回答于 - asked: 提问于 - closed: 已关闭 - follow_a_tag: 关注一个标签 - more: 更多 - personal: - overview: 概览 - answers: 回答 - answer: 回答 - questions: 问题 - question: 问题 - bookmarks: 收藏 - reputation: 声望 - comments: 评论 - votes: 得票 - newest: 最新 - score: 评分 - edit_profile: 编辑资料 - visited_x_days: "已访问 {{ count }} 天" - viewed: 浏览次数 - joined: 加入于 - last_login: 上次登录 - about_me: 关于我 - about_me_empty: "// Hello, World!" - top_answers: 高分回答 - top_questions: 高分问题 - stats: 状态 - list_empty: 没有找到相关的内容。
试试看其他选项卡? - accepted: 已采纳 - answered: 回答于 - asked: 提问于 - downvoted: 点踩 - mod_short: 版主 - mod_long: 版主 - x_reputation: 声望 - x_votes: 得票 - x_answers: 个回答 - x_questions: 个问题 - install: - title: 安装 - next: 下一步 - done: 完成 - config_yaml_error: 无法创建 config.yaml 文件。 - lang: - label: 请选择一种语言 - db_type: - label: 数据库引擎 - db_username: - label: 用户名 - placeholder: root - msg: 用户名不能为空 - db_password: - label: 密码 - placeholder: root - msg: 密码不能为空 - db_host: - label: 数据库主机 - placeholder: "db:3306" - msg: 数据库地址不能为空 - db_name: - label: 数据库名 - placeholder: 回答 - msg: 数据库名称不能为空。 - db_file: - label: 数据库文件 - placeholder: /data/answer.db - msg: 数据库文件不能为空。 - config_yaml: - title: 创建 config.yaml - label: 已创建 config.yaml 文件。 - desc: >- - 你可以手动在 <1>/var/wwww/xxx/ 目录中创建 <1>config.yaml 文件并粘贴以下文本。 - info: 完成后,点击“下一步”按钮。 - site_information: 站点信息 - admin_account: 管理员账号 - site_name: - label: 站点名称 - msg: 站点名称不能为空。 - msg_max_length: 站点名称长度不得超过 30 个字符。 - site_url: - label: 网站网址 - text: 此网站的网址。 - msg: - empty: 网址不能为空。 - incorrect: 网址格式不正确。 - max_length: 网址长度不得超过 512 个字符。 - contact_email: - label: 联系邮箱 - text: 负责本网站的主要联系人的电子邮件地址。 - msg: - empty: 联系人邮箱不能为空。 - incorrect: 联系人邮箱地址不正确。 - login_required: - label: 私有的 - switch: 需要登录 - text: 只有登录用户才能访问这个社区。 - admin_name: - label: 名字 - msg: 名字不能为空。 - character: '只能由 "a-z", "0-9", " - . _" 组成' - msg_max_length: 名字长度不能超过 30 个字符。 - admin_password: - label: 密码 - text: >- - 您需要此密码才能登录。请将其存储在一个安全的位置。 - msg: 密码不能为空。 - msg_min_length: 密码必须至少 8 个字符长。 - msg_max_length: 密码长度不能超过 32 个字符。 - admin_email: - label: 邮箱 - text: 您需要此电子邮件才能登录。 - msg: - empty: 邮箱不能为空。 - incorrect: 邮箱格式不正确。 - ready_title: 您的网站已准备好 - ready_desc: >- - 如果你想改变更多的设置,请访问 <1>管理区域;在网站菜单中找到它。 - good_luck: "玩得愉快,祝你好运!" - warn_title: 警告 - warn_desc: >- - 文件 <1>config.yaml 已存在。如果你要重置该文件中的任何配置项,请先删除它。 - install_now: 您可以尝试 <1>现在安装。 - installed: 已安裝 - installed_desc: >- - 你似乎已经安装过了。如果要重新安装,请先清除旧的数据库表。 - db_failed: 数据连接异常! - db_failed_desc: >- - 这或者意味着数据库信息在 <1>config.yaml 文件不正确,或者无法与数据库服务器建立联系。这可能意味着你的主机数据库服务器故障。 - counts: - views: 次浏览 - votes: 个点赞 - answers: 个回答 - accepted: 已被采纳 - page_error: - http_error: HTTP 错误 {{ code }} - desc_403: 您无权访问此页面。 - desc_404: 很抱歉,此页面不存在。 - desc_50X: 服务器遇到了一个错误,无法完成你的请求。 - back_home: 返回首页 - page_maintenance: - desc: "我们正在进行维护,我们将很快回来。" - nav_menus: - dashboard: 后台管理 - contents: 内容管理 - questions: 问题 - answers: 回答 - users: 用户管理 - flags: 举报管理 - settings: 站点设置 - general: 一般 - interface: 界面 - smtp: SMTP - branding: 品牌 - legal: 法律条款 - write: 撰写 - tos: 服务条款 - privacy: 隐私政策 - seo: SEO - customize: 自定义 - themes: 主题 - css_html: CSS/HTML - login: 登录 - privileges: 特权 - plugins: 插件 - installed_plugins: 已安装插件 - website_welcome: 欢迎来到 {{site_name}} - user_center: - login: 登录 - qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码并登录。 - login_failed_email_tip: 登录失败,请允许此应用访问您的邮箱信息,然后重试。 - admin: - admin_header: - title: 后台管理 - dashboard: - title: 后台管理 - welcome: 欢迎来到管理后台! - site_statistics: 站点统计 - questions: "问题:" - answers: "回答:" - comments: "评论:" - votes: "投票:" - users: "用户:" - flags: "举报:" - reviews: "审查:" - site_health: 网站健康 - version: "版本" - https: "HTTPS:" - upload_folder: "上传文件夹:" - run_mode: "运行模式:" - private: 私有 - public: 公开 - smtp: "SMTP:" - timezone: "时区:" - system_info: 系统信息 - go_version: "Go版本:" - database: "数据库:" - database_size: "数据库大小:" - storage_used: "已用存储空间:" - uptime: "运行时间:" - links: 链接 - plugins: 插件 - github: GitHub - blog: 博客 - contact: 联系 - forum: 论坛 - documents: 文档 - feedback: 用户反馈 - support: 帮助 - review: 审查 - config: 配置 - update_to: 更新到 - latest: 最新版本 - check_failed: 校验失败 - "yes": "是" - "no": "否" - not_allowed: 拒绝 - allowed: 允许 - enabled: 已启用 - disabled: 停用 - writable: 可写 - not_writable: 不可写 - flags: - title: 举报 - pending: 等待处理 - completed: 已完成 - flagged: 被举报内容 - flagged_type: 标记了 {{ type }} - created: 创建于 - action: 操作 - review: 审查 - user_role_modal: - title: 更改用户状态为... - btn_cancel: 取消 - btn_submit: 提交 - new_password_modal: - title: 设置新密码 - form: - fields: - password: - label: 密码 - text: 用户将被退出,需要再次登录。 - msg: 密码的长度必须是8-32个字符。 - btn_cancel: 取消 - btn_submit: 提交 - edit_profile_modal: - title: 编辑资料 - form: - fields: - username: - label: 用户名 - msg_range: 用户名不能超过 30 个字符。 - email: - label: 电子邮件地址 - msg_invalid: 无效的邮箱地址 - edit_success: 修改成功 - btn_cancel: 取消 - btn_submit: 提交 - user_modal: - title: 添加新用户 - form: - fields: - users: - label: 批量添加用户 - placeholder: "John Smith, john@example.com, BUSYopr2\nAlice, alice@example.com, fpDntV8q" - text: 用逗号分隔“name, email, password”,每行一个用户。 - msg: "请输入用户的邮箱,每行一个。" - display_name: - label: 显示名称 - msg: 显示名称长度必须为 4-30 个字符 - email: - label: 邮箱 - msg: 邮箱无效。 - password: - label: 密码 - msg: 密码的长度必须是8-32个字符。 - btn_cancel: 取消 - btn_submit: 提交 - users: - title: 用户 - name: 名称 - email: 邮箱 - reputation: 声望 - created_at: 创建时间 - delete_at: 删除时间 - suspend_at: 封禁时间 - status: 状态 - role: 角色 - action: 操作 - change: 更改 - all: 全部 - staff: 工作人员 - more: 更多 - inactive: 不活跃 - suspended: 已封禁 - deleted: 已删除 - normal: 正常 - Moderator: 版主 - Admin: 管理员 - User: 用户 - filter: - placeholder: "按名称筛选,用户:id" - set_new_password: 设置新密码 - edit_profile: 编辑资料 - change_status: 更改状态 - change_role: 更改角色 - show_logs: 显示日志 - add_user: 添加用户 - deactivate_user: - title: 停用用户 - content: 未激活的用户必须重新验证他们的邮箱。 - delete_user: - title: 删除此用户 - content: 确定要删除此用户?此操作无法撤销! - remove: 移除内容 - label: 删除所有问题、 答案、 评论等 - text: 如果你只想删除用户账户,请不要选中此项。 - suspend_user: - title: 挂起此用户 - content: 被封禁的用户将无法登录。 - questions: - page_title: 问题 - unlisted: 已隐藏 - post: 标题 - votes: 得票数 - answers: 回答数 - created: 创建于 - status: 状态 - action: 操作 - change: 更改 - pending: 等待处理 - filter: - placeholder: "按标题过滤,问题:id" - answers: - page_title: 回答 - post: 标题 - votes: 得票数 - created: 创建于 - status: 状态 - action: 操作 - change: 更改 - filter: - placeholder: "按标题筛选,答案:id" - general: - page_title: 一般 - name: - label: 站点名称 - msg: 不能为空 - text: "站点的名称,作为站点的标题。" - site_url: - label: 网站网址 - msg: 网站网址不能为空。 - validate: 请输入一个有效的 URL。 - text: 此网站的地址。 - short_desc: - label: 简短站点描述 - msg: 简短网站描述不能为空。 - text: "简短的标语,作为网站主页的标题(Html 的 title 标签)。" - desc: - label: 站点描述 - msg: 网站描述不能为空。 - text: "使用一句话描述本站,作为网站的描述(Html 的 meta 标签)。" - contact_email: - label: 联系邮箱 - msg: 联系人邮箱不能为空。 - validate: 联系人邮箱无效。 - text: 本网站的主要联系邮箱地址。 - check_update: - label: 软件更新 - text: 自动检查软件更新 - interface: - page_title: 界面 - language: - label: 界面语言 - msg: 不能为空 - text: 设置用户界面语言,在刷新页面后生效。 - time_zone: - label: 时区 - msg: 时区不能为空。 - text: 选择一个与您相同时区的城市。 - smtp: - page_title: SMTP - from_email: - label: 发件人邮箱 - msg: 发件人邮箱不能为空。 - text: 用于发送邮件的地址。 - from_name: - label: 发件人 - msg: 不能为空 - text: 发件人的名字。 - smtp_host: - label: SMTP 主机 - msg: 不能为空 - text: 邮件服务器 - encryption: - label: 加密 - msg: 不能为空 - text: 对于大多数服务器而言,SSL 是推荐开启的。 - ssl: SSL - tls: TLS - none: 无加密 - smtp_port: - label: SMTP 端口 - msg: SMTP 端口必须在 1 ~ 65535 之间。 - text: 邮件服务器的端口号。 - smtp_username: - label: SMTP 用户名 - msg: 不能为空 - smtp_password: - label: SMTP 密码 - msg: 不能为空 - test_email_recipient: - label: 测试收件邮箱 - text: 提供用于接收测试邮件的邮箱地址。 - msg: 测试收件邮箱无效 - smtp_authentication: - label: 启用身份验证 - title: SMTP 身份验证 - msg: 不能为空 - "yes": "是" - "no": "否" - branding: - page_title: 品牌 - logo: - label: 网站标志(Logo) - msg: 图标不能为空。 - text: 在你的网站左上方的Logo图标。使用一个高度为56,长宽比大于3:1的宽长方形图像。如果留空,将显示网站标题文本。 - mobile_logo: - label: 移动端 Logo - text: 在你的网站的移动版上使用的标志。使用一个高度为56的宽矩形图像。如果留空,将使用 "Logo"设置中的图像。 - square_icon: - label: 方形图标 - msg: 方形图标不能为空。 - text: 用作元数据图标的基础的图像。最好是大于512x512。 - favicon: - label: 收藏夹图标 - text: 网站的图标。要在 CDN 正常工作,它必须是 png。 将调整大小到32x32。如果留空,将使用“方形图标”。 - legal: - page_title: 法律条款 - terms_of_service: - label: 服务条款 - text: "您可以在此添加服务内容的条款。如果您已经在别处托管了文档,请在这里提供完整的URL。" - privacy_policy: - label: 隐私政策 - text: "您可以在此添加隐私政策内容。如果您已经在别处托管了文档,请在这里提供完整的URL。" - write: - page_title: 编辑 - restrict_answer: - title: 限制一个回答 - label: 每个用户对于每个问题只能有一个回答 - text: "用户可以使用编辑按钮优化已有的回答" - recommend_tags: - label: 推荐标签 - text: "请在上方输入标签固定链接,每行一个标签。" - required_tag: - title: 必需的标签 - label: 根据需要设置推荐标签 - text: "每个新问题必须至少有一个推荐标签。" - reserved_tags: - label: 保留标签 - text: "保留的标签只能由版主添加到一个帖子中。" - seo: - page_title: 搜索引擎优化 - permalink: - label: 固定链接 - text: 自定义URL结构可以提高可用性,以及你的链接的向前兼容性。 - robots: - label: robots.txt - text: 这将永久覆盖任何相关的网站设置。 - themes: - page_title: 主题 - themes: - label: 主题 - text: 选择一个现有主题。 - color_scheme: - label: 配色方案 - navbar_style: - label: 导航栏样式 - primary_color: - label: 主色调 - text: 修改您主题使用的颜色 - css_and_html: - page_title: CSS 与 HTML - custom_css: - label: 自定义 CSS - text: > - - head: - label: 头部 - text: > - - header: - label: 页眉 - text: > - - footer: - label: 页脚 - text: 这将在 之前插入. - sidebar: - label: 侧边栏 - text: 这将插入侧边栏中。 - login: - page_title: 登录 - membership: - title: 会员 - label: 允许新注册 - text: 关闭以防止任何人创建新账户。 - email_registration: - title: 邮箱注册 - label: 允许邮箱注册 - text: 关闭以阻止任何人通过邮箱创建新账户。 - allowed_email_domains: - title: 允许的邮箱域 - text: 允许注册账户的邮箱域。每行一个域名。留空时忽略。 - private: - title: 非公开的 - label: 需要登录 - text: 只有登录用户才能访问这个社区。 - password_login: - title: 密码登录 - label: 允许使用邮箱和密码登录 - text: "警告:如果您未配置过其他登录方式,关闭密码登录后您则可能无法登录。" - installed_plugins: - title: 已安装插件 - plugin_link: 插件扩展功能。您可以在<1>插件仓库中找到插件。 - filter: - all: 全部 - active: 已启用 - inactive: 未启用 - outdated: 已过期 - plugins: - label: 插件 - text: 选择一个现有的插件。 - name: 名称 - version: 版本 - status: 状态 - action: 操作 - deactivate: 停用 - activate: 启用 - settings: 设置 - settings_users: - title: 用户 - avatar: - label: 默认头像 - text: 没有自定义头像的用户。 - gravatar_base_url: - label: Gravatar 根路径 URL - text: Gravatar 提供商的 API 基础的 URL。当为空时忽略。 - profile_editable: - title: 个人资料可编辑 - allow_update_display_name: - label: 允许用户修改显示名称 - allow_update_username: - label: 允许用户修改用户名 - allow_update_avatar: - label: 允许用户修改个人头像 - allow_update_bio: - label: 允许用户修改个人介绍 - allow_update_website: - label: 允许用户修改个人主页网址 - allow_update_location: - label: 允许用户更改位置 - privilege: - title: 特权 - level: - label: 级别所需声望 - text: 选择特权所需的声望值 - msg: - should_be_number: 输入必须是数字 - number_larger_1: 数字应该大于等于 1 - form: - optional: (选填) - empty: 不能为空 - invalid: 是无效的 - btn_submit: 保存 - not_found_props: "所需属性 {{ key }} 未找到。" - select: 选择 - page_review: - review: 评论 - proposed: 提案 - question_edit: 问题编辑 - answer_edit: 回答编辑 - tag_edit: '标签管理: 编辑标签' - edit_summary: 编辑备注 - edit_question: 编辑问题 - edit_answer: 编辑回答 - edit_tag: 编辑标签 - empty: 没有剩余的审核任务。 - approve_revision_tip: 您是否批准此修订? - approve_flag_tip: 您是否批准此举报? - approve_post_tip: 您是否批准此帖子? - approve_user_tip: 您是否批准此修订? - suggest_edits: 建议的编辑 - flag_post: 举报帖子 - flag_user: 举报用户 - queued_post: 排队的帖子 - queued_user: 排队用户 - filter_label: 类型 - reputation: 声望值 - flag_post_type: 举报这个帖子的类型是 {{ type }} - flag_user_type: 举报这个用户的类型是 {{ type }} - edit_post: 编辑帖子 - list_post: 文章列表 - unlist_post: 隐藏的帖子 - timeline: - undeleted: 取消删除 - deleted: 删除 - downvote: 反对 - upvote: 点赞 - accept: 采纳 - cancelled: 已取消 - commented: '评论:' - rollback: 回滚 - edited: 最后编辑于 - answered: 回答于 - asked: 提问于 - closed: 关闭 - reopened: 重新开启 - created: 创建于 - pin: 已置顶 - unpin: 取消置頂 - show: 已显示 - hide: 已隐藏 - title: "历史记录" - tag_title: "时间线" - show_votes: "显示投票" - n_or_a: N/A - title_for_question: "时间线" - title_for_answer: "{{ title }} 的 {{ author }} 回答时间线" - title_for_tag: "时间线" - datetime: 日期时间 - type: 类型 - by: 由 - comment: 评论 - no_data: "空空如也" - users: - title: 用户 - users_with_the_most_reputation: 本周声望最高的用户 - users_with_the_most_vote: 本周投票最多的用户 - staffs: 我们的社区工作人员 - reputation: 声望值 - votes: 投票 - prompt: - leave_page: 确定要离开此页面? - changes_not_save: 您的更改尚未保存 - draft: - discard_confirm: 您确定要丢弃您的草稿吗? - messages: - post_deleted: 该帖子已被删除。 - post_pin: 该帖子已被置顶。 - post_unpin: 该帖子已被取消置顶。 - post_hide_list: 此帖子已经从列表中隐藏。 - post_show_list: 该帖子已显示到列表中。 - post_reopen: 这个帖子已被重新打开. - post_list: 这个帖子已经被显示 - post_unlist: 这个帖子已经被隐藏 - post_pending: 您的帖子正在等待审核。它将在它获得批准后可见。 diff --git a/docs/docs.go b/docs/docs.go index cf1264d14..7f5bd8718 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -154,6 +154,119 @@ const docTemplate = `{ } } }, + "/answer/admin/api/badge/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update badge status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "update badge status", + "parameters": [ + { + "description": "UpdateBadgeStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "list all badges by page", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "", + "active", + "inactive" + ], + "type": "string", + "description": "badge status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "search param", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListPagedResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/dashboard": { "get": { "security": [ @@ -2239,6 +2352,270 @@ const docTemplate = `{ } } }, + "/answer/api/v1/badge": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge info", + "parameters": [ + { + "type": "string", + "default": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/awards/page": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge award list", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "badge id", + "name": "badge_id", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "only list the award by username", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards/recent": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges group by group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "list all badges group by group", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/collection/switch": { "post": { "security": [ @@ -2738,7 +3115,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetEmbedOptionResp" + "$ref": "#/definitions/plugin.EmbedConfig" } } } @@ -4185,6 +4562,67 @@ const docTemplate = `{ } } }, + "/answer/api/v1/question/recommend/page": { + "get": { + "description": "get recommend questions by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Question" + ], + "summary": "get recommend questions by page", + "parameters": [ + { + "description": "QuestionPageReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.QuestionPageReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.QuestionPageResp" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/question/recover": { "post": { "security": [ @@ -4410,7 +4848,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetTagResp" + "$ref": "#/definitions/schema.GetTagBasicResp" } } } @@ -4471,7 +4909,42 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/api/v1/render/config": { + "get": { + "description": "GetRenderConfig", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PluginRender" + ], + "summary": "GetRenderConfig", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/plugin.RenderConfig" + } + } + } + ] } } } @@ -5380,7 +5853,7 @@ const docTemplate = `{ }, "/answer/api/v1/tags": { "get": { - "description": "get tags list", + "description": "get tags list by slug name", "produces": [ "application/json" ], @@ -5404,7 +5877,22 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetTagBasicResp" + } + } + } + } + ] } } } @@ -6571,14 +7059,14 @@ const docTemplate = `{ }, "/custom.css": { "get": { - "description": "get site robots information", + "description": "get site custom CSS", "produces": [ - "application/json" + "text/css" ], "tags": [ "site" ], - "summary": "get site robots information", + "summary": "get site custom CSS", "responses": { "200": { "description": "OK", @@ -6884,6 +7372,19 @@ const docTemplate = `{ } } }, + "entity.BadgeLevel": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "BadgeLevelBronze", + "BadgeLevelSilver", + "BadgeLevelGold" + ] + }, "handler.RespBody": { "type": "object", "properties": { @@ -7004,6 +7505,25 @@ const docTemplate = `{ "list": {} } }, + "plugin.EmbedConfig": { + "type": "object", + "properties": { + "enable": { + "type": "boolean" + }, + "platform": { + "type": "string" + } + } + }, + "plugin.RenderConfig": { + "type": "object", + "properties": { + "select_theme": { + "type": "string" + } + } + }, "schema.AcceptAnswerReq": { "type": "object", "required": [ @@ -7327,6 +7847,50 @@ const docTemplate = `{ } } }, + "schema.BadgeListInfo": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "earned_count": { + "description": "badge earned count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.BadgeStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ], + "x-enum-varnames": [ + "BadgeStatusActive", + "BadgeStatusInactive" + ] + }, "schema.CloseQuestionReq": { "type": "object", "required": [ @@ -7578,6 +8142,112 @@ const docTemplate = `{ } } }, + "schema.GetBadgeInfoResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned_count": { + "description": "badge earned count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "is_single": { + "description": "badge is single or multiple", + "type": "boolean" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.GetBadgeListPagedResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned": { + "description": "badge earned count", + "type": "boolean" + }, + "group_name": { + "description": "badge group name", + "type": "string" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, + "schema.GetBadgeListResp": { + "type": "object", + "properties": { + "badges": { + "description": "badge list info", + "type": "array", + "items": { + "$ref": "#/definitions/schema.BadgeListInfo" + } + }, + "group_name": { + "description": "badge group name", + "type": "string" + } + } + }, "schema.GetCommentPersonalWithPageResp": { "type": "object", "properties": { @@ -7813,17 +8483,6 @@ const docTemplate = `{ } } }, - "schema.GetEmbedOptionResp": { - "type": "object", - "properties": { - "enable": { - "type": "boolean" - }, - "platform": { - "type": "string" - } - } - }, "schema.GetFollowingTagsResp": { "type": "object", "properties": { @@ -8242,6 +8901,23 @@ const docTemplate = `{ } } }, + "schema.GetTagBasicResp": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "recommend": { + "type": "boolean" + }, + "reserved": { + "type": "boolean" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.GetTagPageResp": { "type": "object", "properties": { @@ -8469,6 +9145,35 @@ const docTemplate = `{ } } }, + "schema.GetUserBadgeAwardListResp": { + "type": "object", + "properties": { + "earned_count": { + "description": "badge award count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, "schema.GetUserNotificationConfigResp": { "type": "object", "properties": { @@ -8656,10 +9361,16 @@ const docTemplate = `{ }, "schema.NotificationClearRequest": { "type": "object", + "required": [ + "type" + ], "properties": { "type": { - "description": "inbox achievement", - "type": "string" + "type": "string", + "enum": [ + "inbox", + "achievement" + ] } } }, @@ -8841,7 +9552,8 @@ const docTemplate = `{ "active", "hot", "score", - "unanswered" + "unanswered", + "recommend" ] }, "page": { @@ -9819,7 +10531,7 @@ const docTemplate = `{ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9828,7 +10540,7 @@ const docTemplate = `{ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9842,7 +10554,7 @@ const docTemplate = `{ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9851,7 +10563,7 @@ const docTemplate = `{ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9859,6 +10571,20 @@ const docTemplate = `{ } } }, + "schema.SiteWriteTag": { + "type": "object", + "required": [ + "slug_name" + ], + "properties": { + "display_name": { + "type": "string" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.TagItem": { "type": "object", "properties": { @@ -10004,6 +10730,27 @@ const docTemplate = `{ } } }, + "schema.UpdateBadgeStatusReq": { + "type": "object", + "required": [ + "id", + "status" + ], + "properties": { + "id": { + "description": "badge id", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, "schema.UpdateCommentReq": { "type": "object", "required": [ @@ -10783,12 +11530,12 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "", + Version: "= \"v0.0.1\"", Host: "", - BasePath: "", + BasePath: "= \"/\"", Schemes: []string{}, - Title: "", - Description: "", + Title: "\"apache answer\"", + Description: "= \"apache answer api\"", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/img/install-database.png b/docs/img/install-database.png deleted file mode 100644 index 09fbf36a1..000000000 Binary files a/docs/img/install-database.png and /dev/null differ diff --git a/docs/img/install-site-info.png b/docs/img/install-site-info.png deleted file mode 100644 index b8166caf4..000000000 Binary files a/docs/img/install-site-info.png and /dev/null differ diff --git a/docs/swagger.json b/docs/swagger.json index 1e93d2b08..093e0dfcc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,8 +1,12 @@ { "swagger": "2.0", "info": { - "contact": {} + "description": "= \"apache answer api\"", + "title": "\"apache answer\"", + "contact": {}, + "version": "= \"v0.0.1\"" }, + "basePath": "= \"/\"", "paths": { "/": { "get": { @@ -124,6 +128,119 @@ } } }, + "/answer/admin/api/badge/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update badge status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "update badge status", + "parameters": [ + { + "description": "UpdateBadgeStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "list all badges by page", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "", + "active", + "inactive" + ], + "type": "string", + "description": "badge status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "search param", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListPagedResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/dashboard": { "get": { "security": [ @@ -2209,6 +2326,270 @@ } } }, + "/answer/api/v1/badge": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge info", + "parameters": [ + { + "type": "string", + "default": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/awards/page": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge award list", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "badge id", + "name": "badge_id", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "only list the award by username", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards/recent": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges group by group", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "list all badges group by group", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/collection/switch": { "post": { "security": [ @@ -2708,7 +3089,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetEmbedOptionResp" + "$ref": "#/definitions/plugin.EmbedConfig" } } } @@ -4155,6 +4536,67 @@ } } }, + "/answer/api/v1/question/recommend/page": { + "get": { + "description": "get recommend questions by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Question" + ], + "summary": "get recommend questions by page", + "parameters": [ + { + "description": "QuestionPageReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.QuestionPageReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.QuestionPageResp" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/question/recover": { "post": { "security": [ @@ -4380,7 +4822,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetTagResp" + "$ref": "#/definitions/schema.GetTagBasicResp" } } } @@ -4441,7 +4883,42 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/api/v1/render/config": { + "get": { + "description": "GetRenderConfig", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PluginRender" + ], + "summary": "GetRenderConfig", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/plugin.RenderConfig" + } + } + } + ] } } } @@ -5350,7 +5827,7 @@ }, "/answer/api/v1/tags": { "get": { - "description": "get tags list", + "description": "get tags list by slug name", "produces": [ "application/json" ], @@ -5374,7 +5851,22 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetTagBasicResp" + } + } + } + } + ] } } } @@ -6541,14 +7033,14 @@ }, "/custom.css": { "get": { - "description": "get site robots information", + "description": "get site custom CSS", "produces": [ - "application/json" + "text/css" ], "tags": [ "site" ], - "summary": "get site robots information", + "summary": "get site custom CSS", "responses": { "200": { "description": "OK", @@ -6854,6 +7346,19 @@ } } }, + "entity.BadgeLevel": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "BadgeLevelBronze", + "BadgeLevelSilver", + "BadgeLevelGold" + ] + }, "handler.RespBody": { "type": "object", "properties": { @@ -6974,6 +7479,25 @@ "list": {} } }, + "plugin.EmbedConfig": { + "type": "object", + "properties": { + "enable": { + "type": "boolean" + }, + "platform": { + "type": "string" + } + } + }, + "plugin.RenderConfig": { + "type": "object", + "properties": { + "select_theme": { + "type": "string" + } + } + }, "schema.AcceptAnswerReq": { "type": "object", "required": [ @@ -7297,6 +7821,50 @@ } } }, + "schema.BadgeListInfo": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "earned_count": { + "description": "badge earned count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.BadgeStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ], + "x-enum-varnames": [ + "BadgeStatusActive", + "BadgeStatusInactive" + ] + }, "schema.CloseQuestionReq": { "type": "object", "required": [ @@ -7548,6 +8116,112 @@ } } }, + "schema.GetBadgeInfoResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned_count": { + "description": "badge earned count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "is_single": { + "description": "badge is single or multiple", + "type": "boolean" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.GetBadgeListPagedResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned": { + "description": "badge earned count", + "type": "boolean" + }, + "group_name": { + "description": "badge group name", + "type": "string" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, + "schema.GetBadgeListResp": { + "type": "object", + "properties": { + "badges": { + "description": "badge list info", + "type": "array", + "items": { + "$ref": "#/definitions/schema.BadgeListInfo" + } + }, + "group_name": { + "description": "badge group name", + "type": "string" + } + } + }, "schema.GetCommentPersonalWithPageResp": { "type": "object", "properties": { @@ -7783,17 +8457,6 @@ } } }, - "schema.GetEmbedOptionResp": { - "type": "object", - "properties": { - "enable": { - "type": "boolean" - }, - "platform": { - "type": "string" - } - } - }, "schema.GetFollowingTagsResp": { "type": "object", "properties": { @@ -8212,6 +8875,23 @@ } } }, + "schema.GetTagBasicResp": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "recommend": { + "type": "boolean" + }, + "reserved": { + "type": "boolean" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.GetTagPageResp": { "type": "object", "properties": { @@ -8439,6 +9119,35 @@ } } }, + "schema.GetUserBadgeAwardListResp": { + "type": "object", + "properties": { + "earned_count": { + "description": "badge award count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, "schema.GetUserNotificationConfigResp": { "type": "object", "properties": { @@ -8626,10 +9335,16 @@ }, "schema.NotificationClearRequest": { "type": "object", + "required": [ + "type" + ], "properties": { "type": { - "description": "inbox achievement", - "type": "string" + "type": "string", + "enum": [ + "inbox", + "achievement" + ] } } }, @@ -8809,9 +9524,10 @@ "enum": [ "newest", "active", - "frequent", + "hot", "score", - "unanswered" + "unanswered", + "recommend" ] }, "page": { @@ -9789,7 +10505,7 @@ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9798,7 +10514,7 @@ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9812,7 +10528,7 @@ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9821,7 +10537,7 @@ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9829,6 +10545,20 @@ } } }, + "schema.SiteWriteTag": { + "type": "object", + "required": [ + "slug_name" + ], + "properties": { + "display_name": { + "type": "string" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.TagItem": { "type": "object", "properties": { @@ -9974,6 +10704,27 @@ } } }, + "schema.UpdateBadgeStatusReq": { + "type": "object", + "required": [ + "id", + "status" + ], + "properties": { + "id": { + "description": "badge id", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, "schema.UpdateCommentReq": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 93f8116af..6d96c8a17 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +basePath: = "/" definitions: constant.NotificationChannelKey: enum: @@ -32,6 +33,16 @@ definitions: minimum: 1 type: integer type: object + entity.BadgeLevel: + enum: + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - BadgeLevelBronze + - BadgeLevelSilver + - BadgeLevelGold handler.RespBody: properties: code: @@ -117,6 +128,18 @@ definitions: type: integer list: {} type: object + plugin.EmbedConfig: + properties: + enable: + type: boolean + platform: + type: string + type: object + plugin.RenderConfig: + properties: + select_theme: + type: string + type: object schema.AcceptAnswerReq: properties: answer_id: @@ -342,6 +365,36 @@ definitions: maxLength: 100 type: string type: object + schema.BadgeListInfo: + properties: + award_count: + description: badge award count + type: integer + earned_count: + description: badge earned count + type: integer + icon: + description: badge icon + type: string + id: + description: badge id + type: string + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + type: object + schema.BadgeStatus: + enum: + - active + - inactive + type: string + x-enum-varnames: + - BadgeStatusActive + - BadgeStatusInactive schema.CloseQuestionReq: properties: close_msg: @@ -513,6 +566,77 @@ definitions: description: if user is followed object will be true,otherwise false type: boolean type: object + schema.GetBadgeInfoResp: + properties: + award_count: + description: badge award count + type: integer + description: + description: badge description + type: string + earned_count: + description: badge earned count + type: integer + icon: + description: badge icon + type: string + id: + description: badge id + type: string + is_single: + description: badge is single or multiple + type: boolean + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + type: object + schema.GetBadgeListPagedResp: + properties: + award_count: + description: badge award count + type: integer + description: + description: badge description + type: string + earned: + description: badge earned count + type: boolean + group_name: + description: badge group name + type: string + icon: + description: badge icon + type: string + id: + description: badge id + type: string + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + status: + allOf: + - $ref: '#/definitions/schema.BadgeStatus' + description: badge status + type: object + schema.GetBadgeListResp: + properties: + badges: + description: badge list info + items: + $ref: '#/definitions/schema.BadgeListInfo' + type: array + group_name: + description: badge group name + type: string + type: object schema.GetCommentPersonalWithPageResp: properties: answer_id: @@ -687,13 +811,6 @@ definitions: description: website type: string type: object - schema.GetEmbedOptionResp: - properties: - enable: - type: boolean - platform: - type: string - type: object schema.GetFollowingTagsResp: properties: display_name: @@ -983,6 +1100,17 @@ definitions: terms_of_service_parsed_text: type: string type: object + schema.GetTagBasicResp: + properties: + display_name: + type: string + recommend: + type: boolean + reserved: + type: boolean + slug_name: + type: string + type: object schema.GetTagPageResp: properties: created_at: @@ -1139,6 +1267,25 @@ definitions: activation_url: type: string type: object + schema.GetUserBadgeAwardListResp: + properties: + earned_count: + description: badge award count + type: integer + icon: + description: badge icon + type: string + id: + description: badge id + type: string + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + type: object schema.GetUserNotificationConfigResp: properties: all_new_question: @@ -1272,8 +1419,12 @@ definitions: schema.NotificationClearRequest: properties: type: - description: inbox achievement + enum: + - inbox + - achievement type: string + required: + - type type: object schema.OnCompleteAction: properties: @@ -1401,9 +1552,10 @@ definitions: enum: - newest - active - - frequent + - hot - score - unanswered + - recommend type: string page: minimum: 1 @@ -2071,13 +2223,13 @@ definitions: properties: recommend_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array required_tag: type: boolean reserved_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array restrict_answer: type: boolean @@ -2086,17 +2238,26 @@ definitions: properties: recommend_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array required_tag: type: boolean reserved_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array restrict_answer: type: boolean type: object + schema.SiteWriteTag: + properties: + display_name: + type: string + slug_name: + type: string + required: + - slug_name + type: object schema.TagItem: properties: display_name: @@ -2197,6 +2358,19 @@ definitions: url_title: type: string type: object + schema.UpdateBadgeStatusReq: + properties: + id: + description: badge id + type: string + status: + allOf: + - $ref: '#/definitions/schema.BadgeStatus' + description: badge status + required: + - id + - status + type: object schema.UpdateCommentReq: properties: captcha_code: @@ -2736,6 +2910,9 @@ definitions: type: object info: contact: {} + description: = "apache answer api" + title: '"apache answer"' + version: = "v0.0.1" paths: /: get: @@ -2814,6 +2991,75 @@ paths: summary: update answer status tags: - admin + /answer/admin/api/badge/status: + put: + consumes: + - application/json + description: update badge status + parameters: + - description: UpdateBadgeStatusReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UpdateBadgeStatusReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update badge status + tags: + - AdminBadge + /answer/admin/api/badges: + get: + consumes: + - application/json + description: list all badges by page + parameters: + - description: page + in: query + name: page + type: integer + - description: page size + in: query + name: page_size + type: integer + - description: badge status + enum: + - "" + - active + - inactive + in: query + name: status + type: string + - description: search param + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetBadgeListPagedResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: list all badges by page + tags: + - AdminBadge /answer/admin/api/dashboard: get: consumes: @@ -4063,6 +4309,159 @@ paths: summary: recover answer tags: - Answer + /answer/api/v1/badge: + get: + consumes: + - application/json + description: get badge info + parameters: + - default: string + description: id + in: query + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetBadgeInfoResp' + type: object + security: + - ApiKeyAuth: [] + summary: get badge info + tags: + - api-badge + /answer/api/v1/badge/awards/page: + get: + consumes: + - application/json + description: get badge award list + parameters: + - description: page + in: query + name: page + type: integer + - description: page size + in: query + name: page_size + type: integer + - description: badge id + in: query + name: badge_id + required: true + type: string + - description: only list the award by username + in: query + name: username + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetBadgeInfoResp' + type: object + security: + - ApiKeyAuth: [] + summary: get badge award list + tags: + - api-badge + /answer/api/v1/badge/user/awards: + get: + consumes: + - application/json + description: get user badge award list + parameters: + - description: user name + in: query + name: username + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetUserBadgeAwardListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get user badge award list + tags: + - api-badge + /answer/api/v1/badge/user/awards/recent: + get: + consumes: + - application/json + description: get user badge award list + parameters: + - description: user name + in: query + name: username + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetUserBadgeAwardListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get user badge award list + tags: + - api-badge + /answer/api/v1/badges: + get: + consumes: + - application/json + description: list all badges group by group + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetBadgeListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: list all badges group by group + tags: + - api-badge /answer/api/v1/collection/switch: post: consumes: @@ -4353,7 +4752,7 @@ paths: - properties: data: items: - $ref: '#/definitions/schema.GetEmbedOptionResp' + $ref: '#/definitions/plugin.EmbedConfig' type: array type: object summary: GetEmbedConfig @@ -5245,6 +5644,40 @@ paths: summary: get questions by page tags: - Question + /answer/api/v1/question/recommend/page: + get: + consumes: + - application/json + description: get recommend questions by page + parameters: + - description: QuestionPageReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.QuestionPageReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + allOf: + - $ref: '#/definitions/pager.PageModel' + - properties: + list: + items: + $ref: '#/definitions/schema.QuestionPageResp' + type: array + type: object + type: object + summary: get recommend questions by page + tags: + - Question /answer/api/v1/question/recover: post: consumes: @@ -5382,7 +5815,7 @@ paths: - properties: data: items: - $ref: '#/definitions/schema.GetTagResp' + $ref: '#/definitions/schema.GetTagBasicResp' type: array type: object security: @@ -5428,6 +5861,26 @@ paths: summary: get reasons by object type and action tags: - reason + /answer/api/v1/render/config: + get: + consumes: + - application/json + description: GetRenderConfig + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/plugin.RenderConfig' + type: object + summary: GetRenderConfig + tags: + - PluginRender /answer/api/v1/report: post: consumes: @@ -5965,7 +6418,7 @@ paths: - Tag /answer/api/v1/tags: get: - description: get tags list + description: get tags list by slug name parameters: - collectionFormat: csv description: string collection @@ -5980,7 +6433,14 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handler.RespBody' + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetTagBasicResp' + type: array + type: object summary: get tags list tags: - Tag @@ -6677,15 +7137,15 @@ paths: - Activity /custom.css: get: - description: get site robots information + description: get site custom CSS produces: - - application/json + - text/css responses: "200": description: OK schema: type: string - summary: get site robots information + summary: get site custom CSS tags: - site /installation/base-info: diff --git a/go.mod b/go.mod index e1b78ad2c..e14b01b91 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/swaggo/files v1.0.0 github.com/swaggo/gin-swagger v1.5.3 - github.com/swaggo/swag v1.16.1 + github.com/swaggo/swag v1.16.3 github.com/tidwall/gjson v1.14.4 github.com/yuin/goldmark v1.4.13 golang.org/x/crypto v0.21.0 @@ -144,8 +144,8 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect go.uber.org/atomic v1.10.0 // indirect - go.uber.org/multierr v1.8.0 // indirect - go.uber.org/zap v1.23.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.24.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/sys v0.18.0 // indirect diff --git a/go.sum b/go.sum index 501d1e941..4bf5cc08b 100644 --- a/go.sum +++ b/go.sum @@ -690,8 +690,8 @@ github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO github.com/swaggo/gin-swagger v1.5.3 h1:8mWmHLolIbrhJJTflsaFoZzRBYVmEE7JZGIq08EiC0Q= github.com/swaggo/gin-swagger v1.5.3/go.mod h1:3XJKSfHjDMB5dBo/0rrTXidPmgLeqsX89Yp4uA50HpI= github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= -github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg= -github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= @@ -746,21 +746,20 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= -go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= -go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index a123d70d7..70318a386 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -146,6 +146,8 @@ backend: common: invalid_url: other: Invalid URL. + status_invalid: + other: Invalid status. password: space_invalid: other: Password cannot contain spaces. @@ -312,6 +314,9 @@ backend: site_info: config_not_found: other: Site config not found. + badge: + object_not_found: + other: Badge object not found reason: spam: name: @@ -421,7 +426,7 @@ backend: tags_title: other: Tags no_description: - other: The tag has no description. + other: The tag has no description. notification: action: update_question: @@ -460,6 +465,8 @@ backend: other: upvoted comment invited_you_to_answer: other: invited you to answer + earned_badge: + other: You've earned the "{{.BadgeName}}" badge email_tpl: change_email: title: @@ -526,6 +533,263 @@ backend: reaction: tooltip: other: "{{ .Names }} and {{ .Count }} more..." + badge: + default_badges: + autobiographer: + name: + other: Autobiographer + desc: + other: Filled out profile information. + certified: + name: + other: Certified + desc: + other: Completed our new user tutorial. + editor: + name: + other: Editor + desc: + other: First post edit. + first_flag: + name: + other: First Flag + desc: + other: First flagged a post. + first_upvote: + name: + other: First Upvote + desc: + other: First up voted a post. + first_link: + name: + other: First Link + desc: + other: First dirst added a link to another post. + first_reaction: + name: + other: First Reaction + desc: + other: First reacted to the post. + first_share: + name: + other: First Share + desc: + other: First shared a post. + scholar: + name: + other: Scholar + desc: + other: Asked a question and accepted an answer. + commentator: + name: + other: Commentator + desc: + other: Leave 5 comments. + new_user_of_the_month: + name: + other: New User of the Month + desc: + other: Outstanding contributions in their first month. + read_guidelines: + name: + other: Read Guidelines + desc: + other: Read the [community guidelines]. + reader: + name: + other: Reader + desc: + other: Read every answers in a topic with more than 10 answers. + welcome: + name: + other: Welcome + desc: + other: Received a up vote. + nice_share: + name: + other: Nice Share + desc: + other: Shared a post with 25 unique visitors. + good_share: + name: + other: Good Share + desc: + other: Shared a post with 300 unique visitors. + great_share: + name: + other: Great Share + desc: + other: Shared a post with 1000 unique visitors. + out_of_love: + name: + other: Out of Love + desc: + other: Used 50 up votes in a day. + higher_love: + name: + other: Higher Love + desc: + other: Used 50 up votes in a day 5 times. + crazy_in_love: + name: + other: Crazy in Love + desc: + other: Used 50 up votes in a day 20 times. + promoter: + name: + other: Promoter + desc: + other: Invited a user. + campaigner: + name: + other: Campaigner + desc: + other: Invited 3 basic users. + champion: + name: + other: Champion + desc: + other: Invited 5 members. + thank_you: + name: + other: Thank You + desc: + other: Has 20 up voted posts and gave 10 up votes. + gives_back: + name: + other: Gives Back + desc: + other: Has 100 up voted posts and gave 100 up votes. + empathetic: + name: + other: Empathetic + desc: + other: Has 500 up voted posts and gave 1000 up votes. + enthusiast: + name: + other: Enthusiast + desc: + other: Visited 10 consecutive days. + aficionado: + name: + other: Aficionado + desc: + other: Visited 100 consecutive days. + devotee: + name: + other: Devotee + desc: + other: Visited 365 consecutive days. + anniversary: + name: + other: Anniversary + desc: + other: Active member for a year, posted at least once. + appreciated: + name: + other: Appreciated + desc: + other: Received 1 up vote on 20 posts. + respected: + name: + other: Respected + desc: + other: Received 2 up votes on 100 posts. + admired: + name: + other: Admired + desc: + other: Received 5 up votes on 300 posts. + solved: + name: + other: Solved + desc: + other: Have an answer be accepted. + guidance_counsellor: + name: + other: Guidance Counsellor + desc: + other: Have 10 answers be accepted. + know_it_all: + name: + other: Know-it-All + desc: + other: Have 50 answers be accepted. + solution_institution: + name: + other: Solution Institution + desc: + other: Have 150 answers be accepted. + nice_answer: + name: + other: Nice Answer + desc: + other: Answer score of 10 or more. + good_answer: + name: + other: Good Answer + desc: + other: Answer score of 25 or more. + great_answer: + name: + other: Great Answer + desc: + other: Answer score of 50 or more. + nice_question: + name: + other: Nice Question + desc: + other: Question score of 10 or more. + good_question: + name: + other: Good Question + desc: + other: Question score of 25 or more. + great_question: + name: + other: Great Question + desc: + other: Question score of 50 or more. + popular_question: + name: + other: Popular Question + desc: + other: Question with 500 views. + notable_question: + name: + other: Notable Question + desc: + other: Question with 1,000 views. + famous_question: + name: + other: Famous Question + desc: + other: Question with 5,000 views. + popular_link: + name: + other: Popular Link + desc: + other: Posted an external link with 50 clicks. + hot_link: + name: + other: Hot Link + desc: + other: Posted an external link with 300 clicks. + famous_link: + name: + other: Famous Link + desc: + other: Posted an external link with 100 clicks. + default_badge_groups: + getting_started: + name: + other: Getting Started + community: + name: + other: Community + posting: + name: + other: Posting # The following fields are used for interface presentation(Front-end) ui: @@ -589,6 +853,9 @@ ui: posts: Posts invites: Invites votes: Votes + answer: Answer + question: Question + badge_award: Badge suspended: title: Your Account has been Suspended until_time: "Your account was suspended until {{ time }}." @@ -891,6 +1158,7 @@ ui: question: Questions tag: Tags user: Users + badges: Badges profile: Profile setting: Settings logout: Log out @@ -1290,6 +1558,7 @@ ui: newest: Newest active: Active hot: Hot + recommend: Recommend score: Score unanswered: Unanswered modified: modified @@ -1308,12 +1577,14 @@ ui: reputation: Reputation comments: Comments votes: Votes + badges: Badges newest: Newest score: Score edit_profile: Edit profile visited_x_days: "Visited {{ count }} days" viewed: Viewed joined: Joined + comma: "," last_login: Seen about_me: About Me about_me_empty: "// Hello, World !" @@ -1331,6 +1602,7 @@ ui: x_votes: votes received x_answers: answers x_questions: questions + recent_badges: Recent Badges install: title: Installation next: Next @@ -1445,6 +1717,7 @@ ui: questions: Questions answers: Answers users: Users + badges: Badges flags: Flags settings: Settings general: General @@ -1468,6 +1741,18 @@ ui: login: Login qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in. login_failed_email_tip: Login failed, please allow this app to access your email information before try again. + badges: + modal: + title: Congratulations + content: You've earned a new badge. + close: Close + confirm: View badges + title: Badges + awarded: Awarded + earned_×: Earned ×{{ number }} + ×_awarded: "{{ number }} awarded" + can_earn_multiple: You can earn this multiple times. + earned: Earned admin: admin_header: @@ -1870,7 +2155,20 @@ ui: msg: should_be_number: the input should be number number_larger_1: number should be equal or larger than 1 - + badges: + action: Action + active: Active + all: All + awards: Awards + deactivate: Deactivate + filter: + placeholder: Filter by name, badge:id + group: Group + inactive: Inactive + name: Name + show_logs: Show logs + status: Status + title: Badges form: optional: (optional) empty: cannot be empty diff --git a/i18n/no_NO.yaml b/i18n/no_NO.yaml index c91f56b05..859f1ceaf 100644 --- a/i18n/no_NO.yaml +++ b/i18n/no_NO.yaml @@ -859,6 +859,7 @@ ui: newest: Newest active: Active hot: Hot + recommend: Recommend score: Score unanswered: Unanswered modified: modified diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 94e6c97f3..396c2f9b3 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -1920,4 +1920,4 @@ ui: post_reopen: 这个帖子已被重新打开. post_list: 这个帖子已经被显示 post_unlist: 这个帖子已经被隐藏 - post_pending: 您的帖子正在等待审核。它将在它获得批准后可见。 + post_pending: 您的帖子正在等待审核。它将在它获得批准后可见。 \ No newline at end of file diff --git a/internal/base/constant/cache_key.go b/internal/base/constant/cache_key.go index 4135b53c8..987798d19 100644 --- a/internal/base/constant/cache_key.go +++ b/internal/base/constant/cache_key.go @@ -50,4 +50,6 @@ const ( NewQuestionNotificationLimitMax = 50 RateLimitCacheKeyPrefix = "answer:rate-limit:" RateLimitCacheTime = 5 * time.Minute + RedDotCacheKey = "answer:red-dot:%s:%s" + RedDotCacheTime = 30 * 24 * time.Hour ) diff --git a/internal/base/constant/event.go b/internal/base/constant/event.go new file mode 100644 index 000000000..f7fd8412a --- /dev/null +++ b/internal/base/constant/event.go @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package constant + +// EventType event type. It is used to define the type of event. Such as object.action +type EventType string + +// event object +const ( + eventQuestion = "question" + eventAnswer = "answer" + eventComment = "comment" + eventUser = "user" +) + +// event action +const ( + eventCreate = "create" + eventUpdate = "update" + eventDelete = "delete" + eventVote = "vote" + eventAccept = "accept" // only question have the accept event + eventShare = "share" // the object share link has been clicked + eventFlag = "flag" + eventReact = "react" +) + +const ( + EventUserUpdate EventType = eventUser + "." + eventUpdate + EventUserShare EventType = eventUser + "." + eventShare +) + +const ( + EventQuestionCreate EventType = eventQuestion + "." + eventCreate + EventQuestionUpdate EventType = eventQuestion + "." + eventUpdate + EventQuestionDelete EventType = eventQuestion + "." + eventDelete + EventQuestionVote EventType = eventQuestion + "." + eventVote + EventQuestionAccept EventType = eventQuestion + "." + eventAccept + EventQuestionFlag EventType = eventQuestion + "." + eventFlag + EventQuestionReact EventType = eventQuestion + "." + eventReact +) + +const ( + EventAnswerCreate EventType = eventAnswer + "." + eventCreate + EventAnswerUpdate EventType = eventAnswer + "." + eventUpdate + EventAnswerDelete EventType = eventAnswer + "." + eventDelete + EventAnswerVote EventType = eventAnswer + "." + eventVote + EventAnswerFlag EventType = eventAnswer + "." + eventFlag + EventAnswerReact EventType = eventAnswer + "." + eventReact +) + +const ( + EventCommentCreate EventType = eventComment + "." + eventCreate + EventCommentUpdate EventType = eventComment + "." + eventUpdate + EventCommentDelete EventType = eventComment + "." + eventDelete + EventCommentVote EventType = eventComment + "." + eventVote + EventCommentFlag EventType = eventComment + "." + eventFlag +) diff --git a/internal/base/constant/notification.go b/internal/base/constant/notification.go index ceebe7de8..9a7762d8e 100644 --- a/internal/base/constant/notification.go +++ b/internal/base/constant/notification.go @@ -56,6 +56,8 @@ const ( NotificationYourCommentWasDeleted = "notification.action.your_comment_was_deleted" // NotificationInvitedYouToAnswer invited you to answer NotificationInvitedYouToAnswer = "notification.action.invited_you_to_answer" + // NotificationEarnedBadge earned badge + NotificationEarnedBadge = "notification.action.earned_badge" ) type NotificationChannelKey string @@ -71,6 +73,12 @@ const ( EmailChannel NotificationChannelKey = "email" ) +const ( + NotificationTypeInbox = "inbox" + NotificationTypeAchievement = "achievement" + NotificationTypeBadgeAchievement = "badge" +) + var ( NotificationMsgTypeMapping = map[string]int{ NotificationUpdateQuestion: 1, diff --git a/internal/base/constant/object_type.go b/internal/base/constant/object_type.go index b3e3883df..e4ac3d20c 100644 --- a/internal/base/constant/object_type.go +++ b/internal/base/constant/object_type.go @@ -27,6 +27,8 @@ const ( CollectionObjectType = "collection" CommentObjectType = "comment" ReportObjectType = "report" + BadgeObjectType = "badge" + BadgeAwardObjectType = "badge_award" ) var ( @@ -38,15 +40,19 @@ var ( CollectionObjectType: 6, CommentObjectType: 7, ReportObjectType: 8, + BadgeObjectType: 9, + BadgeAwardObjectType: 10, } ObjectTypeNumberMapping = map[int]string{ - 1: QuestionObjectType, - 2: AnswerObjectType, - 3: TagObjectType, - 4: UserObjectType, - 6: CollectionObjectType, - 7: CommentObjectType, - 8: ReportObjectType, + 1: QuestionObjectType, + 2: AnswerObjectType, + 3: TagObjectType, + 4: UserObjectType, + 6: CollectionObjectType, + 7: CommentObjectType, + 8: ReportObjectType, + 9: BadgeObjectType, + 10: BadgeAwardObjectType, } ) diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 66ef1bed0..24d7ab5f9 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -106,6 +106,8 @@ const ( AddBulkUsersAmountError = "error.user.add_bulk_users_amount_error" InvalidURLError = "error.common.invalid_url" MetaObjectNotFound = "error.meta.object_not_found" + BadgeObjectNotFound = "error.badge.object_not_found" + StatusInvalid = "error.common.status_invalid" ) // user external login reasons diff --git a/internal/cli/config.go b/internal/cli/config.go index 06764e41a..9ebefa1da 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -31,6 +31,8 @@ import ( type ConfigField struct { AllowPasswordLogin bool `json:"allow_password_login"` + // The slug name of plugin that you want to deactivate + DeactivatePluginSlugName string `json:"deactivate_plugin_slug_name"` } // SetDefaultConfig set default config @@ -55,6 +57,9 @@ func SetDefaultConfig(dbConf *data.Database, cacheConf *data.CacheConf, field *C if field.AllowPasswordLogin { return defaultLoginConfig(db) } + if len(field.DeactivatePluginSlugName) > 0 { + return deactivatePlugin(db, field.DeactivatePluginSlugName) + } return nil } @@ -82,3 +87,37 @@ func defaultLoginConfig(x *xorm.Engine) (err error) { } return nil } + +func deactivatePlugin(x *xorm.Engine, pluginSlugName string) (err error) { + fmt.Printf("try to deactivate plugin: %s\n", pluginSlugName) + + item := &entity.Config{Key: constant.PluginStatus} + exist, err := x.Get(item) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if !exist { + return nil + } + + pluginStatusMapping := make(map[string]bool) + _ = json.Unmarshal([]byte(item.Value), &pluginStatusMapping) + status, ok := pluginStatusMapping[pluginSlugName] + if !ok { + fmt.Printf("plugin %s not exist\n", pluginSlugName) + return nil + } + if !status { + fmt.Printf("plugin %s already deactivated\n", pluginSlugName) + return nil + } + + pluginStatusMapping[pluginSlugName] = false + dataByte, _ := json.Marshal(pluginStatusMapping) + item.Value = string(dataByte) + _, err = x.ID(item.ID).Cols("value").Update(item) + if err != nil { + return fmt.Errorf("update plugin status failed: %w", err) + } + return nil +} diff --git a/internal/controller/badge_controller.go b/internal/controller/badge_controller.go new file mode 100644 index 000000000..65b594072 --- /dev/null +++ b/internal/controller/badge_controller.go @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/middleware" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/pkg/uid" + "github.com/gin-gonic/gin" +) + +type BadgeController struct { + badgeService *badge.BadgeService + badgeAwardService *badge.BadgeAwardService +} + +func NewBadgeController( + badgeService *badge.BadgeService, + badgeAwardService *badge.BadgeAwardService) *BadgeController { + return &BadgeController{ + badgeService: badgeService, + badgeAwardService: badgeAwardService, + } +} + +// GetBadgeList list all badges +// @Summary list all badges group by group +// @Description list all badges group by group +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} handler.RespBody{data=[]schema.GetBadgeListResp} +// @Router /answer/api/v1/badges [get] +func (b *BadgeController) GetBadgeList(ctx *gin.Context) { + userID := middleware.GetLoginUserIDFromContext(ctx) + resp, err := b.badgeService.ListByGroup(ctx, userID) + handler.HandleResponse(ctx, err, resp) +} + +// GetBadgeInfo get badge info +// @Summary get badge info +// @Description get badge info +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id query string true "id" default(string) +// @Success 200 {object} handler.RespBody{data=schema.GetBadgeInfoResp} +// @Router /answer/api/v1/badge [get] +func (b *BadgeController) GetBadgeInfo(ctx *gin.Context) { + id := ctx.Query("id") + id = uid.DeShortID(id) + + userID := middleware.GetLoginUserIDFromContext(ctx) + resp, err := b.badgeService.GetBadgeInfo(ctx, id, userID) + handler.HandleResponse(ctx, err, resp) +} + +// GetBadgeAwardList get badge award list +// @Summary get badge award list +// @Description get badge award list +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Param badge_id query string true "badge id" +// @Param username query string false "only list the award by username" +// @Success 200 {object} handler.RespBody{data=schema.GetBadgeInfoResp} +// @Router /answer/api/v1/badge/awards/page [get] +func (b *BadgeController) GetBadgeAwardList(ctx *gin.Context) { + req := &schema.GetBadgeAwardWithPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.BadgeID = uid.DeShortID(req.BadgeID) + + resp, total, err := b.badgeAwardService.GetBadgeAwardList(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} + +// GetAllBadgeAwardListByUsername get user badge award list +// @Summary get user badge award list +// @Description get user badge award list +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param username query string true "user name" +// @Success 200 {object} handler.RespBody{data=[]schema.GetUserBadgeAwardListResp} +// @Router /answer/api/v1/badge/user/awards [get] +func (b *BadgeController) GetAllBadgeAwardListByUsername(ctx *gin.Context) { + req := &schema.GetUserBadgeAwardListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, total, err := b.badgeAwardService.GetUserBadgeAwardList(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} + +// GetRecentBadgeAwardListByUsername get user badge award list +// @Summary get user badge award list +// @Description get user badge award list +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param username query string true "user name" +// @Success 200 {object} handler.RespBody{data=[]schema.GetUserBadgeAwardListResp} +// @Router /answer/api/v1/badge/user/awards/recent [get] +func (b *BadgeController) GetRecentBadgeAwardListByUsername(ctx *gin.Context) { + req := &schema.GetUserBadgeAwardListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.Limit = 10 + + resp, total, err := b.badgeAwardService.GetUserRecentBadgeAwardList(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 9eb64c585..cbf80f7fa 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -51,4 +51,6 @@ var ProviderSetController = wire.NewSet( NewCaptchaController, NewMetaController, NewEmbedController, + NewBadgeController, + NewRenderController, ) diff --git a/internal/controller/embed_controller.go b/internal/controller/embed_controller.go index c9691d299..eb54f1a67 100644 --- a/internal/controller/embed_controller.go +++ b/internal/controller/embed_controller.go @@ -21,7 +21,6 @@ package controller import ( "github.com/apache/incubator-answer/internal/base/handler" - "github.com/apache/incubator-answer/internal/schema" "github.com/apache/incubator-answer/plugin" "github.com/gin-gonic/gin" ) @@ -40,27 +39,14 @@ func NewEmbedController() *EmbedController { // @Accept json // @Produce json // @Router /answer/api/v1/embed/config [get] -// @Success 200 {object} handler.RespBody{data=[]schema.GetEmbedOptionResp} +// @Success 200 {object} handler.RespBody{data=[]plugin.EmbedConfig} func (c *EmbedController) GetEmbedConfig(ctx *gin.Context) { - resp := make([]*schema.GetEmbedOptionResp, 0) - var slugName string + resp := make([]*plugin.EmbedConfig, 0) - _ = plugin.CallEmbed(func(base plugin.Embed) error { - slugName = base.Info().SlugName - return nil + err := plugin.CallEmbed(func(embed plugin.Embed) (err error) { + resp, err = embed.GetEmbedConfigs(ctx) + return err }) - _ = plugin.CallConfig(func(fn plugin.Config) error { - if fn.Info().SlugName == slugName { - for _, field := range fn.ConfigFields() { - resp = append(resp, &schema.GetEmbedOptionResp{ - Platform: field.Name, - Enable: field.Value.(bool), - }) - } - return nil - } - return nil - }) - handler.HandleResponse(ctx, nil, resp) + handler.HandleResponse(ctx, err, resp) } diff --git a/internal/controller/notification_controller.go b/internal/controller/notification_controller.go index 15796b9c4..952c262e9 100644 --- a/internal/controller/notification_controller.go +++ b/internal/controller/notification_controller.go @@ -105,8 +105,8 @@ func (nc *NotificationController) ClearRedDot(ctx *gin.Context) { req.CanReviewAnswer = canList[1] req.CanReviewTag = canList[2] - RedDot, err := nc.notificationService.ClearRedDot(ctx, req) - handler.HandleResponse(ctx, err, RedDot) + resp, err := nc.notificationService.ClearRedDot(ctx, req) + handler.HandleResponse(ctx, err, resp) } // ClearUnRead @@ -125,7 +125,7 @@ func (nc *NotificationController) ClearUnRead(ctx *gin.Context) { return } userID := middleware.GetLoginUserIDFromContext(ctx) - err := nc.notificationService.ClearUnRead(ctx, userID, req.TypeStr) + err := nc.notificationService.ClearUnRead(ctx, userID, req.NotificationType) handler.HandleResponse(ctx, err, gin.H{}) } diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 1b0dee92a..5ddc33f30 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -340,6 +340,35 @@ func (qc *QuestionController) QuestionPage(ctx *gin.Context) { handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions)) } +// QuestionRecommendPage get recommend questions by page +// @Summary get recommend questions by page +// @Description get recommend questions by page +// @Tags Question +// @Accept json +// @Produce json +// @Param data body schema.QuestionPageReq true "QuestionPageReq" +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}} +// @Router /answer/api/v1/question/recommend/page [get] +func (qc *QuestionController) QuestionRecommendPage(ctx *gin.Context) { + req := &schema.QuestionPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + + if req.LoginUserID == "" { + handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) + return + } + + questions, total, err := qc.questionService.GetRecommendQuestionPage(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions)) +} + // AddQuestion add question // @Summary add question // @Description add question diff --git a/internal/controller/render_controller.go b/internal/controller/render_controller.go new file mode 100644 index 000000000..05e49d0b7 --- /dev/null +++ b/internal/controller/render_controller.go @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/plugin" + "github.com/gin-gonic/gin" +) + +type RenderController struct { +} + +func NewRenderController() *RenderController { + return &RenderController{} +} + +// GetRenderConfig godoc +// @Summary GetRenderConfig +// @Description GetRenderConfig +// @Tags PluginRender +// @Accept json +// @Produce json +// @Router /answer/api/v1/render/config [get] +// @Success 200 {object} handler.RespBody{data=plugin.RenderConfig} +func (c *RenderController) GetRenderConfig(ctx *gin.Context) { + var resp *plugin.RenderConfig + + _ = plugin.CallRender(func(render plugin.Render) (err error) { + resp = render.GetRenderConfig(ctx) + return nil + }) + + handler.HandleResponse(ctx, nil, resp) +} diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index a7d69ebc8..6b6a20f87 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -22,6 +22,8 @@ package controller import ( "encoding/json" "fmt" + "github.com/apache/incubator-answer/internal/service/content" + "github.com/apache/incubator-answer/internal/service/event_queue" "github.com/apache/incubator-answer/plugin" "html/template" "net/http" @@ -54,12 +56,16 @@ type TemplateController struct { cssPath string templateRenderController *templaterender.TemplateRenderController siteInfoService siteinfo_common.SiteInfoCommonService + eventQueueService event_queue.EventQueueService + userService *content.UserService } // NewTemplateController new controller func NewTemplateController( templateRenderController *templaterender.TemplateRenderController, siteInfoService siteinfo_common.SiteInfoCommonService, + eventQueueService event_queue.EventQueueService, + userService *content.UserService, ) *TemplateController { script, css := GetStyle() return &TemplateController{ @@ -67,6 +73,8 @@ func NewTemplateController( cssPath: css, templateRenderController: templateRenderController, siteInfoService: siteInfoService, + eventQueueService: eventQueueService, + userService: userService, } } func GetStyle() (script []string, css string) { @@ -271,6 +279,7 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) { id := ctx.Param("id") title := ctx.Param("title") answerid := ctx.Param("answerid") + shareUsername := ctx.Query("share") if checker.IsQuestionsIgnorePath(id) { // if id == "ask" { file, err := ui.Build.ReadFile("build/index.html") @@ -291,6 +300,14 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) { tc.Page404(ctx) return } + if len(shareUsername) > 0 { + userInfo, err := tc.userService.GetOtherUserInfoByUsername( + ctx, &schema.GetOtherUserInfoByUsernameReq{Username: shareUsername}) + if err == nil { + tc.eventQueueService.Send(ctx, schema.NewEvent(constant.EventUserShare, userInfo.ID). + QID(id, detail.UserID).AID(answerid, "")) + } + } encodeTitle := htmltext.UrlTitle(detail.Title) if encodeTitle == title { correctTitle = true @@ -562,6 +579,14 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI ctx.HTML(code, tpl, data) } +func (tc *TemplateController) OpenSearch(ctx *gin.Context) { + if tc.checkPrivateMode(ctx) { + tc.Page404(ctx) + return + } + tc.templateRenderController.OpenSearch(ctx) +} + func (tc *TemplateController) Sitemap(ctx *gin.Context) { if tc.checkPrivateMode(ctx) { tc.Page404(ctx) diff --git a/internal/controller/template_render/question.go b/internal/controller/template_render/question.go index a65c3083d..b97536188 100644 --- a/internal/controller/template_render/question.go +++ b/internal/controller/template_render/question.go @@ -89,6 +89,28 @@ func (t *TemplateRenderController) Sitemap(ctx *gin.Context) { ) } +func (t *TemplateRenderController) OpenSearch(ctx *gin.Context) { + general, err := t.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Error("get site general failed:", err) + return + } + + favicon := general.SiteUrl + "/favicon.ico" + branding, err := t.siteInfoService.GetSiteBranding(ctx) + if err == nil && len(branding.Favicon) > 0 { + favicon = branding.Favicon + } + + ctx.Header("Content-Type", "application/xml") + ctx.HTML( + http.StatusOK, "opensearch.xml", gin.H{ + "general": general, + "favicon": favicon, + }, + ) +} + func (t *TemplateRenderController) SitemapPage(ctx *gin.Context, page int) error { general, err := t.siteInfoService.GetSiteGeneral(ctx) if err != nil { diff --git a/internal/controller_admin/badge_controller.go b/internal/controller_admin/badge_controller.go new file mode 100644 index 000000000..4a44f1764 --- /dev/null +++ b/internal/controller_admin/badge_controller.go @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller_admin + +import ( + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/gin-gonic/gin" +) + +type BadgeController struct { + badgeService *badge.BadgeService +} + +func NewBadgeController(badgeService *badge.BadgeService) *BadgeController { + return &BadgeController{ + badgeService: badgeService, + } +} + +// GetBadgeList list all badges by page +// @Summary list all badges by page +// @Description list all badges by page +// @Tags AdminBadge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Param status query string false "badge status" Enums(, active, inactive) +// @Param q query string false "search param" +// @Success 200 {object} handler.RespBody{data=[]schema.GetBadgeListPagedResp} +// @Router /answer/admin/api/badges [get] +func (b *BadgeController) GetBadgeList(ctx *gin.Context) { + req := &schema.GetBadgeListPagedReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, total, err := b.badgeService.ListPaged(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} + +// UpdateBadgeStatus update badge status +// @Summary update badge status +// @Description update badge status +// @Tags AdminBadge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.UpdateBadgeStatusReq true "UpdateBadgeStatusReq" +// @Success 200 {object} handler.RespBody +// @Router /answer/admin/api/badge/status [put] +func (b *BadgeController) UpdateBadgeStatus(ctx *gin.Context) { + req := &schema.UpdateBadgeStatusReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := b.badgeService.UpdateStatus(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/controller.go b/internal/controller_admin/controller.go index de87d105e..ebf32cbfc 100644 --- a/internal/controller_admin/controller.go +++ b/internal/controller_admin/controller.go @@ -28,4 +28,5 @@ var ProviderSetController = wire.NewSet( NewSiteInfoController, NewRoleController, NewPluginController, + NewBadgeController, ) diff --git a/internal/controller_admin/siteinfo_controller.go b/internal/controller_admin/siteinfo_controller.go index 623ab5f79..6429d6719 100644 --- a/internal/controller_admin/siteinfo_controller.go +++ b/internal/controller_admin/siteinfo_controller.go @@ -188,12 +188,12 @@ func (sc *SiteInfoController) GetRobots(ctx *gin.Context) { ctx.String(http.StatusOK, resp.Robots) } -// GetRobots get site robots information -// @Summary get site robots information -// @Description get site robots information +// GetCss get site custom CSS +// @Summary get site custom CSS +// @Description get site custom CSS // @Tags site -// @Produce json -// @Success 200 {string} txt "" +// @Produce text/css +// @Success 200 {string} css "" // @Router /custom.css [get] func (sc *SiteInfoController) GetCss(ctx *gin.Context) { resp, err := sc.siteInfoService.GetSiteCustomCssHTML(ctx) diff --git a/internal/entity/badge_award_entity.go b/internal/entity/badge_award_entity.go new file mode 100644 index 000000000..339b9ca06 --- /dev/null +++ b/internal/entity/badge_award_entity.go @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import "time" + +const ( + IsBadgeNotDeleted = 0 + IsBadgeDeleted = 1 + + BadgeEmptyAwardKey = "0" +) + +// BadgeAward badge_award +type BadgeAward struct { + ID string `xorm:"not null pk BIGINT(20) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + UserID string `xorm:"not null index BIGINT(20) user_id"` + BadgeID string `xorm:"not null index BIGINT(20) badge_id"` + AwardKey string `xorm:"not null index VARCHAR(64) award_key"` + BadgeGroupID int64 `xorm:"not null index BIGINT(20) badge_group_id"` + IsBadgeDeleted int8 `xorm:"not null TINYINT(1) is_badge_deleted"` +} + +// TableName badge_award table name +func (BadgeAward) TableName() string { + return "badge_award" +} + +type BadgeEarnedCount struct { + BadgeID string `xorm:"badge_id"` + EarnedCount int64 `xorm:"earned_count"` +} + +// TableName badge_award table name +func (BadgeEarnedCount) TableName() string { + return "badge_award" +} + +type BadgeAwardRecent struct { + Created time.Time `xorm:"created"` + BadgeID string `xorm:"badge_id"` + AwardKey string `xorm:"award_key"` + EarnedCount int64 `xorm:"earned_count"` + IsBadgeDeleted int8 `xorm:"is_badge_deleted"` +} + +// TableName badge_award table name +func (BadgeAwardRecent) TableName() string { + return "badge_award" +} diff --git a/internal/entity/badge_entity.go b/internal/entity/badge_entity.go new file mode 100644 index 000000000..a370e2750 --- /dev/null +++ b/internal/entity/badge_entity.go @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import ( + "github.com/tidwall/gjson" + "time" +) + +type BadgeLevel int + +const ( + BadgeStatusActive = 1 + BadgeStatusDeleted = 10 + BadgeStatusInactive = 11 + + BadgeLevelBronze BadgeLevel = 1 + BadgeLevelSilver BadgeLevel = 2 + BadgeLevelGold BadgeLevel = 3 + + BadgeSingleAward = 1 + BadgeMultiAward = 2 +) + +// Badge badge +type Badge struct { + ID string `xorm:"not null pk BIGINT(20) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + Name string `xorm:"not null default '' VARCHAR(256) name"` + Icon string `xorm:"not null default '' VARCHAR(1024) icon"` + AwardCount int `xorm:"not null default 0 INT(11) award_count"` + Description string `xorm:"not null MEDIUMTEXT description"` + Status int8 `xorm:"not null default 1 INT(11) status"` + BadgeGroupID int64 `xorm:"not null default 0 BIGINT(20) badge_group_id"` + Level BadgeLevel `xorm:"not null default 1 TINYINT(4) level"` + Single int8 `xorm:"not null default 1 TINYINT(4) single"` + Collect string `xorm:"not null default '' VARCHAR(128) collect"` + Handler string `xorm:"not null default '' VARCHAR(128) handler"` + Param string `xorm:"not null TEXT param"` +} + +// TableName badge table name +func (b *Badge) TableName() string { + return "badge" +} + +func (b *Badge) GetIntParam(key string) int64 { + return gjson.Get(b.Param, key).Int() +} + +func (b *Badge) GetStringParam(key string) string { + return gjson.Get(b.Param, key).String() +} diff --git a/internal/entity/badge_group_entity.go b/internal/entity/badge_group_entity.go new file mode 100644 index 000000000..3be4d8209 --- /dev/null +++ b/internal/entity/badge_group_entity.go @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import "time" + +// BadgeGroup badge_group +type BadgeGroup struct { + ID string `json:"id" xorm:"not null pk autoincr BIGINT(20) id"` + Name string `json:"name" xorm:"not null default '' VARCHAR(256) name"` + CreatedAt time.Time `json:"created_at" xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `json:"updated_at" xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` +} + +// TableName badge_group table name +func (BadgeGroup) TableName() string { + return "badge_group" +} diff --git a/internal/migrations/init.go b/internal/migrations/init.go index a56216aab..4558ef60b 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -79,6 +79,7 @@ func (m *Mentor) InitDB() error { m.do("init site info privilege rank", m.initSiteInfoPrivilegeRank) m.do("init site info write", m.initSiteInfoWrite) m.do("init default content", m.initDefaultContent) + m.do("init default badges", m.initDefaultBadges) return m.err } @@ -411,3 +412,22 @@ func (m *Mentor) initDefaultContent() { return } } + +func (m *Mentor) initDefaultBadges() { + uniqueIDRepo := unique.NewUniqueIDRepo(&data.Data{DB: m.engine}) + + _, m.err = m.engine.Context(m.ctx).Insert(defaultBadgeGroupTable) + if m.err != nil { + return + } + for _, badge := range defaultBadgeTable { + badge.ID, m.err = uniqueIDRepo.GenUniqueIDStr(m.ctx, new(entity.Badge).TableName()) + if m.err != nil { + return + } + if _, m.err = m.engine.Context(m.ctx).Insert(badge); m.err != nil { + return + } + } + return +} diff --git a/internal/migrations/init_data.go b/internal/migrations/init_data.go index adbe71753..50a5651b6 100644 --- a/internal/migrations/init_data.go +++ b/internal/migrations/init_data.go @@ -69,6 +69,9 @@ var ( &entity.UserNotificationConfig{}, &entity.PluginUserConfig{}, &entity.Review{}, + &entity.Badge{}, + &entity.BadgeGroup{}, + &entity.BadgeAward{}, } roles = []*entity.Role{ @@ -344,4 +347,160 @@ var ( {ID: 129, Key: "rank.question.undeleted", Value: `-1`}, {ID: 130, Key: "rank.tag.undeleted", Value: `-1`}, } + + defaultBadgeGroupTable = []*entity.BadgeGroup{ + {ID: "1", Name: "badge.default_badge_groups.getting_started.name"}, + {ID: "2", Name: "badge.default_badge_groups.community.name"}, + {ID: "3", Name: "badge.default_badge_groups.posting.name"}, + } + + defaultBadgeTable = []*entity.Badge{ + { + Name: "badge.default_badges.autobiographer.name", + Icon: "person-badge-fill", + Description: "badge.default_badges.autobiographer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstUpdateUserProfile", + }, + { + Name: "badge.default_badges.editor.name", + Icon: "pencil-fill", + Description: "badge.default_badges.editor.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstPostEdit", + }, + { + Name: "badge.default_badges.first_flag.name", + Icon: "flag-fill", + Description: "badge.default_badges.first_flag.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstFlaggedPost", + }, + { + Name: "badge.default_badges.first_upvote.name", + Icon: "hand-thumbs-up-fill", + Description: "badge.default_badges.first_upvote.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstVotedPost", + }, + { + Name: "badge.default_badges.first_reaction.name", + Icon: "emoji-smile-fill", + Description: "badge.default_badges.first_reaction.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstReactedPost", + }, + { + Name: "badge.default_badges.first_share.name", + Icon: "share-fill", + Description: "badge.default_badges.first_share.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstSharedPost", + }, + { + Name: "badge.default_badges.scholar.name", + Icon: "check-circle-fill", + Description: "badge.default_badges.scholar.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstAcceptAnswer", + }, + { + Name: "badge.default_badges.solved.name", + Icon: "check-square-fill", + Description: "badge.default_badges.solved.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 2, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "ReachAnswerAcceptedAmount", + Param: `{"amount":"1"}`, + }, + { + Name: "badge.default_badges.nice_answer.name", + Icon: "chat-square-text-fill", + Description: "badge.default_badges.nice_answer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeMultiAward, + Handler: "ReachAnswerVote", + Param: `{"amount":"10"}`, + }, + { + Name: "badge.default_badges.good_answer.name", + Icon: "chat-square-text-fill", + Description: "badge.default_badges.good_answer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelSilver, + Single: entity.BadgeMultiAward, + Handler: "ReachAnswerVote", + Param: `{"amount":"25"}`, + }, + { + Name: "badge.default_badges.great_answer.name", + Icon: "chat-square-text-fill", + Description: "badge.default_badges.great_answer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelGold, + Single: entity.BadgeMultiAward, + Handler: "ReachAnswerVote", + Param: `{"amount":"50"}`, + }, + { + Name: "badge.default_badges.nice_question.name", + Icon: "question-circle-fill", + Description: "badge.default_badges.nice_question.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeMultiAward, + Handler: "ReachQuestionVote", + Param: `{"amount":"10"}`, + }, + { + Name: "badge.default_badges.good_question.name", + Icon: "question-circle-fill", + Description: "badge.default_badges.good_question.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelSilver, + Single: entity.BadgeMultiAward, + Handler: "ReachQuestionVote", + Param: `{"amount":"25"}`, + }, + { + Name: "badge.default_badges.great_question.name", + Icon: "question-circle-fill", + Description: "badge.default_badges.great_question.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelGold, + Single: entity.BadgeMultiAward, + Handler: "ReachQuestionVote", + Param: `{"amount":"50"}`, + }, + } ) diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 463f68ed8..a32e851a7 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -97,6 +97,7 @@ var migrations = []Migration{ NewMigration("v1.2.5", "add notification plugin and theme config", addNotificationPluginAndThemeConfig, true), NewMigration("v1.3.0", "add review", addReview, false), NewMigration("v1.3.6", "add hot score to question table", addQuestionHotScore, true), + NewMigration("v1.4.0", "add badge/badge_group/badge_award table", addBadges, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v22.go b/internal/migrations/v22.go new file mode 100644 index 000000000..1543c6add --- /dev/null +++ b/internal/migrations/v22.go @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package migrations + +import ( + "context" + "fmt" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/repo/unique" + "xorm.io/xorm" +) + +func addBadges(ctx context.Context, x *xorm.Engine) (err error) { + uniqueIDRepo := unique.NewUniqueIDRepo(&data.Data{DB: x}) + + err = x.Context(ctx).Sync(new(entity.Badge), new(entity.BadgeGroup), new(entity.BadgeAward)) + if err != nil { + return fmt.Errorf("sync table failed: %w", err) + } + + for _, badgeGroup := range defaultBadgeGroupTable { + exist, err := x.Context(ctx).Get(&entity.BadgeGroup{ID: badgeGroup.ID}) + if err != nil { + return err + } + if exist { + _, err = x.Context(ctx).ID(badgeGroup.ID).Update(badgeGroup) + } else { + _, err = x.Context(ctx).Insert(badgeGroup) + } + if err != nil { + return fmt.Errorf("insert badge group failed: %w", err) + } + } + + for _, badge := range defaultBadgeTable { + beans := &entity.Badge{Name: badge.Name} + exist, err := x.Context(ctx).Get(beans) + if err != nil { + return err + } + if exist { + badge.ID = beans.ID + _, err = x.Context(ctx).ID(beans.ID).Update(badge) + continue + } + badge.ID, err = uniqueIDRepo.GenUniqueIDStr(ctx, new(entity.Badge).TableName()) + if err != nil { + return err + } + + if _, err := x.Context(ctx).Insert(badge); err != nil { + return err + } + } + return +} diff --git a/internal/repo/activity_common/activity_repo.go b/internal/repo/activity_common/activity_repo.go index 70f49962c..9f0a59e4d 100644 --- a/internal/repo/activity_common/activity_repo.go +++ b/internal/repo/activity_common/activity_repo.go @@ -107,6 +107,19 @@ func (ar *ActivityRepo) GetActivity(ctx context.Context, session *xorm.Session, return } +func (ar *ActivityRepo) GetUserActivitysByActivityType(ctx context.Context, userID string, activityType int) ( + activityList []*entity.Activity, err error) { + activityList = make([]*entity.Activity, 0) + err = ar.data.DB.Context(ctx).Where("user_id = ?", userID). + And("activity_type = ?", activityType). + And("cancelled = 0"). + Find(&activityList) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + func (ar *ActivityRepo) GetUserIDObjectIDActivitySum(ctx context.Context, userID, objectID string) (int, error) { sum := &entity.ActivityRankSum{} _, err := ar.data.DB.Context(ctx).Table(entity.Activity{}.TableName()). diff --git a/internal/repo/badge/badge_event_rule.go b/internal/repo/badge/badge_event_rule.go new file mode 100644 index 000000000..11ce38a3e --- /dev/null +++ b/internal/repo/badge/badge_event_rule.go @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/constant" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "strconv" +) + +// eventRuleRepo event rule repo +type eventRuleRepo struct { + data *data.Data + EventRuleMapping map[constant.EventType][]badge.EventRuleHandler +} + +// NewEventRuleRepo creates a new badge repository +func NewEventRuleRepo(data *data.Data) badge.EventRuleRepo { + b := &eventRuleRepo{ + data: data, + } + b.EventRuleMapping = map[constant.EventType][]badge.EventRuleHandler{ + constant.EventUserUpdate: {b.FirstUpdateUserProfile}, + constant.EventUserShare: {b.FirstSharedPost}, + constant.EventQuestionCreate: nil, + constant.EventQuestionUpdate: {b.FirstPostEdit}, + constant.EventQuestionDelete: nil, + constant.EventQuestionVote: {b.FirstVotedPost, b.ReachQuestionVote}, + constant.EventQuestionAccept: {b.FirstAcceptAnswer, b.ReachAnswerAcceptedAmount}, + constant.EventQuestionFlag: {b.FirstFlaggedPost}, + constant.EventQuestionReact: {b.FirstReactedPost}, + constant.EventAnswerCreate: nil, + constant.EventAnswerUpdate: {b.FirstPostEdit}, + constant.EventAnswerDelete: nil, + constant.EventAnswerVote: {b.FirstVotedPost, b.ReachAnswerVote}, + constant.EventAnswerFlag: {b.FirstFlaggedPost}, + constant.EventAnswerReact: {b.FirstReactedPost}, + constant.EventCommentCreate: nil, + constant.EventCommentUpdate: nil, + constant.EventCommentDelete: nil, + constant.EventCommentVote: {b.FirstVotedPost}, + constant.EventCommentFlag: {b.FirstFlaggedPost}, + } + return b +} + +// HandleEventWithRule handle event with rule +func (br *eventRuleRepo) HandleEventWithRule(ctx context.Context, msg *schema.EventMsg) ( + awards []*entity.BadgeAward) { + handlers := br.EventRuleMapping[msg.EventType] + for _, handler := range handlers { + t, err := handler(ctx, msg) + if err != nil { + log.Errorf("error handling badge event %+v: %v", msg, err) + } else { + awards = append(awards, t...) + } + } + return awards +} + +// FirstUpdateUserProfile first update user profile +func (br *eventRuleRepo) FirstUpdateUserProfile(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstUpdateUserProfile") + for _, b := range badges { + bean := &entity.User{ID: event.UserID} + exist, err := br.data.DB.Context(ctx).Get(bean) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if !exist { + continue + } + if len(bean.Bio) > 0 { + awards = append(awards, br.createBadgeAward(event.UserID, entity.BadgeEmptyAwardKey, b)) + } + } + return awards, nil +} + +// FirstPostEdit first post edit +func (br *eventRuleRepo) FirstPostEdit(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstPostEdit") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstFlaggedPost first flagged post. +func (br *eventRuleRepo) FirstFlaggedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstFlaggedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstVotedPost first voted post +func (br *eventRuleRepo) FirstVotedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstVotedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstReactedPost first reacted post +func (br *eventRuleRepo) FirstReactedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstReactedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstSharedPost first shared post +func (br *eventRuleRepo) FirstSharedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstSharedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstAcceptAnswer user first accept answer +func (br *eventRuleRepo) FirstAcceptAnswer(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstAcceptAnswer") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// ReachAnswerAcceptedAmount reach answer accepted amount +func (br *eventRuleRepo) ReachAnswerAcceptedAmount(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "ReachAnswerAcceptedAmount") + if len(event.AnswerUserID) == 0 { + return nil, nil + } + + // count user's accepted answer amount + amount, err := br.data.DB.Context(ctx).Count(&entity.Answer{ + UserID: event.AnswerUserID, + Accepted: schema.AnswerAcceptedEnable, + Status: entity.AnswerStatusAvailable, + }) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + for _, b := range badges { + // get badge requirement + requirement := b.GetIntParam("amount") + if requirement == 0 || amount < requirement { + continue + } + awards = append(awards, br.createBadgeAward(event.AnswerUserID, event.AnswerID, b)) + } + return awards, nil +} + +// ReachAnswerVote reach answer vote +func (br *eventRuleRepo) ReachAnswerVote(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "ReachAnswerVote") + // get vote amount + amount, _ := strconv.Atoi(event.GetExtra("vote_up_amount")) + if amount == 0 { + return nil, nil + } + + for _, b := range badges { + // get badge requirement + requirement := b.GetIntParam("amount") + if requirement == 0 || int64(amount) < requirement { + continue + } + awards = append(awards, br.createBadgeAward(event.AnswerUserID, event.AnswerID, b)) + } + return awards, nil +} + +// ReachQuestionVote reach question vote +func (br *eventRuleRepo) ReachQuestionVote(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "ReachQuestionVote") + // get vote amount + amount, _ := strconv.Atoi(event.GetExtra("vote_up_amount")) + if amount == 0 { + return nil, nil + } + + for _, b := range badges { + // get badge requirement + requirement := b.GetIntParam("amount") + if requirement == 0 || int64(amount) < requirement { + continue + } + awards = append(awards, br.createBadgeAward(event.QuestionUserID, event.QuestionID, b)) + } + return awards, nil +} + +func (br *eventRuleRepo) getBadgesByHandler(ctx context.Context, handler string) (badges []*entity.Badge) { + badges = make([]*entity.Badge, 0) + err := br.data.DB.Context(ctx).Where("handler = ?", handler).Find(&badges) + if err != nil { + log.Errorf("error getting badge by handler %s: %v", handler, err) + return nil + } + return badges +} + +func (br *eventRuleRepo) createBadgeAward(userID, awardKey string, badge *entity.Badge) (awards *entity.BadgeAward) { + return &entity.BadgeAward{ + UserID: userID, + BadgeID: badge.ID, + AwardKey: awardKey, + } +} diff --git a/internal/repo/badge/badge_repo.go b/internal/repo/badge/badge_repo.go new file mode 100644 index 000000000..d52fc1229 --- /dev/null +++ b/internal/repo/badge/badge_repo.go @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/internal/service/unique" + "github.com/segmentfault/pacman/errors" + "xorm.io/xorm" +) + +type badgeRepo struct { + data *data.Data + uniqueIDRepo unique.UniqueIDRepo +} + +// NewBadgeRepo creates a new badge repository +func NewBadgeRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) badge.BadgeRepo { + return &badgeRepo{ + data: data, + uniqueIDRepo: uniqueIDRepo, + } +} + +func (r *badgeRepo) GetByID(ctx context.Context, id string) (badge *entity.Badge, exists bool, err error) { + badge = &entity.Badge{} + exists, err = r.data.DB.Context(ctx).Where("id = ?", id).Get(badge) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (r *badgeRepo) GetByIDs(ctx context.Context, ids []string) (badges []*entity.Badge, err error) { + badges = make([]*entity.Badge, 0) + err = r.data.DB.Context(ctx).In("id", ids).Find(&badges) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListPaged returns a list of activated badges +func (r *badgeRepo) ListPaged(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) { + badges = make([]*entity.Badge, 0) + total = 0 + + session := r.data.DB.Context(ctx).Where("status <> ?", entity.BadgeStatusDeleted) + if page == 0 || pageSize == 0 { + err = session.Find(&badges) + } else { + total, err = pager.Help(page, pageSize, &badges, &entity.Badge{}, session) + } + + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListActivated returns a list of activated badges +func (r *badgeRepo) ListActivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) { + badges = make([]*entity.Badge, 0) + total = 0 + + session := r.data.DB.Context(ctx).Where("status = ?", entity.BadgeStatusActive) + if page == 0 || pageSize == 0 { + err = session.Find(&badges) + } else { + total, err = pager.Help(page, pageSize, &badges, &entity.Badge{}, session) + } + + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListInactivated returns a list of inactivated badges +func (r *badgeRepo) ListInactivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) { + badges = make([]*entity.Badge, 0) + total = 0 + + session := r.data.DB.Context(ctx).Where("status = ?", entity.BadgeStatusInactive) + if page == 0 || pageSize == 0 { + err = session.Find(&badges) + } else { + total, err = pager.Help(page, pageSize, &badges, &entity.Badge{}, session) + } + + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// UpdateStatus updates the award count of a badge +func (r *badgeRepo) UpdateStatus(ctx context.Context, id string, status int8) (err error) { + _, err = r.data.DB.Transaction(func(session *xorm.Session) (result any, err error) { + _, err = session.ID(id).Update(&entity.Badge{ + Status: status, + }) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(session.Rollback()).WithStack() + return + } + if status >= entity.BadgeStatusDeleted { + _, err = session.Where("badge_id = ?", id).Cols("is_badge_deleted").Update(&entity.BadgeAward{ + IsBadgeDeleted: entity.IsBadgeDeleted, + }) + } else { + _, err = session.Where("badge_id = ?", id).Cols("is_badge_deleted").Update(&entity.BadgeAward{ + IsBadgeDeleted: entity.IsBadgeNotDeleted, + }) + } + return + }) + + return +} + +// UpdateAwardCount updates the award count of a badge +func (r *badgeRepo) UpdateAwardCount(ctx context.Context, badgeID string, awardCount int) (err error) { + _, err = r.data.DB.Context(ctx).ID(badgeID).Cols("award_count").Update(&entity.Badge{AwardCount: awardCount}) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/badge_award/badge_award_repo.go b/internal/repo/badge_award/badge_award_repo.go new file mode 100644 index 000000000..c1ce1e584 --- /dev/null +++ b/internal/repo/badge_award/badge_award_repo.go @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge_award + +import ( + "context" + "fmt" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/internal/service/unique" + "github.com/segmentfault/pacman/errors" + "xorm.io/xorm" +) + +type badgeAwardRepo struct { + data *data.Data + uniqueIDRepo unique.UniqueIDRepo +} + +func NewBadgeAwardRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) badge.BadgeAwardRepo { + return &badgeAwardRepo{ + data: data, + uniqueIDRepo: uniqueIDRepo, + } +} + +// AwardBadgeForUser award badge for user +func (r *badgeAwardRepo) AwardBadgeForUser(ctx context.Context, badgeAward *entity.BadgeAward) (err error) { + badgeAward.ID, err = r.uniqueIDRepo.GenUniqueIDStr(ctx, entity.BadgeAward{}.TableName()) + if err != nil { + return err + } + + _, err = r.data.DB.Transaction(func(session *xorm.Session) (result any, err error) { + session = session.Context(ctx) + + badgeInfo := &entity.Badge{} + exist, err := session.ID(badgeAward.BadgeID).ForUpdate().Get(badgeInfo) + if err != nil { + return nil, err + } + if !exist { + return nil, fmt.Errorf("badge not exist") + } + + old := &entity.BadgeAward{ + UserID: badgeAward.UserID, + BadgeID: badgeAward.BadgeID, + IsBadgeDeleted: entity.IsBadgeNotDeleted, + } + if badgeInfo.Single != entity.BadgeSingleAward { + old.AwardKey = badgeAward.AwardKey + } + exist, err = session.Get(old) + if err != nil { + return nil, err + } + if exist { + return nil, fmt.Errorf("badge already awarded") + } + + _, err = session.Insert(badgeAward) + if err != nil { + return nil, err + } + + return session.ID(badgeInfo.ID).Incr("award_count", 1).Update(&entity.Badge{}) + }) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +// CheckIsAward check this badge is awarded for this user or not +func (r *badgeAwardRepo) CheckIsAward(ctx context.Context, badgeID, userID, awardKey string, singleOrMulti int8) ( + isAward bool, err error) { + if singleOrMulti == entity.BadgeSingleAward { + _, isAward, err = r.GetByUserIdAndBadgeId(ctx, userID, badgeID) + } else { + _, isAward, err = r.GetByUserIdAndBadgeIdAndAwardKey(ctx, userID, badgeID, awardKey) + } + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return isAward, err +} + +func (r *badgeAwardRepo) CountByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) (awardCount int64) { + awardCount, err := r.data.DB.Context(ctx).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&entity.BadgeAward{}) + if err != nil { + return 0 + } + return +} + +func (r *badgeAwardRepo) CountByBadgeID(ctx context.Context, badgeID string) (awardCount int64, err error) { + awardCount, err = r.data.DB.Context(ctx).Count(&entity.BadgeAward{BadgeID: badgeID}) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (r *badgeAwardRepo) SumUserEarnedGroupByBadgeID(ctx context.Context, userID string) (earnedCounts []*entity.BadgeEarnedCount, err error) { + err = r.data.DB.Context(ctx).Select("badge_id, count(`id`) AS earned_count").Where("user_id = ?", userID).GroupBy("badge_id").Find(&earnedCounts) + return +} + +// ListPagedByBadgeId list badge awards by badge id +func (r *badgeAwardRepo) ListPagedByBadgeId(ctx context.Context, badgeID string, page int, pageSize int) (badgeAwardList []*entity.BadgeAward, total int64, err error) { + session := r.data.DB.Context(ctx) + session.Where("badge_id = ?", badgeID) + total, err = pager.Help(page, pageSize, &badgeAwardList, &entity.BadgeAward{}, session) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListPagedByBadgeIdAndUserId list badge awards by badge id and user id +func (r *badgeAwardRepo) ListPagedByBadgeIdAndUserId(ctx context.Context, badgeID string, userID string, page int, pageSize int) (badgeAwardList []*entity.BadgeAward, total int64, err error) { + session := r.data.DB.Context(ctx) + session.Where("badge_id = ? AND user_id = ?", badgeID, userID) + total, err = pager.Help(page, pageSize, &badgeAwardList, &entity.Question{}, session) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListNewestEarned list newest earned badge awards +func (r *badgeAwardRepo) ListNewestEarned(ctx context.Context, userID string, limit int) (badgeAwards []*entity.BadgeAwardRecent, err error) { + badgeAwards = make([]*entity.BadgeAwardRecent, 0) + err = r.data.DB.Context(ctx). + Select("badge_id, max(created_at) created,count(*) earned_count"). + Where("user_id = ? AND is_badge_deleted = ? ", userID, entity.IsBadgeNotDeleted). + GroupBy("badge_id"). + OrderBy("created desc"). + Limit(limit).Find(&badgeAwards) + return +} + +// GetByUserIdAndBadgeId get badge award by user id and badge id +func (r *badgeAwardRepo) GetByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) ( + badgeAward *entity.BadgeAward, exists bool, err error) { + badgeAward = &entity.BadgeAward{} + exists, err = r.data.DB.Context(ctx). + Where("user_id = ? AND badge_id = ? AND is_badge_deleted = 0", userID, badgeID).Get(badgeAward) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// GetByUserIdAndBadgeIdAndAwardKey get badge award by user id and badge id and award key +func (r *badgeAwardRepo) GetByUserIdAndBadgeIdAndAwardKey(ctx context.Context, userID string, badgeID string, awardKey string) ( + badgeAward *entity.BadgeAward, exists bool, err error) { + badgeAward = &entity.BadgeAward{} + exists, err = r.data.DB.Context(ctx). + Where("user_id = ? AND badge_id = ? AND award_key = ? AND is_badge_deleted = 0", userID, badgeID, awardKey).Get(badgeAward) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/badge_group/badge_group_repo.go b/internal/repo/badge_group/badge_group_repo.go new file mode 100644 index 000000000..63f438b9e --- /dev/null +++ b/internal/repo/badge_group/badge_group_repo.go @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge_group + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/internal/service/unique" +) + +type badgeGroupRepo struct { + data *data.Data + uniqueIDRepo unique.UniqueIDRepo +} + +func NewBadgeGroupRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) badge.BadgeGroupRepo { + return &badgeGroupRepo{ + data: data, + uniqueIDRepo: uniqueIDRepo, + } +} + +func (r *badgeGroupRepo) ListGroups(ctx context.Context) (groups []*entity.BadgeGroup, err error) { + groups = make([]*entity.BadgeGroup, 0) + err = r.data.DB.Context(ctx).Find(&groups) + return +} + +func (r *badgeGroupRepo) AddGroup(ctx context.Context, group *entity.BadgeGroup) (err error) { + return +} diff --git a/internal/repo/notification/notification_repo.go b/internal/repo/notification/notification_repo.go index 6b4f0040d..5a2a6cc7a 100644 --- a/internal/repo/notification/notification_repo.go +++ b/internal/repo/notification/notification_repo.go @@ -69,7 +69,7 @@ func (nr *notificationRepo) UpdateNotificationContent(ctx context.Context, notif func (nr *notificationRepo) ClearUnRead(ctx context.Context, userID string, notificationType int) (err error) { info := &entity.Notification{} info.IsRead = schema.NotificationRead - _, err = nr.data.DB.Context(ctx).Where("user_id =?", userID).And("type =?", notificationType).Cols("is_read").Update(info) + _, err = nr.data.DB.Context(ctx).Where("user_id = ?", userID).And("type = ?", notificationType).Cols("is_read").Update(info) if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } @@ -79,7 +79,7 @@ func (nr *notificationRepo) ClearUnRead(ctx context.Context, userID string, noti func (nr *notificationRepo) ClearIDUnRead(ctx context.Context, userID string, id string) (err error) { info := &entity.Notification{} info.IsRead = schema.NotificationRead - _, err = nr.data.DB.Context(ctx).Where("user_id =?", userID).And("id =?", id).Cols("is_read").Update(info) + _, err = nr.data.DB.Context(ctx).Where("user_id = ?", userID).And("id = ?", id).Cols("is_read").Update(info) if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } @@ -98,7 +98,7 @@ func (nr *notificationRepo) GetById(ctx context.Context, id string) (*entity.Not func (nr *notificationRepo) GetByUserIdObjectIdTypeId(ctx context.Context, userID, objectID string, notificationType int) (*entity.Notification, bool, error) { info := &entity.Notification{} - exist, err := nr.data.DB.Context(ctx).Where("user_id = ? ", userID).And("object_id = ?", objectID).And("type = ?", notificationType).Get(info) + exist, err := nr.data.DB.Context(ctx).Where("user_id = ?", userID).And("object_id = ?", objectID).And("type = ?", notificationType).Get(info) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() return info, false, err @@ -129,3 +129,11 @@ func (nr *notificationRepo) GetNotificationPage(ctx context.Context, searchCond } return } + +func (nr *notificationRepo) CountNotificationByUser(ctx context.Context, cond *entity.Notification) (int64, error) { + count, err := nr.data.DB.Context(ctx).Count(cond) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return count, err +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 3a517120e..7f222a425 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -25,6 +25,9 @@ import ( "github.com/apache/incubator-answer/internal/repo/activity_common" "github.com/apache/incubator-answer/internal/repo/answer" "github.com/apache/incubator-answer/internal/repo/auth" + "github.com/apache/incubator-answer/internal/repo/badge" + "github.com/apache/incubator-answer/internal/repo/badge_award" + "github.com/apache/incubator-answer/internal/repo/badge_group" "github.com/apache/incubator-answer/internal/repo/captcha" "github.com/apache/incubator-answer/internal/repo/collection" "github.com/apache/incubator-answer/internal/repo/comment" @@ -100,4 +103,8 @@ var ProviderSetRepo = wire.NewSet( limit.NewRateLimitRepo, plugin_config.NewPluginUserConfigRepo, review.NewReviewRepo, + badge.NewBadgeRepo, + badge.NewEventRuleRepo, + badge_group.NewBadgeGroupRepo, + badge_award.NewBadgeAwardRepo, ) diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 5a683af73..2c6e595ab 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -385,7 +385,7 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, case "score": session.OrderBy("question.pin desc,question.vote_count DESC, question.view_count DESC") case "unanswered": - session.Where("question.last_answer_id = 0") + session.Where("question.answer_count = 0") session.OrderBy("question.pin desc,question.created_at DESC") } @@ -401,6 +401,61 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, return questionList, total, err } +// GetRecommendQuestionPageByTags get recommend question page by tags +func (qr *questionRepo) GetRecommendQuestionPageByTags(ctx context.Context, userID string, tagIDs, followedQuestionIDs []string, page, pageSize int) ( + questionList []*entity.Question, total int64, err error) { + questionList = make([]*entity.Question, 0) + orderBySQL := "question.pin DESC, question.created_at DESC" + + // Please Make sure every question has at least one tag + selectSQL := entity.Question{}.TableName() + ".*" + if len(followedQuestionIDs) > 0 { + idStr := "'" + strings.Join(followedQuestionIDs, "','") + "'" + selectSQL += fmt.Sprintf(", CASE WHEN question.id IN (%s) THEN 0 ELSE 1 END AS order_priority", idStr) + orderBySQL = "order_priority, " + orderBySQL + } + session := qr.data.DB.Context(ctx).Select(selectSQL) + + if len(tagIDs) > 0 { + session.Where("question.user_id != ?", userID). + And("question.id NOT IN (SELECT question_id FROM answer WHERE user_id = ?)", userID). + Join("INNER", "tag_rel", "question.id = tag_rel.object_id"). + And("tag_rel.status = ?", entity.TagRelStatusAvailable). + Join("INNER", "tag", "tag.id = tag_rel.tag_id"). + In("tag.id", tagIDs) + } else if len(followedQuestionIDs) == 0 { + return questionList, 0, nil + } + + if len(followedQuestionIDs) > 0 { + if len(tagIDs) > 0 { + // if tags provided, show followed questions and tag questions + session.Or(builder.In("question.id", followedQuestionIDs)) + } else { + // if no tags, only show followed questions + session.Where(builder.In("question.id", followedQuestionIDs)) + } + } + + session. + And("question.show = ? and question.status = ?", entity.QuestionShow, entity.QuestionStatusAvailable). + Distinct("question.id"). + OrderBy(orderBySQL) + + total, err = pager.Help(page, pageSize, &questionList, &entity.Question{}, session) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + if handler.GetEnableShortID(ctx) { + for _, item := range questionList { + item.ID = uid.EnShortID(item.ID) + } + } + + return questionList, total, err +} + func (qr *questionRepo) AdminQuestionPage(ctx context.Context, search *schema.AdminQuestionPageReq) ([]*entity.Question, int64, error) { var ( count int64 diff --git a/internal/repo/repo_test/email_repo_test.go b/internal/repo/repo_test/email_repo_test.go index f9f433723..62d1ccb85 100644 --- a/internal/repo/repo_test/email_repo_test.go +++ b/internal/repo/repo_test/email_repo_test.go @@ -30,8 +30,8 @@ import ( func Test_emailRepo_VerifyCode(t *testing.T) { emailRepo := export.NewEmailRepo(testDataSource) - code, content := "1111", "test" - err := emailRepo.SetCode(context.TODO(), code, content, time.Minute) + code, content := "1111", "{\"source_type\":\"\",\"e_mail\":\"\",\"user_id\":\"1\",\"skip_validation_latest_code\":false}" + err := emailRepo.SetCode(context.TODO(), "1", code, content, time.Minute) assert.NoError(t, err) verifyContent, err := emailRepo.VerifyCode(context.TODO(), code) diff --git a/internal/repo/repo_test/recommend_test.go b/internal/repo/repo_test/recommend_test.go new file mode 100644 index 000000000..6c87dfe74 --- /dev/null +++ b/internal/repo/repo_test/recommend_test.go @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package repo_test + +import ( + "context" + "testing" + + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/repo/activity" + "github.com/apache/incubator-answer/internal/repo/activity_common" + "github.com/apache/incubator-answer/internal/repo/config" + "github.com/apache/incubator-answer/internal/repo/question" + "github.com/apache/incubator-answer/internal/repo/tag" + "github.com/apache/incubator-answer/internal/repo/tag_common" + "github.com/apache/incubator-answer/internal/repo/unique" + "github.com/apache/incubator-answer/internal/repo/user" + config2 "github.com/apache/incubator-answer/internal/service/config" + "github.com/stretchr/testify/assert" +) + +func Test_questionRepo_GetRecommend(t *testing.T) { + var ( + uniqueIDRepo = unique.NewUniqueIDRepo(testDataSource) + questionRepo = question.NewQuestionRepo(testDataSource, uniqueIDRepo) + userRepo = user.NewUserRepo(testDataSource) + tagRelRepo = tag.NewTagRelRepo(testDataSource, uniqueIDRepo) + tagRepo = tag.NewTagRepo(testDataSource, uniqueIDRepo) + tagCommenRepo = tag_common.NewTagCommonRepo(testDataSource, uniqueIDRepo) + configRepo = config.NewConfigRepo(testDataSource) + configService = config2.NewConfigService(configRepo) + activityCommonRepo = activity_common.NewActivityRepo(testDataSource, uniqueIDRepo, configService) + followRepo = activity.NewFollowRepo(testDataSource, uniqueIDRepo, activityCommonRepo) + ) + + // create question and user + user := &entity.User{ + Username: "ferrischi201", + Pass: "ferrischi201", + EMail: "ferrischi201@example.com", + MailStatus: entity.EmailStatusAvailable, + Status: entity.UserStatusAvailable, + DisplayName: "ferrischi201", + IsAdmin: false, + } + err := userRepo.AddUser(context.TODO(), user) + assert.NoError(t, err) + assert.NotEqual(t, "", user.ID) + + questions := make([]*entity.Question, 0) + // tag, unjoin, unfollow + questions = append(questions, &entity.Question{ + UserID: "1", + Title: "Valid recommendation 1", + OriginalText: "A go question", + ParsedText: "Go question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + // tag, unjoin, follow + questions = append(questions, &entity.Question{ + UserID: "1", + Title: "Valid recommendation 2", + OriginalText: "A go question", + ParsedText: "Go question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + // tag, join, unfollow + questions = append(questions, &entity.Question{ + UserID: user.ID, + Title: "Invalid recommendation 1", + OriginalText: "A go question 1", + ParsedText: "Go question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + // tag, join, follow + questions = append(questions, &entity.Question{ + UserID: user.ID, + Title: "Valid recommendation 3", + OriginalText: "A java question", + ParsedText: "Java question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + // untag, unjoin, unfollow + questions = append(questions, &entity.Question{ + UserID: "1", + Title: "Invalid recommendation 2", + OriginalText: "A go question", + ParsedText: "Go question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + // untag, unjoin, follow + questions = append(questions, &entity.Question{ + UserID: "1", + Title: "Valid recommendation 4", + OriginalText: "A go question", + ParsedText: "Go question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + // untag, join, unfollow + questions = append(questions, &entity.Question{ + UserID: user.ID, + Title: "Invalid recommendation 3", + OriginalText: "A go question 1", + ParsedText: "Go question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + // untag, join, follow + questions = append(questions, &entity.Question{ + UserID: user.ID, + Title: "Valid recommendation 5", + OriginalText: "A java question", + ParsedText: "Java question", + Status: entity.QuestionStatusAvailable, + Show: entity.QuestionShow, + }) + + for _, question := range questions { + err = questionRepo.AddQuestion(context.TODO(), question) + assert.NoError(t, err) + assert.NotEqual(t, "", question.ID) + } + + tags := []*entity.Tag{ + { + SlugName: "go", + DisplayName: "Golang", + OriginalText: "golang", + ParsedText: "

golang

", + Status: entity.TagStatusAvailable, + }, + { + SlugName: "java", + DisplayName: "Java", + OriginalText: "java", + ParsedText: "

java

", + Status: entity.TagStatusAvailable, + }, + } + err = tagCommenRepo.AddTagList(context.TODO(), tags) + assert.NoError(t, err) + + tagRels := make([]*entity.TagRel, 0) + for i, question := range questions { + tagRel := &entity.TagRel{ + TagID: tags[i/4].ID, + ObjectID: question.ID, + Status: entity.TagRelStatusAvailable, + } + tagRels = append(tagRels, tagRel) + } + err = tagRelRepo.AddTagRelList(context.TODO(), tagRels) + assert.NoError(t, err) + + followQuestionIDs := make([]string, 0) + for i := range questions { + if i%2 == 0 { + continue + } + err = followRepo.Follow(context.TODO(), questions[i].ID, user.ID) + assert.NoError(t, err) + followQuestionIDs = append(followQuestionIDs, questions[i].ID) + } + + // get recommend + questionList, total, err := questionRepo.GetRecommendQuestionPageByTags(context.TODO(), user.ID, []string{tags[0].ID}, followQuestionIDs, 1, 20) + assert.NoError(t, err) + assert.Equal(t, int64(5), total) + assert.Equal(t, 5, len(questionList)) + + // recovery + t.Cleanup(func() { + tagRelIDs := make([]int64, 0) + for i, tagRel := range tagRels { + if i%2 == 1 { + err = followRepo.FollowCancel(context.TODO(), questions[i].ID, user.ID) + assert.NoError(t, err) + } + tagRelIDs = append(tagRelIDs, tagRel.ID) + } + err = tagRelRepo.RemoveTagRelListByIDs(context.TODO(), tagRelIDs) + assert.NoError(t, err) + for _, tag := range tags { + err = tagRepo.RemoveTag(context.TODO(), tag.ID) + assert.NoError(t, err) + } + for _, q := range questions { + err = questionRepo.RemoveQuestion(context.TODO(), q.ID) + assert.NoError(t, err) + } + }) +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 328541868..cca997bd6 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -55,6 +55,8 @@ type AnswerAPIRouter struct { userPluginController *controller.UserPluginController reviewController *controller.ReviewController metaController *controller.MetaController + badgeController *controller.BadgeController + adminBadgeController *controller_admin.BadgeController } func NewAnswerAPIRouter( @@ -86,6 +88,8 @@ func NewAnswerAPIRouter( userPluginController *controller.UserPluginController, reviewController *controller.ReviewController, metaController *controller.MetaController, + badgeController *controller.BadgeController, + adminBadgeController *controller_admin.BadgeController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -116,6 +120,8 @@ func NewAnswerAPIRouter( userPluginController: userPluginController, reviewController: reviewController, metaController: metaController, + badgeController: badgeController, + adminBadgeController: adminBadgeController, } } @@ -159,6 +165,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/question/info", a.questionController.GetQuestion) r.GET("/question/invite", a.questionController.GetQuestionInviteUserInfo) r.GET("/question/page", a.questionController.QuestionPage) + r.GET("/question/recommend/page", a.questionController.QuestionRecommendPage) r.GET("/question/similar/tag", a.questionController.SimilarQuestion) r.GET("/personal/qa/top", a.questionController.UserTop) r.GET("/personal/question/page", a.questionController.PersonalQuestionPage) @@ -187,6 +194,13 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { // reaction r.GET("/meta/reaction", a.metaController.GetReaction) + + // badges + r.GET("/badge", a.badgeController.GetBadgeInfo) + r.GET("/badge/awards/page", a.badgeController.GetBadgeAwardList) + r.GET("/badge/user/awards/recent", a.badgeController.GetRecentBadgeAwardListByUsername) + r.GET("/badge/user/awards", a.badgeController.GetAllBadgeAwardListByUsername) + r.GET("/badges", a.badgeController.GetBadgeList) } func (a *AnswerAPIRouter) RegisterAuthUserWithAnyStatusAnswerAPIRouter(r *gin.RouterGroup) { @@ -359,4 +373,8 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { r.PUT("/plugin/status", a.pluginController.UpdatePluginStatus) r.GET("/plugin/config", a.pluginController.GetPluginConfig) r.PUT("/plugin/config", a.pluginController.UpdatePluginConfig) + + // badge + r.GET("/badges", a.adminBadgeController.GetBadgeList) + r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus) } diff --git a/internal/router/plugin_api_router.go b/internal/router/plugin_api_router.go index e4a85a154..e69e0b6b0 100644 --- a/internal/router/plugin_api_router.go +++ b/internal/router/plugin_api_router.go @@ -29,6 +29,7 @@ type PluginAPIRouter struct { userCenterController *controller.UserCenterController captchaController *controller.CaptchaController embedController *controller.EmbedController + renderController *controller.RenderController } func NewPluginAPIRouter( @@ -36,12 +37,14 @@ func NewPluginAPIRouter( userCenterController *controller.UserCenterController, captchaController *controller.CaptchaController, embedController *controller.EmbedController, + renderController *controller.RenderController, ) *PluginAPIRouter { return &PluginAPIRouter{ connectorController: connectorController, userCenterController: userCenterController, captchaController: captchaController, embedController: embedController, + renderController: renderController, } } @@ -64,6 +67,7 @@ func (pr *PluginAPIRouter) RegisterUnAuthConnectorRouter(r *gin.RouterGroup) { // captcha plugin r.GET("/captcha/config", pr.captchaController.GetCaptchaConfig) r.GET("/embed/config", pr.embedController.GetEmbedConfig) + r.GET("/render/config", pr.renderController.GetRenderConfig) } func (pr *PluginAPIRouter) RegisterAuthUserConnectorRouter(r *gin.RouterGroup) { diff --git a/internal/router/swagger_router.go b/internal/router/swagger_router.go index 795e88025..55f2d644a 100644 --- a/internal/router/swagger_router.go +++ b/internal/router/swagger_router.go @@ -51,9 +51,5 @@ func (a *SwaggerRouter) Register(r *gin.RouterGroup) { // InitSwaggerDocs init swagger docs func (a *SwaggerRouter) InitSwaggerDocs() { - docs.SwaggerInfo.Title = "answer" - docs.SwaggerInfo.Description = "answer api" - docs.SwaggerInfo.Version = "v0.0.1" docs.SwaggerInfo.Host = fmt.Sprintf("%s%s", a.config.Host, a.config.Address) - docs.SwaggerInfo.BasePath = "/" } diff --git a/internal/router/template_router.go b/internal/router/template_router.go index 01e8b3073..195030f9a 100644 --- a/internal/router/template_router.go +++ b/internal/router/template_router.go @@ -60,6 +60,8 @@ func (a *TemplateRouter) RegisterTemplateRouter(r *gin.RouterGroup, baseURLPath seoNoAuth.GET("/404", a.templateController.Page404) + seoNoAuth.GET("/opensearch.xml", a.templateController.OpenSearch) + seo := r.Group(baseURLPath) seo.Use(a.authUserMiddleware.CheckPrivateMode()) seo.GET("/", a.templateController.Index) diff --git a/internal/schema/badge_schema.go b/internal/schema/badge_schema.go new file mode 100644 index 000000000..a33fb480d --- /dev/null +++ b/internal/schema/badge_schema.go @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +import "github.com/apache/incubator-answer/internal/entity" + +const ( + BadgeStatusActive BadgeStatus = "active" + BadgeStatusInactive BadgeStatus = "inactive" +) + +type BadgeStatus string + +var BadgeStatusMap = map[int8]BadgeStatus{ + entity.BadgeStatusActive: BadgeStatusActive, + entity.BadgeStatusInactive: BadgeStatusInactive, +} + +var BadgeStatusEMap = map[BadgeStatus]int8{ + BadgeStatusActive: entity.BadgeStatusActive, + BadgeStatusInactive: entity.BadgeStatusInactive, +} + +// BadgeListInfo get badge list response +type BadgeListInfo struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge earned count + EarnedCount int64 `json:"earned_count" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} + +type GetBadgeListResp struct { + // badge list info + Badges []*BadgeListInfo `json:"badges" ` + // badge group name + GroupName string `json:"group_name" ` +} + +type UpdateBadgeStatusReq struct { + // badge id + ID string `validate:"required" json:"id"` + // badge status + Status BadgeStatus `validate:"required" json:"status"` +} + +type GetBadgeListPagedReq struct { + // page + Page int `validate:"omitempty,min=1" form:"page"` + // page size + PageSize int `validate:"omitempty,min=1" form:"page_size"` + // badge status + Status BadgeStatus `validate:"omitempty" form:"status"` + // query condition + Query string `validate:"omitempty" form:"q"` +} + +type GetBadgeListPagedResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge description + Description string `json:"description" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge earned count + Earned bool `json:"earned" ` + // badge level + Level entity.BadgeLevel `json:"level" ` + // badge group name + GroupName string `json:"group_name" ` + // badge status + Status BadgeStatus `json:"status"` +} + +type GetBadgeInfoResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge description + Description string `json:"description" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge earned count + EarnedCount int64 `json:"earned_count" ` + // badge is single or multiple + IsSingle bool `json:"is_single" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} + +type GetBadgeAwardWithPageReq struct { + // page + Page int `validate:"omitempty,min=1" form:"page"` + // page size + PageSize int `validate:"omitempty,min=1" form:"page_size"` + // badge id + BadgeID string `validate:"required" form:"badge_id"` + // username + Username string `validate:"omitempty,gt=0,lte=100" form:"username"` + // user id + UserID string `json:"-"` +} + +type GetBadgeAwardWithPageResp struct { + // created time + CreatedAt int64 `json:"created_at"` + // object id + ObjectID string `json:"object_id"` + // question id + QuestionID string `json:"question_id"` + // answer id + AnswerID string `json:"answer_id"` + // comment id + CommentID string `json:"comment_id"` + // object type + ObjectType string `json:"object_type" enums:"question,answer,comment"` + // url title + UrlTitle string `json:"url_title"` + // author user info + AuthorUserInfo UserBasicInfo `json:"author_user_info"` +} + +type GetUserBadgeAwardListReq struct { + // username + Username string `validate:"required,gt=0,lte=100" form:"username"` + // user id + UserID string `json:"-"` + Limit int `json:"-"` +} + +type GetUserBadgeAwardListResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + EarnedCount int64 `json:"earned_count" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} + +// GetBadgeByIDResp get badge by id response +type GetBadgeByIDResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge description + Description string `json:"description" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge is single or multiple + IsSingle bool `json:"is_single" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} diff --git a/internal/schema/event_schema.go b/internal/schema/event_schema.go new file mode 100644 index 000000000..701d5d88f --- /dev/null +++ b/internal/schema/event_schema.go @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +import ( + "github.com/apache/incubator-answer/internal/base/constant" + "github.com/apache/incubator-answer/pkg/uid" +) + +// EventMsg event message +type EventMsg struct { + EventType constant.EventType + UserID string + + TriggerObjectID string + + QuestionID string + QuestionUserID string + + AnswerID string + AnswerUserID string + + CommentID string + CommentUserID string + + ExtraInfo map[string]string +} + +// NewEvent create a new event +func NewEvent(e constant.EventType, userID string) *EventMsg { + return &EventMsg{ + UserID: userID, + EventType: e, + ExtraInfo: make(map[string]string), + } +} + +// QID get question id +func (e *EventMsg) QID(questionID, userID string) *EventMsg { + if len(questionID) > 0 { + e.QuestionID = uid.DeShortID(questionID) + } + e.QuestionUserID = userID + return e +} + +// AID get answer id +func (e *EventMsg) AID(answerID, userID string) *EventMsg { + if len(answerID) > 0 { + e.AnswerID = uid.DeShortID(answerID) + } + e.AnswerUserID = userID + return e +} + +// CID get comment id +func (e *EventMsg) CID(comment, userID string) *EventMsg { + e.CommentID = comment + e.CommentUserID = userID + return e +} + +// TID get trigger object id +func (e *EventMsg) TID(triggerObjectID string) *EventMsg { + e.TriggerObjectID = triggerObjectID + return e +} + +// AddExtra add extra info +func (e *EventMsg) AddExtra(key, value string) *EventMsg { + e.ExtraInfo[key] = value + return e +} + +// GetExtra get extra info +func (e *EventMsg) GetExtra(key string) string { + if v, ok := e.ExtraInfo[key]; ok { + return v + } + return "" +} + +// GetObjectID get object id +func (e *EventMsg) GetObjectID() string { + if len(e.TriggerObjectID) > 0 { + return e.TriggerObjectID + } + if len(e.CommentID) > 0 { + return e.CommentID + } + if len(e.AnswerID) > 0 { + return e.AnswerID + } + return e.QuestionID +} diff --git a/internal/schema/notification_schema.go b/internal/schema/notification_schema.go index 4e0e93169..8d4b694e9 100644 --- a/internal/schema/notification_schema.go +++ b/internal/schema/notification_schema.go @@ -19,6 +19,12 @@ package schema +import ( + "encoding/json" + "github.com/apache/incubator-answer/internal/entity" + "sort" +) + const ( NotificationTypeInbox = 1 NotificationTypeAchievement = 2 @@ -95,10 +101,70 @@ type ObjectInfo struct { } type RedDot struct { - Inbox int64 `json:"inbox"` - Achievement int64 `json:"achievement"` - Revision int64 `json:"revision"` - CanRevision bool `json:"can_revision"` + Inbox int64 `json:"inbox"` + Achievement int64 `json:"achievement"` + Revision int64 `json:"revision"` + CanRevision bool `json:"can_revision"` + BadgeAward *RedDotBadgeAward `json:"badge_award"` +} + +type RedDotBadgeAward struct { + NotificationID string `json:"notification_id"` + BadgeID string `json:"badge_id"` + Name string `json:"name"` + Icon string `json:"icon"` + Level entity.BadgeLevel `json:"level"` +} + +type RedDotBadgeAwardCache struct { + BadgeAwardList map[string]*RedDotBadgeAward `json:"badge_award_list"` +} + +// NewRedDotBadgeAwardCache new red dot badge award cache +func NewRedDotBadgeAwardCache() *RedDotBadgeAwardCache { + return &RedDotBadgeAwardCache{ + BadgeAwardList: make(map[string]*RedDotBadgeAward), + } +} + +// GetBadgeAward get badge award +func (r *RedDotBadgeAwardCache) GetBadgeAward() *RedDotBadgeAward { + if len(r.BadgeAwardList) == 0 { + return nil + } + var ids []string + for _, v := range r.BadgeAwardList { + ids = append(ids, v.NotificationID) + } + sort.Strings(ids) + return r.BadgeAwardList[ids[0]] +} + +// FromJSON from json +func (r *RedDotBadgeAwardCache) FromJSON(data string) { + _ = json.Unmarshal([]byte(data), r) +} + +// ToJSON to json +func (r *RedDotBadgeAwardCache) ToJSON() string { + data, _ := json.Marshal(r) + return string(data) +} + +// AddBadgeAward add badge award +func (r *RedDotBadgeAwardCache) AddBadgeAward(badgeAward *RedDotBadgeAward) { + if r.BadgeAwardList == nil { + r.BadgeAwardList = make(map[string]*RedDotBadgeAward) + } + r.BadgeAwardList[badgeAward.NotificationID] = badgeAward +} + +// RemoveBadgeAward remove badge award +func (r *RedDotBadgeAwardCache) RemoveBadgeAward(notificationID string) { + if r.BadgeAwardList == nil { + return + } + delete(r.BadgeAwardList, notificationID) } type NotificationSearch struct { @@ -112,8 +178,8 @@ type NotificationSearch struct { } type NotificationClearRequest struct { + NotificationType string `validate:"required,oneof=inbox achievement" json:"type"` UserID string `json:"-"` - TypeStr string `json:"type" form:"type"` // inbox achievement CanReviewQuestion bool `json:"-"` CanReviewAnswer bool `json:"-"` CanReviewTag bool `json:"-"` diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index a4c6ee8c1..2a6e5f122 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -355,7 +355,7 @@ const ( type QuestionPageReq struct { Page int `validate:"omitempty,min=1" form:"page"` PageSize int `validate:"omitempty,min=1" form:"page_size"` - OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered" form:"order"` + OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend" form:"order"` Tag string `validate:"omitempty,gt=0,lte=100" form:"tag"` Username string `validate:"omitempty,gt=0,lte=100" form:"username"` InDays int `validate:"omitempty,min=1" form:"in_days"` diff --git a/internal/service/activity_common/activity.go b/internal/service/activity_common/activity.go index db565f1e0..6b3557c0d 100644 --- a/internal/service/activity_common/activity.go +++ b/internal/service/activity_common/activity.go @@ -37,6 +37,7 @@ type ActivityRepo interface { GetActivityTypeByObjectType(ctx context.Context, objectKey, action string) (activityType int, err error) GetActivity(ctx context.Context, session *xorm.Session, objectID, userID string, activityType int) ( existsActivity *entity.Activity, exist bool, err error) + GetUserActivitysByActivityType(ctx context.Context, userID string, activityType int) (activityList []*entity.Activity, err error) GetUserIDObjectIDActivitySum(ctx context.Context, userID, objectID string) (int, error) GetActivityTypeByConfigKey(ctx context.Context, configKey string) (activityType int, err error) AddActivity(ctx context.Context, activity *entity.Activity) (err error) diff --git a/internal/service/badge/badge_award_service.go b/internal/service/badge/badge_award_service.go new file mode 100644 index 000000000..feb95a657 --- /dev/null +++ b/internal/service/badge/badge_award_service.go @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/constant" + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/base/translator" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/notice_queue" + "github.com/apache/incubator-answer/internal/service/object_info" + usercommon "github.com/apache/incubator-answer/internal/service/user_common" + "github.com/apache/incubator-answer/pkg/uid" + "github.com/gin-gonic/gin" + "github.com/jinzhu/copier" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +type BadgeAwardRepo interface { + CheckIsAward(ctx context.Context, badgeID string, userID string, awardKey string, singleOrMulti int8) (isAward bool, err error) + AwardBadgeForUser(ctx context.Context, badgeAward *entity.BadgeAward) (err error) + + CountByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) (awardCount int64) + CountByBadgeID(ctx context.Context, badgeID string) (awardCount int64, err error) + + SumUserEarnedGroupByBadgeID(ctx context.Context, userID string) (earnedCounts []*entity.BadgeEarnedCount, err error) + + ListPagedByBadgeId(ctx context.Context, badgeID string, page int, pageSize int) (badgeAwardList []*entity.BadgeAward, total int64, err error) + ListPagedByBadgeIdAndUserId(ctx context.Context, badgeID string, userID string, page int, pageSize int) (badgeAwards []*entity.BadgeAward, total int64, err error) + ListNewestEarned(ctx context.Context, userID string, limit int) (badgeAwards []*entity.BadgeAwardRecent, err error) + + GetByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) (badgeAward *entity.BadgeAward, exists bool, err error) + GetByUserIdAndBadgeIdAndAwardKey(ctx context.Context, userID string, badgeID string, awardKey string) (badgeAward *entity.BadgeAward, exists bool, err error) +} + +type BadgeAwardService struct { + badgeAwardRepo BadgeAwardRepo + badgeRepo BadgeRepo + userCommon *usercommon.UserCommon + objectInfoService *object_info.ObjService + notificationQueueService notice_queue.NotificationQueueService +} + +func NewBadgeAwardService( + badgeAwardRepo BadgeAwardRepo, + badgeRepo BadgeRepo, + userCommon *usercommon.UserCommon, + objectInfoService *object_info.ObjService, + notificationQueueService notice_queue.NotificationQueueService, +) *BadgeAwardService { + return &BadgeAwardService{ + badgeAwardRepo: badgeAwardRepo, + badgeRepo: badgeRepo, + userCommon: userCommon, + objectInfoService: objectInfoService, + notificationQueueService: notificationQueueService, + } +} + +// GetBadgeAwardList get badge award list +func (bs *BadgeAwardService) GetBadgeAwardList( + ctx context.Context, + req *schema.GetBadgeAwardWithPageReq, +) (resp []*schema.GetBadgeAwardWithPageResp, total int64, err error) { + var ( + badgeAwardList []*entity.BadgeAward + ) + + req.UserID, err = bs.validateUserByUsername(ctx, req.Username) + if err != nil { + badgeAwardList, total, err = bs.badgeAwardRepo.ListPagedByBadgeId(ctx, req.BadgeID, req.Page, req.PageSize) + } else { + badgeAwardList, total, err = bs.badgeAwardRepo.ListPagedByBadgeIdAndUserId(ctx, req.BadgeID, req.UserID, req.Page, req.PageSize) + } + + if err != nil { + return + } + + resp = make([]*schema.GetBadgeAwardWithPageResp, len(badgeAwardList)) + + for i, badgeAward := range badgeAwardList { + var ( + objectID, questionID, answerID, commentID, objectType, urlTitle string + ) + + // if exist object info + objInfo, e := bs.objectInfoService.GetInfo(ctx, badgeAward.AwardKey) + if e == nil && !objInfo.IsDeleted() { + objectID = objInfo.ObjectID + questionID = objInfo.QuestionID + answerID = objInfo.AnswerID + commentID = objInfo.CommentID + objectType = objInfo.ObjectType + urlTitle = objInfo.Title + } + + row := &schema.GetBadgeAwardWithPageResp{ + CreatedAt: badgeAward.CreatedAt.Unix(), + ObjectID: objectID, + QuestionID: questionID, + AnswerID: answerID, + CommentID: commentID, + ObjectType: objectType, + UrlTitle: urlTitle, + AuthorUserInfo: schema.UserBasicInfo{}, + } + + // get user info + userInfo, exists, e := bs.userCommon.GetUserBasicInfoByID(ctx, badgeAward.UserID) + if e != nil { + log.Errorf("user not found by id: %s, err: %v", badgeAward.UserID, e) + } + if exists { + _ = copier.Copy(&row.AuthorUserInfo, userInfo) + } + + resp[i] = row + } + + return +} + +// Award award badge +func (bs *BadgeAwardService) Award(ctx context.Context, badgeID string, userID string, awardKey string) (err error) { + badgeData, exists, err := bs.badgeRepo.GetByID(ctx, badgeID) + if err != nil { + return err + } + + if !exists || badgeData.Status == entity.BadgeStatusInactive { + return errors.BadRequest(reason.BadgeObjectNotFound) + } + + alreadyAwarded, err := bs.badgeAwardRepo.CheckIsAward(ctx, badgeID, userID, awardKey, badgeData.Single) + if err != nil { + return err + } + if alreadyAwarded { + return nil + } + + badgeAward := &entity.BadgeAward{ + UserID: userID, + BadgeID: badgeID, + AwardKey: awardKey, + BadgeGroupID: badgeData.BadgeGroupID, + IsBadgeDeleted: entity.IsBadgeNotDeleted, + } + err = bs.badgeAwardRepo.AwardBadgeForUser(ctx, badgeAward) + if err != nil { + return err + } + + msg := &schema.NotificationMsg{ + TriggerUserID: badgeAward.UserID, + ReceiverUserID: badgeAward.UserID, + Type: schema.NotificationTypeAchievement, + ObjectID: badgeAward.ID, + ObjectType: constant.BadgeAwardObjectType, + Title: badgeData.Name, + ExtraInfo: map[string]string{"badge_id": badgeData.ID}, + NotificationAction: constant.NotificationEarnedBadge, + } + bs.notificationQueueService.Send(ctx, msg) + return nil +} + +// GetUserBadgeAwardList get user badge award list +func (bs *BadgeAwardService) GetUserBadgeAwardList( + ctx *gin.Context, + req *schema.GetUserBadgeAwardListReq, +) ( + resp []*schema.GetUserBadgeAwardListResp, + total int64, + err error, +) { + var ( + earnedCounts []*entity.BadgeEarnedCount + ) + + req.UserID, err = bs.validateUserByUsername(ctx, req.Username) + if err != nil { + return + } + + earnedCounts, err = bs.badgeAwardRepo.SumUserEarnedGroupByBadgeID(ctx, req.UserID) + if err != nil { + return + } + total = int64(len(earnedCounts)) + resp = make([]*schema.GetUserBadgeAwardListResp, total) + + for i, earnedCount := range earnedCounts { + badge, exists, e := bs.badgeRepo.GetByID(ctx, earnedCount.BadgeID) + if e != nil { + err = e + return + } + if !exists { + continue + } + resp[i] = &schema.GetUserBadgeAwardListResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Icon: badge.Icon, + EarnedCount: earnedCount.EarnedCount, + Level: badge.Level, + } + } + + return +} + +// GetUserRecentBadgeAwardList get user badge award list +func (bs *BadgeAwardService) GetUserRecentBadgeAwardList(ctx *gin.Context, req *schema.GetUserBadgeAwardListReq) ( + resp []*schema.GetUserBadgeAwardListResp, total int64, err error) { + var ( + earnedCounts []*entity.BadgeAwardRecent + ) + + req.UserID, err = bs.validateUserByUsername(ctx, req.Username) + if err != nil { + return + } + + earnedCounts, err = bs.badgeAwardRepo.ListNewestEarned(ctx, req.UserID, req.Limit) + if err != nil { + return + } + + total = int64(len(earnedCounts)) + resp = make([]*schema.GetUserBadgeAwardListResp, total) + + for i, earnedCount := range earnedCounts { + badge, exists, e := bs.badgeRepo.GetByID(ctx, earnedCount.BadgeID) + if e != nil { + err = e + return + } + if !exists { + continue + } + resp[i] = &schema.GetUserBadgeAwardListResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Icon: badge.Icon, + EarnedCount: earnedCount.EarnedCount, + Level: badge.Level, + } + } + + return +} + +func (bs *BadgeAwardService) validateUserByUsername(ctx context.Context, userName string) (userID string, err error) { + var ( + userInfo *schema.UserBasicInfo + exist bool + ) + // validate user exists or not + if len(userName) > 0 { + userInfo, exist, err = bs.userCommon.GetUserBasicInfoByUserName(ctx, userName) + if err != nil { + return + } + if !exist { + err = errors.BadRequest(reason.UserNotFound) + return + } + userID = userInfo.ID + } + if len(userID) == 0 { + err = errors.BadRequest(reason.UserNotFound) + return + } + return +} diff --git a/internal/service/badge/badge_event_handler.go b/internal/service/badge/badge_event_handler.go new file mode 100644 index 000000000..8a92f08b6 --- /dev/null +++ b/internal/service/badge/badge_event_handler.go @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/event_queue" + "github.com/segmentfault/pacman/log" +) + +type BadgeEventService struct { + data *data.Data + eventQueueService event_queue.EventQueueService + badgeAwardRepo BadgeAwardRepo + badgeRepo BadgeRepo + eventRuleRepo EventRuleRepo + badgeAwardService *BadgeAwardService +} + +type EventRuleHandler func(ctx context.Context, event *schema.EventMsg) (awards []*entity.BadgeAward, err error) + +type EventRuleRepo interface { + HandleEventWithRule(ctx context.Context, msg *schema.EventMsg) (awards []*entity.BadgeAward) +} + +func NewBadgeEventService( + data *data.Data, + eventQueueService event_queue.EventQueueService, + badgeRepo BadgeRepo, + eventRuleRepo EventRuleRepo, + badgeAwardService *BadgeAwardService, +) *BadgeEventService { + n := &BadgeEventService{ + data: data, + eventQueueService: eventQueueService, + badgeRepo: badgeRepo, + eventRuleRepo: eventRuleRepo, + badgeAwardService: badgeAwardService, + } + eventQueueService.RegisterHandler(n.Handler) + return n +} + +func (ns *BadgeEventService) Handler(ctx context.Context, msg *schema.EventMsg) error { + awards := ns.eventRuleRepo.HandleEventWithRule(ctx, msg) + if len(awards) == 0 { + return nil + } + + for _, award := range awards { + err := ns.badgeAwardService.Award(ctx, award.BadgeID, award.UserID, award.AwardKey) + if err != nil { + log.Debugf("error awarding badge %s: %v", award.BadgeID, err) + } + } + return nil +} diff --git a/internal/service/badge/badge_group_service.go b/internal/service/badge/badge_group_service.go new file mode 100644 index 000000000..16dd74ee4 --- /dev/null +++ b/internal/service/badge/badge_group_service.go @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/entity" +) + +type BadgeGroupRepo interface { + ListGroups(ctx context.Context) (groups []*entity.BadgeGroup, err error) + AddGroup(ctx context.Context, group *entity.BadgeGroup) (err error) +} + +type BadgeGroupService struct { + badgeGroupRepo BadgeGroupRepo +} + +func NewBadgeGroupService(badgeGroupRepo BadgeGroupRepo) *BadgeGroupService { + return &BadgeGroupService{ + badgeGroupRepo: badgeGroupRepo, + } +} diff --git a/internal/service/badge/badge_service.go b/internal/service/badge/badge_service.go new file mode 100644 index 000000000..faf9b628f --- /dev/null +++ b/internal/service/badge/badge_service.go @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/base/translator" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/pkg/converter" + "github.com/apache/incubator-answer/pkg/uid" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "strings" +) + +type BadgeRepo interface { + GetByID(ctx context.Context, id string) (badge *entity.Badge, exists bool, err error) + GetByIDs(ctx context.Context, ids []string) (badges []*entity.Badge, err error) + + ListPaged(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) + ListActivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) + ListInactivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) + + UpdateStatus(ctx context.Context, id string, status int8) (err error) + UpdateAwardCount(ctx context.Context, badgeID string, awardCount int) (err error) +} + +type BadgeService struct { + badgeRepo BadgeRepo + badgeGroupRepo BadgeGroupRepo + badgeAwardRepo BadgeAwardRepo + badgeEventService *BadgeEventService +} + +func NewBadgeService( + badgeRepo BadgeRepo, + badgeGroupRepo BadgeGroupRepo, + badgeAwardRepo BadgeAwardRepo, + badgeEventService *BadgeEventService, +) *BadgeService { + return &BadgeService{ + badgeRepo: badgeRepo, + badgeGroupRepo: badgeGroupRepo, + badgeAwardRepo: badgeAwardRepo, + badgeEventService: badgeEventService, + } +} + +// ListByGroup list all badges group by group +func (b *BadgeService) ListByGroup(ctx context.Context, userID string) (resp []*schema.GetBadgeListResp, err error) { + var ( + groups []*entity.BadgeGroup + badges []*entity.Badge + earnedCounts []*entity.BadgeEarnedCount + + groupMap = make(map[int64]string, 0) + badgesMap = make(map[int64][]*schema.BadgeListInfo, 0) + ) + resp = make([]*schema.GetBadgeListResp, 0) + + groups, err = b.badgeGroupRepo.ListGroups(ctx) + if err != nil { + return + } + badges, _, err = b.badgeRepo.ListActivated(ctx, 0, 0) + if err != nil { + return + } + + if len(userID) > 0 { + earnedCounts, err = b.badgeAwardRepo.SumUserEarnedGroupByBadgeID(ctx, userID) + if err != nil { + return + } + } + + for _, group := range groups { + groupMap[converter.StringToInt64(group.ID)] = translator.Tr(handler.GetLangByCtx(ctx), group.Name) + } + + for _, badge := range badges { + // check is earned + var earned int64 = 0 + if len(earnedCounts) > 0 { + for _, earnedCount := range earnedCounts { + if badge.ID == earnedCount.BadgeID && earnedCount.EarnedCount > 0 { + earned = earnedCount.EarnedCount + break + } + } + } + + badgesMap[badge.BadgeGroupID] = append(badgesMap[badge.BadgeGroupID], &schema.BadgeListInfo{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Icon: badge.Icon, + AwardCount: badge.AwardCount, + EarnedCount: earned, + Level: badge.Level, + }) + } + + for _, group := range groups { + resp = append(resp, &schema.GetBadgeListResp{ + GroupName: translator.Tr(handler.GetLangByCtx(ctx), group.Name), + Badges: badgesMap[converter.StringToInt64(group.ID)], + }) + } + + return +} + +// ListPaged list all badges by page +func (b *BadgeService) ListPaged(ctx context.Context, req *schema.GetBadgeListPagedReq) (resp []*schema.GetBadgeListPagedResp, total int64, err error) { + var ( + groups []*entity.BadgeGroup + badges []*entity.Badge + badge *entity.Badge + exists bool + groupMap = make(map[int64]string, 0) + ) + + total = 0 + + if len(req.Query) > 0 { + isID := strings.Index(req.Query, "badge:") + if isID != 0 { + badges, err = b.searchByName(ctx, req.Query) + if err != nil { + return + } + // paged result + count := len(badges) + total = int64(count) + start := (req.Page - 1) * req.PageSize + end := req.Page * req.PageSize + if start >= count { + start = count + end = count + } + if end > count { + end = count + } + badges = badges[start:end] + } else { + req.Query = strings.TrimSpace(strings.TrimLeft(req.Query, "badge:")) + id := uid.DeShortID(req.Query) + if len(id) == 0 { + return + } + badge, exists, err = b.badgeRepo.GetByID(ctx, id) + if err != nil || !exists { + return + } + badges = append(badges, badge) + } + } else { + switch req.Status { + case schema.BadgeStatusActive: + badges, total, err = b.badgeRepo.ListActivated(ctx, req.Page, req.PageSize) + case schema.BadgeStatusInactive: + badges, total, err = b.badgeRepo.ListInactivated(ctx, req.Page, req.PageSize) + default: + badges, total, err = b.badgeRepo.ListPaged(ctx, req.Page, req.PageSize) + } + if err != nil { + return + } + } + + // find all group and build group map + groups, err = b.badgeGroupRepo.ListGroups(ctx) + if err != nil { + return + } + for _, group := range groups { + groupMap[converter.StringToInt64(group.ID)] = translator.Tr(handler.GetLangByCtx(ctx), group.Name) + } + + resp = make([]*schema.GetBadgeListPagedResp, len(badges)) + + for i, badge := range badges { + resp[i] = &schema.GetBadgeListPagedResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Description: translator.Tr(handler.GetLangByCtx(ctx), badge.Description), + Icon: badge.Icon, + AwardCount: badge.AwardCount, + Level: badge.Level, + GroupName: groupMap[badge.BadgeGroupID], + Status: schema.BadgeStatusMap[badge.Status], + } + } + return +} + +// searchByName +func (b *BadgeService) searchByName(ctx context.Context, name string) (result []*entity.Badge, err error) { + var badges []*entity.Badge + name = strings.ToLower(name) + result = make([]*entity.Badge, 0) + + badges, _, err = b.badgeRepo.ListPaged(ctx, 0, 0) + for _, badge := range badges { + tn := strings.ToLower(translator.Tr(handler.GetLangByCtx(ctx), badge.Name)) + if strings.Contains(tn, name) { + result = append(result, badge) + } + } + return +} + +// GetBadgeInfo get badge info +func (b *BadgeService) GetBadgeInfo(ctx *gin.Context, id string, userID string) (info *schema.GetBadgeInfoResp, err error) { + var ( + badge *entity.Badge + earnedTotal int64 = 0 + exists = false + ) + + badge, exists, err = b.badgeRepo.GetByID(ctx, id) + if err != nil { + return + } + + if !exists || badge.Status == entity.BadgeStatusInactive { + err = errors.BadRequest(reason.BadgeObjectNotFound) + return + } + + if len(userID) > 0 { + earnedTotal = b.badgeAwardRepo.CountByUserIdAndBadgeId(ctx, userID, badge.ID) + } + + info = &schema.GetBadgeInfoResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Description: translator.Tr(handler.GetLangByCtx(ctx), badge.Description), + Icon: badge.Icon, + AwardCount: badge.AwardCount, + EarnedCount: earnedTotal, + IsSingle: badge.Single == entity.BadgeSingleAward, + Level: badge.Level, + } + return +} + +// UpdateStatus update badge status +func (b *BadgeService) UpdateStatus(ctx *gin.Context, req *schema.UpdateBadgeStatusReq) (err error) { + req.ID = uid.DeShortID(req.ID) + + badge, exists, err := b.badgeRepo.GetByID(ctx, req.ID) + if err != nil { + return err + } + if !exists { + return errors.BadRequest(reason.BadgeObjectNotFound) + } + + // check duplicate action + status, ok := schema.BadgeStatusEMap[req.Status] + if !ok { + err = errors.BadRequest(reason.StatusInvalid) + return + } + if badge.Status == status { + return + } + + err = b.badgeRepo.UpdateStatus(ctx, req.ID, status) + if err != nil { + return err + } + + if status == entity.BadgeStatusActive { + count, err := b.badgeAwardRepo.CountByBadgeID(ctx, badge.ID) + if err != nil { + log.Errorf("count badge award failed: %v", err) + return nil + } + err = b.badgeRepo.UpdateAwardCount(ctx, badge.ID, int(count)) + if err != nil { + log.Errorf("update badge award count failed: %v", err) + return nil + } + } + return nil +} diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index 9f2c45f1a..012149e73 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -21,6 +21,7 @@ package comment import ( "context" + "github.com/apache/incubator-answer/internal/service/event_queue" "time" "github.com/apache/incubator-answer/internal/base/constant" @@ -86,6 +87,7 @@ type CommentService struct { notificationQueueService notice_queue.NotificationQueueService externalNotificationQueueService notice_queue.ExternalNotificationQueueService activityQueueService activity_queue.ActivityQueueService + eventQueueService event_queue.EventQueueService } // NewCommentService new comment service @@ -100,6 +102,7 @@ func NewCommentService( notificationQueueService notice_queue.NotificationQueueService, externalNotificationQueueService notice_queue.ExternalNotificationQueueService, activityQueueService activity_queue.ActivityQueueService, + eventQueueService event_queue.EventQueueService, ) *CommentService { return &CommentService{ commentRepo: commentRepo, @@ -112,6 +115,7 @@ func NewCommentService( notificationQueueService: notificationQueueService, externalNotificationQueueService: externalNotificationQueueService, activityQueueService: activityQueueService, + eventQueueService: eventQueueService, } } @@ -184,13 +188,19 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment OriginalObjectID: req.ObjectID, ActivityTypeKey: constant.ActQuestionCommented, } + var event *schema.EventMsg switch objInfo.ObjectType { case constant.QuestionObjectType: activityMsg.ActivityTypeKey = constant.ActQuestionCommented + event = schema.NewEvent(constant.EventCommentCreate, req.UserID).TID(comment.ID). + CID(comment.ID, comment.UserID).QID(objInfo.QuestionID, objInfo.ObjectCreatorUserID) case constant.AnswerObjectType: activityMsg.ActivityTypeKey = constant.ActAnswerCommented + event = schema.NewEvent(constant.EventCommentCreate, req.UserID).TID(comment.ID). + CID(comment.ID, comment.UserID).AID(objInfo.AnswerID, objInfo.ObjectCreatorUserID) } cs.activityQueueService.Send(ctx, activityMsg) + cs.eventQueueService.Send(ctx, event) return resp, nil } @@ -241,7 +251,13 @@ func (cs *CommentService) addCommentNotification( // RemoveComment delete comment func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveCommentReq) (err error) { - return cs.commentRepo.RemoveComment(ctx, req.CommentID) + err = cs.commentRepo.RemoveComment(ctx, req.CommentID) + if err != nil { + return err + } + cs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventCommentDelete, req.UserID). + TID(req.CommentID).CID(req.CommentID, req.UserID)) + return nil } // UpdateComment update comment @@ -273,6 +289,8 @@ func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateC OriginalText: req.OriginalText, ParsedText: req.ParsedText, } + cs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventCommentUpdate, req.UserID).TID(old.ID). + CID(old.ID, old.UserID)) return resp, nil } diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index f8feda8af..c34e9081a 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -22,6 +22,7 @@ package content import ( "context" "encoding/json" + "github.com/apache/incubator-answer/internal/service/event_queue" "time" "github.com/apache/incubator-answer/internal/base/constant" @@ -67,6 +68,7 @@ type AnswerService struct { externalNotificationQueueService notice_queue.ExternalNotificationQueueService activityQueueService activity_queue.ActivityQueueService reviewService *review.ReviewService + eventQueueService event_queue.EventQueueService } func NewAnswerService( @@ -86,6 +88,7 @@ func NewAnswerService( externalNotificationQueueService notice_queue.ExternalNotificationQueueService, activityQueueService activity_queue.ActivityQueueService, reviewService *review.ReviewService, + eventQueueService event_queue.EventQueueService, ) *AnswerService { return &AnswerService{ answerRepo: answerRepo, @@ -104,6 +107,7 @@ func NewAnswerService( externalNotificationQueueService: externalNotificationQueueService, activityQueueService: activityQueueService, reviewService: reviewService, + eventQueueService: eventQueueService, } } @@ -175,6 +179,8 @@ func (as *AnswerService) RemoveAnswer(ctx context.Context, req *schema.RemoveAns OriginalObjectID: answerInfo.ID, ActivityTypeKey: constant.ActAnswerDeleted, }) + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventAnswerDelete, req.UserID).TID(answerInfo.ID). + AID(answerInfo.ID, answerInfo.UserID)) return } @@ -295,6 +301,8 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) ( OriginalObjectID: questionInfo.ID, ActivityTypeKey: constant.ActQuestionAnswered, }) + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventAnswerCreate, req.UserID).TID(insertData.ID). + AID(insertData.ID, insertData.UserID)) return insertData.ID, nil } @@ -383,6 +391,8 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq ActivityTypeKey: constant.ActAnswerEdited, RevisionID: revisionID, }) + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventAnswerUpdate, req.UserID).TID(insertData.ID). + AID(insertData.ID, insertData.UserID)) } return insertData.ID, nil @@ -436,6 +446,11 @@ func (as *AnswerService) AcceptAnswer(ctx context.Context, req *schema.AcceptAns oldAnswerInfo.ID = uid.DeShortID(oldAnswerInfo.ID) } + if acceptedAnswerInfo != nil { + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionAccept, req.UserID).TID(acceptedAnswerInfo.ID). + QID(questionInfo.ID, questionInfo.UserID).AID(acceptedAnswerInfo.ID, acceptedAnswerInfo.UserID)) + } + as.updateAnswerRank(ctx, req.UserID, questionInfo, acceptedAnswerInfo, oldAnswerInfo) return nil } diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index 6d8b37ba9..9a6dd2805 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -22,7 +22,7 @@ package content import ( "encoding/json" "fmt" - answercommon "github.com/apache/incubator-answer/internal/service/answer_common" + "github.com/apache/incubator-answer/internal/service/event_queue" "strings" "time" @@ -35,11 +35,13 @@ import ( "github.com/apache/incubator-answer/internal/entity" "github.com/apache/incubator-answer/internal/schema" "github.com/apache/incubator-answer/internal/service/activity" + "github.com/apache/incubator-answer/internal/service/activity_common" "github.com/apache/incubator-answer/internal/service/activity_queue" + answercommon "github.com/apache/incubator-answer/internal/service/answer_common" collectioncommon "github.com/apache/incubator-answer/internal/service/collection_common" "github.com/apache/incubator-answer/internal/service/config" "github.com/apache/incubator-answer/internal/service/export" - "github.com/apache/incubator-answer/internal/service/meta_common" + metacommon "github.com/apache/incubator-answer/internal/service/meta_common" "github.com/apache/incubator-answer/internal/service/notice_queue" "github.com/apache/incubator-answer/internal/service/notification" "github.com/apache/incubator-answer/internal/service/permission" @@ -48,6 +50,7 @@ import ( "github.com/apache/incubator-answer/internal/service/revision_common" "github.com/apache/incubator-answer/internal/service/role" "github.com/apache/incubator-answer/internal/service/siteinfo_common" + "github.com/apache/incubator-answer/internal/service/tag" tagcommon "github.com/apache/incubator-answer/internal/service/tag_common" usercommon "github.com/apache/incubator-answer/internal/service/user_common" "github.com/apache/incubator-answer/pkg/checker" @@ -65,9 +68,11 @@ import ( // QuestionService user service type QuestionService struct { + activityRepo activity_common.ActivityRepo questionRepo questioncommon.QuestionRepo answerRepo answercommon.AnswerRepo tagCommon *tagcommon.TagCommonService + tagService *tag.TagService questioncommon *questioncommon.QuestionCommon userCommon *usercommon.UserCommon userRepo usercommon.UserRepo @@ -84,12 +89,15 @@ type QuestionService struct { newQuestionNotificationService *notification.ExternalNotificationService reviewService *review.ReviewService configService *config.ConfigService + eventQueueService event_queue.EventQueueService } func NewQuestionService( + activityRepo activity_common.ActivityRepo, questionRepo questioncommon.QuestionRepo, answerRepo answercommon.AnswerRepo, tagCommon *tagcommon.TagCommonService, + tagService *tag.TagService, questioncommon *questioncommon.QuestionCommon, userCommon *usercommon.UserCommon, userRepo usercommon.UserRepo, @@ -106,11 +114,14 @@ func NewQuestionService( newQuestionNotificationService *notification.ExternalNotificationService, reviewService *review.ReviewService, configService *config.ConfigService, + eventQueueService event_queue.EventQueueService, ) *QuestionService { return &QuestionService{ + activityRepo: activityRepo, questionRepo: questionRepo, answerRepo: answerRepo, tagCommon: tagCommon, + tagService: tagService, questioncommon: questioncommon, userCommon: userCommon, userRepo: userRepo, @@ -127,6 +138,7 @@ func NewQuestionService( newQuestionNotificationService: newQuestionNotificationService, reviewService: reviewService, configService: configService, + eventQueueService: eventQueueService, } } @@ -385,6 +397,8 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question qs.externalNotificationQueueService.Send(ctx, schema.CreateNewQuestionNotificationMsg(question.ID, question.Title, question.UserID, tags)) } + qs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionCreate, req.UserID).TID(question.ID). + QID(question.ID, question.UserID)) questionInfo, err = qs.GetQuestion(ctx, question.ID, question.UserID, req.QuestionPermission) return @@ -546,6 +560,8 @@ func (qs *QuestionService) RemoveQuestion(ctx context.Context, req *schema.Remov OriginalObjectID: questionInfo.ID, ActivityTypeKey: constant.ActQuestionDeleted, }) + qs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionDelete, req.UserID).TID(questionInfo.ID). + QID(questionInfo.ID, questionInfo.UserID)) return nil } @@ -937,6 +953,8 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest RevisionID: revisionID, OriginalObjectID: question.ID, }) + qs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionUpdate, req.UserID).TID(question.ID). + QID(question.ID, question.UserID)) } questionInfo, err = qs.GetQuestion(ctx, question.ID, question.UserID, req.QuestionPermission) @@ -1348,6 +1366,47 @@ func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.Ques return questions, total, nil } +// GetRecommendQuestionPage retrieves recommended question page based on following tags and questions. +func (qs *QuestionService) GetRecommendQuestionPage(ctx context.Context, req *schema.QuestionPageReq) ( + questions []*schema.QuestionPageResp, total int64, err error) { + followingTagsResp, err := qs.tagService.GetFollowingTags(ctx, req.LoginUserID) + if err != nil { + return nil, 0, err + } + tagIDs := make([]string, 0, len(followingTagsResp)) + for _, tag := range followingTagsResp { + tagIDs = append(tagIDs, tag.TagID) + } + + activityType, err := qs.activityRepo.GetActivityTypeByObjectType(ctx, constant.QuestionObjectType, "follow") + if err != nil { + return nil, 0, err + } + activities, err := qs.activityRepo.GetUserActivitysByActivityType(ctx, req.LoginUserID, activityType) + if err != nil { + return nil, 0, err + } + + followedQuestionIDs := make([]string, 0, len(activities)) + for _, activity := range activities { + if activity.Cancelled == entity.ActivityCancelled { + continue + } + followedQuestionIDs = append(followedQuestionIDs, activity.ObjectID) + } + questionList, total, err := qs.questionRepo.GetRecommendQuestionPageByTags(ctx, req.LoginUserID, tagIDs, followedQuestionIDs, req.Page, req.PageSize) + if err != nil { + return nil, 0, err + } + + questions, err = qs.questioncommon.FormatQuestionsPage(ctx, questionList, req.LoginUserID, "frequent") + if err != nil { + return nil, 0, err + } + + return questions, total, nil +} + func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, req *schema.AdminUpdateQuestionStatusReq) error { setStatus, ok := entity.AdminQuestionSearchStatus[req.Status] if !ok { diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index 11f3bb63b..7a839b5d7 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/apache/incubator-answer/internal/service/event_queue" "time" "github.com/apache/incubator-answer/internal/base/constant" @@ -65,6 +66,7 @@ type UserService struct { userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo userNotificationConfigService *user_notification_config.UserNotificationConfigService questionService *questioncommon.QuestionCommon + eventQueueService event_queue.EventQueueService } func NewUserService(userRepo usercommon.UserRepo, @@ -79,6 +81,7 @@ func NewUserService(userRepo usercommon.UserRepo, userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo, userNotificationConfigService *user_notification_config.UserNotificationConfigService, questionService *questioncommon.QuestionCommon, + eventQueueService event_queue.EventQueueService, ) *UserService { return &UserService{ userCommonService: userCommonService, @@ -93,6 +96,7 @@ func NewUserService(userRepo usercommon.UserRepo, userNotificationConfigRepo: userNotificationConfigRepo, userNotificationConfigService: userNotificationConfigService, questionService: questionService, + eventQueueService: eventQueueService, } } @@ -352,6 +356,10 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req, siteUsers) err = us.userRepo.UpdateInfo(ctx, cond) + if err != nil { + return nil, err + } + us.eventQueueService.Send(ctx, schema.NewEvent(constant.EventUserUpdate, req.UserID)) return nil, err } @@ -527,6 +535,7 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri } if err = us.userActivity.UserActive(ctx, userInfo.ID); err != nil { log.Error(err) + return nil, err } // In the case of three-party login, the associated users are bound @@ -660,6 +669,7 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string if userInfo.MailStatus == entity.EmailStatusToBeVerified { if err = us.userActivity.UserActive(ctx, userInfo.ID); err != nil { log.Error(err) + return nil, err } } diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go index bfb403c9f..b4b92ddd4 100644 --- a/internal/service/content/vote_service.go +++ b/internal/service/content/vote_service.go @@ -21,6 +21,8 @@ package content import ( "context" + "fmt" + "github.com/apache/incubator-answer/internal/service/event_queue" "strings" "github.com/apache/incubator-answer/internal/service/activity_common" @@ -62,6 +64,7 @@ type VoteService struct { commentCommonRepo comment_common.CommentCommonRepo objectService *object_info.ObjService activityRepo activity_common.ActivityRepo + eventQueueService event_queue.EventQueueService } func NewVoteService( @@ -71,6 +74,7 @@ func NewVoteService( answerRepo answercommon.AnswerRepo, commentCommonRepo comment_common.CommentCommonRepo, objectService *object_info.ObjService, + eventQueueService event_queue.EventQueueService, ) *VoteService { return &VoteService{ voteRepo: voteRepo, @@ -79,6 +83,7 @@ func NewVoteService( answerRepo: answerRepo, commentCommonRepo: commentCommonRepo, objectService: objectService, + eventQueueService: eventQueueService, } } @@ -112,6 +117,9 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s return nil, err } err = vs.voteRepo.Vote(ctx, voteUpOperationInfo) + if err != nil { + return nil, err + } } if err != nil { return nil, err @@ -125,6 +133,7 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s resp.Votes = resp.UpVotes - resp.DownVotes if !req.IsCancel { resp.VoteStatus = constant.ActVoteUp + vs.sendEvent(ctx, req, objectInfo, resp) } return resp, nil } @@ -173,6 +182,7 @@ func (vs *VoteService) VoteDown(ctx context.Context, req *schema.VoteReq) (resp resp.Votes = resp.UpVotes - resp.DownVotes if !req.IsCancel { resp.VoteStatus = constant.ActVoteDown + vs.sendEvent(ctx, req, objectInfo, resp) } return resp, nil } @@ -289,3 +299,24 @@ func (vs *VoteService) getActivities(ctx context.Context, op *schema.VoteOperati } return activities } + +func (vs *VoteService) sendEvent(ctx context.Context, + req *schema.VoteReq, objectInfo *schema.SimpleObjectInfo, resp *schema.VoteResp) { + var event *schema.EventMsg + switch objectInfo.ObjectType { + case constant.QuestionObjectType: + event = schema.NewEvent(constant.EventQuestionVote, req.UserID).TID(objectInfo.QuestionID). + QID(objectInfo.QuestionID, objectInfo.ObjectCreatorUserID) + case constant.AnswerObjectType: + event = schema.NewEvent(constant.EventAnswerVote, req.UserID).TID(objectInfo.AnswerID). + AID(objectInfo.AnswerID, objectInfo.ObjectCreatorUserID) + case constant.CommentObjectType: + event = schema.NewEvent(constant.EventCommentVote, req.UserID).TID(objectInfo.CommentID). + CID(objectInfo.CommentID, objectInfo.ObjectCreatorUserID) + default: + return + } + event.AddExtra("vote_up_amount", fmt.Sprintf("%d", resp.UpVotes)) + event.AddExtra("vote_down_amount", fmt.Sprintf("%d", resp.DownVotes)) + vs.eventQueueService.Send(ctx, event) +} diff --git a/internal/service/event_queue/event_queue.go b/internal/service/event_queue/event_queue.go new file mode 100644 index 000000000..b89a3ccc4 --- /dev/null +++ b/internal/service/event_queue/event_queue.go @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package event_queue + +import ( + "context" + + "github.com/apache/incubator-answer/internal/schema" + "github.com/segmentfault/pacman/log" +) + +type EventQueueService interface { + Send(ctx context.Context, msg *schema.EventMsg) + RegisterHandler(handler func(ctx context.Context, msg *schema.EventMsg) error) +} + +type eventQueueService struct { + Queue chan *schema.EventMsg + Handler func(ctx context.Context, msg *schema.EventMsg) error +} + +func (ns *eventQueueService) Send(ctx context.Context, msg *schema.EventMsg) { + ns.Queue <- msg +} + +func (ns *eventQueueService) RegisterHandler( + handler func(ctx context.Context, msg *schema.EventMsg) error) { + ns.Handler = handler +} + +func (ns *eventQueueService) working() { + go func() { + for msg := range ns.Queue { + log.Debugf("received badge %+v", msg) + if ns.Handler == nil { + log.Warnf("no handler for badge") + continue + } + if err := ns.Handler(context.Background(), msg); err != nil { + log.Error(err) + } + } + }() +} + +// NewEventQueueService create a new badge queue service +func NewEventQueueService() EventQueueService { + ns := &eventQueueService{} + ns.Queue = make(chan *schema.EventMsg, 128) + ns.working() + return ns +} diff --git a/internal/service/meta/meta_service.go b/internal/service/meta/meta_service.go index 1026b1733..b85aecf6d 100644 --- a/internal/service/meta/meta_service.go +++ b/internal/service/meta/meta_service.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "errors" + "github.com/apache/incubator-answer/internal/service/event_queue" "strconv" "strings" @@ -46,14 +47,22 @@ type MetaService struct { userCommon *usercommon.UserCommon questionRepo questioncommon.QuestionRepo answerRepo answercommon.AnswerRepo + eventQueueService event_queue.EventQueueService } -func NewMetaService(metaCommonService *metacommon.MetaCommonService, userCommon *usercommon.UserCommon, answerRepo answercommon.AnswerRepo, questionRepo questioncommon.QuestionRepo) *MetaService { +func NewMetaService( + metaCommonService *metacommon.MetaCommonService, + userCommon *usercommon.UserCommon, + answerRepo answercommon.AnswerRepo, + questionRepo questioncommon.QuestionRepo, + eventQueueService event_queue.EventQueueService, +) *MetaService { return &MetaService{ metaCommonService: metaCommonService, questionRepo: questionRepo, userCommon: userCommon, answerRepo: answerRepo, + eventQueueService: eventQueueService, } } @@ -86,22 +95,27 @@ func (ms *MetaService) AddOrUpdateReaction(ctx context.Context, req *schema.Upda if err != nil { return nil, err } + var event *schema.EventMsg if objectType == constant.AnswerObjectType { - _, exist, err := ms.answerRepo.GetAnswer(ctx, req.ObjectID) + answerInfo, exist, err := ms.answerRepo.GetAnswer(ctx, req.ObjectID) if err != nil { return nil, err } if !exist { return nil, myErrors.BadRequest(reason.AnswerNotFound) } + event = schema.NewEvent(constant.EventAnswerReact, req.UserID).TID(answerInfo.ID). + AID(answerInfo.ID, answerInfo.UserID) } else if objectType == constant.QuestionObjectType { - _, exist, err := ms.questionRepo.GetQuestion(ctx, req.ObjectID) + questionInfo, exist, err := ms.questionRepo.GetQuestion(ctx, req.ObjectID) if err != nil { return nil, err } if !exist { return nil, myErrors.BadRequest(reason.QuestionNotFound) } + event = schema.NewEvent(constant.EventQuestionReact, req.UserID).TID(questionInfo.ID). + QID(questionInfo.ID, questionInfo.UserID) } else { return nil, myErrors.BadRequest(reason.ObjectNotFound) } @@ -138,7 +152,7 @@ func (ms *MetaService) AddOrUpdateReaction(ctx context.Context, req *schema.Upda if err != nil { return nil, err } - + ms.eventQueueService.Send(ctx, event) return resp, nil } diff --git a/internal/service/notification/notification_service.go b/internal/service/notification/notification_service.go index 71febb677..0860c894f 100644 --- a/internal/service/notification/notification_service.go +++ b/internal/service/notification/notification_service.go @@ -23,12 +23,6 @@ import ( "context" "encoding/json" "fmt" - - "github.com/apache/incubator-answer/internal/service/report_common" - "github.com/apache/incubator-answer/internal/service/review" - usercommon "github.com/apache/incubator-answer/internal/service/user_common" - "github.com/apache/incubator-answer/pkg/converter" - "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/data" "github.com/apache/incubator-answer/internal/base/handler" @@ -36,8 +30,13 @@ import ( "github.com/apache/incubator-answer/internal/base/translator" "github.com/apache/incubator-answer/internal/entity" "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/badge" notficationcommon "github.com/apache/incubator-answer/internal/service/notification_common" + "github.com/apache/incubator-answer/internal/service/report_common" + "github.com/apache/incubator-answer/internal/service/review" "github.com/apache/incubator-answer/internal/service/revision_common" + usercommon "github.com/apache/incubator-answer/internal/service/user_common" + "github.com/apache/incubator-answer/pkg/converter" "github.com/apache/incubator-answer/pkg/uid" "github.com/jinzhu/copier" "github.com/segmentfault/pacman/log" @@ -52,6 +51,7 @@ type NotificationService struct { reportRepo report_common.ReportRepo reviewService *review.ReviewService userRepo usercommon.UserRepo + badgeRepo badge.BadgeRepo } func NewNotificationService( @@ -62,6 +62,7 @@ func NewNotificationService( userRepo usercommon.UserRepo, reportRepo report_common.ReportRepo, reviewService *review.ReviewService, + badgeRepo badge.BadgeRepo, ) *NotificationService { return &NotificationService{ data: data, @@ -71,35 +72,60 @@ func NewNotificationService( userRepo: userRepo, reportRepo: reportRepo, reviewService: reviewService, + badgeRepo: badgeRepo, } } func (ns *NotificationService) GetRedDot(ctx context.Context, req *schema.GetRedDot) (resp *schema.RedDot, err error) { + inboxKey := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeInbox, req.UserID) + achievementKey := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeAchievement, req.UserID) + redBot := &schema.RedDot{} - inboxKey := fmt.Sprintf("answer_RedDot_%d_%s", schema.NotificationTypeInbox, req.UserID) - achievementKey := fmt.Sprintf("answer_RedDot_%d_%s", schema.NotificationTypeAchievement, req.UserID) - inboxValue, _, err := ns.data.Cache.GetInt64(ctx, inboxKey) - if err != nil { - redBot.Inbox = 0 - } else { - redBot.Inbox = inboxValue - } - achievementValue, _, err := ns.data.Cache.GetInt64(ctx, achievementKey) - if err != nil { - redBot.Achievement = 0 - } else { - redBot.Achievement = achievementValue - } - revisionCount := &schema.RevisionSearch{} - _ = copier.Copy(revisionCount, req) + redBot.Inbox, _, err = ns.data.Cache.GetInt64(ctx, inboxKey) + redBot.Achievement, _, err = ns.data.Cache.GetInt64(ctx, achievementKey) + + // get review amount if req.CanReviewAnswer || req.CanReviewQuestion || req.CanReviewTag { redBot.CanRevision = true redBot.Revision = ns.countAllReviewAmount(ctx, req) } + // get badge award + redBot.BadgeAward = ns.getBadgeAward(ctx, req.UserID) return redBot, nil } +func (ns *NotificationService) getBadgeAward(ctx context.Context, userID string) (badgeAward *schema.RedDotBadgeAward) { + key := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeBadgeAchievement, userID) + cacheData, exist, err := ns.data.Cache.GetString(ctx, key) + if err != nil { + log.Errorf("get badge award failed: %v", err) + return nil + } + if !exist { + return nil + } + + c := schema.NewRedDotBadgeAwardCache() + c.FromJSON(cacheData) + award := c.GetBadgeAward() + if award == nil { + return nil + } + badgeInfo, exists, err := ns.badgeRepo.GetByID(ctx, award.BadgeID) + if err != nil { + log.Errorf("get badge info failed: %v", err) + return nil + } + if !exists { + return nil + } + award.Name = translator.Tr(handler.GetLangByCtx(ctx), badgeInfo.Name) + award.Icon = badgeInfo.Icon + award.Level = badgeInfo.Level + return award +} + func (ns *NotificationService) countAllReviewAmount(ctx context.Context, req *schema.GetRedDot) (amount int64) { // get queue amount if req.IsAdmin { @@ -137,21 +163,14 @@ func (ns *NotificationService) countAllReviewAmount(ctx context.Context, req *sc } func (ns *NotificationService) ClearRedDot(ctx context.Context, req *schema.NotificationClearRequest) (*schema.RedDot, error) { - botType, ok := schema.NotificationType[req.TypeStr] - if ok { - key := fmt.Sprintf("answer_RedDot_%d_%s", botType, req.UserID) - err := ns.data.Cache.Del(ctx, key) - if err != nil { - log.Error("ClearRedDot del cache error", err.Error()) - } - } - getRedDotreq := &schema.GetRedDot{} - _ = copier.Copy(getRedDotreq, req) - return ns.GetRedDot(ctx, getRedDotreq) + _ = ns.notificationCommon.DeleteRedDot(ctx, req.UserID, schema.NotificationType[req.NotificationType]) + resp := &schema.GetRedDot{} + _ = copier.Copy(resp, req) + return ns.GetRedDot(ctx, resp) } -func (ns *NotificationService) ClearUnRead(ctx context.Context, userID string, botTypeStr string) error { - botType, ok := schema.NotificationType[botTypeStr] +func (ns *NotificationService) ClearUnRead(ctx context.Context, userID string, notificationType string) error { + botType, ok := schema.NotificationType[notificationType] if ok { err := ns.notificationRepo.ClearUnRead(ctx, userID, botType) if err != nil { @@ -164,19 +183,25 @@ func (ns *NotificationService) ClearUnRead(ctx context.Context, userID string, b func (ns *NotificationService) ClearIDUnRead(ctx context.Context, userID string, id string) error { notificationInfo, exist, err := ns.notificationRepo.GetById(ctx, id) if err != nil { - log.Error("notificationRepo.GetById error", err.Error()) + log.Errorf("get notification failed: %v", err) return nil } - if !exist { + if !exist || notificationInfo.UserID != userID { return nil } - if notificationInfo.UserID == userID && notificationInfo.IsRead == schema.NotificationNotRead { + if notificationInfo.IsRead == schema.NotificationNotRead { err := ns.notificationRepo.ClearIDUnRead(ctx, userID, id) if err != nil { return err } } + err = ns.notificationCommon.RemoveBadgeAwardAlertCache(ctx, userID, id) + if err != nil { + log.Errorf("remove badge award alert cache failed: %v", err) + } + + _ = ns.notificationCommon.DecreaseRedDot(ctx, userID, notificationInfo.Type) return nil } @@ -224,6 +249,14 @@ func (ns *NotificationService) formatNotificationPage(ctx context.Context, notif item.NotificationAction == constant.NotificationDownVotedTheAnswer { item.UserInfo = nil } + // If notification is badge, the user info is not needed and the title need to be translated. + if item.ObjectInfo.ObjectType == constant.BadgeAwardObjectType { + badgeName := translator.Tr(lang, item.ObjectInfo.Title) + item.ObjectInfo.Title = translator.TrWithData(lang, constant.NotificationEarnedBadge, struct { + BadgeName string + }{BadgeName: badgeName}) + item.UserInfo = nil + } item.ID = notificationInfo.ID item.NotificationAction = translator.Tr(lang, item.NotificationAction) diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index 319403b24..9d08e2582 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -54,6 +54,7 @@ type NotificationRepo interface { GetByUserIdObjectIdTypeId(ctx context.Context, userID, objectID string, notificationType int) (*entity.Notification, bool, error) UpdateNotificationContent(ctx context.Context, notification *entity.Notification) (err error) GetById(ctx context.Context, id string) (*entity.Notification, bool, error) + CountNotificationByUser(ctx context.Context, cond *entity.Notification) (int64, error) } type NotificationCommon struct { @@ -103,7 +104,7 @@ func NewNotificationCommon( // ObjectInfo.Title // ObjectInfo.ObjectID // ObjectInfo.ObjectType -func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.NotificationMsg) error { +func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.NotificationMsg) (err error) { if msg.Type == schema.NotificationTypeAchievement && plugin.RankAgentEnabled() { return nil } @@ -119,17 +120,25 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N Type: msg.Type, } var questionID string // just for notify all followers - objInfo, err := ns.objectInfoService.GetInfo(ctx, req.ObjectInfo.ObjectID) - if err != nil { - log.Error(err) - } else { - req.ObjectInfo.Title = objInfo.Title - questionID = objInfo.QuestionID + var objInfo *schema.SimpleObjectInfo + if msg.ObjectType == constant.BadgeAwardObjectType { + req.ObjectInfo.Title = msg.Title objectMap := make(map[string]string) - objectMap["question"] = uid.DeShortID(objInfo.QuestionID) - objectMap["answer"] = uid.DeShortID(objInfo.AnswerID) - objectMap["comment"] = objInfo.CommentID + objectMap["badge_id"] = msg.ExtraInfo["badge_id"] req.ObjectInfo.ObjectMap = objectMap + } else { + objInfo, err = ns.objectInfoService.GetInfo(ctx, req.ObjectInfo.ObjectID) + if err != nil { + log.Error(err) + } else { + req.ObjectInfo.Title = objInfo.Title + questionID = objInfo.QuestionID + objectMap := make(map[string]string) + objectMap["question"] = uid.DeShortID(objInfo.QuestionID) + objectMap["answer"] = uid.DeShortID(objInfo.AnswerID) + objectMap["comment"] = objInfo.CommentID + req.ObjectInfo.ObjectMap = objectMap + } } if msg.Type == schema.NotificationTypeAchievement { @@ -188,10 +197,13 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N if err != nil { return fmt.Errorf("add notification error: %w", err) } - err = ns.addRedDot(ctx, info.UserID, info.Type) + err = ns.addRedDot(ctx, info.UserID, msg.Type) if err != nil { log.Error("addRedDot Error", err.Error()) } + if req.ObjectInfo.ObjectType == constant.BadgeAwardObjectType { + err = ns.AddBadgeAwardAlertCache(ctx, info.UserID, info.ID, req.ObjectInfo.ObjectMap["badge_id"]) + } go ns.SendNotificationToAllFollower(ctx, msg, questionID) @@ -201,19 +213,115 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N return nil } -func (ns *NotificationCommon) addRedDot(ctx context.Context, userID string, botType int) error { - key := fmt.Sprintf("answer_RedDot_%d_%s", botType, userID) - err := ns.data.Cache.SetInt64(ctx, key, 1, 30*24*time.Hour) //Expiration time is one month. +func (ns *NotificationCommon) addRedDot(ctx context.Context, userID string, noticeType int) error { + var key string + if noticeType == schema.NotificationTypeInbox { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeInbox, userID) + } else { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeAchievement, userID) + } + _, exist, err := ns.data.Cache.GetInt64(ctx, key) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if exist { + if _, err := ns.data.Cache.Increase(ctx, key, 1); err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + return nil + } + err = ns.data.Cache.SetInt64(ctx, key, 1, constant.RedDotCacheTime) if err != nil { return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() } return nil } +func (ns *NotificationCommon) DecreaseRedDot(ctx context.Context, userID string, notificationType int) error { + var key string + if notificationType == schema.NotificationTypeInbox { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeInbox, userID) + } else { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeAchievement, userID) + } + _, exist, err := ns.data.Cache.GetInt64(ctx, key) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if !exist { + return nil + } + res, err := ns.data.Cache.Decrease(ctx, key, 1) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if res <= 0 { + return ns.DeleteRedDot(ctx, userID, notificationType) + } + return nil +} + +func (ns *NotificationCommon) DeleteRedDot(ctx context.Context, userID string, notificationType int) error { + var key string + if notificationType == schema.NotificationTypeInbox { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeInbox, userID) + } else { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeAchievement, userID) + } + err := ns.data.Cache.Del(ctx, key) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + return nil +} + +// AddBadgeAwardAlertCache add badge award alert cache +func (ns *NotificationCommon) AddBadgeAwardAlertCache(ctx context.Context, userID, notificationID, badgeID string) (err error) { + key := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeBadgeAchievement, userID) + cacheData, exist, err := ns.data.Cache.GetString(ctx, key) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if !exist { + c := schema.NewRedDotBadgeAwardCache() + c.AddBadgeAward(&schema.RedDotBadgeAward{ + NotificationID: notificationID, + BadgeID: badgeID, + }) + return ns.data.Cache.SetString(ctx, key, c.ToJSON(), constant.RedDotCacheTime) + } + c := schema.NewRedDotBadgeAwardCache() + c.FromJSON(cacheData) + c.AddBadgeAward(&schema.RedDotBadgeAward{ + NotificationID: notificationID, + BadgeID: badgeID, + }) + return ns.data.Cache.SetString(ctx, key, c.ToJSON(), constant.RedDotCacheTime) +} + +// RemoveBadgeAwardAlertCache remove badge award alert cache +func (ns *NotificationCommon) RemoveBadgeAwardAlertCache(ctx context.Context, userID, notificationID string) (err error) { + key := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeBadgeAchievement, userID) + cacheData, exist, err := ns.data.Cache.GetString(ctx, key) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if !exist { + return nil + } + c := schema.NewRedDotBadgeAwardCache() + c.FromJSON(cacheData) + c.RemoveBadgeAward(notificationID) + if len(c.BadgeAwardList) == 0 { + return ns.data.Cache.Del(ctx, key) + } + return ns.data.Cache.SetString(ctx, key, c.ToJSON(), constant.RedDotCacheTime) +} + // SendNotificationToAllFollower send notification to all followers func (ns *NotificationCommon) SendNotificationToAllFollower(ctx context.Context, msg *schema.NotificationMsg, questionID string) { - if msg.NoNeedPushAllFollow { + if msg.NoNeedPushAllFollow || len(questionID) == 0 { return } if msg.NotificationAction != constant.NotificationUpdateQuestion && diff --git a/internal/service/object_info/object_info.go b/internal/service/object_info/object_info.go index 6c2e89a9d..9a85f07e8 100644 --- a/internal/service/object_info/object_info.go +++ b/internal/service/object_info/object_info.go @@ -21,7 +21,6 @@ package object_info import ( "context" - "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/reason" "github.com/apache/incubator-answer/internal/schema" diff --git a/internal/service/provider.go b/internal/service/provider.go index e82d93167..12e0db797 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -26,6 +26,7 @@ import ( "github.com/apache/incubator-answer/internal/service/activity_queue" answercommon "github.com/apache/incubator-answer/internal/service/answer_common" "github.com/apache/incubator-answer/internal/service/auth" + "github.com/apache/incubator-answer/internal/service/badge" "github.com/apache/incubator-answer/internal/service/collection" collectioncommon "github.com/apache/incubator-answer/internal/service/collection_common" "github.com/apache/incubator-answer/internal/service/comment" @@ -33,6 +34,7 @@ import ( "github.com/apache/incubator-answer/internal/service/config" "github.com/apache/incubator-answer/internal/service/content" "github.com/apache/incubator-answer/internal/service/dashboard" + "github.com/apache/incubator-answer/internal/service/event_queue" "github.com/apache/incubator-answer/internal/service/export" "github.com/apache/incubator-answer/internal/service/follow" "github.com/apache/incubator-answer/internal/service/meta" @@ -117,4 +119,9 @@ var ProviderSetService = wire.NewSet( notice_queue.NewNewQuestionNotificationQueueService, review.NewReviewService, meta.NewMetaService, + event_queue.NewEventQueueService, + badge.NewBadgeService, + badge.NewBadgeEventService, + badge.NewBadgeAwardService, + badge.NewBadgeGroupService, ) diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index fce3761ea..5dab12144 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -57,6 +57,7 @@ type QuestionRepo interface { GetQuestionList(ctx context.Context, question *entity.Question) (questions []*entity.Question, err error) GetQuestionPage(ctx context.Context, page, pageSize int, tagIDs []string, userID, orderCond string, inDays int, showHidden, showPending bool) ( questionList []*entity.Question, total int64, err error) + GetRecommendQuestionPageByTags(ctx context.Context, userID string, tagIDs, followedQuestionIDs []string, page, pageSize int) (questionList []*entity.Question, total int64, err error) UpdateQuestionStatus(ctx context.Context, questionID string, status int) (err error) UpdateQuestionStatusWithOutUpdateTime(ctx context.Context, question *entity.Question) (err error) RecoverQuestion(ctx context.Context, questionID string) (err error) @@ -145,6 +146,15 @@ func (qs *QuestionCommon) UpdateAnswerCount(ctx context.Context, questionID stri if err != nil { return err } + if count == 0 { + err = qs.questionRepo.UpdateLastAnswer(ctx, &entity.Question{ + ID: questionID, + LastAnswerID: "0", + }) + if err != nil { + return err + } + } return qs.questionRepo.UpdateAnswerCount(ctx, questionID, int(count)) } diff --git a/internal/service/report/report_service.go b/internal/service/report/report_service.go index 7f060a39f..2ca44855f 100644 --- a/internal/service/report/report_service.go +++ b/internal/service/report/report_service.go @@ -21,6 +21,7 @@ package report import ( "encoding/json" + "github.com/apache/incubator-answer/internal/service/event_queue" "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/handler" @@ -55,6 +56,7 @@ type ReportService struct { commentCommonRepo comment_common.CommentCommonRepo reportHandle *report_handle.ReportHandle configService *config.ConfigService + eventQueueService event_queue.EventQueueService } // NewReportService new report service @@ -67,6 +69,7 @@ func NewReportService( commentCommonRepo comment_common.CommentCommonRepo, reportHandle *report_handle.ReportHandle, configService *config.ConfigService, + eventQueueService event_queue.EventQueueService, ) *ReportService { return &ReportService{ reportRepo: reportRepo, @@ -77,6 +80,7 @@ func NewReportService( commentCommonRepo: commentCommonRepo, reportHandle: reportHandle, configService: configService, + eventQueueService: eventQueueService, } } @@ -112,7 +116,12 @@ func (rs *ReportService) AddReport(ctx context.Context, req *schema.AddReportReq Content: req.Content, Status: entity.ReportStatusPending, } - return rs.reportRepo.AddReport(ctx, report) + err = rs.reportRepo.AddReport(ctx, report) + if err != nil { + return err + } + rs.sendEvent(ctx, report, objInfo) + return nil } // GetUnreviewedReportPostPage get unreviewed report post page @@ -218,3 +227,22 @@ func (rs *ReportService) ReviewReport(ctx context.Context, req *schema.ReviewRep return rs.reportRepo.UpdateStatus(ctx, report.ID, entity.ReportStatusCompleted) } + +func (rs *ReportService) sendEvent(ctx context.Context, + report *entity.Report, objectInfo *schema.SimpleObjectInfo) { + var event *schema.EventMsg + switch objectInfo.ObjectType { + case constant.QuestionObjectType: + event = schema.NewEvent(constant.EventQuestionFlag, report.UserID).TID(objectInfo.QuestionID). + QID(objectInfo.QuestionID, objectInfo.ObjectCreatorUserID) + case constant.AnswerObjectType: + event = schema.NewEvent(constant.EventAnswerFlag, report.UserID).TID(objectInfo.AnswerID). + AID(objectInfo.AnswerID, objectInfo.ObjectCreatorUserID) + case constant.CommentObjectType: + event = schema.NewEvent(constant.EventCommentFlag, report.UserID).TID(objectInfo.CommentID). + CID(objectInfo.CommentID, objectInfo.ObjectCreatorUserID) + default: + return + } + rs.eventQueueService.Send(ctx, event) +} diff --git a/internal/service/user_external_login/user_center_login_service.go b/internal/service/user_external_login/user_center_login_service.go index 6089d6268..5ff47bce4 100644 --- a/internal/service/user_external_login/user_center_login_service.go +++ b/internal/service/user_external_login/user_center_login_service.go @@ -129,7 +129,9 @@ func (us *UserCenterLoginService) ExternalLogin( return nil, err } - us.activeUser(ctx, oldUserInfo) + if err := us.activeUser(ctx, oldUserInfo); err != nil { + return nil, err + } accessToken, _, err := us.userCommonService.CacheLoginUserInfo( ctx, oldUserInfo.ID, oldUserInfo.MailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID) @@ -181,10 +183,12 @@ func (us *UserCenterLoginService) registerNewUser(ctx context.Context, provider return userInfo, nil } -func (us *UserCenterLoginService) activeUser(ctx context.Context, oldUserInfo *entity.User) { +func (us *UserCenterLoginService) activeUser(ctx context.Context, oldUserInfo *entity.User) error { if err := us.userActivity.UserActive(ctx, oldUserInfo.ID); err != nil { log.Error(err) + return err } + return nil } func (us *UserCenterLoginService) UserCenterUserSettings(ctx context.Context, userID string) ( diff --git a/plugin/embed.go b/plugin/embed.go index 55149f664..e853c8c27 100644 --- a/plugin/embed.go +++ b/plugin/embed.go @@ -19,8 +19,16 @@ package plugin +import "github.com/gin-gonic/gin" + +type EmbedConfig struct { + Platform string `json:"platform"` + Enable bool `json:"enable"` +} + type Embed interface { Base + GetEmbedConfigs(ctx *gin.Context) (embedConfigs []*EmbedConfig, err error) } var ( diff --git a/plugin/plugin.go b/plugin/plugin.go index 61ed0d8f7..9c144346a 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -103,6 +103,10 @@ func Register(p Base) { registerEmbed(p.(Embed)) } + if _, ok := p.(Render); ok { + registerRender(p.(Render)) + } + if _, ok := p.(CDN); ok { registerCDN(p.(CDN)) } @@ -219,7 +223,7 @@ func MakeTranslator(key string) Translator { // Translate translates the key to the current language of the context func (t Translator) Translate(ctx *GinContext) string { - if &t == nil || t.Fn == nil { + if t.Fn == nil { return "" } return t.Fn(ctx) diff --git a/plugin/render.go b/plugin/render.go new file mode 100644 index 000000000..4fc2edf6f --- /dev/null +++ b/plugin/render.go @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package plugin + +import "github.com/gin-gonic/gin" + +type RenderConfig struct { + SelectTheme string `json:"select_theme"` +} + +// select_theme + +type Render interface { + Base + GetRenderConfig(ctx *gin.Context) (renderConfig *RenderConfig) +} + +var ( + // CallReviewer is a function that calls all registered parsers + CallRender, + registerRender = MakePlugin[Render](false) +) diff --git a/script/check-asf-header.sh b/script/check-asf-header.sh index ff765eab5..808efa108 100755 --- a/script/check-asf-header.sh +++ b/script/check-asf-header.sh @@ -16,5 +16,16 @@ # specific language governing permissions and limitations # under the License. -docker run -it --rm -v $(pwd):/github/workspace ghcr.io/korandoru/hawkeye-native format +# check if docker or podman is installed +if command -v docker >/dev/null 2>&1; then + CONTAINER_RUNTIME="docker" +elif command -v podman >/dev/null 2>&1; then + CONTAINER_RUNTIME="podman" +else + echo "Neither Docker nor Podman is installed. Please install either Docker or Podman." + exit 1 +fi + +$CONTAINER_RUNTIME run -it --rm -v "$(pwd)":/github/workspace ghcr.io/korandoru/hawkeye-native format + gofmt -w -l . diff --git a/ui/package.json b/ui/package.json index 19e669818..2344125da 100644 --- a/ui/package.json +++ b/ui/package.json @@ -31,9 +31,9 @@ "diff": "^5.1.0", "front-matter": "^4.0.2", "i18next": "^21.9.0", + "js-sha256": "0.11.0", "lodash": "^4.17.21", "marked": "^4.0.19", - "md5": "^2.3.0", "next-share": "^0.18.1", "qrcode": "^1.5.1", "qs": "^6.11.0", @@ -98,4 +98,4 @@ "pnpm": ">=8" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index b855bfa05..0fd9c0f1a 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -53,15 +53,15 @@ importers: i18next: specifier: ^21.9.0 version: 21.9.2 + js-sha256: + specifier: 0.11.0 + version: 0.11.0 lodash: specifier: ^4.17.21 version: 4.17.21 marked: specifier: ^4.0.19 version: 4.1.0 - md5: - specifier: ^2.3.0 - version: 2.3.0 next-share: specifier: ^0.18.1 version: 0.18.1(react-dom@18.2.0)(react-scripts@5.0.1)(react@18.2.0) @@ -2495,14 +2495,14 @@ packages: engines: {node: '>=6.0.0'} dependencies: '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/sourcemap-codec': 1.4.15 /@jridgewell/gen-mapping@0.3.2: resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==} engines: {node: '>=6.0.0'} dependencies: '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.17 /@jridgewell/gen-mapping@0.3.3: @@ -2510,7 +2510,7 @@ packages: engines: {node: '>=6.0.0'} dependencies: '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.22 dev: true @@ -2545,6 +2545,9 @@ packages: /@jridgewell/sourcemap-codec@1.4.15: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + /@jridgewell/sourcemap-codec@1.5.0: + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} dev: true /@jridgewell/trace-mapping@0.3.15: @@ -2563,14 +2566,14 @@ packages: resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} dependencies: '@jridgewell/resolve-uri': 3.1.1 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 dev: true /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/sourcemap-codec': 1.4.15 /@leichtgewicht/ip-codec@2.0.4: resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} @@ -3148,7 +3151,7 @@ packages: resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==} dependencies: '@types/eslint': 8.4.6 - '@types/estree': 0.0.51 + '@types/estree': 1.0.5 /@types/eslint-scope@3.7.7: resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3160,7 +3163,7 @@ packages: /@types/eslint@8.4.6: resolution: {integrity: sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==} dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 '@types/json-schema': 7.0.15 /@types/eslint@8.56.2: @@ -3176,12 +3179,8 @@ packages: /@types/estree@0.0.51: resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==} - /@types/estree@1.0.0: - resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} - /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - dev: true /@types/express-serve-static-core@4.17.31: resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==} @@ -3878,12 +3877,12 @@ packages: acorn: 8.11.3 dev: true - /acorn-jsx@5.3.2(acorn@8.10.0): + /acorn-jsx@5.3.2(acorn@8.11.3): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - acorn: 8.10.0 + acorn: 8.11.3 /acorn-node@1.8.2: resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} @@ -3905,11 +3904,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - /acorn@8.10.0: - resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} - engines: {node: '>=0.4.0'} - hasBin: true - /acorn@8.11.3: resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} engines: {node: '>=0.4.0'} @@ -4642,10 +4636,6 @@ packages: resolution: {integrity: sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==} engines: {node: '>=12.20'} - /charenc@0.0.2: - resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} - dev: false - /check-types@11.1.2: resolution: {integrity: sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ==} @@ -4977,10 +4967,6 @@ packages: shebang-command: 2.0.0 which: 2.0.2 - /crypt@0.0.2: - resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} - dev: false - /crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} @@ -6291,8 +6277,8 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - acorn: 8.10.0 - acorn-jsx: 5.3.2(acorn@8.10.0) + acorn: 8.11.3 + acorn-jsx: 5.3.2(acorn@8.11.3) eslint-visitor-keys: 3.4.3 /esprima@4.0.1: @@ -7207,10 +7193,6 @@ packages: call-bind: 1.0.5 has-tostringtag: 1.0.0 - /is-buffer@1.1.6: - resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} - dev: false - /is-callable@1.2.6: resolution: {integrity: sha512-krO72EO2NptOGAX2KYyqbP9vYMlNAXdB53rq6f8LXY6RY7JdSR/3BD6wLUlPHSAesmY9vstNrjvqGaCiRK/91Q==} engines: {node: '>= 0.4'} @@ -8017,6 +7999,10 @@ packages: - ts-node - utf-8-validate + /js-sha256@0.11.0: + resolution: {integrity: sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8362,14 +8348,6 @@ packages: hasBin: true dev: false - /md5@2.3.0: - resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} - dependencies: - charenc: 0.0.2 - crypt: 0.0.2 - is-buffer: 1.1.6 - dev: false - /mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} @@ -8900,6 +8878,10 @@ packages: /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + /picocolors@1.0.1: + resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + dev: true + /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -11614,7 +11596,7 @@ packages: dependencies: browserslist: 4.22.2 escalade: 3.1.1 - picocolors: 1.0.0 + picocolors: 1.0.1 dev: true /update-browserslist-db@1.0.9(browserslist@4.21.4): diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index c846d4ddc..d52f2b877 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -93,6 +93,9 @@ export const ADMIN_NAV_MENUS = [ { name: 'users', }, + { + name: 'badges', + }, { name: 'customize', children: [ diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index ff7c09027..8a933471b 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -220,11 +220,19 @@ export interface SetNoticeReq { notice_switch: boolean; } +export interface NotificationBadgeAward { + notification_id: string; + badge_id: string; + name: string; + icon: string; + level: number; +} export interface NotificationStatus { inbox: number; achievement: number; revision: number; can_revision: boolean; + badge_award: NotificationBadgeAward | null; } export interface QuestionDetailRes { @@ -288,6 +296,7 @@ export interface LangsType { * @description interface for Question */ export type QuestionOrderBy = + | 'recommend' | 'newest' | 'active' | 'hot' @@ -328,6 +337,8 @@ export type UserFilterBy = | 'suspended' | 'deleted'; +export type BadgeFilterBy = 'all' | 'active' | 'inactive'; + export type InstalledPluginsFilterBy = | 'all' | 'active' @@ -733,3 +744,53 @@ export interface ReactionItem { tooltip: string; is_active: boolean; } + +export interface BadgeListItem { + id: string; + name: string; + icon: string; + award_count: number; + earned: boolean; + /** 1: bronze 2: silver 3:gold */ + level: number; + earned_count?: number; +} + +export interface BadgeListGroupItem { + badges: BadgeListItem[]; + group_name: string; +} + +export interface BadgeInfo extends BadgeListItem { + description: string; + earned_count: number; + is_single: boolean; +} + +export interface AdminBadgeListItem extends BadgeListItem { + group_name: string; + status: string; + description: string; +} + +export interface BadgeDetailListReq { + page: number; + page_size: number; + badge_id: string; + username?: string | null; +} +export interface BadgeDetailListItem { + created_at: number; + author_user_info: UserInfoBase; + object_type: string; + object_id: string; + url_title: string; + question_id: string; + answer_id: string; + comment_id: string; +} + +export interface BadgeDetailListRes { + count: number; + list: BadgeDetailListItem[]; +} diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index 91c4d1c57..a0e666e17 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -20,7 +20,7 @@ import React, { FC, useEffect, useState } from 'react'; import { Accordion, Nav } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useMatch } from 'react-router-dom'; +import { useNavigate, useMatch, NavLink } from 'react-router-dom'; import classNames from 'classnames'; @@ -38,30 +38,54 @@ function MenuNode({ const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); const isLeaf = !menu.children.length; const href = isLeaf ? `${path}${menu.path}` : '#'; - return ( - { - callback(evt, menu, href, isLeaf); - }} - href={href} - className={classNames( - 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, 'link-dark': activeKey !== menu.path }, - )}> - - {menu.displayName ? menu.displayName : t(menu.name)} - - {menu.badgeContent ? ( - {menu.badgeContent} - ) : null} - {!isLeaf && ( - - )} - + {isLeaf ? ( + { + callback(evt, menu, href, isLeaf); + }} + className={classNames( + 'text-nowrap d-flex flex-nowrap align-items-center w-100', + { expanding, 'link-dark': activeKey !== menu.path }, + )}> + + {menu.displayName ? menu.displayName : t(menu.name)} + + {menu.badgeContent ? ( + {menu.badgeContent} + ) : null} + {!isLeaf && ( + + )} + + ) : ( + { + callback(evt, menu, href, isLeaf); + }} + className={classNames( + 'text-nowrap d-flex flex-nowrap align-items-center w-100', + { expanding, 'link-dark': activeKey !== menu.path }, + )}> + + {menu.displayName ? menu.displayName : t(menu.name)} + + {menu.badgeContent ? ( + {menu.badgeContent} + ) : null} + {!isLeaf && ( + + )} + + )} + {menu.children.length ? ( <> diff --git a/internal/schema/plugin_option_schema.go b/ui/src/components/CardBadge/index.scss similarity index 80% rename from internal/schema/plugin_option_schema.go rename to ui/src/components/CardBadge/index.scss index 8db229738..30c30cb1a 100644 --- a/internal/schema/plugin_option_schema.go +++ b/ui/src/components/CardBadge/index.scss @@ -17,9 +17,20 @@ * under the License. */ -package schema +.badge-card { + width: 194px; + margin: 12px; -type GetEmbedOptionResp struct { - Platform string `json:"platform"` - Enable bool `json:"enable"` + .label { + position: absolute; + top: 1rem; + right: 1rem; + } +} + + +@media screen and (max-width: 768px) { + .badge-card { + width: 163.5px; + } } diff --git a/ui/src/components/CardBadge/index.tsx b/ui/src/components/CardBadge/index.tsx new file mode 100644 index 000000000..214e3f5d0 --- /dev/null +++ b/ui/src/components/CardBadge/index.tsx @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useTranslation } from 'react-i18next'; +import { FC } from 'react'; +import { Card, Badge } from 'react-bootstrap'; +import { Link } from 'react-router-dom'; + +import classnames from 'classnames'; + +import { Icon } from '@/components'; +import * as Type from '@/common/interface'; +import { formatCount } from '@/utils'; + +import './index.scss'; + +interface IProps { + data: Type.BadgeListItem; + showAwardedCount?: boolean; + urlSearchParams?: string; + badgePillType?: 'earned' | 'count'; +} + +const Index: FC = ({ + data, + badgePillType = 'earned', + showAwardedCount = false, + urlSearchParams, +}) => { + const { t } = useTranslation('translation', { keyPrefix: 'badges' }); + return ( + + + {Number(data?.earned_count) > 0 && badgePillType === 'earned' && ( + + {`${t('earned')}${ + Number(data?.earned_count) > 1 ? ` ×${data.earned_count}` : '' + }`} + + )} + + {badgePillType === 'count' && Number(data?.earned_count) > 1 && ( + + ×{data.earned_count} + + )} + {data.icon.startsWith('http') ? ( + {data.name} + ) : ( + + )} + +
{data.name}
+ {showAwardedCount && ( +
+ {t('×_awarded', { number: formatCount(data.award_count) })} +
+ )} +
+ + ); +}; + +export default Index; diff --git a/ui/src/components/Editor/index.tsx b/ui/src/components/Editor/index.tsx index dd0040219..ead37653d 100644 --- a/ui/src/components/Editor/index.tsx +++ b/ui/src/components/Editor/index.tsx @@ -27,7 +27,7 @@ import { import classNames from 'classnames'; -import { PluginType } from '@/utils/pluginKit'; +import { PluginType, useRenderPlugin } from '@/utils/pluginKit'; import PluginRender from '../PluginRender'; import { @@ -84,6 +84,8 @@ const MDEditor: ForwardRefRenderFunction = ( const editorRef = useRef(null); const previewRef = useRef<{ getHtml; element } | null>(null); + useRenderPlugin(previewRef.current?.element); + const editor = useEditor({ editorRef, onChange, diff --git a/ui/src/components/Editor/utils/index.ts b/ui/src/components/Editor/utils/index.ts index 0624418a8..759033cca 100644 --- a/ui/src/components/Editor/utils/index.ts +++ b/ui/src/components/Editor/utils/index.ts @@ -98,7 +98,6 @@ export const useEditor = ({ '.cm-line': { whiteSpace: 'pre-wrap', wordWrap: 'break-word', - wordBreak: 'break-all', }, '.ͼ7, .ͼ6': { textDecoration: 'none', diff --git a/ui/src/components/Header/components/NavItems/index.tsx b/ui/src/components/Header/components/NavItems/index.tsx index f3368b0f5..bd80729d3 100644 --- a/ui/src/components/Header/components/NavItems/index.tsx +++ b/ui/src/components/Header/components/NavItems/index.tsx @@ -35,24 +35,25 @@ interface Props { const Index: FC = ({ redDot, userInfo, logOut }) => { const { t } = useTranslation(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const navigate = useNavigate(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { agent: ucAgent } = userCenterStore(); const handleLinkClick = (evt) => { if (floppyNavigation.shouldProcessLinkClick(evt)) { evt.preventDefault(); const href = evt.currentTarget.getAttribute('href'); - navigate(href); + floppyNavigation.navigate(href, { + handler: navigate, + }); } }; return ( <> @@ -95,25 +95,31 @@ const Index: FC = ({ redDot, userInfo, logOut }) => { - 'dropdown-item'} onClick={handleLinkClick}> {t('header.nav.profile')} - - + 'dropdown-item'} onClick={handleLinkClick}> {t('header.nav.bookmark')} - - + 'dropdown-item'} onClick={handleLinkClick}> {t('header.nav.setting')} - + - logOut(e)}> + 'dropdown-item'} + onClick={(e) => logOut(e)}> {t('header.nav.logout')} - + {/* Dropdown for user center agent info */} diff --git a/ui/src/components/Modal/BadgeModal.tsx b/ui/src/components/Modal/BadgeModal.tsx new file mode 100644 index 000000000..303c96778 --- /dev/null +++ b/ui/src/components/Modal/BadgeModal.tsx @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FC, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import classNames from 'classnames'; + +import type * as Type from '@/common/interface'; +import { loggedUserInfoStore } from '@/stores'; +import { readNotification, useQueryNotificationStatus } from '@/services'; +import AnimateGift from '@/utils/animateGift'; +import Icon from '../Icon'; + +import Modal from './Modal'; + +interface BadgeModalProps { + badge?: Type.NotificationBadgeAward | null; + visible: boolean; +} + +let bg1: AnimateGift; +let bg2: AnimateGift; +let timeout: NodeJS.Timeout; +const BadgeModal: FC = ({ badge, visible }) => { + const { t } = useTranslation('translation', { keyPrefix: 'badges.modal' }); + const { user } = loggedUserInfoStore(); + const navigate = useNavigate(); + const { data, mutate } = useQueryNotificationStatus(); + + const handle = async () => { + if (!data) return; + await readNotification(badge?.notification_id); + await mutate({ + ...data, + badge_award: null, + }); + clearTimeout(timeout); + bg1?.destroy(); + bg2?.destroy(); + }; + const handleCancel = async () => { + await handle(); + }; + const handleConfirm = async () => { + await handle(); + + const url = `/badges/${badge?.badge_id}?username=${user.username}`; + navigate(url); + }; + + useEffect(() => { + const DURATION = 8000; + const LENGTH = 200; + const bgNode = document.documentElement || document.body; + + if (visible) { + const paranetNode = document.getElementById('badgeModal')?.parentNode; + + bg1 = new AnimateGift({ + elm: paranetNode, + width: bgNode.clientWidth, + height: bgNode.clientHeight, + length: LENGTH, + duration: DURATION, + isLoop: true, + }); + + timeout = setTimeout(() => { + bg2 = new AnimateGift({ + elm: paranetNode, + width: window.innerWidth, + height: window.innerHeight, + length: LENGTH, + duration: DURATION, + }); + }, DURATION / 2); + } + }, [visible]); + + return ( + + {badge && ( +
+ {badge.icon?.startsWith('http') ? ( + {badge.name} + ) : ( + + )} +
{badge?.name}
+

{t('content')}

+
+ )} +
+ ); +}; + +export default BadgeModal; diff --git a/ui/src/components/Modal/Modal.tsx b/ui/src/components/Modal/Modal.tsx index 4d66137a9..cbf98c249 100644 --- a/ui/src/components/Modal/Modal.tsx +++ b/ui/src/components/Modal/Modal.tsx @@ -22,6 +22,7 @@ import { Button, Modal } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; export interface Props { + id?: string; /** header title */ title?: string; children?: React.ReactNode; @@ -43,6 +44,7 @@ export interface Props { className?: string; } const Index: FC = ({ + id = '', title = 'title', visible = false, centered = true, @@ -63,6 +65,7 @@ const Index: FC = ({ const { t } = useTranslation(); return ( void; @@ -32,4 +33,4 @@ Modal.confirm = function (props: Config) { export default Modal; -export { LoginToContinueModal }; +export { LoginToContinueModal, BadgeModal }; diff --git a/ui/src/components/QueryGroup/index.tsx b/ui/src/components/QueryGroup/index.tsx index a201ae7aa..9b37c6e9c 100644 --- a/ui/src/components/QueryGroup/index.tsx +++ b/ui/src/components/QueryGroup/index.tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; +import { REACT_BASE_PATH } from '@/router/alias'; import { floppyNavigation } from '@/utils'; interface Props { @@ -82,7 +83,6 @@ const Index: FC = ({ const name = typeof btn === 'string' ? btn : btn.name; return (