From 0fbdba5ccf662ff88a4073c510b822d3b6d2f467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E5=AE=87=E5=AE=81?= Date: Tue, 29 Sep 2026 11:47:47 +0700 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 5.5 --- .codex/figma-reference/account-pc.png | Bin 0 -> 131869 bytes .gitignore | 1 + Makefile | 36 + app/Makefile | 30 + app/api/.gitkeep | 0 app/api/web/about.go | 8 + app/api/web/account_pagination.go | 52 + app/api/web/auth.go | 290 +++ app/api/web/chapterLike.go | 120 + app/api/web/chapterUnlock.go | 64 + app/api/web/chapterWatch.go | 75 + app/api/web/comment.go | 155 ++ app/api/web/compliance2257.go | 8 + app/api/web/dmca.go | 8 + app/api/web/favorites.go | 338 +++ app/api/web/feedback.go | 154 ++ app/api/web/guessLike.go | 35 + app/api/web/help.go | 23 + app/api/web/history.go | 375 +++ app/api/web/index.go | 59 + app/api/web/list.go | 100 + app/api/web/login.go | 11 + app/api/web/moreList.go | 96 + app/api/web/moreSeriesList.go | 70 + app/api/web/nav.go | 15 + app/api/web/notFound.go | 14 + app/api/web/pagination.go | 8 + app/api/web/play.go | 388 ++++ app/api/web/playletLike.go | 64 + app/api/web/playlet_collect_set.go | 64 + app/api/web/popularRecommend.go | 53 + app/api/web/privacy.go | 8 + app/api/web/profile.go | 229 ++ app/api/web/purchased.go | 87 + app/api/web/rank.go | 53 + app/api/web/recentlyAdded.go | 48 + app/api/web/recharge.go | 279 +++ app/api/web/register.go | 11 + app/api/web/render.go | 660 ++++++ app/api/web/render_test.go | 176 ++ app/api/web/search.go | 284 +++ app/api/web/seo.go | 56 + app/api/web/seo_filters.go | 30 + app/api/web/series.go | 103 + app/api/web/setting.go | 85 + app/api/web/tags.go | 89 + app/api/web/terms.go | 8 + app/api/web/topic.go | 58 + app/api/web/topicDetail.go | 97 + app/api/web/tracker_page.go | 80 + app/api/web/videoCollect.go | 64 + app/api/web/videoLike.go | 64 + app/appg/appg.go | 46 + app/conf/app.yaml | 11 + app/conf/m3u8.key | 1 + app/deploy_prod.sh | 28 + app/innercache/.gitkeep | 0 app/main.go | 10 + app/middleware/.gitkeep | 0 app/router/client_headers.go | 16 + app/router/gin.go | 264 +++ app/router/html.go | 34 + app/router/m3u8_proxy.go | 187 ++ app/router/sitemap.go | 80 + app/server/server.go | 182 ++ app/service/applistser/list.go | 37 + app/service/applistser/types.go | 38 + app/service/applistser/view.go | 83 + app/service/authser/auth.go | 290 +++ app/service/authser/guest_enter.go | 314 +++ app/service/authser/guest_enter_test.go | 345 +++ app/service/authser/request_state.go | 67 + app/service/authser/session.go | 58 + app/service/authser/types.go | 95 + app/service/authser/view.go | 97 + app/service/authser/vip.go | 187 ++ app/service/commentser/like.go | 22 + app/service/commentser/list.go | 143 ++ app/service/commentser/send.go | 43 + app/service/commentser/tplfuncs.go | 11 + app/service/commentser/types.go | 83 + app/service/configser/footer.go | 51 + app/service/configser/home_road.go | 88 + app/service/configser/info.go | 56 + app/service/configser/richtext.go | 130 ++ app/service/configser/seo.go | 94 + app/service/configser/tracker.go | 25 + app/service/configser/types.go | 185 ++ app/service/configser/view.go | 244 ++ app/service/feedbackser/feedback.go | 30 + app/service/feedbackser/types.go | 23 + app/service/playletser/bought.go | 56 + app/service/playletser/category_detail.go | 81 + .../playletser/category_detail_view.go | 63 + app/service/playletser/chapter.go | 272 +++ app/service/playletser/chapter_collect.go | 84 + app/service/playletser/chapter_like.go | 63 + app/service/playletser/chapter_unlock.go | 40 + app/service/playletser/chapter_watch.go | 39 + app/service/playletser/collect.go | 161 ++ app/service/playletser/collect_view.go | 89 + app/service/playletser/detail.go | 133 ++ app/service/playletser/envelope.go | 69 + app/service/playletser/forward_heat.go | 33 + app/service/playletser/guess_like.go | 132 ++ app/service/playletser/history.go | 166 ++ app/service/playletser/history_view.go | 229 ++ app/service/playletser/home.go | 239 ++ app/service/playletser/home_view.go | 37 + app/service/playletser/hot_like.go | 44 + app/service/playletser/hot_rank.go | 83 + app/service/playletser/hot_rank_view.go | 82 + app/service/playletser/like.go | 35 + app/service/playletser/list.go | 355 +++ app/service/playletser/list_view.go | 254 ++ app/service/playletser/more_list.go | 152 ++ app/service/playletser/more_list_view.go | 177 ++ app/service/playletser/operate.go | 81 + app/service/playletser/rank.go | 80 + app/service/playletser/rank_view.go | 111 + app/service/playletser/recommend.go | 83 + app/service/playletser/recommend_view.go | 64 + app/service/playletser/search.go | 176 ++ app/service/playletser/seo.go | 381 +++ app/service/playletser/series_view.go | 123 + app/service/playletser/short_play_view.go | 110 + app/service/playletser/tag.go | 155 ++ app/service/playletser/tag_view.go | 291 +++ app/service/playletser/theme_index.go | 72 + app/service/playletser/today_update.go | 67 + app/service/playletser/today_update_view.go | 36 + app/service/playletser/topic_detail.go | 218 ++ app/service/playletser/topic_list.go | 167 ++ app/service/playletser/tplfuncs.go | 488 ++++ app/service/playletser/types.go | 429 ++++ app/service/playletser/view.go | 180 ++ app/service/rechargeser/list.go | 281 +++ app/service/rechargeser/types.go | 125 + app/service/rechargeser/view.go | 496 ++++ app/service/seoser/indexable.go | 180 ++ app/service/seoser/jsonld.go | 940 ++++++++ app/service/seoser/llms.go | 59 + app/service/seoser/robots.go | 75 + app/service/seoser/site.go | 226 ++ app/service/seoser/verify.go | 59 + app/service/translationser/list.go | 90 + app/service/translationser/types.go | 46 + app/service/translationser/view.go | 465 ++++ app/service/upstream/client.go | 222 ++ app/service/usercenterser/center.go | 42 + app/service/videoser/collect.go | 130 ++ app/service/videoser/detail.go | 70 + app/service/videoser/history.go | 141 ++ app/service/videoser/like.go | 37 + app/service/videoser/list.go | 60 + app/service/videoser/topic.go | 186 ++ app/service/videoser/types.go | 203 ++ app/static/common/DPlayer.min.css | 4 + app/static/common/DPlayer.min.js | 2 + app/static/common/hls.min.js | 2 + app/static/common/imgDecypt.js | 298 +++ app/static/common/qrcode.min.js | 1 + app/static/common/swiper-bundle.min.css | 13 + app/static/common/swiper-bundle.min.js | 14 + app/static/common/web-sdk-v1.2.0.js | 1 + app/static/css/about-mobile.css | 452 ++++ app/static/css/about.css | 415 ++++ app/static/css/account-empty.css | 72 + app/static/css/ads.css | 2 + app/static/css/base.css | 772 ++++++ app/static/css/dialogAd.css | 223 ++ app/static/css/downloadPop.css | 155 ++ app/static/css/eighteen.css | 142 ++ app/static/css/favorites-mobile.css | 266 +++ app/static/css/favorites.css | 352 +++ app/static/css/feedback.css | 667 ++++++ app/static/css/footer.css | 257 ++ app/static/css/header.css | 1196 ++++++++++ app/static/css/help.css | 449 ++++ app/static/css/history-mobile.css | 256 ++ app/static/css/history.css | 256 ++ app/static/css/index.css | 1366 +++++++++++ app/static/css/legal.css | 478 ++++ app/static/css/list.css | 246 ++ app/static/css/login.css | 283 +++ app/static/css/loginPop.css | 308 +++ app/static/css/mobile-search.css | 521 +++++ app/static/css/moreList.css | 12 + app/static/css/nav.css | 428 ++++ app/static/css/notFound.css | 106 + app/static/css/pagination.css | 68 + app/static/css/play.css | 1819 +++++++++++++++ app/static/css/playComments.css | 396 ++++ app/static/css/playLinks.css | 67 + app/static/css/popularRecommend.css | 12 + app/static/css/profile-mobile.css | 590 +++++ app/static/css/profile.css | 557 +++++ app/static/css/promo.css | 155 ++ app/static/css/purchased-mobile.css | 152 ++ app/static/css/purchased.css | 148 ++ app/static/css/rank.css | 350 +++ app/static/css/recentlyAdded.css | 12 + app/static/css/recharge-mobile.css | 117 + app/static/css/recharge-modal.css | 516 +++++ app/static/css/recharge.css | 271 +++ app/static/css/register.css | 255 ++ app/static/css/registerPop.css | 304 +++ app/static/css/search.css | 219 ++ app/static/css/series.css | 201 ++ app/static/css/setting.css | 65 + app/static/css/tags.css | 24 + app/static/css/toast.css | 77 + app/static/css/topic.css | 179 ++ app/static/css/topicDetail.css | 527 +++++ app/static/embed.go | 9 + app/static/favicon.ico | Bin 0 -> 4286 bytes app/static/favicon.png | Bin 0 -> 8062 bytes app/static/favicon.svg | 9 + app/static/images/18jia.svg | 5 + app/static/images/about/about-hero.png | Bin 0 -> 331667 bytes app/static/images/app-qr.png | Bin 0 -> 17289 bytes app/static/images/arrow-right-line.svg | 4 + app/static/images/arrow-up-right.svg | 4 + app/static/images/badge_bj.png | Bin 0 -> 2744 bytes app/static/images/badge_yc.png | Bin 0 -> 2044 bytes app/static/images/badge_yj.png | Bin 0 -> 899 bytes app/static/images/bell.svg | 4 + app/static/images/bookmark.svg | 3 + app/static/images/brand-android.svg | 3 + app/static/images/brand-apple.svg | 10 + app/static/images/brand-instagram.svg | 10 + app/static/images/brand-telegram.svg | 10 + app/static/images/brand-tiktok.svg | 10 + app/static/images/brand-x.svg | 10 + app/static/images/brand-youtube.svg | 3 + app/static/images/check-circle.svg | 4 + app/static/images/check.svg | 3 + app/static/images/checked.svg | 11 + app/static/images/chevron-left.svg | 3 + app/static/images/chevron-right.svg | 3 + app/static/images/clock-dim.svg | 4 + app/static/images/close.svg | 4 + app/static/images/close2.svg | 5 + app/static/images/coin.png | Bin 0 -> 1423 bytes app/static/images/copy.svg | 4 + app/static/images/download.svg | 5 + app/static/images/error-tip.svg | 5 + app/static/images/external-link.svg | 5 + app/static/images/eye-off.svg | 4 + app/static/images/eye.svg | 4 + .../images/favorites-empty/arrow-right.svg | 4 + .../images/favorites-empty/bookmark.svg | 3 + app/static/images/favorites/sliders.svg | 11 + app/static/images/feedback/help-circle.svg | 12 + app/static/images/film.svg | 17 + app/static/images/film2.svg | 17 + app/static/images/gift-dim.svg | 7 + app/static/images/gift.svg | 7 + app/static/images/heart.svg | 3 + app/static/images/help-circle.svg | 5 + app/static/images/home.svg | 4 + app/static/images/image-dim.svg | 5 + app/static/images/link_award.svg | 4 + app/static/images/link_globe.svg | 12 + app/static/images/link_refresh-cw.svg | 5 + app/static/images/link_send.svg | 11 + app/static/images/link_shield.svg | 3 + app/static/images/link_smartphone.svg | 4 + app/static/images/lock-dim.svg | 4 + app/static/images/lock.svg | 4 + app/static/images/logout.svg | 5 + app/static/images/mail-dim.svg | 4 + app/static/images/mail.svg | 4 + app/static/images/menu.svg | 5 + app/static/images/menu_dj.svg | 10 + app/static/images/menu_gkls.svg | 4 + app/static/images/menu_grzx.svg | 4 + app/static/images/menu_jbcz.svg | 3 + app/static/images/menu_jj.svg | 4 + app/static/images/menu_phb.svg | 4 + app/static/images/menu_sc.svg | 3 + app/static/images/menu_sy.svg | 4 + app/static/images/menu_zhsz.svg | 11 + app/static/images/menu_zt.svg | 5 + app/static/images/message-circle.svg | 3 + app/static/images/nav_bg.webp | Bin 0 -> 61925 bytes app/static/images/nav_grid.svg | 4 + app/static/images/nav_star.svg | 3 + app/static/images/nav_trend.svg | 4 + app/static/images/nav_zap.svg | 3 + app/static/images/ok.svg | 5 + app/static/images/placeholder.png | Bin 0 -> 20170 bytes app/static/images/play.svg | 3 + app/static/images/play_hollow.svg | 3 + app/static/images/plus-dim.svg | 4 + app/static/images/plus.svg | 4 + app/static/images/profile/avatar.png | Bin 0 -> 37995 bytes app/static/images/profile/water-margin.jpg | Bin 0 -> 92764 bytes .../images/purchased-empty/bag-play.svg | 5 + app/static/images/recharge/award.svg | 4 + app/static/images/recharge/back.svg | 3 + app/static/images/recharge/check-circle.svg | 4 + app/static/images/recharge/check.svg | 3 + app/static/images/recharge/close.svg | 4 + app/static/images/recharge/coin-star.svg | 3 + app/static/images/recharge/coin3d.png | Bin 0 -> 348795 bytes app/static/images/recharge/download.svg | 5 + app/static/images/recharge/help.svg | 5 + app/static/images/recharge/lock.svg | 4 + app/static/images/recharge/zap.svg | 3 + app/static/images/search.svg | 4 + app/static/images/search/search-cover-1.jpg | Bin 0 -> 112871 bytes app/static/images/search/search-cover-10.jpg | Bin 0 -> 109691 bytes app/static/images/search/search-cover-11.jpg | Bin 0 -> 86125 bytes app/static/images/search/search-cover-12.jpg | Bin 0 -> 61257 bytes app/static/images/search/search-cover-13.jpg | Bin 0 -> 76521 bytes app/static/images/search/search-cover-2.jpg | Bin 0 -> 67358 bytes app/static/images/search/search-cover-3.jpg | Bin 0 -> 81347 bytes app/static/images/search/search-cover-4.jpg | Bin 0 -> 65070 bytes app/static/images/search/search-cover-5.jpg | Bin 0 -> 81677 bytes app/static/images/search/search-cover-6.jpg | Bin 0 -> 94660 bytes app/static/images/search/search-cover-7.jpg | Bin 0 -> 87414 bytes app/static/images/search/search-cover-8.jpg | Bin 0 -> 76953 bytes app/static/images/search/search-cover-9.jpg | Bin 0 -> 70675 bytes app/static/images/search_h5.svg | 4 + app/static/images/send.svg | 4 + app/static/images/settings/eye.svg | 4 + app/static/images/settings/image.svg | 5 + app/static/images/settings/lock.svg | 4 + app/static/images/settings/mail.svg | 4 + app/static/images/settings/message.svg | 3 + app/static/images/settings/shield.svg | 3 + app/static/images/settings/user.svg | 4 + app/static/images/share.svg | 7 + app/static/images/trending-up.svg | 4 + app/static/images/user.svg | 4 + app/static/js/common/auth.js | 515 ++++ app/static/js/common/channel.js | 92 + app/static/js/common/compat.js | 143 ++ app/static/js/common/cookie.js | 204 ++ app/static/js/common/qr.js | 22 + app/static/js/common/request.js | 325 +++ app/static/js/common/sharePage.js | 67 + app/static/js/common/toast.js | 90 + app/static/js/eighteen.js | 55 + app/static/js/favorites-manage.js | 56 + app/static/js/favorites.js | 111 + app/static/js/header.js | 633 +++++ app/static/js/hero.js | 337 +++ app/static/js/history.js | 115 + app/static/js/mobile-search.js | 320 +++ app/static/js/pages/dialogAd.js | 264 +++ app/static/js/pages/downloadPop.js | 36 + app/static/js/pages/feedback.js | 313 +++ app/static/js/pages/help.js | 15 + app/static/js/pages/legal.js | 106 + app/static/js/pages/login.js | 137 ++ app/static/js/pages/loginPop.js | 252 ++ app/static/js/pages/play.js | 2062 +++++++++++++++++ app/static/js/pages/playComments.js | 599 +++++ app/static/js/pages/register.js | 169 ++ app/static/js/pages/registerPop.js | 256 ++ app/static/js/recharge-modal.js | 246 ++ app/static/js/recharge.js | 322 +++ app/static/js/search.js | 106 + app/static/js/setting.js | 143 ++ app/static/js/tracker.js | 921 ++++++++ app/static/logo.png | Bin 0 -> 10150 bytes app/static/logo2.png | Bin 0 -> 14283 bytes app/static/logo_icon.png | Bin 0 -> 8062 bytes app/static/og-default.png | Bin 0 -> 33616 bytes app/static/sitemap.xsl | 45 + app/static/sitemapindex.xsl | 45 + app/template/base/accountPagination.html | 9 + app/template/base/ads.html | 15 + app/template/base/dialogAd.html | 64 + app/template/base/downloadPop.html | 29 + app/template/base/eighteen.html | 27 + app/template/base/footer.html | 82 + app/template/base/header.html | 294 +++ app/template/base/loginPop.html | 93 + app/template/base/pagination.html | 41 + app/template/base/playComments.html | 115 + app/template/base/playLinks.html | 43 + app/template/base/rechargeModal.html | 87 + app/template/base/registerPop.html | 124 + app/template/base/seoCommon.html | 30 + app/template/base/subpageHeader.html | 7 + app/template/base/toast.html | 19 + app/template/base/trackerInit.html | 36 + app/template/base/userCenterShell.html | 46 + app/template/fs.go | 6 + app/template/pages/2257.html | 63 + app/template/pages/404.html | 34 + app/template/pages/about.html | 95 + app/template/pages/dmca.html | 68 + app/template/pages/favorites.html | 69 + app/template/pages/feedback.html | 159 ++ app/template/pages/help.html | 81 + app/template/pages/history.html | 79 + app/template/pages/index.html | 342 +++ app/template/pages/list.html | 110 + app/template/pages/login.html | 121 + app/template/pages/moreList.html | 114 + app/template/pages/moreSeriesList.html | 113 + app/template/pages/nav.html | 104 + app/template/pages/play.html | 536 +++++ app/template/pages/popularRecommend.html | 99 + app/template/pages/privacy.html | 90 + app/template/pages/profile.html | 62 + app/template/pages/purchased.html | 51 + app/template/pages/rank.html | 98 + app/template/pages/recentlyAdded.html | 88 + app/template/pages/recharge.html | 103 + app/template/pages/register.html | 146 ++ app/template/pages/robots.txt | 14 + app/template/pages/search.html | 111 + app/template/pages/searchMobile.html | 91 + app/template/pages/series.html | 112 + app/template/pages/seriesPlay.html | 342 +++ app/template/pages/setting.html | 86 + app/template/pages/tags.html | 99 + app/template/pages/terms.html | 87 + app/template/pages/topic.html | 82 + app/template/pages/topicDetail.html | 204 ++ go.mod | 91 + go.sum | 442 ++++ make.sh | 44 + .../indexation-analysis-20260908.md | 187 ++ .../seo优化清单-91av-wz-20260908.xlsx | Bin 0 -> 31158 bytes .../tdk-source-analysis-20260908.md | 98 + .../~$seo优化清单-91av-wz-20260908.xlsx | Bin 0 -> 165 bytes ...seo-audit-jha-1045-hgdjnet-web-20260922.md | 365 +++ project.md | 83 + pub/common/comm/comm.go | 138 ++ pub/common/common.go | 131 ++ pub/common/convUtil.go | 135 ++ pub/common/cronmgr/crommgr.go | 119 + pub/common/cronx/cron.go | 148 ++ pub/common/crypt/aesecb.go | 43 + pub/common/crypt/crypt.go | 566 +++++ pub/common/crypt/rsa.go | 145 ++ pub/common/enum/consts/consts.go | 126 + pub/common/enum/redisconst/const.go | 100 + pub/common/enum/video/video.go | 27 + pub/common/errors/code.go | 169 ++ pub/common/errors/error.go | 39 + pub/common/errors/make.go | 24 + pub/common/errors/msg_app.go | 19 + pub/common/fconst/fconst.go | 6 + pub/common/godef/godef.go | 26 + pub/common/googauth/googauth.go | 34 + pub/common/googauth/htop.go | 67 + pub/common/http/client_headers.go | 27 + pub/common/http/forward_headers.go | 24 + pub/common/http/http.go | 242 ++ pub/common/httputil/httputil.go | 321 +++ pub/common/linode/linode.go | 212 ++ pub/common/lock/cache.go | 86 + pub/common/lock/dict.go | 143 ++ pub/common/lock/queue.go | 75 + pub/common/log/log.go | 135 ++ .../log/zapcorex/bufferpool/bufferpool.go | 31 + pub/common/log/zapcorex/color.go | 44 + pub/common/log/zapcorex/console_encoder.go | 150 ++ pub/common/log/zapcorex/field.go | 9 + pub/common/log/zapcorex/json_encoder.go | 518 +++++ pub/common/log/zapcorex/memory_encoder.go | 183 ++ pub/common/m3u8/enkey.go | 44 + pub/common/m3u8/media_play_list.go | 42 + pub/common/m3u8/play_list.go | 146 ++ pub/common/m3u8/transformer.go | 133 ++ pub/common/m3u8/types.go | 13 + pub/common/m3u8/validate.go | 18 + pub/common/maths/maths.go | 68 + pub/common/netutil/netutil.go | 52 + pub/common/pageopt/match.go | 580 +++++ pub/common/pointers/pointers.go | 83 + pub/common/random/number.go | 53 + pub/common/recomapi/define.go | 42 + pub/common/recomapi/feedback.go | 45 + pub/common/recomapi/itemapi.go | 106 + pub/common/recomapi/recomapi.go | 36 + pub/common/recomapi/usersapi.go | 1 + pub/common/regapi.go | 26 + pub/common/sms/fengniao/fengniao.go | 76 + pub/common/sms/sms.go | 45 + pub/common/srvdef/codedef.go | 328 +++ pub/common/srvdef/msgdef.go | 58 + pub/common/srvdef/srvdef.go | 67 + pub/common/srvdef/tipdef.go | 162 ++ pub/common/srvversion/version.go | 46 + pub/common/swaggerutil/swagger.go | 27 + pub/common/synclock/lock.go | 102 + pub/common/tgbot/helper.go | 94 + pub/common/tgbot/tgbot.go | 169 ++ pub/common/timehelper.go | 55 + pub/common/topicmessage/topicMessage.go | 79 + pub/common/typeConv.go | 64 + pub/common/useragent/build.go | 542 +++++ pub/common/useragent/sdk_common.go | 80 + pub/common/useragent/ua.go | 97 + pub/common/utils/array.go | 292 +++ pub/common/utils/convUtil.go | 184 ++ pub/common/utils/copy.go | 130 ++ pub/common/utils/file.go | 56 + pub/common/utils/hash.go | 12 + pub/common/utils/http.go | 283 +++ pub/common/utils/jwt.go | 127 + pub/common/utils/num.go | 20 + pub/common/utils/page.go | 19 + pub/common/utils/path.go | 72 + pub/common/utils/redis.go | 56 + pub/common/utils/shellcmd.go | 15 + pub/common/utils/slices.go | 116 + pub/common/utils/strings.go | 27 + pub/common/utils/structToMap.go | 52 + pub/common/utils/time.go | 276 +++ pub/common/utils/uasplit.go | 30 + pub/common/utils/utils.go | 63 + pub/common/utils/verify.go | 10 + pub/common/utils/version.go | 43 + pub/common/v10/upgradev10.go | 55 + pub/common/v10/validate.go | 171 ++ pub/common/wordsfilter/filter.go | 113 + pub/common/wordsfilter/trie_tree.go | 272 +++ pub/esmanager/client.go | 165 ++ pub/esmanager/elastic.go | 143 ++ pub/esmanager/manager.go | 53 + pub/google/googleAuth.go | 92 + pub/models/analyticsdailystat/analytics.go | 182 ++ pub/models/analyticsdailystat/tag.go | 42 + pub/models/analyticsdailystat/type.go | 27 + pub/models/analyticsstat/analytics.go | 182 ++ pub/models/analyticsstat/tag.go | 42 + pub/models/analyticsstat/type.go | 27 + pub/models/cfg/dbhelper.go | 9 + pub/models/cfg/dbop.go | 55 + pub/models/cfg/tag.go | 68 + pub/models/cfg/type.go | 42 + pub/models/commentmod/comment.go | 206 ++ pub/models/commentmod/tag.go | 44 + pub/models/commentmod/type.go | 26 + pub/models/esmod/eshelper.go | 54 + pub/models/esmod/esop.go | 124 + pub/models/esmod/recordop.go | 144 ++ pub/models/esmod/tag.go | 90 + pub/models/esmod/type.go | 85 + pub/models/esmod/video.go | 135 ++ pub/models/ipblackmod/ipblack.go | 109 + pub/models/ipblackmod/tag.go | 26 + pub/models/ipblackmod/type.go | 18 + pub/models/models.go | 53 + pub/models/mwacc/acc.go | 170 ++ pub/models/mwacc/dbhelper.go | 13 + pub/models/mwacc/drop.go | 66 + pub/models/mwacc/fun.go | 61 + pub/models/mwacc/mwacc_gen.go | 130 ++ pub/models/mwacc/role.go | 158 ++ pub/models/mwacc/tag.go | 88 + pub/models/mwacc/token.go | 84 + pub/models/mwacc/type.go | 78 + pub/models/mwacc/web.go | 53 + pub/models/oplog/dbhelper.go | 9 + pub/models/oplog/oplog.go | 175 ++ pub/models/oplog/tag.go | 34 + pub/models/oplog/type.go | 22 + .../richtextcontentmod/richtextcontent.go | 153 ++ pub/models/richtextcontentmod/tag.go | 40 + pub/models/richtextcontentmod/type.go | 22 + pub/models/skdrecord/record.go | 153 ++ pub/models/skdrecord/tag.go | 30 + pub/models/skdrecord/type.go | 27 + pub/models/syncfilemod/sync.go | 108 + pub/models/syncfilemod/tag.go | 30 + pub/models/syncfilemod/type.go | 32 + pub/models/translationcategorymod/category.go | 112 + pub/models/translationcategorymod/tag.go | 30 + pub/models/translationcategorymod/type.go | 17 + pub/models/translationmod/tag.go | 36 + pub/models/translationmod/translation.go | 112 + pub/models/translationmod/type.go | 20 + pub/models/utils/dbhelper.go | 1 + pub/models/utils/dbop.go | 11 + pub/models/utils/tag.go | 30 + pub/models/utils/type.go | 18 + pub/models/videocategorymod/category.go | 132 ++ pub/models/videocategorymod/tag.go | 46 + pub/models/videocategorymod/type.go | 30 + pub/models/videocollectionmediamod/tag.go | 30 + pub/models/videocollectionmediamod/type.go | 21 + .../videocollection.go | 154 ++ pub/models/videocreatermod/creater.go | 127 + pub/models/videocreatermod/tag.go | 38 + pub/models/videocreatermod/type.go | 19 + pub/models/videoinformationmod/information.go | 248 ++ pub/models/videoinformationmod/tag.go | 100 + pub/models/videoinformationmod/type.go | 85 + pub/models/videotagmod/tag.go | 38 + pub/models/videotagmod/taginfo.go | 132 ++ pub/models/videotagmod/type.go | 19 + pub/mongos/QueryBuilder.go | 150 ++ pub/mongos/base_mod.go | 582 +++++ pub/mongos/codec.go | 119 + pub/mongos/generate_id.go | 80 + pub/mongos/lid.go | 102 + pub/mongos/manager.go | 168 ++ pub/pubdef/pubdef.go | 25 + pub/pubdef/user.go | 24 + pub/redis/List.go | 95 + pub/redis/channeldef.go | 75 + pub/redis/hash.go | 112 + pub/redis/manager.go | 177 ++ pub/redis/options.go | 9 + pub/redis/redis.go | 275 +++ pub/redis/zset.go | 136 ++ pub/storage/proxy/object.go | 384 +++ pub/storage/proxy/test.html | 3 + pub/storage/proxy/types.go | 404 ++++ pub/storage/proxy/utils.go | 139 ++ pub/storage/upload.go | 276 +++ read.md | 5 + 622 files changed, 72105 insertions(+) create mode 100644 .codex/figma-reference/account-pc.png create mode 100644 .gitignore create mode 100644 Makefile create mode 100755 app/Makefile create mode 100644 app/api/.gitkeep create mode 100644 app/api/web/about.go create mode 100644 app/api/web/account_pagination.go create mode 100644 app/api/web/auth.go create mode 100644 app/api/web/chapterLike.go create mode 100644 app/api/web/chapterUnlock.go create mode 100644 app/api/web/chapterWatch.go create mode 100644 app/api/web/comment.go create mode 100644 app/api/web/compliance2257.go create mode 100644 app/api/web/dmca.go create mode 100644 app/api/web/favorites.go create mode 100644 app/api/web/feedback.go create mode 100644 app/api/web/guessLike.go create mode 100644 app/api/web/help.go create mode 100644 app/api/web/history.go create mode 100644 app/api/web/index.go create mode 100644 app/api/web/list.go create mode 100644 app/api/web/login.go create mode 100644 app/api/web/moreList.go create mode 100644 app/api/web/moreSeriesList.go create mode 100644 app/api/web/nav.go create mode 100644 app/api/web/notFound.go create mode 100644 app/api/web/pagination.go create mode 100644 app/api/web/play.go create mode 100644 app/api/web/playletLike.go create mode 100644 app/api/web/playlet_collect_set.go create mode 100644 app/api/web/popularRecommend.go create mode 100644 app/api/web/privacy.go create mode 100644 app/api/web/profile.go create mode 100644 app/api/web/purchased.go create mode 100644 app/api/web/rank.go create mode 100644 app/api/web/recentlyAdded.go create mode 100644 app/api/web/recharge.go create mode 100644 app/api/web/register.go create mode 100644 app/api/web/render.go create mode 100644 app/api/web/render_test.go create mode 100644 app/api/web/search.go create mode 100644 app/api/web/seo.go create mode 100644 app/api/web/seo_filters.go create mode 100644 app/api/web/series.go create mode 100644 app/api/web/setting.go create mode 100644 app/api/web/tags.go create mode 100644 app/api/web/terms.go create mode 100644 app/api/web/topic.go create mode 100644 app/api/web/topicDetail.go create mode 100644 app/api/web/tracker_page.go create mode 100644 app/api/web/videoCollect.go create mode 100644 app/api/web/videoLike.go create mode 100644 app/appg/appg.go create mode 100644 app/conf/app.yaml create mode 100644 app/conf/m3u8.key create mode 100755 app/deploy_prod.sh create mode 100644 app/innercache/.gitkeep create mode 100644 app/main.go create mode 100644 app/middleware/.gitkeep create mode 100644 app/router/client_headers.go create mode 100644 app/router/gin.go create mode 100644 app/router/html.go create mode 100644 app/router/m3u8_proxy.go create mode 100644 app/router/sitemap.go create mode 100644 app/server/server.go create mode 100644 app/service/applistser/list.go create mode 100644 app/service/applistser/types.go create mode 100644 app/service/applistser/view.go create mode 100644 app/service/authser/auth.go create mode 100644 app/service/authser/guest_enter.go create mode 100644 app/service/authser/guest_enter_test.go create mode 100644 app/service/authser/request_state.go create mode 100644 app/service/authser/session.go create mode 100644 app/service/authser/types.go create mode 100644 app/service/authser/view.go create mode 100644 app/service/authser/vip.go create mode 100644 app/service/commentser/like.go create mode 100644 app/service/commentser/list.go create mode 100644 app/service/commentser/send.go create mode 100644 app/service/commentser/tplfuncs.go create mode 100644 app/service/commentser/types.go create mode 100644 app/service/configser/footer.go create mode 100644 app/service/configser/home_road.go create mode 100644 app/service/configser/info.go create mode 100644 app/service/configser/richtext.go create mode 100644 app/service/configser/seo.go create mode 100644 app/service/configser/tracker.go create mode 100644 app/service/configser/types.go create mode 100644 app/service/configser/view.go create mode 100644 app/service/feedbackser/feedback.go create mode 100644 app/service/feedbackser/types.go create mode 100644 app/service/playletser/bought.go create mode 100644 app/service/playletser/category_detail.go create mode 100644 app/service/playletser/category_detail_view.go create mode 100644 app/service/playletser/chapter.go create mode 100644 app/service/playletser/chapter_collect.go create mode 100644 app/service/playletser/chapter_like.go create mode 100644 app/service/playletser/chapter_unlock.go create mode 100644 app/service/playletser/chapter_watch.go create mode 100644 app/service/playletser/collect.go create mode 100644 app/service/playletser/collect_view.go create mode 100644 app/service/playletser/detail.go create mode 100644 app/service/playletser/envelope.go create mode 100644 app/service/playletser/forward_heat.go create mode 100644 app/service/playletser/guess_like.go create mode 100644 app/service/playletser/history.go create mode 100644 app/service/playletser/history_view.go create mode 100644 app/service/playletser/home.go create mode 100644 app/service/playletser/home_view.go create mode 100644 app/service/playletser/hot_like.go create mode 100644 app/service/playletser/hot_rank.go create mode 100644 app/service/playletser/hot_rank_view.go create mode 100644 app/service/playletser/like.go create mode 100644 app/service/playletser/list.go create mode 100644 app/service/playletser/list_view.go create mode 100644 app/service/playletser/more_list.go create mode 100644 app/service/playletser/more_list_view.go create mode 100644 app/service/playletser/operate.go create mode 100644 app/service/playletser/rank.go create mode 100644 app/service/playletser/rank_view.go create mode 100644 app/service/playletser/recommend.go create mode 100644 app/service/playletser/recommend_view.go create mode 100644 app/service/playletser/search.go create mode 100644 app/service/playletser/seo.go create mode 100644 app/service/playletser/series_view.go create mode 100644 app/service/playletser/short_play_view.go create mode 100644 app/service/playletser/tag.go create mode 100644 app/service/playletser/tag_view.go create mode 100644 app/service/playletser/theme_index.go create mode 100644 app/service/playletser/today_update.go create mode 100644 app/service/playletser/today_update_view.go create mode 100644 app/service/playletser/topic_detail.go create mode 100644 app/service/playletser/topic_list.go create mode 100644 app/service/playletser/tplfuncs.go create mode 100644 app/service/playletser/types.go create mode 100644 app/service/playletser/view.go create mode 100644 app/service/rechargeser/list.go create mode 100644 app/service/rechargeser/types.go create mode 100644 app/service/rechargeser/view.go create mode 100644 app/service/seoser/indexable.go create mode 100644 app/service/seoser/jsonld.go create mode 100644 app/service/seoser/llms.go create mode 100644 app/service/seoser/robots.go create mode 100644 app/service/seoser/site.go create mode 100644 app/service/seoser/verify.go create mode 100644 app/service/translationser/list.go create mode 100644 app/service/translationser/types.go create mode 100644 app/service/translationser/view.go create mode 100644 app/service/upstream/client.go create mode 100644 app/service/usercenterser/center.go create mode 100644 app/service/videoser/collect.go create mode 100644 app/service/videoser/detail.go create mode 100644 app/service/videoser/history.go create mode 100644 app/service/videoser/like.go create mode 100644 app/service/videoser/list.go create mode 100644 app/service/videoser/topic.go create mode 100644 app/service/videoser/types.go create mode 100644 app/static/common/DPlayer.min.css create mode 100644 app/static/common/DPlayer.min.js create mode 100644 app/static/common/hls.min.js create mode 100644 app/static/common/imgDecypt.js create mode 100644 app/static/common/qrcode.min.js create mode 100644 app/static/common/swiper-bundle.min.css create mode 100644 app/static/common/swiper-bundle.min.js create mode 100644 app/static/common/web-sdk-v1.2.0.js create mode 100644 app/static/css/about-mobile.css create mode 100644 app/static/css/about.css create mode 100644 app/static/css/account-empty.css create mode 100644 app/static/css/ads.css create mode 100644 app/static/css/base.css create mode 100644 app/static/css/dialogAd.css create mode 100644 app/static/css/downloadPop.css create mode 100644 app/static/css/eighteen.css create mode 100644 app/static/css/favorites-mobile.css create mode 100644 app/static/css/favorites.css create mode 100644 app/static/css/feedback.css create mode 100644 app/static/css/footer.css create mode 100644 app/static/css/header.css create mode 100644 app/static/css/help.css create mode 100644 app/static/css/history-mobile.css create mode 100644 app/static/css/history.css create mode 100644 app/static/css/index.css create mode 100644 app/static/css/legal.css create mode 100644 app/static/css/list.css create mode 100644 app/static/css/login.css create mode 100644 app/static/css/loginPop.css create mode 100644 app/static/css/mobile-search.css create mode 100644 app/static/css/moreList.css create mode 100644 app/static/css/nav.css create mode 100644 app/static/css/notFound.css create mode 100644 app/static/css/pagination.css create mode 100644 app/static/css/play.css create mode 100644 app/static/css/playComments.css create mode 100644 app/static/css/playLinks.css create mode 100644 app/static/css/popularRecommend.css create mode 100644 app/static/css/profile-mobile.css create mode 100644 app/static/css/profile.css create mode 100644 app/static/css/promo.css create mode 100644 app/static/css/purchased-mobile.css create mode 100644 app/static/css/purchased.css create mode 100644 app/static/css/rank.css create mode 100644 app/static/css/recentlyAdded.css create mode 100644 app/static/css/recharge-mobile.css create mode 100644 app/static/css/recharge-modal.css create mode 100644 app/static/css/recharge.css create mode 100644 app/static/css/register.css create mode 100644 app/static/css/registerPop.css create mode 100644 app/static/css/search.css create mode 100644 app/static/css/series.css create mode 100644 app/static/css/setting.css create mode 100644 app/static/css/tags.css create mode 100644 app/static/css/toast.css create mode 100644 app/static/css/topic.css create mode 100644 app/static/css/topicDetail.css create mode 100644 app/static/embed.go create mode 100644 app/static/favicon.ico create mode 100644 app/static/favicon.png create mode 100644 app/static/favicon.svg create mode 100644 app/static/images/18jia.svg create mode 100644 app/static/images/about/about-hero.png create mode 100644 app/static/images/app-qr.png create mode 100644 app/static/images/arrow-right-line.svg create mode 100644 app/static/images/arrow-up-right.svg create mode 100644 app/static/images/badge_bj.png create mode 100644 app/static/images/badge_yc.png create mode 100644 app/static/images/badge_yj.png create mode 100644 app/static/images/bell.svg create mode 100644 app/static/images/bookmark.svg create mode 100644 app/static/images/brand-android.svg create mode 100644 app/static/images/brand-apple.svg create mode 100644 app/static/images/brand-instagram.svg create mode 100644 app/static/images/brand-telegram.svg create mode 100644 app/static/images/brand-tiktok.svg create mode 100644 app/static/images/brand-x.svg create mode 100644 app/static/images/brand-youtube.svg create mode 100644 app/static/images/check-circle.svg create mode 100644 app/static/images/check.svg create mode 100644 app/static/images/checked.svg create mode 100644 app/static/images/chevron-left.svg create mode 100644 app/static/images/chevron-right.svg create mode 100644 app/static/images/clock-dim.svg create mode 100644 app/static/images/close.svg create mode 100644 app/static/images/close2.svg create mode 100644 app/static/images/coin.png create mode 100644 app/static/images/copy.svg create mode 100644 app/static/images/download.svg create mode 100644 app/static/images/error-tip.svg create mode 100644 app/static/images/external-link.svg create mode 100644 app/static/images/eye-off.svg create mode 100644 app/static/images/eye.svg create mode 100644 app/static/images/favorites-empty/arrow-right.svg create mode 100644 app/static/images/favorites-empty/bookmark.svg create mode 100644 app/static/images/favorites/sliders.svg create mode 100644 app/static/images/feedback/help-circle.svg create mode 100644 app/static/images/film.svg create mode 100644 app/static/images/film2.svg create mode 100644 app/static/images/gift-dim.svg create mode 100644 app/static/images/gift.svg create mode 100644 app/static/images/heart.svg create mode 100644 app/static/images/help-circle.svg create mode 100644 app/static/images/home.svg create mode 100644 app/static/images/image-dim.svg create mode 100644 app/static/images/link_award.svg create mode 100644 app/static/images/link_globe.svg create mode 100644 app/static/images/link_refresh-cw.svg create mode 100644 app/static/images/link_send.svg create mode 100644 app/static/images/link_shield.svg create mode 100644 app/static/images/link_smartphone.svg create mode 100644 app/static/images/lock-dim.svg create mode 100644 app/static/images/lock.svg create mode 100644 app/static/images/logout.svg create mode 100644 app/static/images/mail-dim.svg create mode 100644 app/static/images/mail.svg create mode 100644 app/static/images/menu.svg create mode 100644 app/static/images/menu_dj.svg create mode 100644 app/static/images/menu_gkls.svg create mode 100644 app/static/images/menu_grzx.svg create mode 100644 app/static/images/menu_jbcz.svg create mode 100644 app/static/images/menu_jj.svg create mode 100644 app/static/images/menu_phb.svg create mode 100644 app/static/images/menu_sc.svg create mode 100644 app/static/images/menu_sy.svg create mode 100644 app/static/images/menu_zhsz.svg create mode 100644 app/static/images/menu_zt.svg create mode 100644 app/static/images/message-circle.svg create mode 100644 app/static/images/nav_bg.webp create mode 100644 app/static/images/nav_grid.svg create mode 100644 app/static/images/nav_star.svg create mode 100644 app/static/images/nav_trend.svg create mode 100644 app/static/images/nav_zap.svg create mode 100644 app/static/images/ok.svg create mode 100644 app/static/images/placeholder.png create mode 100644 app/static/images/play.svg create mode 100644 app/static/images/play_hollow.svg create mode 100644 app/static/images/plus-dim.svg create mode 100644 app/static/images/plus.svg create mode 100644 app/static/images/profile/avatar.png create mode 100644 app/static/images/profile/water-margin.jpg create mode 100644 app/static/images/purchased-empty/bag-play.svg create mode 100644 app/static/images/recharge/award.svg create mode 100644 app/static/images/recharge/back.svg create mode 100644 app/static/images/recharge/check-circle.svg create mode 100644 app/static/images/recharge/check.svg create mode 100644 app/static/images/recharge/close.svg create mode 100644 app/static/images/recharge/coin-star.svg create mode 100644 app/static/images/recharge/coin3d.png create mode 100644 app/static/images/recharge/download.svg create mode 100644 app/static/images/recharge/help.svg create mode 100644 app/static/images/recharge/lock.svg create mode 100644 app/static/images/recharge/zap.svg create mode 100644 app/static/images/search.svg create mode 100644 app/static/images/search/search-cover-1.jpg create mode 100644 app/static/images/search/search-cover-10.jpg create mode 100644 app/static/images/search/search-cover-11.jpg create mode 100644 app/static/images/search/search-cover-12.jpg create mode 100644 app/static/images/search/search-cover-13.jpg create mode 100644 app/static/images/search/search-cover-2.jpg create mode 100644 app/static/images/search/search-cover-3.jpg create mode 100644 app/static/images/search/search-cover-4.jpg create mode 100644 app/static/images/search/search-cover-5.jpg create mode 100644 app/static/images/search/search-cover-6.jpg create mode 100644 app/static/images/search/search-cover-7.jpg create mode 100644 app/static/images/search/search-cover-8.jpg create mode 100644 app/static/images/search/search-cover-9.jpg create mode 100644 app/static/images/search_h5.svg create mode 100644 app/static/images/send.svg create mode 100644 app/static/images/settings/eye.svg create mode 100644 app/static/images/settings/image.svg create mode 100644 app/static/images/settings/lock.svg create mode 100644 app/static/images/settings/mail.svg create mode 100644 app/static/images/settings/message.svg create mode 100644 app/static/images/settings/shield.svg create mode 100644 app/static/images/settings/user.svg create mode 100644 app/static/images/share.svg create mode 100644 app/static/images/trending-up.svg create mode 100644 app/static/images/user.svg create mode 100644 app/static/js/common/auth.js create mode 100644 app/static/js/common/channel.js create mode 100644 app/static/js/common/compat.js create mode 100644 app/static/js/common/cookie.js create mode 100644 app/static/js/common/qr.js create mode 100644 app/static/js/common/request.js create mode 100644 app/static/js/common/sharePage.js create mode 100644 app/static/js/common/toast.js create mode 100644 app/static/js/eighteen.js create mode 100644 app/static/js/favorites-manage.js create mode 100644 app/static/js/favorites.js create mode 100644 app/static/js/header.js create mode 100644 app/static/js/hero.js create mode 100644 app/static/js/history.js create mode 100644 app/static/js/mobile-search.js create mode 100644 app/static/js/pages/dialogAd.js create mode 100644 app/static/js/pages/downloadPop.js create mode 100644 app/static/js/pages/feedback.js create mode 100644 app/static/js/pages/help.js create mode 100644 app/static/js/pages/legal.js create mode 100644 app/static/js/pages/login.js create mode 100644 app/static/js/pages/loginPop.js create mode 100644 app/static/js/pages/play.js create mode 100644 app/static/js/pages/playComments.js create mode 100644 app/static/js/pages/register.js create mode 100644 app/static/js/pages/registerPop.js create mode 100644 app/static/js/recharge-modal.js create mode 100644 app/static/js/recharge.js create mode 100644 app/static/js/search.js create mode 100644 app/static/js/setting.js create mode 100644 app/static/js/tracker.js create mode 100644 app/static/logo.png create mode 100644 app/static/logo2.png create mode 100644 app/static/logo_icon.png create mode 100644 app/static/og-default.png create mode 100644 app/static/sitemap.xsl create mode 100644 app/static/sitemapindex.xsl create mode 100644 app/template/base/accountPagination.html create mode 100644 app/template/base/ads.html create mode 100644 app/template/base/dialogAd.html create mode 100644 app/template/base/downloadPop.html create mode 100644 app/template/base/eighteen.html create mode 100644 app/template/base/footer.html create mode 100644 app/template/base/header.html create mode 100644 app/template/base/loginPop.html create mode 100644 app/template/base/pagination.html create mode 100644 app/template/base/playComments.html create mode 100644 app/template/base/playLinks.html create mode 100644 app/template/base/rechargeModal.html create mode 100644 app/template/base/registerPop.html create mode 100644 app/template/base/seoCommon.html create mode 100644 app/template/base/subpageHeader.html create mode 100644 app/template/base/toast.html create mode 100644 app/template/base/trackerInit.html create mode 100644 app/template/base/userCenterShell.html create mode 100644 app/template/fs.go create mode 100644 app/template/pages/2257.html create mode 100644 app/template/pages/404.html create mode 100644 app/template/pages/about.html create mode 100644 app/template/pages/dmca.html create mode 100644 app/template/pages/favorites.html create mode 100644 app/template/pages/feedback.html create mode 100644 app/template/pages/help.html create mode 100644 app/template/pages/history.html create mode 100644 app/template/pages/index.html create mode 100644 app/template/pages/list.html create mode 100644 app/template/pages/login.html create mode 100644 app/template/pages/moreList.html create mode 100644 app/template/pages/moreSeriesList.html create mode 100644 app/template/pages/nav.html create mode 100644 app/template/pages/play.html create mode 100644 app/template/pages/popularRecommend.html create mode 100644 app/template/pages/privacy.html create mode 100644 app/template/pages/profile.html create mode 100644 app/template/pages/purchased.html create mode 100644 app/template/pages/rank.html create mode 100644 app/template/pages/recentlyAdded.html create mode 100644 app/template/pages/recharge.html create mode 100644 app/template/pages/register.html create mode 100644 app/template/pages/robots.txt create mode 100644 app/template/pages/search.html create mode 100644 app/template/pages/searchMobile.html create mode 100644 app/template/pages/series.html create mode 100644 app/template/pages/seriesPlay.html create mode 100644 app/template/pages/setting.html create mode 100644 app/template/pages/tags.html create mode 100644 app/template/pages/terms.html create mode 100644 app/template/pages/topic.html create mode 100644 app/template/pages/topicDetail.html create mode 100644 go.mod create mode 100644 go.sum create mode 100755 make.sh create mode 100644 outputs/seo-audit-20260908/indexation-analysis-20260908.md create mode 100644 outputs/seo-audit-20260908/seo优化清单-91av-wz-20260908.xlsx create mode 100644 outputs/seo-audit-20260908/tdk-source-analysis-20260908.md create mode 100644 outputs/seo-audit-20260908/~$seo优化清单-91av-wz-20260908.xlsx create mode 100644 outputs/seo-audit-jha-1045-hgdjnet-web-20260922.md create mode 100644 project.md create mode 100644 pub/common/comm/comm.go create mode 100644 pub/common/common.go create mode 100644 pub/common/convUtil.go create mode 100644 pub/common/cronmgr/crommgr.go create mode 100644 pub/common/cronx/cron.go create mode 100644 pub/common/crypt/aesecb.go create mode 100644 pub/common/crypt/crypt.go create mode 100644 pub/common/crypt/rsa.go create mode 100644 pub/common/enum/consts/consts.go create mode 100644 pub/common/enum/redisconst/const.go create mode 100644 pub/common/enum/video/video.go create mode 100644 pub/common/errors/code.go create mode 100644 pub/common/errors/error.go create mode 100644 pub/common/errors/make.go create mode 100644 pub/common/errors/msg_app.go create mode 100644 pub/common/fconst/fconst.go create mode 100644 pub/common/godef/godef.go create mode 100644 pub/common/googauth/googauth.go create mode 100644 pub/common/googauth/htop.go create mode 100644 pub/common/http/client_headers.go create mode 100644 pub/common/http/forward_headers.go create mode 100644 pub/common/http/http.go create mode 100644 pub/common/httputil/httputil.go create mode 100644 pub/common/linode/linode.go create mode 100644 pub/common/lock/cache.go create mode 100644 pub/common/lock/dict.go create mode 100644 pub/common/lock/queue.go create mode 100644 pub/common/log/log.go create mode 100644 pub/common/log/zapcorex/bufferpool/bufferpool.go create mode 100644 pub/common/log/zapcorex/color.go create mode 100644 pub/common/log/zapcorex/console_encoder.go create mode 100644 pub/common/log/zapcorex/field.go create mode 100644 pub/common/log/zapcorex/json_encoder.go create mode 100644 pub/common/log/zapcorex/memory_encoder.go create mode 100644 pub/common/m3u8/enkey.go create mode 100644 pub/common/m3u8/media_play_list.go create mode 100644 pub/common/m3u8/play_list.go create mode 100644 pub/common/m3u8/transformer.go create mode 100644 pub/common/m3u8/types.go create mode 100644 pub/common/m3u8/validate.go create mode 100644 pub/common/maths/maths.go create mode 100644 pub/common/netutil/netutil.go create mode 100644 pub/common/pageopt/match.go create mode 100644 pub/common/pointers/pointers.go create mode 100644 pub/common/random/number.go create mode 100644 pub/common/recomapi/define.go create mode 100644 pub/common/recomapi/feedback.go create mode 100644 pub/common/recomapi/itemapi.go create mode 100644 pub/common/recomapi/recomapi.go create mode 100644 pub/common/recomapi/usersapi.go create mode 100644 pub/common/regapi.go create mode 100644 pub/common/sms/fengniao/fengniao.go create mode 100644 pub/common/sms/sms.go create mode 100644 pub/common/srvdef/codedef.go create mode 100644 pub/common/srvdef/msgdef.go create mode 100644 pub/common/srvdef/srvdef.go create mode 100644 pub/common/srvdef/tipdef.go create mode 100644 pub/common/srvversion/version.go create mode 100644 pub/common/swaggerutil/swagger.go create mode 100644 pub/common/synclock/lock.go create mode 100644 pub/common/tgbot/helper.go create mode 100644 pub/common/tgbot/tgbot.go create mode 100644 pub/common/timehelper.go create mode 100644 pub/common/topicmessage/topicMessage.go create mode 100644 pub/common/typeConv.go create mode 100644 pub/common/useragent/build.go create mode 100644 pub/common/useragent/sdk_common.go create mode 100644 pub/common/useragent/ua.go create mode 100644 pub/common/utils/array.go create mode 100644 pub/common/utils/convUtil.go create mode 100644 pub/common/utils/copy.go create mode 100644 pub/common/utils/file.go create mode 100644 pub/common/utils/hash.go create mode 100644 pub/common/utils/http.go create mode 100644 pub/common/utils/jwt.go create mode 100644 pub/common/utils/num.go create mode 100644 pub/common/utils/page.go create mode 100644 pub/common/utils/path.go create mode 100644 pub/common/utils/redis.go create mode 100644 pub/common/utils/shellcmd.go create mode 100644 pub/common/utils/slices.go create mode 100644 pub/common/utils/strings.go create mode 100644 pub/common/utils/structToMap.go create mode 100644 pub/common/utils/time.go create mode 100644 pub/common/utils/uasplit.go create mode 100644 pub/common/utils/utils.go create mode 100644 pub/common/utils/verify.go create mode 100644 pub/common/utils/version.go create mode 100644 pub/common/v10/upgradev10.go create mode 100644 pub/common/v10/validate.go create mode 100644 pub/common/wordsfilter/filter.go create mode 100644 pub/common/wordsfilter/trie_tree.go create mode 100644 pub/esmanager/client.go create mode 100644 pub/esmanager/elastic.go create mode 100644 pub/esmanager/manager.go create mode 100644 pub/google/googleAuth.go create mode 100644 pub/models/analyticsdailystat/analytics.go create mode 100755 pub/models/analyticsdailystat/tag.go create mode 100644 pub/models/analyticsdailystat/type.go create mode 100644 pub/models/analyticsstat/analytics.go create mode 100755 pub/models/analyticsstat/tag.go create mode 100644 pub/models/analyticsstat/type.go create mode 100644 pub/models/cfg/dbhelper.go create mode 100644 pub/models/cfg/dbop.go create mode 100755 pub/models/cfg/tag.go create mode 100644 pub/models/cfg/type.go create mode 100644 pub/models/commentmod/comment.go create mode 100755 pub/models/commentmod/tag.go create mode 100644 pub/models/commentmod/type.go create mode 100644 pub/models/esmod/eshelper.go create mode 100644 pub/models/esmod/esop.go create mode 100644 pub/models/esmod/recordop.go create mode 100755 pub/models/esmod/tag.go create mode 100644 pub/models/esmod/type.go create mode 100644 pub/models/esmod/video.go create mode 100644 pub/models/ipblackmod/ipblack.go create mode 100755 pub/models/ipblackmod/tag.go create mode 100644 pub/models/ipblackmod/type.go create mode 100644 pub/models/models.go create mode 100644 pub/models/mwacc/acc.go create mode 100644 pub/models/mwacc/dbhelper.go create mode 100644 pub/models/mwacc/drop.go create mode 100644 pub/models/mwacc/fun.go create mode 100644 pub/models/mwacc/mwacc_gen.go create mode 100644 pub/models/mwacc/role.go create mode 100755 pub/models/mwacc/tag.go create mode 100644 pub/models/mwacc/token.go create mode 100644 pub/models/mwacc/type.go create mode 100644 pub/models/mwacc/web.go create mode 100644 pub/models/oplog/dbhelper.go create mode 100644 pub/models/oplog/oplog.go create mode 100755 pub/models/oplog/tag.go create mode 100644 pub/models/oplog/type.go create mode 100644 pub/models/richtextcontentmod/richtextcontent.go create mode 100755 pub/models/richtextcontentmod/tag.go create mode 100644 pub/models/richtextcontentmod/type.go create mode 100644 pub/models/skdrecord/record.go create mode 100755 pub/models/skdrecord/tag.go create mode 100644 pub/models/skdrecord/type.go create mode 100644 pub/models/syncfilemod/sync.go create mode 100755 pub/models/syncfilemod/tag.go create mode 100644 pub/models/syncfilemod/type.go create mode 100644 pub/models/translationcategorymod/category.go create mode 100755 pub/models/translationcategorymod/tag.go create mode 100644 pub/models/translationcategorymod/type.go create mode 100755 pub/models/translationmod/tag.go create mode 100644 pub/models/translationmod/translation.go create mode 100644 pub/models/translationmod/type.go create mode 100644 pub/models/utils/dbhelper.go create mode 100644 pub/models/utils/dbop.go create mode 100755 pub/models/utils/tag.go create mode 100644 pub/models/utils/type.go create mode 100644 pub/models/videocategorymod/category.go create mode 100755 pub/models/videocategorymod/tag.go create mode 100644 pub/models/videocategorymod/type.go create mode 100755 pub/models/videocollectionmediamod/tag.go create mode 100644 pub/models/videocollectionmediamod/type.go create mode 100644 pub/models/videocollectionmediamod/videocollection.go create mode 100644 pub/models/videocreatermod/creater.go create mode 100755 pub/models/videocreatermod/tag.go create mode 100644 pub/models/videocreatermod/type.go create mode 100644 pub/models/videoinformationmod/information.go create mode 100755 pub/models/videoinformationmod/tag.go create mode 100644 pub/models/videoinformationmod/type.go create mode 100755 pub/models/videotagmod/tag.go create mode 100644 pub/models/videotagmod/taginfo.go create mode 100644 pub/models/videotagmod/type.go create mode 100644 pub/mongos/QueryBuilder.go create mode 100644 pub/mongos/base_mod.go create mode 100644 pub/mongos/codec.go create mode 100644 pub/mongos/generate_id.go create mode 100644 pub/mongos/lid.go create mode 100644 pub/mongos/manager.go create mode 100644 pub/pubdef/pubdef.go create mode 100644 pub/pubdef/user.go create mode 100644 pub/redis/List.go create mode 100644 pub/redis/channeldef.go create mode 100644 pub/redis/hash.go create mode 100644 pub/redis/manager.go create mode 100644 pub/redis/options.go create mode 100644 pub/redis/redis.go create mode 100644 pub/redis/zset.go create mode 100644 pub/storage/proxy/object.go create mode 100644 pub/storage/proxy/test.html create mode 100644 pub/storage/proxy/types.go create mode 100644 pub/storage/proxy/utils.go create mode 100644 pub/storage/upload.go create mode 100644 read.md diff --git a/.codex/figma-reference/account-pc.png b/.codex/figma-reference/account-pc.png new file mode 100644 index 0000000000000000000000000000000000000000..26954d054d722fc153fb41f5c018df57b15affc1 GIT binary patch literal 131869 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

    {+lCE!wxFrrzB@lPyvwyyFdc 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�$$8+WM0l5=3xV{B%bwb2VF>kG)qON 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 maxAccountPage { + return 0, false + } + return page, true +} + +func makeAccountPage(path string, page, size int, count int64) accountPage { + total := int((count + int64(size) - 1) / int64(size)) + p := accountPage{Current: page, Total: total, Count: count} + link := func(n int) string { + path = strings.TrimRight(path, "/") + if n <= 1 { + return path + } + return path + "/page/" + strconv.Itoa(n) + } + if page > 1 { + p.PrevURL = link(page - 1) + } + if page < total { + p.NextURL = link(page + 1) + } + return p +} diff --git a/app/api/web/auth.go b/app/api/web/auth.go new file mode 100644 index 0000000..8697013 --- /dev/null +++ b/app/api/web/auth.go @@ -0,0 +1,290 @@ +package web + +import ( + "net/http" + "strings" + "videoweb/app/service/authser" + "videoweb/pub/common/log" + + "github.com/gin-gonic/gin" +) + +type registerBody struct { + AccountNo string `json:"accountNo"` + Email string `json:"email"` + Password string `json:"password"` + AgentInviteCode string `json:"agentInviteCode"` + DevID string `json:"devID"` + Channel string `json:"channel"` +} + +type loginBody struct { + Account string `json:"account"` + Password string `json:"password"` + DevID string `json:"devID"` + Channel string `json:"channel"` +} + +// Register 代理上游注册(POST /api/app/auth/register),成功后写入登录 cookie。 +// 请求头带游客 Authorization 时升级该游客;邮箱缺省时用账号生成占位邮箱。 +func Register(c *gin.Context) { + var body registerBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 1001, + "msg": "参数错误", + "tip": "参数错误", + "data": nil, + }) + return + } + + body.AccountNo = strings.TrimSpace(body.AccountNo) + body.Email = strings.TrimSpace(body.Email) + body.AgentInviteCode = strings.TrimSpace(body.AgentInviteCode) + body.DevID = strings.TrimSpace(body.DevID) + body.Channel = authser.ResolveChannel(c, body.Channel) + + if body.AccountNo == "" || body.Password == "" { + c.JSON(http.StatusOK, gin.H{ + "code": 1001, + "msg": "参数错误", + "tip": "请填写账号和密码", + "data": nil, + }) + return + } + + // 上游 registerReq 要求 email;前端无邮箱时用账号生成唯一占位邮箱。 + if body.Email == "" { + body.Email = body.AccountNo + "@hgdj.local" + } + + env, user, err := authser.Register(c, authser.RegisterReq{ + AccountNo: body.AccountNo, + Email: body.Email, + Password: body.Password, + AgentInviteCode: body.AgentInviteCode, + DevID: body.DevID, + Channel: body.Channel, + }) + if err != nil { + log.Error("register upstream failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "register failed", + "tip": "注册失败,请稍后重试", + "data": nil, + }) + return + } + + if env == nil || env.Code != 200 || user == nil { + tip := "注册失败" + msg := "" + code := 500 + if env != nil { + code = env.Code + msg = env.Msg + if t := strings.TrimSpace(env.Tip); t != "" { + tip = t + } else if m := strings.TrimSpace(env.Msg); m != "" { + tip = m + } + } + c.JSON(http.StatusOK, gin.H{ + "code": code, + "msg": msg, + "tip": tip, + "data": nil, + }) + return + } + + authser.SetAuthCookies(c, user) + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": firstNonEmptyAuth(env.Msg, "success"), + "tip": firstNonEmptyAuth(env.Tip, "注册成功"), + "data": user, + }) +} + +// Login 代理上游登录(POST /api/app/auth/login),成功后写入登录 cookie。 +func Login(c *gin.Context) { + var body loginBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 1001, + "msg": "参数错误", + "tip": "参数错误", + "data": nil, + }) + return + } + + body.Account = strings.TrimSpace(body.Account) + body.DevID = strings.TrimSpace(body.DevID) + body.Channel = authser.ResolveChannel(c, body.Channel) + if body.Account == "" || body.Password == "" { + c.JSON(http.StatusOK, gin.H{ + "code": 1001, + "msg": "参数错误", + "tip": "请填写账号和密码", + "data": nil, + }) + return + } + + env, user, err := authser.Login(c, authser.LoginReq{ + Account: body.Account, + Password: body.Password, + DevID: body.DevID, + Channel: body.Channel, + }) + if err != nil { + log.Error("login upstream failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "login failed", + "tip": "登录失败,请稍后重试", + "data": nil, + }) + return + } + + if env == nil || env.Code != 200 || user == nil { + tip := "登录失败" + msg := "" + code := 500 + if env != nil { + code = env.Code + msg = env.Msg + if t := strings.TrimSpace(env.Tip); t != "" { + tip = t + } else if m := strings.TrimSpace(env.Msg); m != "" { + tip = m + } + } + c.JSON(http.StatusOK, gin.H{ + "code": code, + "msg": msg, + "tip": tip, + "data": nil, + }) + return + } + + authser.SetAuthCookies(c, user) + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": firstNonEmptyAuth(env.Msg, "success"), + "tip": firstNonEmptyAuth(env.Tip, "登录成功"), + "data": user, + }) +} + +// Guest 代理上游游客登录,成功后写入登录 cookie。 +// 对应 POST /api/app/auth/guest;需传 devID,上游要求 X-User-Agent / X-Real-IP(由 BFF 组装)。 +func Guest(c *gin.Context) { + var body struct { + DevID string `json:"devID"` + DeviceID string `json:"deviceId"` + Mac string `json:"mac"` + Channel string `json:"channel"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 1001, + "msg": "参数错误", + "tip": "参数错误", + "data": nil, + }) + return + } + + body.DevID = strings.TrimSpace(body.DevID) + body.DeviceID = strings.TrimSpace(body.DeviceID) + body.Mac = strings.TrimSpace(body.Mac) + body.Channel = authser.ResolveChannel(c, body.Channel) + if body.DevID == "" { + c.JSON(http.StatusOK, gin.H{ + "code": 1001, + "msg": "参数错误", + "tip": "缺少设备标识", + "data": nil, + }) + return + } + if body.DeviceID == "" { + body.DeviceID = body.DevID + } + + env, user, err := authser.GuestLogin(c, authser.GuestLoginReq{ + DevID: body.DevID, + DeviceID: body.DeviceID, + Mac: body.Mac, + Channel: body.Channel, + }) + if err != nil { + log.Error("guest login upstream failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "guest login failed", + "tip": "游客登录失败,请稍后重试", + "data": nil, + }) + return + } + + if env == nil || env.Code != 200 || user == nil { + tip := "游客登录失败" + msg := "" + code := 500 + if env != nil { + code = env.Code + msg = env.Msg + if t := strings.TrimSpace(env.Tip); t != "" { + tip = t + } else if m := strings.TrimSpace(env.Msg); m != "" { + tip = m + } + } + c.JSON(http.StatusOK, gin.H{ + "code": code, + "msg": msg, + "tip": tip, + "data": nil, + }) + return + } + + authser.SetAuthCookies(c, user) + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": firstNonEmptyAuth(env.Msg, "success"), + "tip": firstNonEmptyAuth(env.Tip, "游客登录成功"), + "data": user, + }) +} + +// Logout 清除登录 cookie,并标记跳过紧接着的一次自动游客登录。 +func Logout(c *gin.Context) { + authser.ClearAuthCookies(c) + authser.MarkGuestSkipAuto(c) + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "success", + "tip": "已退出登录", + "data": nil, + }) +} + +func firstNonEmptyAuth(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/app/api/web/chapterLike.go b/app/api/web/chapterLike.go new file mode 100644 index 0000000..a65358c --- /dev/null +++ b/app/api/web/chapterLike.go @@ -0,0 +1,120 @@ +package web + +import ( + "net/http" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// ChapterLikeSet BFF:代理 POST /api/app/playlet/chapter/like/set。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_like_set +func ChapterLikeSet(c *gin.Context) { + var body struct { + ChapterID int64 `json:"chapterId"` + IsLike *bool `json:"isLike"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ChapterID <= 0 || body.IsLike == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "chapterId/isLike required", + "tip": "缺少剧集 ID 或点赞状态", + "data": nil, + }) + return + } + + env, err := playletser.ChapterLikeSet(c, playletser.PlayletChapterLikeSetReq{ + ChapterID: body.ChapterID, + IsLike: *body.IsLike, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} + +// ChapterCollectSet BFF:代理 POST /api/app/playlet/chapter/collect/set。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_collect_set +func ChapterCollectSet(c *gin.Context) { + var body struct { + ChapterID int64 `json:"chapterId"` + IsCollect *bool `json:"isCollect"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ChapterID <= 0 || body.IsCollect == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "chapterId/isCollect required", + "tip": "缺少剧集 ID 或收藏状态", + "data": nil, + }) + return + } + + env, err := playletser.ChapterCollectSet(c, playletser.PlayletChapterCollectSetReq{ + ChapterID: body.ChapterID, + IsCollect: *body.IsCollect, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/api/web/chapterUnlock.go b/app/api/web/chapterUnlock.go new file mode 100644 index 0000000..7082bc7 --- /dev/null +++ b/app/api/web/chapterUnlock.go @@ -0,0 +1,64 @@ +package web + +import ( + "net/http" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// ChapterUnlock BFF:代理 POST /api/app/playlet/chapter/unlock。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_unlock +func ChapterUnlock(c *gin.Context) { + var body struct { + ChapterID int64 `json:"chapterId"` + PlayletID int64 `json:"playletId"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ChapterID <= 0 || body.PlayletID <= 0 { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "chapterId/playletId required", + "tip": "缺少剧集或短剧 ID", + "data": nil, + }) + return + } + + env, err := playletser.ChapterUnlock(c, playletser.PlayletChapterUnlockReq{ + ChapterID: body.ChapterID, + PlayletID: body.PlayletID, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "解锁失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "解锁失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/api/web/chapterWatch.go b/app/api/web/chapterWatch.go new file mode 100644 index 0000000..4ec026a --- /dev/null +++ b/app/api/web/chapterWatch.go @@ -0,0 +1,75 @@ +package web + +import ( + "net/http" + "videoweb/app/service/playletser" + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +// ChapterWatchReport BFF:代理 POST /api/app/playlet/chapter/watch/report。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_watch_report +func ChapterWatchReport(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{ + "code": 401, + "msg": "unauthorized", + "tip": "请先登录", + "data": nil, + }) + return + } + + var body struct { + ChapterID int64 `json:"chapterId"` + PlayletID int64 `json:"playletId"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ChapterID <= 0 { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "chapterId required", + "tip": "缺少剧集 ID", + "data": nil, + }) + return + } + + env, err := playletser.ChapterWatchReport(c, playletser.PlayletChapterWatchReportReq{ + ChapterID: body.ChapterID, + PlayletID: body.PlayletID, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "上报失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "上报失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/api/web/comment.go b/app/api/web/comment.go new file mode 100644 index 0000000..6f3c044 --- /dev/null +++ b/app/api/web/comment.go @@ -0,0 +1,155 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + "videoweb/app/service/commentser" + + "github.com/gin-gonic/gin" +) + +// CommentList BFF:代理 GET /api/app/comment/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/get_api_app_comment_list +func CommentList(c *gin.Context) { + objectID, _ := strconv.ParseInt(c.Query("objectId"), 10, 64) + objectType, _ := strconv.Atoi(c.Query("objectType")) + pageNum, _ := strconv.Atoi(c.Query("pageNum")) + pageSize, _ := strconv.Atoi(c.Query("pageSize")) + sortOrder, _ := strconv.Atoi(c.Query("sortOrder")) + if objectID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "objectId required", + "tip": "缺少评论对象", + "data": nil, + }) + return + } + data := commentser.FetchListOrEmpty(c, commentser.ListQuery{ + ObjectID: objectID, + ObjectType: objectType, + PageNum: pageNum, + PageSize: pageSize, + SortOrder: sortOrder, + }) + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "ok", + "tip": "成功", + "data": data, + }) +} + +// CommentSend BFF:代理 POST /api/app/comment/send。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_send +func CommentSend(c *gin.Context) { + var req commentser.CommentSendReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + env, err := commentser.Send(c, req) + if err != nil { + tip := "发布失败" + msg := err.Error() + switch { + case strings.Contains(msg, "empty text"): + tip = "请输入评论内容" + case strings.Contains(msg, "too long"): + tip = "评论不能超过 200 字" + case strings.Contains(msg, "invalid objectId"): + tip = "缺少评论对象" + } + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": msg, + "tip": tip, + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "发布失败", + "data": nil, + }) + return + } + tip := env.Tip + if tip == "" { + tip = env.Msg + } + // 上游参数错误兜底提示(例如 objectType 不被支持) + if env.Code == 1001 && (tip == "" || tip == "参数错误") { + tip = "评论参数错误,请稍后重试" + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": tip, + "data": env.Data, + }) +} + +// CommentLikeSet BFF:代理 POST /api/app/comment/like/set。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_like_set +func CommentLikeSet(c *gin.Context) { + var body struct { + CommentID int64 `json:"commentId"` + IsLike *bool `json:"isLike"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.CommentID <= 0 || body.IsLike == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "commentId/isLike required", + "tip": "缺少评论 ID 或点赞状态", + "data": nil, + }) + return + } + env, err := commentser.LikeSet(c, commentser.CommentLikeSetReq{ + CommentID: body.CommentID, + IsLike: *body.IsLike, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/api/web/compliance2257.go b/app/api/web/compliance2257.go new file mode 100644 index 0000000..e8eed49 --- /dev/null +++ b/app/api/web/compliance2257.go @@ -0,0 +1,8 @@ +package web + +import "github.com/gin-gonic/gin" + +// Compliance2257 renders the static 18 U.S.C. 2257 compliance statement page. +func Compliance2257(c *gin.Context) { + HTMLOK(c, "2257.html", gin.H{}) +} diff --git a/app/api/web/dmca.go b/app/api/web/dmca.go new file mode 100644 index 0000000..a2a51d2 --- /dev/null +++ b/app/api/web/dmca.go @@ -0,0 +1,8 @@ +package web + +import "github.com/gin-gonic/gin" + +// DMCA renders the static DMCA copyright notice page. +func DMCA(c *gin.Context) { + HTMLOK(c, "dmca.html", gin.H{}) +} diff --git a/app/api/web/favorites.go b/app/api/web/favorites.go new file mode 100644 index 0000000..d01bb57 --- /dev/null +++ b/app/api/web/favorites.go @@ -0,0 +1,338 @@ +package web + +import ( + "fmt" + "strconv" + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/playletser" + "videoweb/app/service/videoser" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +type favoriteWork struct { + ID int64 + Title string + Genre string + DesktopMeta string + Episodes string + Badge string + Image string + Kind string + URL string + DramaID int64 + Episode int + VideoID int64 + TotalEpisode int +} + +// Favorites 我的收藏:短剧 / 剧集(视频) / 章节收藏。 +// 文档: +// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_collect_list +// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_collect_list +func Favorites(c *gin.Context) { + filter, page, validRoute := favoritesRoute(c) + if !validRoute { + NotFound(c) + return + } + + data := gin.H{"title": "我的收藏 - 黄果短剧", "navActive": "favorites", "accountTab": "favorites"} + responses := gin.H{} + loadAccountSummary(c, data, responses) + + status := 0 + switch filter { + case "short", "chapter", "series": + case "following": + status = 1 + case "finished": + status = 2 + default: + filter = "all" + } + data["filter"] = filter + + pageSize := 12 + query := playletser.CollectListQuery{PageNum: page, PageSize: pageSize, WatchStatus: status} + + if useragent.TokenFromRequest(c.Request) == "" { + responses["playlet/collect/list"] = gin.H{"error": "暂无登录会话"} + responses["video/collect/list"] = gin.H{"error": "暂无登录会话"} + data["favoritesResponse"] = responses["playlet/collect/list"] + data["playletCollectResponse"] = responses["playlet/collect/list"] + data["videoCollectResponse"] = responses["video/collect/list"] + data["favoritesDebug"] = responses + renderFavorites(c, data) + return + } + + if filter == "chapter" { + list, code, err := playletser.FetchChapterCollectList(c, query) + if retry, _ := authser.OnSessionExpired(c, code); retry { + list, _, err = playletser.FetchChapterCollectList(c, query) + } + if err != nil { + log.Error("fetch chapter favorites page failed", log.E(err)) + responses["playlet/chapter/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()} + data["favoritesUnavailable"] = true + } else if list != nil { + responses["playlet/chapter/collect/list"] = gin.H{"code": 200, "data": list} + data["paging"] = makeFavoritesPage(query.PageNum, query.PageSize, list.TotalCount, filter) + data["favorites"] = chapterFavoriteWorks(list.List) + } else { + responses["playlet/chapter/collect/list"] = gin.H{"code": code, "data": list} + } + data["favoritesResponse"] = responses["playlet/chapter/collect/list"] + data["playletCollectResponse"] = responses["playlet/chapter/collect/list"] + data["favoritesDebug"] = responses + renderFavorites(c, data) + return + } + + if filter == "series" { + loadVideoFavorites(c, data, responses, page, pageSize) + data["favoritesResponse"] = responses["video/collect/list"] + data["playletCollectResponse"] = gin.H{} + data["videoCollectResponse"] = responses["video/collect/list"] + data["favoritesDebug"] = responses + renderFavorites(c, data) + return + } + + var works []favoriteWork + var totalCount int64 + playletFailed := false + videoFailed := false + + if filter == "all" || filter == "short" || filter == "following" || filter == "finished" { + list, code, err := playletser.FetchCollectList(c, query) + if retry, _ := authser.OnSessionExpired(c, code); retry { + list, code, err = playletser.FetchCollectList(c, query) + } + if err == nil && list != nil { + responses["playlet/collect/list"] = gin.H{"code": 200, "data": list} + works = append(works, playletFavoriteWorks(list.List)...) + totalCount += list.TotalCount + } else if err != nil { + responses["playlet/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()} + log.Error("fetch favorites page failed", log.E(err)) + playletFailed = true + } else { + responses["playlet/collect/list"] = gin.H{"code": code, "data": list} + } + } + + if filter == "all" { + videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize} + videoList, videoCode, videoErr := videoser.FetchCollectList(c, videoQuery) + if retry, _ := authser.OnSessionExpired(c, videoCode); retry { + videoList, videoCode, videoErr = videoser.FetchCollectList(c, videoQuery) + } + if videoErr == nil && videoList != nil { + responses["video/collect/list"] = gin.H{"code": 200, "data": videoList} + works = append(works, videoFavoriteWorks(videoList.List)...) + totalCount += videoList.TotalCount + } else if videoErr != nil { + responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()} + log.Error("fetch video favorites page failed", log.E(videoErr)) + videoFailed = true + } else { + responses["video/collect/list"] = gin.H{"code": videoCode, "data": videoList} + } + } + + if playletFailed && (filter != "all" || videoFailed) { + data["favoritesUnavailable"] = true + } + if filter == "all" && playletFailed && videoFailed { + data["favoritesUnavailable"] = true + } + + data["paging"] = makeFavoritesPage(page, pageSize, totalCount, filter) + data["favorites"] = works + data["favoritesResponse"] = responses["playlet/collect/list"] + data["playletCollectResponse"] = responses["playlet/collect/list"] + data["videoCollectResponse"] = responses["video/collect/list"] + data["favoritesDebug"] = responses + renderFavorites(c, data) +} + +// renderFavorites 收藏为空时附带「大家都在看」后渲染。 +func renderFavorites(c *gin.Context, data gin.H) { + attachFavoritesRecommended(c, data) + HTMLOK(c, "favorites.html", data) +} + +// attachFavoritesRecommended 列表为空时拉取最热短剧(与搜索无结果一致)。 +// 上游:GET /api/app/playlet/all/list sortType=2 +func attachFavoritesRecommended(c *gin.Context, data gin.H) { + if works, ok := data["favorites"].([]favoriteWork); ok && len(works) > 0 { + return + } + watchingQ := playletser.AllListQuery{ + PageNum: 1, + PageSize: 6, + SortType: 2, // 最热 + UpdateStatus: 0, + } + watching := playletser.FetchAllListOrEmpty(c, watchingQ) + if watching == nil || len(watching.List) == 0 { + return + } + list := watching.List + if len(list) > 6 { + list = list[:6] + } + data["recommended"] = list +} + +func loadVideoFavorites(c *gin.Context, data gin.H, responses gin.H, page, pageSize int) { + videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize} + list, code, err := videoser.FetchCollectList(c, videoQuery) + if retry, _ := authser.OnSessionExpired(c, code); retry { + list, code, err = videoser.FetchCollectList(c, videoQuery) + } + if err != nil { + log.Error("fetch video favorites page failed", log.E(err)) + responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()} + data["favoritesUnavailable"] = true + return + } + if list == nil { + responses["video/collect/list"] = gin.H{"code": code, "data": list} + return + } + responses["video/collect/list"] = gin.H{"code": 200, "data": list} + data["paging"] = makeFavoritesPage(page, pageSize, list.TotalCount, "series") + data["favorites"] = videoFavoriteWorks(list.List) +} + +func favoritesRoute(c *gin.Context) (filter string, page int, valid bool) { + if c.Request.URL.Query().Has("filter") { + return "", 0, false + } + filter = strings.TrimSpace(c.Param("filter")) + switch filter { + case "": + filter = "all" + case "short", "chapter", "series", "following", "finished": + default: + return "", 0, false + } + + page, valid = accountPageNum(c) + if !valid { + return "", 0, false + } + return filter, page, true +} + +func favoritesPagePath(filter string, page int) string { + path := "/favorites" + if filter != "" && filter != "all" { + path += "/" + filter + } + if page > 1 { + path += "/page/" + strconv.Itoa(page) + } + return path +} + +func makeFavoritesPage(page, size int, count int64, filter string) accountPage { + total := int((count + int64(size) - 1) / int64(size)) + pagination := accountPage{Current: page, Total: total, Count: count} + if page > 1 { + pagination.PrevURL = favoritesPagePath(filter, page-1) + } + if page < total { + pagination.NextURL = favoritesPagePath(filter, page+1) + } + return pagination +} + +func playletFavoriteWorks(items []playletser.PlayletCollectData) []favoriteWork { + works := make([]favoriteWork, 0, len(items)) + for _, item := range items { + title := strings.TrimSpace(item.PlayName) + if title == "" { + title = strings.TrimSpace(item.Title) + } + if title == "" { + continue + } + work := favoriteWork{ + ID: item.ID, Title: title, Genre: strings.Join(item.ThemeNames, " "), + Image: profileCover(item.Cover), Kind: "short", + URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID), + DramaID: item.ID, Episode: item.LastEpisode, VideoID: item.LastChapterID, + TotalEpisode: item.TotalEpisode, + } + if work.Episode <= 0 { + work.Episode = 1 + } + if item.TotalEpisode > 0 { + work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode) + work.DesktopMeta = work.Episodes + } + if item.IsFeatured { + work.Badge = "爆剧" + } + works = append(works, work) + } + return works +} + +func chapterFavoriteWorks(items []playletser.PlayletChapterCollectData) []favoriteWork { + works := make([]favoriteWork, 0, len(items)) + for _, item := range items { + title := strings.TrimSpace(item.PlayletTitle) + if title == "" { + continue + } + work := favoriteWork{ + ID: item.ChapterID, Title: title, Image: profileCover(item.PlayletCover), Kind: "chapter", + URL: playletser.ChapterPlayPath(item.PlayletSlug, item.PlayletID, item.ChapterID), DesktopMeta: item.ChapterTitle, + DramaID: item.PlayletID, Episode: item.CurrentEpisode, VideoID: item.ChapterID, + TotalEpisode: item.TotalEpisode, + } + if work.Episode <= 0 { + work.Episode = 1 + } + if item.CurrentEpisode > 0 { + work.Genre = fmt.Sprintf("第 %d 集", item.CurrentEpisode) + } + if item.TotalEpisode > 0 { + work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode) + } + works = append(works, work) + } + return works +} + +func videoFavoriteWorks(items []videoser.VideoCollectData) []favoriteWork { + works := make([]favoriteWork, 0, len(items)) + for _, item := range items { + title := strings.TrimSpace(item.Title) + if title == "" { + continue + } + work := favoriteWork{ + ID: item.ID, Title: title, Genre: strings.Join(item.Keyworld, " "), + Image: profileCover(item.CoverImg), Kind: "series", + URL: videoser.VideoPlayURL(item.Slug, item.ID), + } + if item.VideoTime > 0 { + work.Episodes = formatVideoDuration(item.VideoTime) + work.DesktopMeta = work.Episodes + } else { + work.Episodes = "剧集" + work.DesktopMeta = "剧集" + } + works = append(works, work) + } + return works +} diff --git a/app/api/web/feedback.go b/app/api/web/feedback.go new file mode 100644 index 0000000..d67942d --- /dev/null +++ b/app/api/web/feedback.go @@ -0,0 +1,154 @@ +package web + +import ( + "net/http" + "path/filepath" + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/feedbackser" + + "github.com/gin-gonic/gin" +) + +// Feedback 意见反馈页:渲染 template/pages/feedback.html。 +func Feedback(c *gin.Context) { + HTMLOK(c, "feedback.html", gin.H{ + "title": "意见反馈 - 黄果短剧", + }) +} + +// FeedbackAdd BFF:代理 POST /api/app/feedback/add。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/意见反馈相关接口/post_api_app_feedback_add +func FeedbackAdd(c *gin.Context) { + var body feedbackser.FeedbackReq + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + body.Content = strings.TrimSpace(body.Content) + body.Contact = strings.TrimSpace(body.Contact) + if body.Type < 1 || body.Type > 4 { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "invalid type", + "tip": "请选择问题类型", + "data": nil, + }) + return + } + if body.Content == "" { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "content required", + "tip": "请填写问题描述", + "data": nil, + }) + return + } + if runes := []rune(body.Content); len(runes) > 500 { + body.Content = string(runes[:500]) + } + if len(body.Screenshots) > 3 { + body.Screenshots = body.Screenshots[:3] + } + + env, err := feedbackser.Add(c, body) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "提交失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "提交失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} + +// UploadImage BFF:代理 POST /api/app/upload/image。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/上传/post_api_app_upload_image +func UploadImage(c *gin.Context) { + fileHeader, err := c.FormFile("file") + if err != nil || fileHeader == nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "file required", + "tip": "请选择图片文件", + "data": nil, + }) + return + } + f, err := fileHeader.Open() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "读取图片失败", + "data": nil, + }) + return + } + defer f.Close() + + name := filepath.Base(fileHeader.Filename) + if name == "" || name == "." || name == "/" { + name = "image.jpg" + } + resp, err := authser.UploadImage(c, name, f) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "上传失败,请稍后重试", + "data": nil, + }) + return + } + if resp == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "上传失败,请稍后重试", + "data": nil, + }) + return + } + + var data gin.H + if resp.Data != nil { + url := strings.TrimSpace(resp.Data.SourceNameFs) + if url == "" { + url = strings.TrimSpace(resp.Data.ID) + } + data = gin.H{ + "id": resp.Data.ID, + "sourceNameFs": resp.Data.SourceNameFs, + "url": url, + } + } + c.JSON(http.StatusOK, gin.H{ + "code": resp.Code, + "msg": resp.Msg, + "tip": resp.Tip, + "data": data, + }) +} diff --git a/app/api/web/guessLike.go b/app/api/web/guessLike.go new file mode 100644 index 0000000..61b1648 --- /dev/null +++ b/app/api/web/guessLike.go @@ -0,0 +1,35 @@ +package web + +import ( + "net/http" + "strconv" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// GuessLikeList BFF:代理 GET /api/app/playlet/guess-like/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_guess_like_list +func GuessLikeList(c *gin.Context) { + playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64) + pageNum, _ := strconv.Atoi(c.Query("pageNum")) + pageSize, _ := strconv.Atoi(c.Query("pageSize")) + if playletID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "playletId required", + "data": nil, + }) + return + } + data := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{ + PlayletID: playletID, + PageNum: pageNum, + PageSize: pageSize, + }) + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "ok", + "data": data, + }) +} diff --git a/app/api/web/help.go b/app/api/web/help.go new file mode 100644 index 0000000..9f0dfc3 --- /dev/null +++ b/app/api/web/help.go @@ -0,0 +1,23 @@ +package web + +import ( + "videoweb/app/service/seoser" + + "github.com/gin-gonic/gin" +) + +// Help renders the static Help page. +func Help(c *gin.Context) { + HTMLOK(c, "help.html", gin.H{ + "faqItems": []seoser.FAQItem{ + {Question: "黄果短剧网页版可以看全集吗?", Answer: "可以。免费集直接观看,付费集可用金币按集解锁,解锁后永久有效。"}, + {Question: "解锁过的剧集还需要再付费吗?", Answer: "不需要。已解锁的剧集可在登录后继续观看,无需重复付费。"}, + {Question: "如何充值金币?", Answer: "登录后进入个人中心,选择金币充值,即可查看可用的充值方式。"}, + {Question: "支持哪些支付方式?", Answer: "可用的支付方式会在充值页面列出,请以页面显示为准。"}, + {Question: "忘记密码怎么办?", Answer: "请通过意见反馈联系我们,提交账号信息后由客服协助处理。"}, + {Question: "内容多久更新一次?", Answer: "黄果短剧持续更新内容,欢迎关注首页的新剧和最近上新。"}, + {Question: "如何联系客服或洽谈合作?", Answer: "请发送邮件至 support@huangguo.com,我们会尽快回复。"}, + {Question: "如何注销账号?", Answer: "请通过意见反馈或客服邮箱提交注销申请。"}, + }, + }) +} diff --git a/app/api/web/history.go b/app/api/web/history.go new file mode 100644 index 0000000..626dc76 --- /dev/null +++ b/app/api/web/history.go @@ -0,0 +1,375 @@ +package web + +import ( + "fmt" + "net/http" + "sort" + "strings" + "time" + "videoweb/app/service/authser" + "videoweb/app/service/playletser" + "videoweb/app/service/videoser" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +type historyEntry struct { + ID int64 + Title string + Current int + Total int + EpisodeText string + Genre string + Time string + Image string + Progress string + Kind string + URL string + sortAt time.Time +} + +type historyGroup struct { + Title string + Entries []historyEntry +} + +// History 观看历史页:短剧 GET /api/app/playlet/history/list + 剧集 GET /api/app/video/history/list。 +// 文档: +// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_history_list +// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_history_list +func History(c *gin.Context) { + page, validPage := accountPageNum(c) + if !validPage { + NotFound(c) + return + } + + data := gin.H{"title": "观看历史 - 黄果短剧", "navActive": "history", "accountTab": "history"} + responses := gin.H{} + loadAccountSummary(c, data, responses) + + pageSize := 20 + var entries []historyEntry + var totalCount int64 + + if useragent.TokenFromRequest(c.Request) == "" { + responses["playlet/history/list"] = gin.H{"error": "暂无登录会话"} + responses["video/history/list"] = gin.H{"error": "暂无登录会话"} + } else { + playletQuery := playletser.HistoryListQuery{PageNum: page, PageSize: pageSize} + playletList, playletCode, playletErr := playletser.FetchHistoryList(c, playletQuery) + if retry, _ := authser.OnSessionExpired(c, playletCode); retry { + playletList, playletCode, playletErr = playletser.FetchHistoryList(c, playletQuery) + } + if playletErr == nil && playletList != nil { + responses["playlet/history/list"] = gin.H{"code": 200, "data": playletList} + entries = append(entries, playletHistoryEntries(playletList.List)...) + totalCount += playletList.TotalCount + } else if playletErr != nil { + responses["playlet/history/list"] = gin.H{"error": "加载失败", "msg": playletErr.Error()} + log.Error("fetch playlet history page failed", log.E(playletErr)) + } else { + responses["playlet/history/list"] = gin.H{"code": playletCode, "data": playletList} + } + + videoQuery := videoser.HistoryListQuery{PageNum: page, PageSize: pageSize} + videoList, videoCode, videoErr := videoser.FetchHistoryList(c, videoQuery) + if retry, _ := authser.OnSessionExpired(c, videoCode); retry { + videoList, videoCode, videoErr = videoser.FetchHistoryList(c, videoQuery) + } + if videoErr == nil && videoList != nil { + responses["video/history/list"] = gin.H{"code": 200, "data": videoList} + entries = append(entries, videoHistoryEntries(videoList.List)...) + totalCount += videoList.TotalCount + } else if videoErr != nil { + responses["video/history/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()} + log.Error("fetch video history page failed", log.E(videoErr)) + } else { + responses["video/history/list"] = gin.H{"code": videoCode, "data": videoList} + } + + if playletErr != nil && videoErr != nil { + data["historyUnavailable"] = true + } + } + + data["paging"] = makeAccountPage("/history", page, pageSize, totalCount) + data["groups"] = groupHistoryEntries(entries) + data["historyResponse"] = responses["playlet/history/list"] + data["playletHistoryResponse"] = responses["playlet/history/list"] + data["videoHistoryResponse"] = responses["video/history/list"] + data["historyDebug"] = responses + HTMLOK(c, "history.html", data) +} + +// HistoryClear BFF:代理 POST /api/app/playlet/history/clear。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_clear +func HistoryClear(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil}) + return + } + env, err := playletser.HistoryClear(c) + if err != nil { + log.Error("clear playlet history failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil}) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil}) + return + } + if env.Code != 200 { + tip := strings.TrimSpace(env.Tip) + if tip == "" { + tip = strings.TrimSpace(env.Msg) + } + if tip == "" { + tip = "清空失败,请稍后重试" + } + c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data}) +} + +// VideoHistoryClear BFF:代理 POST /api/app/video/history/clear。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_clear +func VideoHistoryClear(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil}) + return + } + env, err := videoser.HistoryClear(c) + if err != nil { + log.Error("clear video history failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil}) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil}) + return + } + if env.Code != 200 { + tip := strings.TrimSpace(env.Tip) + if tip == "" { + tip = strings.TrimSpace(env.Msg) + } + if tip == "" { + tip = "清空失败,请稍后重试" + } + c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data}) +} + +// HistoryDelete BFF:代理 POST /api/app/playlet/history/delete。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_delete +func HistoryDelete(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil}) + return + } + var body playletser.PlayletHistoryDeleteReq + if err := c.ShouldBindJSON(&body); err != nil || body.PlayletID <= 0 { + c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "playletId required", "tip": "参数错误", "data": nil}) + return + } + env, err := playletser.HistoryDelete(c, body) + if err != nil { + log.Error("delete playlet history failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil}) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil}) + return + } + if env.Code != 200 { + tip := strings.TrimSpace(env.Tip) + if tip == "" { + tip = strings.TrimSpace(env.Msg) + } + if tip == "" { + tip = "删除失败,请稍后重试" + } + c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data}) +} + +// VideoHistoryDelete BFF:代理 POST /api/app/video/history/delete。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_delete +func VideoHistoryDelete(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil}) + return + } + var body videoser.VideoHistoryDeleteReq + if err := c.ShouldBindJSON(&body); err != nil || body.VideoID <= 0 { + c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "videoId required", "tip": "参数错误", "data": nil}) + return + } + env, err := videoser.HistoryDelete(c, body) + if err != nil { + log.Error("delete video history failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil}) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil}) + return + } + if env.Code != 200 { + tip := strings.TrimSpace(env.Tip) + if tip == "" { + tip = strings.TrimSpace(env.Msg) + } + if tip == "" { + tip = "删除失败,请稍后重试" + } + c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data}) +} + +func playletHistoryEntries(items []playletser.PlayletHistoryData) []historyEntry { + out := make([]historyEntry, 0, len(items)) + for _, item := range items { + title := strings.TrimSpace(item.PlayName) + if title == "" { + title = strings.TrimSpace(item.Title) + } + if title == "" { + continue + } + progress := 0 + if item.TotalEpisode > 0 { + progress = item.WatchedCount * 100 / item.TotalEpisode + } + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + out = append(out, historyEntry{ + ID: item.ID, + Title: title, + Current: item.LastEpisode, + Total: item.TotalEpisode, + Genre: strings.Join(item.ThemeNames, " "), + Time: playletser.FormatHistoryListTime(item.HistoryAt), + Image: profileCover(item.Cover), + Progress: fmt.Sprintf("%d%%", progress), + Kind: "short", + URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID), + sortAt: historySortAt(item.HistoryAt), + }) + } + return out +} + +func videoHistoryEntries(items []videoser.VideoHistoryData) []historyEntry { + out := make([]historyEntry, 0, len(items)) + for _, item := range items { + title := strings.TrimSpace(item.Title) + if title == "" { + continue + } + out = append(out, historyEntry{ + ID: item.ID, + Title: title, + EpisodeText: videoHistoryEpisodeText(item), + Genre: strings.Join(item.Keyworld, " "), + Time: playletser.FormatHistoryListTime(item.HistoryAt), + Image: profileCover(item.CoverImg), + Progress: "", + Kind: "series", + URL: videoser.VideoPlayURL(item.Slug, item.ID), + sortAt: historySortAt(item.HistoryAt), + }) + } + return out +} + +func videoHistoryEpisodeText(item videoser.VideoHistoryData) string { + if item.VideoTime > 0 { + return formatVideoDuration(item.VideoTime) + } + return "剧集" +} + +func formatVideoDuration(sec int) string { + if sec <= 0 { + return "" + } + h := sec / 3600 + m := (sec % 3600) / 60 + s := sec % 60 + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%d:%02d", m, s) +} + +func historySortAt(raw string) time.Time { + t, ok := playletser.ParseHistoryTime(raw) + if !ok { + return time.Time{} + } + return t +} + +func groupHistoryEntries(entries []historyEntry) []historyGroup { + if len(entries) == 0 { + return nil + } + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].sortAt.Equal(entries[j].sortAt) { + return entries[i].ID > entries[j].ID + } + return entries[i].sortAt.After(entries[j].sortAt) + }) + order := []string{"今天", "昨天", "更早"} + buckets := map[string][]historyEntry{ + "今天": {}, + "昨天": {}, + "更早": {}, + } + for _, entry := range entries { + bucket := historyBucketFromTime(entry.sortAt) + buckets[bucket] = append(buckets[bucket], entry) + } + groups := make([]historyGroup, 0, 3) + for _, title := range order { + if len(buckets[title]) == 0 { + continue + } + groups = append(groups, historyGroup{Title: title, Entries: buckets[title]}) + } + return groups +} + +func historyBucketFromTime(t time.Time) string { + if t.IsZero() { + return "更早" + } + now := time.Now() + startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + dayDiff := int(startToday.Sub(startThat).Hours() / 24) + switch { + case dayDiff <= 0: + return "今天" + case dayDiff == 1: + return "昨天" + default: + return "更早" + } +} diff --git a/app/api/web/index.go b/app/api/web/index.go new file mode 100644 index 0000000..f9fb0a2 --- /dev/null +++ b/app/api/web/index.go @@ -0,0 +1,59 @@ +package web + +import ( + "videoweb/app/service/playletser" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" +) + +// Index 首页 SSR:拉取 GET /api/app/playlet/home,按接口字段注入模板。 +// 接口文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home +// 继续观看:GET /api/app/playlet/history/list?pageNum=1&pageSize=6 +// 热播榜:短剧 hot/rank/list;剧集 video/all/list sortType=最热 +func Index(c *gin.Context) { + raw := playletser.FetchHomeOrEmpty(c) + title := "黄果短剧" + if raw != nil && raw.SeoInfo.Title != "" { + title = raw.SeoInfo.Title + } + history := playletser.FetchHistoryListOrEmpty(c, playletser.HistoryListQuery{PageNum: 1, PageSize: 6}) + continueWatch := history.List + if len(continueWatch) > 6 { + continueWatch = continueWatch[:6] + } + + hotPlaylets := playletser.FetchHotRankListOrEmpty(c, playletser.HotRankQuery{ + PageNum: 1, + PageSize: 10, + RankType: 1, + }).List + if len(hotPlaylets) > 10 { + hotPlaylets = hotPlaylets[:10] + } + + hotVideos := videoser.FetchVideoAllListOrEmpty(c, videoser.SeriesListQuery{ + PageNum: 1, + PageSize: 10, + SortType: videoser.SortHeat, + MediaType: videoser.MediaAll, + }).List + if len(hotVideos) > 10 { + hotVideos = hotVideos[:10] + } + + HTMLOK(c, "index.html", gin.H{ + "title": title, + "navActive": "home", + "seoInfo": raw.SeoInfo, + "announcements": raw.Announcements, + "advitise": raw.Advitise, + "homeBannerData": playletser.ActiveHomeBanners(raw.HomeBannerData), + "homeConfigData": playletser.ActiveHomeConfigs(raw.HomeConfigData), + // GET /api/app/playlet/tag/options → list(已上架标签) + "tagOptions": playletser.FetchTagOptionsOrEmpty(c), + "continueWatch": continueWatch, + "hotRankPlaylets": hotPlaylets, + "hotRankVideos": hotVideos, + }) +} diff --git a/app/api/web/list.go b/app/api/web/list.go new file mode 100644 index 0000000..6a1f92f --- /dev/null +++ b/app/api/web/list.go @@ -0,0 +1,100 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// List 短剧列表页:题材 + GET /api/app/playlet/all/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_all_list +func List(c *gin.Context) { + q := playletser.ParseAllListQuery(c) + themes := playletser.FetchThemesOrEmpty(c, 0) + playletser.ResolveAllListTheme(&q, themes) + themeResolved := listThemeResolved(q, themes) + raw := playletser.FetchAllListOrEmpty(c, q) + pageHeading, seoSelections := listSEOContext(q, themes) + title := pageHeading + " - 黄果短剧" + if raw != nil && raw.SeoInfo.Title != "" { + title = raw.SeoInfo.Title + } + list := []playletser.CommonData(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo playletser.SeoInfo + var advitise any + if raw != nil { + list = raw.List + total = raw.TotalCount + seo = raw.SeoInfo + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + advitise = raw.Advitise + } + HTMLOK(c, "list.html", gin.H{ + "title": title, + "navActive": "list", + "seoInfo": seo, + "seoSelections": seoSelections, + "pageHeading": pageHeading, + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "listCount": fmt.Sprintf("共 %d 部", total), + "advitise": advitise, + "themeList": themes, + "themeSlug": q.ThemeSlug, + "themeId": q.ThemeID, + "updateStatus": q.UpdateStatus, + "sortType": q.SortType, + "seoEntityValid": themeResolved || total > 0, + "pagination": playletser.BuildAllListPagination(q, total), + "canonicalPath": q.URL(), + }) +} + +func listThemeResolved(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) bool { + if q.ThemeID <= 0 && q.ThemeSlug == "" { + return true + } + for _, theme := range themes { + if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) { + return true + } + } + return false +} + +func listSEOContext(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) (string, []playletser.SEOSelection) { + heading := "全部短剧" + selections := make([]playletser.SEOSelection, 0, 3) + themeName := q.ThemeSlug + for _, theme := range themes { + if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) { + themeName = theme.Name + break + } + } + if themeName != "" { + heading = themeName + "短剧" + selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName}) + } + if q.UpdateStatus == 1 { + selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "连载中"}) + } else if q.UpdateStatus == 2 { + selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "已完结"}) + } + if q.SortType == 2 { + selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"}) + } else if q.SortType == 3 { + selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"}) + } + return heading, selections +} diff --git a/app/api/web/login.go b/app/api/web/login.go new file mode 100644 index 0000000..4736777 --- /dev/null +++ b/app/api/web/login.go @@ -0,0 +1,11 @@ +package web + +import "github.com/gin-gonic/gin" + +// LoginPage H5 登录页:渲染 template/pages/login.html。 +// PC 端由页面内脚本回首页并打开 loginPop;H5 直接展示本页。 +func LoginPage(c *gin.Context) { + HTMLOK(c, "login.html", gin.H{ + "title": "登录 - 黄果短剧", + }) +} diff --git a/app/api/web/moreList.go b/app/api/web/moreList.go new file mode 100644 index 0000000..2d7d816 --- /dev/null +++ b/app/api/web/moreList.go @@ -0,0 +1,96 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// MoreList 首页配置更多页:GET /api/app/playlet/home/config/detail/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list +func MoreList(c *gin.Context) { + q := playletser.ParseMoreListQuery(c) + themes := playletser.FetchThemesOrEmpty(c, 0) + playletser.ResolveMoreListTheme(&q, themes) + raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q) + if raw != nil { + playletser.ApplyHomeConfigDetailIdentity(&q, raw) + } + home := playletser.FetchHomeOrEmpty(c) + homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData) + + name := "更多短剧" + desc := "" + entityName := "" + var seo playletser.SeoInfo + if raw != nil { + if raw.Name != "" { + name = raw.Name + entityName = raw.Name + } + desc = raw.Desc + seo = raw.SeoInfo + } + if config, ok := playletser.FindHomeConfigByIdentity(homeConfigData, q.HomeConfigID, q.HomeConfigSlug); ok { + seo = playletser.MergeSeoInfo(config.SeoInfo, seo) + if raw == nil || raw.Name == "" { + if config.Name != "" { + name = config.Name + entityName = config.Name + } + } + if desc == "" { + desc = config.Desc + } + } + title := name + " - 黄果短剧" + if seo.Title != "" { + title = seo.Title + } + + list := []playletser.CommonData(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var advitise any + entityValid := false + if raw != nil { + list = raw.List + total = raw.TotalCount + advitise = raw.Advitise + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + entityValid = playletser.MoreListEntityValid(q, entityName, total) + } + + HTMLOK(c, "moreList.html", gin.H{ + "title": title, + "seoInfo": seo, + "routeConfigSEO": true, + "homeConfigData": homeConfigData, + "seoSelections": moreListSEOSelections(q, themes), + "navActive": "home", + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "listCount": fmt.Sprintf("共 %d 部", total), + "configId": q.HomeConfigID, + "configSlug": q.HomeConfigSlug, + "configName": name, + "configDesc": desc, + "timeRange": q.TimeRange, + "themeSlug": q.ThemeSlug, + "themeId": q.ThemeID, + "seoEntityValid": entityValid, + "themeList": themes, + "timeTabs": playletser.BuildMoreTimeTabs(q), + "advitise": advitise, + "pagination": playletser.BuildMoreListPagination(q, total), + "canonicalPath": playletser.MoreListCanonicalPath(q), + }) +} diff --git a/app/api/web/moreSeriesList.go b/app/api/web/moreSeriesList.go new file mode 100644 index 0000000..0411f04 --- /dev/null +++ b/app/api/web/moreSeriesList.go @@ -0,0 +1,70 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// MoreSeriesList 首页影集配置更多页(type=2):GET /api/app/playlet/home/config/detail/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list +func MoreSeriesList(c *gin.Context) { + q := playletser.ParseMoreListQuery(c) + themes := playletser.FetchThemesOrEmpty(c, 0) + playletser.ResolveMoreListTheme(&q, themes) + raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q) + + name := "更多视频" + desc := "" + if raw != nil { + if raw.Name != "" { + name = raw.Name + } + desc = raw.Desc + } + title := name + " - 黄果短剧" + + list := []playletser.VideoItemResp(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var advitise any + entityValid := false + if raw != nil { + list = raw.VideoList + total = raw.TotalCount + advitise = raw.Advitise + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + playletser.ApplyHomeConfigDetailIdentity(&q, raw) + entityValid = playletser.MoreListEntityValid(q, raw.Name, total) + } + + HTMLOK(c, "moreSeriesList.html", gin.H{ + "title": title, + "seoSelections": moreListSEOSelections(q, themes), + "navActive": "home", + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "listCount": fmt.Sprintf("共 %d 部", total), + "configId": q.HomeConfigID, + "configSlug": q.HomeConfigSlug, + "configName": name, + "configDesc": desc, + "timeRange": q.TimeRange, + "themeSlug": q.ThemeSlug, + "themeId": q.ThemeID, + "seoEntityValid": entityValid, + "themeList": themes, + "timeTabs": playletser.BuildMoreSeriesTimeTabs(q), + "advitise": advitise, + "pagination": playletser.BuildMoreSeriesListPagination(q, total), + "canonicalPath": playletser.MoreSeriesListCanonicalPath(q), + }) +} diff --git a/app/api/web/nav.go b/app/api/web/nav.go new file mode 100644 index 0000000..4a0f251 --- /dev/null +++ b/app/api/web/nav.go @@ -0,0 +1,15 @@ +package web + +import ( + "github.com/gin-gonic/gin" +) + +// Nav 官方导航页 SSR:直接使用 config/info 注入的 appConfig。 +func Nav(c *gin.Context) { + HTMLOK(c, "nav.html", gin.H{ + "title": "黄果短剧 · 官方导航", + "isNavPage": true, + "pageKey": "nav", + "pageName": "官方导航", + }) +} diff --git a/app/api/web/notFound.go b/app/api/web/notFound.go new file mode 100644 index 0000000..88c3399 --- /dev/null +++ b/app/api/web/notFound.go @@ -0,0 +1,14 @@ +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// NotFound 404 页面:渲染 template/pages/404.html。 +func NotFound(c *gin.Context) { + HTML(c, http.StatusNotFound, "404.html", gin.H{ + "title": "页面不存在 - 黄果短剧", + }) +} diff --git a/app/api/web/pagination.go b/app/api/web/pagination.go new file mode 100644 index 0000000..481c177 --- /dev/null +++ b/app/api/web/pagination.go @@ -0,0 +1,8 @@ +package web + +import "videoweb/app/service/playletser" + +// mockPagination 为静态稿页生成分页数据;pageURL 暂统一为 #。 +func mockPagination(page, pageSize int, total int64) playletser.PaginationVM { + return playletser.BuildPagination(func(int) string { return "#" }, page, pageSize, total) +} diff --git a/app/api/web/play.go b/app/api/web/play.go new file mode 100644 index 0000000..b5cdeef --- /dev/null +++ b/app/api/web/play.go @@ -0,0 +1,388 @@ +package web + +import ( + "strconv" + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/commentser" + "videoweb/app/service/playletser" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" +) + +// Play 播放页:GET /api/app/playlet/detail + GET /api/app/playlet/chapter/detail。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_detail +func Play(c *gin.Context) { + if c.Request.URL.Query().Has("guessPage") { + NotFound(c) + return + } + q := playletser.ParsePlayQuery(c.Param("slug"), c.Param("ep")) + if strings.TrimSpace(c.Param("ep")) != "" && q.ChapterID <= 0 { + NotFound(c) + return + } + playletKey := c.Param("slug") + playlet := playletser.FetchDetailOrNilByKey(c, playletKey) + if playlet == nil { + NotFound(c) + return + } + + chapterID := playletser.ResolvePlayChapterID(playlet, q.ChapterID) + listedChapter := playletser.FindChapter(playlet, chapterID) + if chapterID <= 0 || (q.ChapterID > 0 && listedChapter == nil) { + NotFound(c) + return + } + chapter := playletser.FetchChapterDetailOrNil(c, chapterID, playlet.ID, playlet.Slug) + if chapter == nil { + if listedChapter != nil { + chapter = playletser.ChapterFromList(*listedChapter) + } + } + if chapter == nil { + NotFound(c) + return + } + + seo := playletser.ResolvePlayPageSEO(chapter, playlet) + title := playletser.ResolvePlayPageTitle(chapter, playlet) + if title == "" { + title = "在线播放 - 黄果短剧" + } + + hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{ + PageNum: 1, + PageSize: 8, + SortType: 2, // 最热 + UpdateStatus: 0, // 全部 + }) + hotList := []playletser.CommonData(nil) + if hot != nil { + hotList = hot.List + } + + guessPage := playletser.ParseGuessLikePage(c) + guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{ + PlayletID: playlet.ID, + PageNum: guessPage, + PageSize: 6, + }) + if len(guessLike.List) == 0 && guessPage > 1 { + guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{ + PlayletID: playlet.ID, + PageNum: 1, + PageSize: 6, + }) + } + + commentObjectID := playlet.ID + commentObjectType := commentser.ObjectTypePlaylet + if chapterID > 0 { + commentObjectID = chapterID + commentObjectType = commentser.ObjectTypeChapter + } + comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{ + ObjectID: commentObjectID, + ObjectType: commentObjectType, + PageNum: 1, + PageSize: 4, + SortOrder: commentser.SortHot, + }) + + unlockCost := int64(0) + balance := int64(0) + canWatch := false + episode := 0 + if chapter != nil { + unlockCost = chapter.UnlockCost() + balance = chapter.Balance + canWatch = chapter.AllowWatch() + episode = chapter.CurrentEpisode + } + if episode <= 0 { + for i, item := range playlet.PlayletChapterInfo { + if item.ID != chapterID { + continue + } + episode = item.CurrentEpisode + if episode <= 0 { + episode = i + 1 + } + break + } + } + if episode <= 0 { + episode = 1 + } + if unlockCost <= 0 { + unlockCost = int64(playletser.FirstUnlockCost(playlet.PlayletChapterInfo)) + } + + previewURL := strings.TrimSpace(playlet.Preview) + mainURL := "" + if canWatch && chapter != nil { + mainURL = strings.TrimSpace(chapter.VideoURL) + } + + data := gin.H{ + "title": title, + "navActive": "list", + "seoInfo": seo, + "playlet": playlet, + "chapter": chapter, + "chapterID": chapterID, + "episode": episode, + "canWatch": canWatch, + "mainURL": mainURL, + "previewURL": previewURL, + "hasPreview": previewURL != "", + "unlockCost": unlockCost, + "balance": balance, + "balanceText": authser.FormatRecharge(balance), + "nextChapterID": playletser.NextChapterID(playlet, chapterID), + "advitise": playlet.Advitise, + "related": playlet.RelatedRecommendData, + "guessLike": guessLike.List, + "guessPage": guessLike.PageNum, + "guessPageSize": guessLike.PageSize, + "guessTotal": guessLike.TotalCount, + "hotList": hotList, + "hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1), + "comments": comments.List, + "commentTotal": comments.TotalCount, + "commentPage": comments.PageNum, + "commentSize": comments.PageSize, + "commentObjectID": commentObjectID, + "commentObjectType": commentObjectType, + "trackerDrama": dramaTrackingData(playlet, chapter, chapterID, episode), + "playResponse": gin.H{ + "playlet": playlet, + "chapter": chapter, + "chapterID": chapterID, + "canWatch": canWatch, + "mainURL": mainURL, + "previewURL": previewURL, + "hasPreview": previewURL != "", + "guessLike": guessLike, + "hotList": hotList, + "comments": comments, + "commentObjectID": commentObjectID, + "commentObjectType": commentObjectType, + }, + } + data["canonicalPath"] = playletser.PlayPath(playlet.Slug, playlet.ID) + attachRechargeModal(c, data) + HTMLOK(c, "play.html", data) +} + +func dramaTrackingData(playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp, chapterID int64, episode int) gin.H { + name := strings.TrimSpace(playlet.PlayName) + if name == "" { + name = strings.TrimSpace(playlet.Title) + } + totalEpisode := playlet.TotalEpisode + if totalEpisode <= 0 { + totalEpisode = len(playlet.PlayletChapterInfo) + } + if totalEpisode <= 0 { + totalEpisode = 1 + } + data := gin.H{ + "MediaID": "", + "DramaID": playlet.ID, + "DramaTitle": name, + "Episode": max(1, episode), + "TotalEpisode": totalEpisode, + "VideoID": chapterID, + "DramaTypeID": "default", + "DramaTypeName": "默认分类", + "DramaTagKey": "default", + "DramaTagName": "默认标签", + "DramaDuration": 0, + "RecommendTraceID": "", + } + if chapter != nil { + if chapter.ID > 0 { + data["VideoID"] = chapter.ID + } + if chapter.MediaTime > 0 { + data["DramaDuration"] = chapter.MediaTime + } + } + for i, id := range playlet.ThemeIDs { + if id <= 0 || i >= len(playlet.ThemeNames) { + continue + } + name := strings.TrimSpace(playlet.ThemeNames[i]) + if name == "" { + continue + } + data["DramaTypeID"] = strconv.FormatInt(id, 10) + data["DramaTypeName"] = name + break + } + tagIDs := make([]string, 0, len(playlet.TagIDs)) + tagNames := make([]string, 0, len(playlet.TagIDs)) + for i, id := range playlet.TagIDs { + if id <= 0 || i >= len(playlet.TagNames) { + continue + } + name := strings.TrimSpace(playlet.TagNames[i]) + if name == "" { + continue + } + tagIDs = append(tagIDs, strconv.FormatInt(id, 10)) + tagNames = append(tagNames, name) + } + if len(tagIDs) > 0 { + data["DramaTagKey"] = strings.Join(tagIDs, ",") + data["DramaTagName"] = strings.Join(tagNames, ",") + } + return data +} + +// SeriesPlay 视频播放页:GET /api/app/video/detail。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_detail +func SeriesPlay(c *gin.Context) { + if c.Request.URL.Query().Has("guessPage") { + NotFound(c) + return + } + key := c.Param("slug") + video := videoser.FetchVideoDetailOrNilByKey(c, key) + if video == nil { + NotFound(c) + return + } + + title := videoser.ResolveVideoPageTitle(video) + if title == "" { + title = "在线播放 - 黄果短剧" + } else { + title = title + " - 黄果短剧" + } + + cover := video.CoverImg + if cover == "" { + cover = video.Video.Cover + } + videoURL := strings.TrimSpace(video.Video.URL) + canWatch := videoURL != "" + + commentObjectID := video.ID + // 视频评论:objectType=20(swagger:10-短剧 11-短剧剧集 20-视频) + commentObjectType := commentser.ObjectTypeVideo + comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{ + ObjectID: commentObjectID, + ObjectType: commentObjectType, + PageNum: 1, + PageSize: 4, + SortOrder: commentser.SortHot, + }) + if comments.PageNum <= 0 { + comments.PageNum = 1 + } + if comments.PageSize <= 0 { + comments.PageSize = 4 + } + commentTotal := comments.TotalCount + if commentTotal <= 0 && video.CountComment > 0 { + commentTotal = video.CountComment + } + + hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{ + PageNum: 1, + PageSize: 8, + SortType: 2, // 最热 + UpdateStatus: 0, // 全部 + }) + hotList := []playletser.CommonData(nil) + if hot != nil { + hotList = hot.List + } + + // 视频详情无 recommendVideos:猜你喜欢与 play.html 一致,走短剧同题材推荐。 + // 以热榜首部短剧为种子 playletId(排除该剧本身)。 + guessPlayletID := int64(0) + if len(hotList) > 0 { + guessPlayletID = hotList[0].ID + } + guessPage := playletser.ParseGuessLikePage(c) + guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{ + PlayletID: guessPlayletID, + PageNum: guessPage, + PageSize: 6, + }) + if len(guessLike.List) == 0 && guessPage > 1 { + guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{ + PlayletID: guessPlayletID, + PageNum: 1, + PageSize: 6, + }) + } + + data := gin.H{ + "title": title, + "navActive": "aiSeries", + "seoInfo": video.SeoInfo, + "video": video, + "videoID": video.ID, + "videoSlug": video.Slug, + "cover": cover, + "videoURL": videoURL, + "canWatch": canWatch, + "guessLike": guessLike.List, + "guessPage": guessLike.PageNum, + "guessPageSize": guessLike.PageSize, + "guessTotal": guessLike.TotalCount, + "guessPlayletID": guessPlayletID, + "advitise": video.Advitise, + "comments": comments.List, + "commentTotal": commentTotal, + "commentPage": comments.PageNum, + "commentSize": comments.PageSize, + "commentObjectID": commentObjectID, + "commentObjectType": commentObjectType, + "hotList": hotList, + "hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1), + "videoResponse": gin.H{ + "video": video, + "videoURL": videoURL, + "canWatch": canWatch, + "comments": comments, + "hotList": hotList, + "guessLike": guessLike, + "guessPlayletID": guessPlayletID, + "commentObjectID": commentObjectID, + "commentObjectType": commentObjectType, + }, + "commentDebug": gin.H{ + "path": "/api/app/comment/list", + "objectId": commentObjectID, + "objectType": commentObjectType, // 20-视频 + "pageNum": comments.PageNum, + "pageSize": comments.PageSize, + "totalCount": commentTotal, + "list": comments.List, + "apis": []string{ + "GET /api/app/comment/list", + "POST /api/app/comment/send", + "POST /api/app/comment/like/set", + }, + }, + "playDebug": gin.H{ + "canWatch": canWatch, + "videoURL": videoURL, + "cover": cover, + "videoTime": video.Video.VideoTime, + }, + } + if strings.TrimSpace(c.Param("guessPage")) != "" { + data["canonicalPath"] = videoser.VideoPlayURL(video.Slug, video.ID) + } + attachRechargeModal(c, data) + HTMLOK(c, "seriesPlay.html", data) +} diff --git a/app/api/web/playletLike.go b/app/api/web/playletLike.go new file mode 100644 index 0000000..593531e --- /dev/null +++ b/app/api/web/playletLike.go @@ -0,0 +1,64 @@ +package web + +import ( + "net/http" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// PlayletLikeSet BFF:代理 POST /api/app/playlet/like/set。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_like_set +func PlayletLikeSet(c *gin.Context) { + var body struct { + ID int64 `json:"id"` + IsLike *bool `json:"isLike"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ID <= 0 || body.IsLike == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "id/isLike required", + "tip": "缺少短剧 ID 或点赞状态", + "data": nil, + }) + return + } + + env, err := playletser.LikeSet(c, playletser.PlayletLikeSetReq{ + ID: body.ID, + IsLike: *body.IsLike, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/api/web/playlet_collect_set.go b/app/api/web/playlet_collect_set.go new file mode 100644 index 0000000..eeb7523 --- /dev/null +++ b/app/api/web/playlet_collect_set.go @@ -0,0 +1,64 @@ +package web + +import ( + "net/http" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// PlayletCollectSet BFF:代理 POST /api/app/playlet/collect/set。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_collect_set +func PlayletCollectSet(c *gin.Context) { + var body struct { + ID int64 `json:"id"` + IsCollect *bool `json:"isCollect"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ID <= 0 || body.IsCollect == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "id/isCollect required", + "tip": "缺少短剧 ID 或收藏状态", + "data": nil, + }) + return + } + + env, err := playletser.CollectSet(c, playletser.PlayletCollectSetReq{ + ID: body.ID, + IsCollect: *body.IsCollect, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/api/web/popularRecommend.go b/app/api/web/popularRecommend.go new file mode 100644 index 0000000..53de2bc --- /dev/null +++ b/app/api/web/popularRecommend.go @@ -0,0 +1,53 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// PopularRecommend 热播榜页:GET /api/app/playlet/hot/rank/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_rank_list +func PopularRecommend(c *gin.Context) { + q := playletser.ParseHotRankQuery(c) + raw := playletser.FetchHotRankListOrEmpty(c, q) + tabTitle := playletser.HotRankTabTitle(q.RankType) + title := tabTitle + " - 黄果短剧" + if raw != nil && raw.SeoInfo.Title != "" { + title = raw.SeoInfo.Title + } + + list := []playletser.CommonData(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo playletser.SeoInfo + if raw != nil { + list = raw.List + total = raw.TotalCount + seo = raw.SeoInfo + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + } + + HTMLOK(c, "popularRecommend.html", gin.H{ + "title": title, + "navActive": "popular", + "seoInfo": seo, + "seoSelections": rankingSEOSelections(tabTitle), + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "rankType": q.RankType, + "tabTitle": tabTitle, + "rankTabs": playletser.BuildHotRankTabs(q), + "listCount": fmt.Sprintf("共 %d 部", total), + "pagination": playletser.BuildHotRankPagination(q, total), + "canonicalPath": playletser.HotRankCanonicalPath(q), + }) +} diff --git a/app/api/web/privacy.go b/app/api/web/privacy.go new file mode 100644 index 0000000..80bead2 --- /dev/null +++ b/app/api/web/privacy.go @@ -0,0 +1,8 @@ +package web + +import "github.com/gin-gonic/gin" + +// Privacy renders the static Privacy Policy page. +func Privacy(c *gin.Context) { + HTMLOK(c, "privacy.html", gin.H{}) +} diff --git a/app/api/web/profile.go b/app/api/web/profile.go new file mode 100644 index 0000000..95b7b48 --- /dev/null +++ b/app/api/web/profile.go @@ -0,0 +1,229 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/playletser" + "videoweb/app/service/usercenterser" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +type profileWork struct { + Title, Meta, Episodes, Badge, Image, Progress, Remaining, URL string +} + +// Profile 从当前会话拉取用户信息、观看历史和收藏,不展示演示账号数据。 +func Profile(c *gin.Context) { + data := gin.H{ + "title": "个人中心 - 黄果短剧", "navActive": "profile", "accountTab": "profile", + } + responses := gin.H{} + loadAccountSummary(c, data, responses) + if useragent.TokenFromRequest(c.Request) != "" { + fetchProfileLists(c, data, responses) + } + if pretty, err := json.MarshalIndent(responses, "", " "); err == nil { + data["responseJSON"] = string(pretty) + } + HTMLOK(c, "profile.html", data) +} + +func loadAccountSummary(c *gin.Context, data, responses gin.H) { + data["userName"] = "未登录" + data["balance"] = "—" + data["historyCount"] = "—" + data["favoriteCount"] = "—" + data["chasingCount"] = "—" + data["boughtChapterCount"] = "—" + user := authser.SyncUserOnEnter(c) + if user == nil { + if useragent.TokenFromRequest(c.Request) != "" { + responses["auth/getInfo"] = gin.H{"error": "加载失败"} + fetchProfileStats(c, data, responses) + return + } + responses["auth/getInfo"] = gin.H{"error": "暂无登录会话"} + return + } + applyProfileInfo(data, responses, user) + if useragent.TokenFromRequest(c.Request) != "" { + fetchProfileStats(c, data, responses) + } +} + +func fetchProfileStats(c *gin.Context, data, responses gin.H) { + stats, code, err := usercenterser.Fetch(c) + if retry, _ := authser.OnSessionExpired(c, code); retry { + stats, code, err = usercenterser.Fetch(c) + } + if err != nil || stats == nil { + responses["user/center/data"] = gin.H{"error": "加载失败"} + if err != nil { + log.Error("fetch user center data failed", log.E(err)) + } + return + } + responses["user/center/data"] = gin.H{"code": code, "data": stats} + data["historyCount"] = fmt.Sprint(stats.WatchCount) + data["favoriteCount"] = fmt.Sprint(stats.CollectCount) + data["chasingCount"] = fmt.Sprint(stats.ChasingCount) + data["boughtChapterCount"] = fmt.Sprint(stats.BoughtChapterCount) +} + +func fetchProfileLists(c *gin.Context, data, responses gin.H) { + query := playletser.HistoryListQuery{PageNum: 1, PageSize: 6} + history, code, err := playletser.FetchHistoryList(c, query) + if retry, _ := authser.OnSessionExpired(c, code); retry { + history, _, err = playletser.FetchHistoryList(c, query) + } + if err == nil && history != nil { + responses["playlet/history/list"] = gin.H{"code": 200, "data": history} + data["history"] = profileHistoryWorks(history.List) + } else if err != nil { + responses["playlet/history/list"] = gin.H{"error": "加载失败"} + log.Error("fetch profile history failed", log.E(err)) + data["historyUnavailable"] = true + } + + collectQuery := playletser.CollectListQuery{PageNum: 1, PageSize: 6, WatchStatus: 0} + favorites, code, err := playletser.FetchCollectList(c, collectQuery) + if retry, _ := authser.OnSessionExpired(c, code); retry { + favorites, _, err = playletser.FetchCollectList(c, collectQuery) + } + if err == nil && favorites != nil { + responses["playlet/collect/list"] = gin.H{"code": 200, "data": favorites} + data["favorites"] = profileFavoriteWorks(favorites.List) + } else if err != nil { + responses["playlet/collect/list"] = gin.H{"error": "加载失败"} + log.Error("fetch profile favorites failed", log.E(err)) + data["favoritesUnavailable"] = true + } +} + +func applyProfileInfo(data, responses gin.H, user *authser.LoginResp) { + if user == nil { + return + } + // 登录响应可能包含 token;调试区域只展示非凭证字段。 + rawUser, _ := json.Marshal(user) + var safeUser map[string]any + _ = json.Unmarshal(rawUser, &safeUser) + delete(safeUser, "token") + responses["auth/getInfo"] = gin.H{"code": 200, "data": safeUser} + name := strings.TrimSpace(user.NickName) + if name == "" { + name = strings.TrimSpace(user.AccountNo) + } + if name == "" { + name = "用户" + } + data["userName"] = name + data["setting"] = authser.BuildSettingVM(user) + data["userID"] = user.UserID + data["userId"] = fmt.Sprint(user.UserID) + data["balance"] = fmt.Sprint(user.Recharge) + data["recharge"] = user.Recharge + data["rechargeText"] = authser.FormatRecharge(user.Recharge) + if avatar := safeProfileImage(user.Avatar); avatar != "" { + data["avatar"] = avatar + } +} + +func safeProfileImage(raw string) string { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "/static/") { + return raw + } + u, err := url.Parse(raw) + if err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" { + return raw + } + return "" +} + +func profileHistoryWorks(items []playletser.PlayletHistoryData) []profileWork { + works := make([]profileWork, 0, len(items)) + for _, item := range items { + if len(works) == 6 { + break + } + name := strings.TrimSpace(item.Title) + if name == "" { + name = strings.TrimSpace(item.PlayName) + } + if name == "" { + continue + } + progress := 0 + if item.TotalEpisode > 0 { + progress = item.WatchedCount * 100 / item.TotalEpisode + } + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + meta := "观看记录" + if item.LastEpisode > 0 { + meta = fmt.Sprintf("看至第 %d 集", item.LastEpisode) + } + remaining := "" + if item.UnwatchedCount > 0 { + remaining = fmt.Sprintf("剩 %d 集", item.UnwatchedCount) + } + works = append(works, profileWork{Title: name, Meta: meta, Image: profileCover(item.Cover), Progress: fmt.Sprintf("%d%%", progress), Remaining: remaining, URL: profilePlayURL(item.Slug, item.ID, item.LastEpisode)}) + } + return works +} + +func profileFavoriteWorks(items []playletser.PlayletCollectData) []profileWork { + works := make([]profileWork, 0, len(items)) + for _, item := range items { + if len(works) == 6 { + break + } + name := strings.TrimSpace(item.Title) + if name == "" { + name = strings.TrimSpace(item.PlayName) + } + if name == "" { + continue + } + work := profileWork{Title: name, Meta: strings.Join(item.ThemeNames, " "), Image: profileCover(item.Cover), URL: profilePlayURL(item.Slug, item.ID, 1)} + if item.TotalEpisode > 0 { + work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode) + } + if item.IsFeatured { + work.Badge = "爆剧" + } + works = append(works, work) + } + return works +} + +func profileCover(raw string) string { + if safe := safeProfileImage(raw); safe != "" { + return safe + } + return "/static/images/placeholder.png?v=2" +} + +func profilePlayURL(slug string, id int64, episode int) string { + if slug == "" { + if id <= 0 { + return "" + } + slug = fmt.Sprint(id) + } + if episode < 1 { + episode = 1 + } + return "/play/" + url.PathEscape(slug) + "/" + fmt.Sprint(episode) +} diff --git a/app/api/web/purchased.go b/app/api/web/purchased.go new file mode 100644 index 0000000..f19cd5c --- /dev/null +++ b/app/api/web/purchased.go @@ -0,0 +1,87 @@ +package web + +import ( + "fmt" + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/playletser" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +type purchasedWork struct { + Title, Image, Info, Date, Progress, URL string +} + +// PurchasedSeries 已购剧集:GET /api/app/playlet/bought/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_bought_list +func PurchasedSeries(c *gin.Context) { + page, validPage := accountPageNum(c) + if !validPage { + NotFound(c) + return + } + + data := gin.H{"title": "已购剧集 - 黄果短剧", "navActive": "purchased", "accountTab": "purchased"} + responses := gin.H{} + loadAccountSummary(c, data, responses) + + size := 20 + if useragent.TokenFromRequest(c.Request) == "" { + responses["playlet/bought/list"] = gin.H{"error": "暂无登录会话"} + data["purchasedResponse"] = responses["playlet/bought/list"] + HTMLOK(c, "purchased.html", data) + return + } + + list, code, err := playletser.FetchBoughtList(c, page, size) + if retry, _ := authser.OnSessionExpired(c, code); retry { + list, _, err = playletser.FetchBoughtList(c, page, size) + } + if err == nil && list != nil { + responses["playlet/bought/list"] = gin.H{"code": 200, "data": list} + data["paging"] = makeAccountPage("/purchased", page, size, list.TotalCount) + works := make([]purchasedWork, 0, len(list.List)) + for _, item := range list.List { + title := strings.TrimSpace(item.PlayName) + if title == "" { + title = strings.TrimSpace(item.Title) + } + if title == "" { + continue + } + info := fmt.Sprintf("已购 %d 集", item.BoughtChapterCount) + if item.TotalEpisode > 0 { + info += fmt.Sprintf(" / 共 %d 集", item.TotalEpisode) + } + info += fmt.Sprintf(" · %d 金币", item.CostCoins) + progress := 0 + if item.TotalEpisode > 0 { + progress = item.WatchedCount * 100 / item.TotalEpisode + } + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + works = append(works, purchasedWork{ + Title: title, Image: profileCover(item.Cover), Info: info, + Date: playletser.FormatHistoryAt(item.BoughtAt), Progress: fmt.Sprintf("%d%%", progress), + URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID), + }) + } + data["purchased"] = works + } else if err != nil { + responses["playlet/bought/list"] = gin.H{"error": "加载失败", "msg": err.Error()} + log.Error("fetch bought playlets failed", log.E(err)) + data["purchasedUnavailable"] = true + } else { + responses["playlet/bought/list"] = gin.H{"code": code, "data": list} + } + + data["purchasedResponse"] = responses["playlet/bought/list"] + HTMLOK(c, "purchased.html", data) +} diff --git a/app/api/web/rank.go b/app/api/web/rank.go new file mode 100644 index 0000000..bf791de --- /dev/null +++ b/app/api/web/rank.go @@ -0,0 +1,53 @@ +package web + +import ( + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// Rank 排行榜页:GET /api/app/playlet/rank。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_rank +func Rank(c *gin.Context) { + q := playletser.ParseRankQuery(c) + raw := playletser.FetchRankOrEmpty(c, q) + tabTitle := playletser.RankTabTitle(q.SortOrder) + title := tabTitle + " - 黄果短剧" + if raw != nil && raw.SeoInfo.Title != "" { + title = raw.SeoInfo.Title + } + + list := []playletser.CommonData(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo playletser.SeoInfo + if raw != nil { + list = raw.List + total = raw.TotalCount + seo = raw.SeoInfo + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + } + + HTMLOK(c, "rank.html", gin.H{ + "title": title, + "navActive": "rank", + "seoInfo": seo, + "seoSelections": rankingSEOSelections(tabTitle), + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "sortOrder": q.SortOrder, + "tabTitle": tabTitle, + "rankTabs": playletser.BuildRankTabs(q), + "rankOffset": playletser.RankOffset(q), + "isBinge": playletser.IsRankBinge(q.SortOrder), + "pagination": playletser.BuildRankPagination(q, total), + "canonicalPath": playletser.RankCanonicalPath(q), + }) +} diff --git a/app/api/web/recentlyAdded.go b/app/api/web/recentlyAdded.go new file mode 100644 index 0000000..95ebe92 --- /dev/null +++ b/app/api/web/recentlyAdded.go @@ -0,0 +1,48 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// RecentlyAdded 最近上新页:GET /api/app/playlet/today-update。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_today_update +func RecentlyAdded(c *gin.Context) { + q := playletser.ParseTodayUpdateQuery(c.Param("page")) + raw := playletser.FetchTodayUpdateOrEmpty(c, q) + title := "最近上新 - 黄果短剧" + if raw != nil && raw.SeoInfo.Title != "" { + title = raw.SeoInfo.Title + } + + list := []playletser.CommonData(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo playletser.SeoInfo + if raw != nil { + list = raw.List + total = raw.TotalCount + seo = raw.SeoInfo + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + } + + HTMLOK(c, "recentlyAdded.html", gin.H{ + "title": title, + "navActive": "todayUpdate", + "seoInfo": seo, + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "listCount": fmt.Sprintf("共 %d 部", total), + "pagination": playletser.BuildTodayUpdatePagination(q, total), + "canonicalPath": playletser.TodayUpdateCanonicalPath(q), + }) +} diff --git a/app/api/web/recharge.go b/app/api/web/recharge.go new file mode 100644 index 0000000..1dc5777 --- /dev/null +++ b/app/api/web/recharge.go @@ -0,0 +1,279 @@ +package web + +import ( + "net/http" + "net/url" + "strconv" + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/rechargeser" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +const rechargePageSize = 5 + +// Recharge 金币充值页:套餐 GET /api/app/recharge/package/list,购买 POST /api/app/coin/buy,流水 GET /api/app/coin/trans/list。 +func Recharge(c *gin.Context) { + data := gin.H{ + "title": "金币充值 - 黄果短剧", "pageHeading": "金币充值", + "backHref": "/profile", "helpHref": "/help", + } + responses := gin.H{} + loadAccountSummary(c, data, responses) + + packages, err := rechargeser.FetchPackageList(c) + if err != nil { + log.Error("fetch recharge packages failed", log.E(err)) + data["packageUnavailable"] = true + packages = &rechargeser.RechargePackageListResp{} + responses["recharge/package/list"] = gin.H{"error": "加载失败", "msg": err.Error()} + } else { + responses["recharge/package/list"] = gin.H{"code": 200, "data": packages} + } + vm := rechargeser.BuildDepositPageVM(packages, "coin") + data["packages"] = vm.CoinList + data["paymentMethods"] = vm.PayMethods + data["rechargePackageResponse"] = packages + + balance := authser.RechargeFromCookies(c) + if _, ok := data["recharge"]; ok { + balance = toInt64(data["recharge"]) + } + data["balance"] = balance + data["balanceText"] = authser.FormatRecharge(balance) + + page, _, err := fetchRechargeTransactions(c, 1, rechargePageSize, 0) + if err != nil { + log.Error("fetch recharge transactions failed", log.E(err)) + data["transactionsUnavailable"] = true + page = &rechargeser.CoinTransPage{PageNum: 1, PageSize: rechargePageSize} + responses["coin/trans/list"] = gin.H{"error": "加载失败", "msg": err.Error()} + } else { + responses["coin/trans/list"] = gin.H{"code": 200, "data": page} + } + data["transactions"] = rechargeser.BuildTxListFromCoinTrans(page.List, 0) + data["transactionPage"] = page + data["transactionResponse"] = page + data["rechargeDebug"] = responses + HTMLOK(c, "recharge.html", data) +} + +// RechargeCoinBuy BFF:代理 POST /api/app/coin/buy。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/post_api_app_coin_buy +func RechargeCoinBuy(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil}) + return + } + var body rechargeser.BuyPayReq + if err := c.ShouldBindJSON(&body); err != nil || body.PackageID <= 0 { + c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "packageId required", "tip": "请选择充值套餐", "data": nil}) + return + } + body.PayMode = rechargeser.NormalizePayMode(body.PayMode) + if body.PayMode == "" { + c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "payMode required", "tip": "请选择支付方式", "data": nil}) + return + } + if !validCoinPackagePayment(c, body.PackageID, body.PayMode) { + c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "invalid package/payMode", "tip": "套餐或支付方式不可用,请刷新后重试", "data": nil}) + return + } + + env, err := rechargeser.BuyCoin(c, body) + if err != nil { + log.Error("buy coin failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil}) + return + } + if env != nil { + if retry, needLogin := authser.OnSessionExpired(c, env.Code); needLogin { + c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": "登录已过期,请重新登录", "data": nil}) + return + } else if retry { + env, err = rechargeser.BuyCoin(c, body) + if err != nil { + log.Error("buy coin retry failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil}) + return + } + } + } + if env == nil || env.Code != 200 { + code, tip, msg := 500, "创建支付订单失败", "buy failed" + if env != nil { + code = env.Code + msg = env.Msg + tip = firstNonEmptyAuth(env.Tip, env.Msg, tip) + } + c.JSON(http.StatusOK, gin.H{"code": code, "msg": msg, "tip": tip, "data": nil}) + return + } + safeURL := safePaymentURL(env.Data.PayURL) + if safeURL == "" { + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "invalid payUrl", "tip": "支付地址无效,请稍后重试", "data": nil}) + return + } + env.Data.PayURL = safeURL + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": env.Msg, + "tip": firstNonEmptyAuth(env.Tip, "订单创建成功"), + "data": env.Data, + }) +} + +// RechargeTransactions BFF:代理 GET /api/app/coin/trans/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_list +// query:page / pageSize / type(0全部 1充值 2消费 3奖励) +func RechargeTransactions(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil}) + return + } + page := queryInt(c, "page", 1) + pageSize := queryInt(c, "pageSize", rechargePageSize) + typ := queryIntAllowZero(c, "type", 0) + if typ < 0 || typ > 3 { + typ = 0 + } + result, code, err := fetchRechargeTransactions(c, page, pageSize, typ) + if err != nil { + log.Error("fetch recharge transactions failed", log.E(err)) + tip := "交易明细加载失败" + if code > 0 { + c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "tip": tip, "data": nil}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": tip, "data": nil}) + return + } + if result == nil { + result = &rechargeser.CoinTransPage{PageNum: page, PageSize: pageSize} + } + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "ok", + "tip": "", + "data": gin.H{ + "list": rechargeser.BuildTxListFromCoinTrans(result.List, 0), + "rawList": result.List, + "pageNum": result.PageNum, + "pageSize": result.PageSize, + "totalCount": result.TotalCount, + }, + }) +} + +// RechargeCoinTransExport BFF:代理 GET /api/app/coin/trans/export。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_export +// query:type(0全部 1充值 2消费 3奖励),返回 CSV。 +func RechargeCoinTransExport(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil}) + return + } + typ := queryIntAllowZero(c, "type", 0) + if typ < 0 || typ > 3 { + typ = 0 + } + result, err := rechargeser.FetchCoinTransExport(c, typ) + if err != nil { + log.Error("export coin trans failed", log.E(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "导出失败,请稍后重试", "data": nil}) + return + } + ct := strings.TrimSpace(result.ContentType) + if ct == "" || strings.Contains(strings.ToLower(ct), "json") { + ct = "text/csv; charset=utf-8" + } + disposition := strings.TrimSpace(result.ContentDisposition) + if disposition == "" { + disposition = `attachment; filename="coin-trans.csv"` + } + c.Header("Content-Type", ct) + c.Header("Content-Disposition", disposition) + c.Header("Cache-Control", "no-store") + c.Data(http.StatusOK, ct, result.Body) +} + +func fetchRechargeTransactions(c *gin.Context, page, pageSize, typ int) (*rechargeser.CoinTransPage, int, error) { + result, code, err := rechargeser.FetchCoinTransPage(c, page, pageSize, typ) + if retry, _ := authser.OnSessionExpired(c, code); retry { + result, code, err = rechargeser.FetchCoinTransPage(c, page, pageSize, typ) + } + return result, code, err +} + +// attachRechargeModal 注入充值弹窗数据:GET /api/app/recharge/package/list → coinList。 +func attachRechargeModal(c *gin.Context, data gin.H) { + if data == nil { + return + } + packages := rechargeser.FetchPackageListOrEmpty(c) + vm := rechargeser.BuildDepositPageVM(packages, "coin") + data["rechargePackages"] = vm.CoinList + data["rechargePayMethods"] = vm.PayMethods + data["rechargePackageResponse"] = packages + if _, ok := data["unlockCost"]; !ok { + data["unlockCost"] = int64(0) + } + if _, ok := data["balance"]; !ok { + data["balance"] = authser.RechargeFromCookies(c) + } + if _, ok := data["balanceText"]; !ok { + data["balanceText"] = authser.FormatRecharge(toInt64(data["balance"])) + } +} + +func validCoinPackagePayment(c *gin.Context, packageID int64, payMode string) bool { + data, err := rechargeser.FetchPackageList(c) + if err != nil || data == nil { + return false + } + for _, item := range data.CoinList { + if item.ID != packageID { + continue + } + for _, method := range item.PayMethods { + if rechargeser.NormalizePayMode(method.Type) == payMode { + return true + } + } + return false + } + return false +} + +func safePaymentURL(raw string) string { + raw = strings.TrimSpace(raw) + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "" + } + return raw +} + +func queryInt(c *gin.Context, name string, fallback int) int { + v, err := strconv.Atoi(strings.TrimSpace(c.Query(name))) + if err != nil || v <= 0 { + return fallback + } + return v +} + +// queryIntAllowZero 允许 0(如 type=0 全部)。 +func queryIntAllowZero(c *gin.Context, name string, fallback int) int { + raw := strings.TrimSpace(c.Query(name)) + if raw == "" { + return fallback + } + v, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return v +} diff --git a/app/api/web/register.go b/app/api/web/register.go new file mode 100644 index 0000000..ead157b --- /dev/null +++ b/app/api/web/register.go @@ -0,0 +1,11 @@ +package web + +import "github.com/gin-gonic/gin" + +// RegisterPage H5 注册页:渲染 template/pages/register.html。 +// PC 端由页面内脚本回首页并打开 registerPop;H5 直接展示本页。 +func RegisterPage(c *gin.Context) { + HTMLOK(c, "register.html", gin.H{ + "title": "注册账号 - 黄果短剧", + }) +} diff --git a/app/api/web/render.go b/app/api/web/render.go new file mode 100644 index 0000000..f39059a --- /dev/null +++ b/app/api/web/render.go @@ -0,0 +1,660 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/configser" + "videoweb/app/service/playletser" + "videoweb/app/service/seoser" + "videoweb/app/service/translationser" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" +) + +// HTML 渲染页面并注入登录态(以 cookie 为准)。 +// 无 token 时自动游客登录(POST /api/app/auth/guest),保证默认进入即有会话。 +func HTML(c *gin.Context, code int, name string, data gin.H) { + if data == nil { + data = gin.H{} + } + + // 所有服务端页面先完成渠道首次归因;已有会话也不会漏记落地页渠道。 + if channel := authser.ResolveChannel(c, ""); channel != "" { + data["channel"] = channel + } + authser.SyncUserOnEnter(c) + + loggedIn, userName, avatar, email := authser.AuthFromCookies(c) + registered := authser.IsRegisteredFromCookies(c) + data["isLoggedIn"] = loggedIn + data["isRegistered"] = registered + // 已登录(含游客)展示用户菜单;未登录才展示「登录」按钮。 + if _, ok := data["guestHeader"]; !ok { + data["guestHeader"] = !loggedIn + } + if _, ok := data["isVip"]; !ok { + data["isVip"] = loggedIn && authser.IsActiveVipFromCookies(c) + } + isVip, _ := data["isVip"].(bool) + data["isVip"] = isVip + + if loggedIn { + if _, provided := data["userName"]; !provided && userName != "" { + data["userName"] = userName + } + if _, provided := data["avatar"]; !provided && avatar != "" { + data["avatar"] = avatar + } + if email != "" { + data["email"] = email + } + if _, ok := data["userId"]; !ok { + if uid := authser.UserIDFromCookies(c); uid > 0 { + data["userId"] = authser.FormatUserID(uid) + } + } + if _, ok := data["recharge"]; !ok { + recharge := authser.RechargeFromCookies(c) + data["recharge"] = recharge + data["rechargeText"] = authser.FormatRecharge(recharge) + } else if _, ok := data["rechargeText"]; !ok { + data["rechargeText"] = authser.FormatRecharge(toInt64(data["recharge"])) + } + if _, ok := data["vipType"]; !ok { + data["vipType"] = authser.VipTypeFromCookies(c) + } + } else { + delete(data, "userName") + delete(data, "avatar") + delete(data, "email") + delete(data, "userId") + delete(data, "recharge") + delete(data, "rechargeText") + delete(data, "vipType") + } + + // 导航分类:GET /api/app/translation/category/list → categoryList(包含分类 seoInfo) + if _, ok := data["categoryList"]; !ok { + data["categoryList"] = translationser.FetchHeaderCategoryListOrDefault(c) + } + if _, ok := data["requestPath"]; !ok { + data["requestPath"] = c.Request.URL.Path + } + // 抽屉题材:GET /api/app/theme/list + if _, ok := data["themeList"]; !ok { + data["themeList"] = playletser.FetchThemesOrEmpty(c, 0) + } + // 顶栏短剧下拉快捷入口:homeConfigData type=1 + if _, ok := data["homeConfigPlaylets"]; !ok { + if configs, ok := data["homeConfigData"].([]playletser.HomeConfigData); ok { + data["homeConfigPlaylets"] = playletser.HomeConfigsByType(configs, 1) + } else { + home := playletser.FetchHomeOrEmpty(c) + data["homeConfigPlaylets"] = playletser.HomeConfigsByType(home.HomeConfigData, 1) + } + } + + // PC 搜索下拉热搜:GET /api/app/playlet/hot-like + if _, ok := data["searchTrends"]; !ok { + trends := hotSearchTrends(playletser.FetchHotLikeOrEmpty(c).HotSearch) + if len(trends) > 8 { + trends = trends[:8] + } + data["searchTrends"] = trends + } + + // 公共配置:GET /api/app/config/info → config / appConfig + if _, ok := data["appConfig"]; !ok { + vm := configser.FetchAppConfigVMOrEmpty(c) + data["appConfig"] = vm + if _, ok := data["config"]; !ok { + data["config"] = vm.Config + } + } else if _, ok := data["config"]; !ok { + if vm, ok := data["appConfig"].(configser.AppConfigVM); ok { + data["config"] = vm.Config + } + } + + data["trackerAppId"] = configser.ResolveTrackerAppID() + attachTrackerPage(c.Request.URL.Path, code, data) + + attachPageSEO(c, code, data) + c.HTML(code, name, data) +} + +// HTMLOK 以 200 渲染页面。 +func HTMLOK(c *gin.Context, name string, data gin.H) { + if pageRepresentsMissingResource(c, data) { + NotFound(c) + return + } + HTML(c, http.StatusOK, name, data) +} + +func toInt64(v any) int64 { + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + case int32: + return int64(n) + case float64: + return int64(n) + case string: + i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64) + return i + default: + return 0 + } +} + +// attachPageSEO 在一个入口统一生成页面级 SEO 信号,避免各模板各自维护后产生冲突。 +func attachPageSEO(c *gin.Context, code int, data gin.H) { + if c == nil || c.Request == nil { + return + } + + cfg, ok := data["appConfig"].(configser.AppConfigVM) + if !ok { + cfg = configser.FetchAppConfigVMOrEmpty(c) + data["appConfig"] = cfg + } + path := c.Request.URL.Path + pageTitle, _ := data["title"].(string) + pageTitle = strings.TrimSpace(pageTitle) + + seoInfo := pageSEOFromData(data["seoInfo"]) + siteName := firstNonEmptySEO(seoInfo.WebSiteName, cfg.SeoInfo.WebSiteName, "黄果短剧") + routeConfigSEO, _ := data["routeConfigSEO"].(bool) + if !routeConfigSEO { + seoInfo = replaceSitewideSEOWithRouteSEO(path, data, seoInfo, pageSEOFromData(cfg.SeoInfo), siteName) + } + navigationSEOApplied := false + if categories, ok := data["categoryList"].([]translationser.TranslationCategory); ok { + headerNav := translationser.BuildHeaderNav(&translationser.AppTranslationInfo{CategoryList: categories}) + activeKey, _ := data["navActive"].(string) + if navSEO, found := translationser.FindHeaderNavSEOByPath(headerNav, path); found { + // 与 jha-1040-cgdj-web 一致:导航分类页面以分类接口 TDK 为主,页面接口只补缺。 + seoInfo = playletser.MergeSeoInfo(playletser.SeoInfo{ + Title: navSEO.Title, + Description: navSEO.Description, + Keywords: navSEO.Keyworld, + }, seoInfo) + navigationSEOApplied = true + } else if !routeConfigSEO { + // 当前项目的详情/播放页会复用 navActive;这类页面保持内容 TDK 为主,导航只补缺。 + if navSEO, found := translationser.FindHeaderNavSEO(headerNav, activeKey, path); found { + seoInfo = playletser.MergeSeoInfo(seoInfo, playletser.SeoInfo{ + Title: navSEO.Title, + Description: navSEO.Description, + Keywords: navSEO.Keyworld, + }) + } + } + } + if selections, ok := data["seoSelections"].([]playletser.SEOSelection); ok && len(selections) > 0 { + seoInfo = playletser.DifferentiateSeoInfo(seoInfo, selections) + } + if seoInfo.Title == "" { + seoInfo.Title = firstNonEmptySEO(pageTitle, defaultPageTitle(path, siteName)) + } + if seoInfo.Description == "" { + seoInfo.Description = defaultMetaDescription(path, seoInfo.Title, data, siteName) + } + if len(seoInfo.Keywords) == 0 { + seoInfo.Keywords = defaultMetaKeywords(path, siteName) + } + if pageNum := paginationPage(data["pagination"]); pageNum > 1 { + seoInfo = playletser.PaginateSeoInfo(seoInfo, seoInfo.Description, pageNum) + } + metaTitle := seoInfo.Title + if !navigationSEOApplied { + metaTitle = ensureBrandTitle(metaTitle, siteName) + } + data["title"] = metaTitle + data["metaTitle"] = metaTitle + data["metaDescription"] = seoInfo.Description + data["metaKeywords"] = strings.Join(seoInfo.Keywords, ",") + data["siteName"] = siteName + data["seoInfo"] = seoInfo + + siteURL := seoser.ResolveSiteURL(c, cfg) + data["siteURL"] = siteURL + data["foreverUrlAddr"] = siteURL + data["seoVerify"] = seoser.VerifyFromConfig() + data["ogType"] = pageOGType(path) + data["ogImage"] = pageOGImage(siteURL, path, data) + attachPaginationLinks(data, siteURL) + + robots, _ := data["robots"].(string) + robots = strings.TrimSpace(robots) + if robots == "" { + robots = defaultRobotsForPath(path, code) + } + if !robotsHasNoIndex(robots) && pageShouldNotIndex(c, data) { + robots = "noindex,follow" + } + data["robots"] = robots + noindex := robotsHasNoIndex(robots) + if noindex { + data["noindex"] = true + c.Header("X-Robots-Tag", strings.ReplaceAll(robots, ",", ", ")) + } else { + delete(data, "noindex") + } + + canonicalEligible := code >= http.StatusOK && code < http.StatusMultipleChoices && !noindex && seoser.IsIndexablePath(path) + if canonicalEligible { + canonicalURL := seoser.CanonicalWithoutQuery(seoser.PageCanonicalURL(siteURL, cfg.ForeverURLAddr, c)) + if canonicalPath, _ := data["canonicalPath"].(string); strings.HasPrefix(strings.TrimSpace(canonicalPath), "/") { + canonicalURL = seoser.CanonicalWithoutQuery(seoser.AbsURL(siteURL, strings.TrimSpace(canonicalPath))) + } + canonicalURL = seoser.CanonicalWithoutQuery(seoser.ContentCanonicalURL(siteURL, canonicalURL, map[string]any(data))) + data["canonicalURL"] = canonicalURL + data["jsonLD"] = seoser.PageJSONLD(siteURL, canonicalURL, c.Request.URL.RequestURI(), map[string]any(data), cfg) + } else { + delete(data, "canonicalURL") + delete(data, "jsonLD") + } +} + +func pageShouldNotIndex(c *gin.Context, data gin.H) bool { + if isNavPage, _ := data["isNavPage"].(bool); isNavPage { + return true + } + if c != nil && c.Request != nil { + path := c.Request.URL.Path + if isEpisodePlayPath(path, c.Param("ep")) { + return true + } + if strings.HasPrefix(path, "/series-play/") { + if canWatch, ok := data["canWatch"].(bool); ok && !canWatch { + return true + } + } + } + return pageRepresentsMissingResource(c, data) +} + +// pageRepresentsMissingResource 识别不存在的实体、非法分页与越界分页。 +// 这些请求必须返回真实 404,不能仅依赖 noindex 掩盖 200 软 404。 +func pageRepresentsMissingResource(c *gin.Context, data gin.H) bool { + if valid, exists := data["seoEntityValid"].(bool); exists && !valid { + return true + } + pageNum := int(toInt64(data["pageNum"])) + if c != nil { + if rawPage := strings.TrimSpace(c.Param("page")); rawPage != "" { + requestedPage, err := strconv.Atoi(rawPage) + // 分页二级路由只允许第 2 页及以后;第 1 页使用更短的规范地址。 + if err != nil || requestedPage <= 1 { + return true + } + pageNum = requestedPage + } + } + pageSize := int(toInt64(data["pageSize"])) + totalCount, hasTotal := data["totalCount"] + return hasTotal && playletser.PageOutOfRange(pageNum, pageSize, toInt64(totalCount)) +} + +func pageSEOFromData(value any) playletser.SeoInfo { + var seo playletser.SeoInfo + switch value := value.(type) { + case playletser.SeoInfo: + seo = value + case *playletser.SeoInfo: + if value != nil { + seo = *value + } + case videoser.SeoInfo: + seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} + case *videoser.SeoInfo: + if value != nil { + seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} + } + case configser.SeoInfo: + seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} + case *configser.SeoInfo: + if value != nil { + seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName} + } + } + seo.Normalize() + return seo +} + +// replaceSitewideSEOWithRouteSEO 只替换接口误复用的全站首页 TDK。 +// 页面接口真正下发的独立 SEO 仍保持最高优先级。 +func replaceSitewideSEOWithRouteSEO(path string, data gin.H, seo, siteDefault playletser.SeoInfo, siteName string) playletser.SeoInfo { + routeSEO, ok := routeSpecificSEO(path, data, siteName) + if !ok { + return seo + } + if sameNonEmptySEOValue(seo.Title, siteDefault.Title) { + seo.Title = routeSEO.Title + } + if sameNonEmptySEOValue(seo.Description, siteDefault.Description) { + seo.Description = routeSEO.Description + } + if sameSEOKeywords(seo.Keywords, siteDefault.Keywords) { + seo.Keywords = append([]string(nil), routeSEO.Keywords...) + } + seo.Normalize() + return seo +} + +func isEpisodePlayPath(path, epParam string) bool { + if strings.TrimSpace(epParam) != "" { + return true + } + segments := strings.Split(strings.Trim(path, "/"), "/") + return len(segments) >= 3 && segments[0] == "play" && segments[1] != "" && segments[2] != "" +} + +func routeSpecificSEO(path string, data gin.H, siteName string) (playletser.SeoInfo, bool) { + path = strings.TrimRight(strings.TrimSpace(path), "/") + title := "" + switch { + case path == "/list" || strings.HasPrefix(path, "/list/"): + title = firstNonEmptySEO(dataString(data, "pageHeading"), "全部短剧") + case path == "/series" || strings.HasPrefix(path, "/series/"): + title = firstNonEmptySEO(dataString(data, "pageHeading"), "剧集与动画") + case path == "/topic" || strings.HasPrefix(path, "/topic/"): + title = "专题剧单" + case strings.HasPrefix(path, "/topic-detail/"): + if name := dataString(data, "name"); name != "" { + title = name + "专题短剧" + } else { + title = "短剧专题" + } + case strings.HasPrefix(path, "/tags/"): + if name := dataString(data, "tagName"); name != "" { + title = name + "标签短剧" + } else { + title = "标签短剧" + } + case strings.HasPrefix(path, "/more-list/"): + title = firstNonEmptySEO(dataString(data, "configName"), "精选短剧") + case strings.HasPrefix(path, "/more-series-list/"): + title = firstNonEmptySEO(dataString(data, "configName"), "精选剧集") + case path == "/rank" || strings.HasPrefix(path, "/rank/"): + title = "短剧排行榜" + case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): + title = "热门短剧推荐" + case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): + title = "最近上新短剧" + default: + return playletser.SeoInfo{}, false + } + return playletser.SeoInfo{ + Title: title, + Description: defaultMetaDescription(path, title, data, siteName), + Keywords: defaultMetaKeywords(path, siteName), + }, true +} + +func dataString(data gin.H, key string) string { + value, _ := data[key].(string) + return strings.TrimSpace(value) +} + +func sameNonEmptySEOValue(value, generic string) bool { + value = strings.TrimSpace(value) + generic = strings.TrimSpace(generic) + return generic != "" && strings.EqualFold(value, generic) +} + +func sameSEOKeywords(value, generic []string) bool { + if len(generic) == 0 || len(value) != len(generic) { + return false + } + for i := range value { + if !strings.EqualFold(strings.TrimSpace(value[i]), strings.TrimSpace(generic[i])) { + return false + } + } + return true +} + +func paginationPage(value any) int { + switch value := value.(type) { + case playletser.PaginationVM: + return value.Page + case *playletser.PaginationVM: + if value != nil { + return value.Page + } + } + return 0 +} + +func attachPaginationLinks(data gin.H, siteURL string) { + var pagination playletser.PaginationVM + switch value := data["pagination"].(type) { + case playletser.PaginationVM: + pagination = value + case *playletser.PaginationVM: + if value == nil { + return + } + pagination = *value + default: + return + } + if !pagination.Visible { + return + } + if pagination.HasPrev && strings.TrimSpace(pagination.PrevURL) != "" { + data["paginationPrevURL"] = seoser.AbsURL(siteURL, pagination.PrevURL) + } + if pagination.HasNext && strings.TrimSpace(pagination.NextURL) != "" { + data["paginationNextURL"] = seoser.AbsURL(siteURL, pagination.NextURL) + } +} + +func defaultRobotsForPath(path string, code int) string { + if code >= http.StatusBadRequest { + return "noindex,follow" + } + if seoser.IsIndexablePath(path) { + return "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" + } + if seoser.IsPrivatePath(path) { + return "noindex,nofollow" + } + return "noindex,follow" +} + +func robotsHasNoIndex(robots string) bool { + for _, directive := range strings.Split(strings.ToLower(robots), ",") { + if strings.TrimSpace(directive) == "noindex" { + return true + } + } + return false +} + +func defaultPageTitle(path, siteName string) string { + path = strings.TrimRight(strings.TrimSpace(path), "/") + if siteName == "" { + siteName = "黄果短剧" + } + switch { + case path == "": + return siteName + " - 热门短剧在线看" + case path == "/about": + return "关于我们" + case path == "/nav" || path == "/dh": + return siteName + " · 官方导航" + case path == "/help": + return "常见问题" + case path == "/terms": + return "用户条款" + case path == "/privacy": + return "隐私政策" + case path == "/2257": + return "2257 合规声明" + case path == "/dmca": + return "DMCA 版权声明" + case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile": + return "搜索" + case path == "/list" || strings.HasPrefix(path, "/list/"): + return "全部短剧" + case path == "/series" || strings.HasPrefix(path, "/series/"): + return "剧集与动画" + case path == "/topic" || strings.HasPrefix(path, "/topic/"): + return "专题剧单" + case path == "/rank" || strings.HasPrefix(path, "/rank/"): + return "短剧排行榜" + case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): + return "最近上新" + case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): + return "热门推荐" + case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"): + return "在线播放" + default: + return siteName + } +} + +func defaultMetaDescription(path, title string, data gin.H, siteName string) string { + for _, key := range []string{"description", "desc", "heroDesc", "categoryDescription", "tagDescription", "topicDescription"} { + if value, _ := data[key].(string); strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + if siteName == "" { + siteName = "黄果短剧" + } + cleanTitle := strings.TrimSpace(strings.TrimSuffix(title, " - "+siteName)) + path = strings.TrimRight(strings.TrimSpace(path), "/") + if isNavPage, _ := data["isNavPage"].(bool); isNavPage { + return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。" + } + switch { + case path == "": + return siteName + "汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。" + case path == "/about": + return "了解" + siteName + "的平台理念、内容特色与联系方式。" + case path == "/nav" || path == "/dh": + return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。" + case path == "/help": + return "查看" + siteName + "常见问题、播放帮助、账号使用与客服支持信息。" + case path == "/terms": + return "查看" + siteName + "用户服务条款及平台使用规则。" + case path == "/privacy": + return "查看" + siteName + "隐私政策与个人信息保护说明。" + case path == "/2257": + return "查看" + siteName + " 18 U.S.C. 第 2257 章合规声明、年龄限制与内容反馈处理原则。" + case path == "/dmca": + return "查看" + siteName + " DMCA 版权声明、投诉材料要求与处理流程说明。" + case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile": + return "在" + siteName + "搜索剧名、演员、标签与相关作品。" + case strings.HasPrefix(path, "/play/"): + return "在线观看《" + cleanTitle + "》,查看选集、剧情信息与相关推荐。" + case strings.HasPrefix(path, "/series-play/"): + return "在线观看《" + cleanTitle + "》,浏览视频信息与相关推荐。" + case path == "/list" || strings.HasPrefix(path, "/list/"): + return "浏览" + siteName + "全部短剧,按题材、更新状态和热度筛选精彩作品。" + case path == "/series" || strings.HasPrefix(path, "/series/"): + return "浏览" + siteName + "精选剧集与动画,按类型、专题和热度发现更多内容。" + case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"): + return "浏览" + siteName + "精选专题剧单,发现同题材热门作品与持续更新内容。" + case path == "/rank" || strings.HasPrefix(path, "/rank/"): + return "查看" + siteName + "实时更新的短剧排行榜,浏览热播榜、新剧榜与追剧榜。" + case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): + return "浏览" + siteName + "热门推荐,发现近期高热度、广受欢迎的精选短剧。" + case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): + return "查看" + siteName + "最近上新的短剧与剧集,及时追更最新内容。" + default: + return cleanTitle + ",浏览" + siteName + "相关内容与推荐。" + } +} + +func defaultMetaKeywords(path, siteName string) []string { + path = strings.TrimRight(strings.TrimSpace(path), "/") + if siteName == "" { + siteName = "黄果短剧" + } + switch { + case path == "": + return []string{siteName, "短剧", "在线短剧", "热门短剧"} + case path == "/list" || strings.HasPrefix(path, "/list/"): + return []string{"短剧列表", "短剧分类", "在线短剧", siteName} + case path == "/series" || strings.HasPrefix(path, "/series/"): + return []string{"剧集", "动画", "在线观看", siteName} + case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"): + return []string{"在线播放", "短剧播放", siteName} + case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"): + return []string{"短剧专题", "专题剧单", siteName} + case path == "/rank" || strings.HasPrefix(path, "/rank/"): + return []string{"短剧排行榜", "热门短剧", siteName} + case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"): + return []string{"热门短剧", "短剧推荐", siteName} + case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"): + return []string{"最新短剧", "最近上新", siteName} + case strings.HasPrefix(path, "/tags/"): + return []string{"标签短剧", "短剧分类", siteName} + case strings.HasPrefix(path, "/more-list/"): + return []string{"精选短剧", "热门短剧", siteName} + case strings.HasPrefix(path, "/more-series-list/"): + return []string{"精选剧集", "剧集推荐", siteName} + default: + return []string{siteName, "在线短剧"} + } +} + +func ensureBrandTitle(title, siteName string) string { + title = strings.TrimSpace(title) + siteName = strings.TrimSpace(siteName) + if title == "" { + return siteName + } + if siteName == "" || strings.Contains(title, siteName) || strings.Contains(title, "黄果短剧") { + return title + } + return title + " - " + siteName +} + +func pageOGType(path string) string { + path = strings.TrimRight(strings.TrimSpace(path), "/") + if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") { + return "video.episode" + } + if path == "/terms" || path == "/privacy" || path == "/2257" || path == "/dmca" { + return "article" + } + return "website" +} + +func pageOGImage(siteURL string, path string, data gin.H) string { + path = strings.TrimRight(strings.TrimSpace(path), "/") + // 播放页分享小卡片统一用品牌 OG 图,避免微信等抓取剧集封面。 + if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") { + return seoser.AbsURL(siteURL, "/static/og-default.png") + } + if playlet, ok := data["playlet"].(*playletser.PlayletInfoResp); ok && playlet != nil { + if cover := firstNonEmptySEO(playlet.Cover, playlet.Banner); cover != "" { + return seoser.AbsURL(siteURL, cover) + } + } + if cover, _ := data["cover"].(string); strings.TrimSpace(cover) != "" { + return seoser.AbsURL(siteURL, cover) + } + return seoser.AbsURL(siteURL, "/static/og-default.png") +} + +func firstNonEmptySEO(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} diff --git a/app/api/web/render_test.go b/app/api/web/render_test.go new file mode 100644 index 0000000..16b97c3 --- /dev/null +++ b/app/api/web/render_test.go @@ -0,0 +1,176 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" + "videoweb/app/service/configser" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +func TestAttachPageSEOUsesPageTypeInsteadOfRequestDomain(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + requestURL string + foreverURL string + status int + data gin.H + wantRobots string + wantRobotsHeader string + wantCanonical string + wantJSONLD bool + }{ + { + name: "public page on alternate domain remains indexable", + requestURL: "https://alternate.example/", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{}, + wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1", + wantCanonical: "https://primary.example/", + wantJSONLD: true, + }, + { + name: "public page without permanent address remains indexable", + requestURL: "https://alternate.example/about", + status: http.StatusOK, + data: gin.H{}, + wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1", + wantCanonical: "https://alternate.example/about", + wantJSONLD: true, + }, + { + name: "search page is not indexable", + requestURL: "https://alternate.example/search/short-drama", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{}, + wantRobots: "noindex,follow", + wantRobotsHeader: "noindex, follow", + }, + { + name: "private page is neither indexed nor followed", + requestURL: "https://alternate.example/profile", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{}, + wantRobots: "noindex,nofollow", + wantRobotsHeader: "noindex, nofollow", + }, + { + name: "navigation page is not indexable", + requestURL: "https://alternate.example/", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{"isNavPage": true}, + wantRobots: "noindex,follow", + wantRobotsHeader: "noindex, follow", + }, + { + name: "missing public entity is not indexable", + requestURL: "https://alternate.example/tags/missing", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{"seoEntityValid": false}, + wantRobots: "noindex,follow", + wantRobotsHeader: "noindex, follow", + }, + { + name: "error page is not indexable", + requestURL: "https://alternate.example/about", + foreverURL: "https://primary.example", + status: http.StatusNotFound, + data: gin.H{}, + wantRobots: "noindex,follow", + wantRobotsHeader: "noindex, follow", + }, + { + name: "canonical play page stays indexable", + requestURL: "https://alternate.example/play/zhuazou", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{}, + wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1", + wantCanonical: "https://primary.example/play/zhuazou", + wantJSONLD: true, + }, + { + name: "play page with chapter still canonicalizes to two-segment path", + requestURL: "https://alternate.example/play/zhuazou", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{ + "playlet": playletser.PlayletInfoResp{ID: 12, Slug: "zhuazou"}, + "chapter": playletser.PlayletChapterInfoResp{ID: 901, PlayletID: 12}, + }, + wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1", + wantCanonical: "https://primary.example/play/zhuazou", + wantJSONLD: true, + }, + { + name: "episode play page is not indexable", + requestURL: "https://alternate.example/play/zhuazou/12", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{}, + wantRobots: "noindex,follow", + wantRobotsHeader: "noindex, follow", + }, + { + name: "unwatchable series play page is not indexable", + requestURL: "https://alternate.example/series-play/film-1", + foreverURL: "https://primary.example", + status: http.StatusOK, + data: gin.H{"canWatch": false}, + wantRobots: "noindex,follow", + wantRobotsHeader: "noindex, follow", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest(http.MethodGet, tt.requestURL, nil) + + data := gin.H{ + "appConfig": configser.AppConfigVM{ + ForeverURLAddr: tt.foreverURL, + }, + } + for key, value := range tt.data { + data[key] = value + } + + attachPageSEO(ctx, tt.status, data) + + if got := data["robots"]; got != tt.wantRobots { + t.Fatalf("robots = %q, want %q", got, tt.wantRobots) + } + if got := response.Header().Get("X-Robots-Tag"); got != tt.wantRobotsHeader { + t.Fatalf("X-Robots-Tag = %q, want %q", got, tt.wantRobotsHeader) + } + + canonicalValue, hasCanonical := data["canonicalURL"] + if tt.wantCanonical == "" { + if hasCanonical { + t.Fatalf("canonicalURL must be absent, got %#v", canonicalValue) + } + } else { + canonical, ok := canonicalValue.(string) + if !hasCanonical || !ok || canonical != tt.wantCanonical { + t.Fatalf("canonicalURL = %#v, want %q", canonicalValue, tt.wantCanonical) + } + } + + jsonLD, hasJSONLD := data["jsonLD"] + if hasJSONLD != tt.wantJSONLD { + t.Fatalf("jsonLD presence = %v, want %v (value %#v)", hasJSONLD, tt.wantJSONLD, jsonLD) + } + }) + } +} diff --git a/app/api/web/search.go b/app/api/web/search.go new file mode 100644 index 0000000..175134d --- /dev/null +++ b/app/api/web/search.go @@ -0,0 +1,284 @@ +package web + +import ( + "fmt" + "net/url" + "strings" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +type searchTrend struct { + Rank int + Title string + Heat string + Badge string +} + +// SearchMobile 移动端搜索入口。 +// 热搜 + 猜你喜欢:GET /api/app/playlet/hot-like +// 联想:前端调用 GET /api/web/search/suggest → 上游 /api/app/playlet/search/suggest +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_like +func SearchMobile(c *gin.Context) { + if hasLegacySearchQuery(c) { + NotFound(c) + return + } + raw := playletser.FetchHotLikeOrEmpty(c) + trends := hotSearchTrends(raw.HotSearch) + recommended := raw.GuessLike + if len(recommended) > 15 { + recommended = recommended[:15] + } + + HTMLOK(c, "searchMobile.html", gin.H{ + "title": "搜索 - 黄果短剧", + "query": searchQueryFromRequest(c), + "trends": trends, + "recommended": recommended, + "hotLikeDebug": gin.H{ + "path": "/api/app/playlet/hot-like", + "response": raw, + }, + }) +} + +func hotSearchTrends(items []playletser.PlayletHotSearchItem) []searchTrend { + out := make([]searchTrend, 0, len(items)) + for i, item := range items { + keyword := strings.TrimSpace(item.Keyword) + if keyword == "" { + continue + } + rank := i + 1 + badge := "" + switch rank { + case 1: + badge = "热" + case 2: + badge = "新" + } + out = append(out, searchTrend{ + Rank: rank, + Title: keyword, + Heat: formatSearchHeat(item.Count), + Badge: badge, + }) + } + return out +} + +func formatSearchHeat(n int64) string { + if n <= 0 { + return "0" + } + if n < 10000 { + return fmt.Sprintf("%d", n) + } + v := float64(n) / 10000 + if v >= 100 { + return fmt.Sprintf("%.0f 万", v) + } + return fmt.Sprintf("%.1f 万", v) +} + +// Search 搜索结果页:GET /api/app/playlet/search。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search +// 无结果时「大家都在看」:GET /api/app/playlet/all/list(取 6 条最热)。 +func Search(c *gin.Context) { + if hasLegacySearchQuery(c) { + NotFound(c) + return + } + keyword := searchQueryFromRequest(c) + pageStr := strings.TrimSpace(c.Param("page")) + q := playletser.ParseSearchQuery(keyword, pageStr) + + list := []playletser.CommonData(nil) + recommended := []playletser.CommonData(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var searchDebug any + var watchingDebug any + + if q.Keyword != "" { + raw := playletser.FetchSearchOrEmpty(c, q) + searchDebug = gin.H{ + "path": "/api/app/playlet/search", + "keyword": q.Keyword, + "pageNum": q.PageNum, + "pageSize": q.PageSize, + "response": raw, + } + if raw != nil { + list = raw.List + total = raw.TotalCount + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + if strings.TrimSpace(raw.Keyword) != "" { + keyword = raw.Keyword + } + } + } else { + searchDebug = gin.H{ + "path": "/api/app/playlet/search", + "keyword": "", + "note": "empty keyword, skip upstream", + } + } + + if len(list) == 0 { + watchingQ := playletser.AllListQuery{ + PageNum: 1, + PageSize: 6, + SortType: 2, // 最热 + UpdateStatus: 0, // 全部 + } + watching := playletser.FetchAllListOrEmpty(c, watchingQ) + watchingDebug = gin.H{ + "path": "/api/app/playlet/all/list", + "pageNum": watchingQ.PageNum, + "pageSize": watchingQ.PageSize, + "sortType": watchingQ.SortType, + "response": watching, + } + if watching != nil { + recommended = watching.List + if len(recommended) > 6 { + recommended = recommended[:6] + } + } + } + + HTMLOK(c, "search.html", gin.H{ + "title": "搜索 - 黄果短剧", + "query": keyword, + "hasResults": len(list) > 0, + "resultCount": total, + "works": list, + "recommended": recommended, + "pageNum": pageNum, + "pageSize": pageSize, + "pagination": playletser.BuildSearchPagination(q, total), + "searchDebug": searchDebug, + "watchingDebug": watchingDebug, + }) +} + +type searchSuggestFeature struct { + Title string `json:"title"` + Cover string `json:"cover"` + URL string `json:"url"` + Meta string `json:"meta"` +} + +type searchSuggestItem struct { + Title string `json:"title"` + Kind string `json:"kind"` + Query string `json:"query"` + URL string `json:"url"` +} + +// SearchSuggest PC 顶栏输入联想:GET /api/web/search/suggest?keyword= +// 上游:GET /api/app/playlet/search/suggest → proto.PlayletSearchSuggestResp +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search_suggest +func SearchSuggest(c *gin.Context) { + keyword := strings.TrimSpace(c.Query("keyword")) + if keyword == "" { + keyword = strings.TrimSpace(c.Query("q")) + } + if keyword == "" { + c.JSON(200, gin.H{"code": 200, "keyword": "", "feature": nil, "items": []searchSuggestItem{}}) + return + } + + raw := playletser.FetchSearchSuggestOrEmpty(c, keyword) + if kw := strings.TrimSpace(raw.Keyword); kw != "" { + keyword = kw + } + + var feature *searchSuggestFeature + items := make([]searchSuggestItem, 0, len(raw.List)+1) + for _, item := range raw.List { + title := playletser.PlayletDisplayName(item.PlayName, item.Title) + if title == "" { + continue + } + href := playletser.PlayPath(item.Slug, item.ID) + if feature == nil { + feature = &searchSuggestFeature{ + Title: title, + Cover: strings.TrimSpace(item.Cover), + URL: href, + Meta: buildSearchSuggestMeta(item), + } + continue + } + items = append(items, searchSuggestItem{ + Title: title, + Kind: "短剧 剧集", + Query: title, + URL: href, + }) + } + + items = append(items, searchSuggestItem{ + Title: keyword, + Kind: "搜索", + Query: keyword, + URL: playletser.SearchResultsURL(keyword), + }) + + c.JSON(200, gin.H{ + "code": 200, + "keyword": keyword, + "feature": feature, + "items": items, + }) +} + +func buildSearchSuggestMeta(item playletser.CommonData) string { + parts := make([]string, 0, 4) + for _, name := range item.ThemeNames { + name = strings.TrimSpace(name) + if name != "" { + parts = append(parts, name) + } + } + for _, name := range item.TagNames { + name = strings.TrimSpace(name) + if name != "" && len(parts) < 2 { + parts = append(parts, name) + } + } + if item.TotalEpisode > 0 { + parts = append(parts, fmt.Sprintf("全 %d 集", item.TotalEpisode)) + } else if item.UpdateStatus == 1 { + parts = append(parts, "连载中") + } + if anchor := strings.TrimSpace(item.Anchor); anchor != "" && anchor != "暂无" && anchor != "原创" { + parts = append(parts, "主演 "+anchor) + } + return strings.Join(parts, " ") +} + +func hasLegacySearchQuery(c *gin.Context) bool { + query := c.Request.URL.Query() + return query.Has("q") || query.Has("page") +} + +func searchQueryFromRequest(c *gin.Context) string { + q := strings.TrimSpace(c.Param("q")) + if q == "" { + return "" + } + if decoded, err := url.PathUnescape(q); err == nil { + return strings.TrimSpace(decoded) + } + return q +} diff --git a/app/api/web/seo.go b/app/api/web/seo.go new file mode 100644 index 0000000..cb40abc --- /dev/null +++ b/app/api/web/seo.go @@ -0,0 +1,56 @@ +package web + +import ( + "net/http" + "strings" + "videoweb/app/service/configser" + "videoweb/app/service/seoser" + + "github.com/gin-gonic/gin" +) + +// RobotsTXT 输出可抓取规则及绝对 sitemap 地址。 +func RobotsTXT(c *gin.Context) { + cfg := configser.FetchAppConfigVMOrEmpty(c) + siteURL := seoser.ResolveSiteURL(c, cfg) + c.Header("Content-Type", "text/plain; charset=utf-8") + c.String(http.StatusOK, seoser.BuildRobotsTXT(siteURL)) +} + +// LLMSTXT 输出简洁站点说明及核心页面的规范地址,供 AI 抓取工具发现内容。 +func LLMSTXT(c *gin.Context) { + cfg := configser.FetchAppConfigVMOrEmpty(c) + writeLLMSTXT(c, cfg) +} + +func writeLLMSTXT(c *gin.Context, cfg configser.AppConfigVM) { + siteURL := seoser.ResolveSiteURL(c, cfg) + c.Header("Content-Type", "text/plain; charset=utf-8") + c.String(http.StatusOK, seoser.BuildLLMSTXT(siteURL, cfg)) +} + +// SEOVerifyFile 输出 IndexNow 所有权验证文本。 +func SEOVerifyFile(c *gin.Context) { + key := strings.TrimSuffix(strings.TrimPrefix(c.Request.URL.Path, "/"), ".txt") + configured := seoser.VerifyFromConfig().IndexNowKey + if key == "" || key != configured { + NotFound(c) + return + } + c.Header("Content-Type", "text/plain; charset=utf-8") + c.String(http.StatusOK, configured) +} + +// DownloadApp 将站内统一下载入口重定向到配置的官方地址,避免模板回退链接产生 404。 +func DownloadApp(c *gin.Context) { + cfg := configser.FetchAppConfigVMOrEmpty(c) + target := strings.TrimSpace(cfg.AppAddr) + if target == "" { + target = strings.TrimSpace(cfg.OfficialAppAddr) + } + if target == "" { + target = "/" + } + c.Header("X-Robots-Tag", "noindex, nofollow") + c.Redirect(http.StatusFound, target) +} diff --git a/app/api/web/seo_filters.go b/app/api/web/seo_filters.go new file mode 100644 index 0000000..a097525 --- /dev/null +++ b/app/api/web/seo_filters.go @@ -0,0 +1,30 @@ +package web + +import "videoweb/app/service/playletser" + +func rankingSEOSelections(value string) []playletser.SEOSelection { + if value == "" { + return nil + } + return []playletser.SEOSelection{{Dimension: "榜单", Value: value}} +} + +func moreListSEOSelections(q playletser.MoreListQuery, themes []playletser.PlayletThemeItem) []playletser.SEOSelection { + selections := make([]playletser.SEOSelection, 0, 2) + if q.TimeRange == 1 { + selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本周"}) + } else if q.TimeRange == 2 { + selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本月"}) + } + themeName := q.ThemeSlug + for _, theme := range themes { + if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) { + themeName = theme.Name + break + } + } + if themeName != "" { + selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName}) + } + return selections +} diff --git a/app/api/web/series.go b/app/api/web/series.go new file mode 100644 index 0000000..fc4cc30 --- /dev/null +++ b/app/api/web/series.go @@ -0,0 +1,103 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" +) + +// Series 剧集列表页:GET /api/app/video/all/list + topic/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_all_list +func Series(c *gin.Context) { + q := videoser.ParseSeriesListQuery(c) + topics := videoser.FetchVideoTopicListOrEmpty(c, q.MediaType) + topicResolved := seriesTopicResolved(q, topics) + raw := videoser.FetchVideoAllListOrEmpty(c, q) + pageHeading, seoSelections := seriesSEOContext(q, topics) + + title := pageHeading + " - 黄果短剧" + if raw != nil && raw.SeoInfo.Title != "" { + title = raw.SeoInfo.Title + } + + list := []videoser.VideoAllListItemResp(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo videoser.SeoInfo + var advitise any + if raw != nil { + list = raw.List + total = raw.TotalCount + seo = raw.SeoInfo + advitise = raw.Advitise + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + } + + HTMLOK(c, "series.html", gin.H{ + "title": title, + "navActive": "series", + "seoInfo": seo, + "seoSelections": seoSelections, + "pageHeading": pageHeading, + "mediaType": q.MediaType, + "topicId": q.TopicID, + "sortType": q.SortType, + "seoEntityValid": topicResolved || total > 0, + "topicList": topics, + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "listCount": fmt.Sprintf("共 %d 部", total), + "advitise": advitise, + "pagination": videoser.BuildSeriesListPagination(q, total), + "canonicalPath": q.URL(), + }) +} + +func seriesTopicResolved(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) bool { + if q.TopicID <= 0 { + return true + } + for _, topic := range topics { + if topic.ID == q.TopicID { + return true + } + } + return false +} + +func seriesSEOContext(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) (string, []playletser.SEOSelection) { + heading := "剧集与动画" + selections := make([]playletser.SEOSelection, 0, 3) + if q.MediaType == videoser.MediaFilm || q.MediaType == videoser.MediaAnime { + mediaName := videoser.MediaTypeLabel(q.MediaType) + heading = mediaName + selections = append(selections, playletser.SEOSelection{Dimension: "类型", Value: mediaName}) + } + if q.TopicID > 0 { + for _, topic := range topics { + if topic.ID == q.TopicID { + name := topic.DisplayName() + if name != "" { + heading = name + heading + selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: name}) + } + break + } + } + } + if q.SortType == videoser.SortHeat { + selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"}) + } else if q.SortType == videoser.SortCollect { + selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"}) + } + return heading, selections +} diff --git a/app/api/web/setting.go b/app/api/web/setting.go new file mode 100644 index 0000000..9572866 --- /dev/null +++ b/app/api/web/setting.go @@ -0,0 +1,85 @@ +package web + +import ( + "net/http" + "strings" + "videoweb/app/service/authser" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +// Setting 展示账号设置,字段来自最新的用户信息接口。 +func Setting(c *gin.Context) { + data := gin.H{"title": "账号设置 - 黄果短剧", "navActive": "setting", "accountTab": "setting"} + loadAccountSummary(c, data, gin.H{}) + HTMLOK(c, "setting.html", data) +} + +// UpdateProfile 代理账号资料修改。可选字段必须显式传入,避免覆盖未修改内容。 +func UpdateProfile(c *gin.Context) { + if useragent.TokenFromRequest(c.Request) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "tip": "请先登录"}) + return + } + var body authser.UpdateProfileReq + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "参数错误"}) + return + } + if body.NickName != nil { + v := strings.TrimSpace(*body.NickName) + if v == "" || len([]rune(v)) > 40 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "昵称须为 1 至 40 个字符"}) + return + } + body.NickName = &v + } + if body.Introduction != nil { + v := strings.TrimSpace(*body.Introduction) + if len([]rune(v)) > 300 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "简介不能超过 300 个字符"}) + return + } + body.Introduction = &v + } + if body.Avatar != nil { + v := strings.TrimSpace(*body.Avatar) + if safeProfileImage(v) == "" || strings.HasPrefix(v, "/static/") { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "头像地址无效"}) + return + } + body.Avatar = &v + } + if (body.OldPassword == nil) != (body.NewPassword == nil) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "请输入旧密码和新密码"}) + return + } + if body.OldPassword != nil && (*body.OldPassword == "" || *body.NewPassword == "") { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "密码不能为空"}) + return + } + if body.NickName == nil && body.Introduction == nil && body.Avatar == nil && body.OldPassword == nil && body.PrivacyMode == nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "没有需要保存的修改"}) + return + } + env, err := authser.UpdateProfile(c, body) + if err != nil { + log.Error("update profile failed", log.E(err)) + c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"}) + return + } + if env == nil || env.Code != 200 { + if env == nil { + c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": env.Code, "tip": firstNonEmptyAuth(env.Tip, env.Msg, "保存失败")}) + return + } + if infoEnv, user, err := authser.GetInfo(c); err == nil && infoEnv != nil && infoEnv.Code == 200 && user != nil { + authser.SetAuthCookies(c, user) + } + c.JSON(http.StatusOK, gin.H{"code": 200, "tip": firstNonEmptyAuth(env.Tip, "保存成功")}) +} diff --git a/app/api/web/tags.go b/app/api/web/tags.go new file mode 100644 index 0000000..ed65c4a --- /dev/null +++ b/app/api/web/tags.go @@ -0,0 +1,89 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// Tags 标签作品列表页:GET /api/app/playlet/tag/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_tag_list +func Tags(c *gin.Context) { + q := playletser.ParseTagListQuery(c) + tagOptions := playletser.FetchTagOptionsOrEmpty(c) + tagName := playletser.ResolveTagListIdentity(&q, tagOptions) + tagResolved := tagName != "" + raw := playletser.FetchTagListOrEmpty(c, q) + + if tagName == "" && raw != nil { + if raw.SeoInfo.Title != "" { + tagName = raw.SeoInfo.Title + } else { + tagName = playletser.FindTagNameBySlug(raw.List, q.TagSlug) + if tagName == "" { + tagName = playletser.FindTagName(raw.List, q.TagID) + } + } + } + + title := "标签 - 黄果短剧" + if raw != nil { + title = playletser.TagListPageTitle(tagName, q.PageNum, raw.SeoInfo) + } else if tagName != "" { + title = playletser.TagListPageTitle(tagName, q.PageNum, playletser.SeoInfo{}) + } + + list := []playletser.CommonData(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo playletser.SeoInfo + if raw != nil { + list = raw.List + total = raw.TotalCount + seo = raw.SeoInfo + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + } + + desc := "" + if seo.Description != "" { + desc = seo.Description + } else if total > 0 { + desc = fmt.Sprintf("%d 部作品带有此标签", total) + } + seoSelections := make([]playletser.SEOSelection, 0, 2) + if tagName != "" { + seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "标签", Value: tagName}) + } + if q.SortType == 2 { + seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "最热"}) + } else if q.SortType == 3 { + seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"}) + } + + HTMLOK(c, "tags.html", gin.H{ + "title": title, + "navActive": "list", + "seoInfo": seo, + "seoSelections": seoSelections, + "tagName": tagName, + "tagSlug": q.TagSlug, + "tagId": q.TagID, + "sortType": q.SortType, + "seoEntityValid": tagResolved || total > 0, + "relatedTags": playletser.RelatedTagOptions(tagOptions, q.TagID, q.TagSlug), + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "listCount": fmt.Sprintf("共 %d 部", total), + "heroDesc": desc, + "pagination": playletser.BuildTagListPagination(q, total), + "canonicalPath": playletser.TagListCanonicalPath(q), + }) +} diff --git a/app/api/web/terms.go b/app/api/web/terms.go new file mode 100644 index 0000000..5761192 --- /dev/null +++ b/app/api/web/terms.go @@ -0,0 +1,8 @@ +package web + +import "github.com/gin-gonic/gin" + +// Terms renders the static Terms of Service page. +func Terms(c *gin.Context) { + HTMLOK(c, "terms.html", gin.H{}) +} diff --git a/app/api/web/topic.go b/app/api/web/topic.go new file mode 100644 index 0000000..39f8e14 --- /dev/null +++ b/app/api/web/topic.go @@ -0,0 +1,58 @@ +package web + +import ( + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// Topic 专题剧单列表页:GET /api/app/playlet/topic/list。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_list +func Topic(c *gin.Context) { + q := playletser.ParseTopicListQuery(c) + raw := playletser.FetchTopicListOrEmpty(c, q) + home := playletser.FetchHomeOrEmpty(c) + homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData) + list := []playletser.PlayletTopicPageItem(nil) + total := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo playletser.SeoInfo + var seoSelections []playletser.SEOSelection + if q.SortType == 2 { + seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: "最热"}} + } + if raw != nil { + list = raw.List + total = raw.TotalCount + seo = raw.SeoInfo + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + } + // 首页“专题”区块的配置 TDK 优先;专题列表接口 SEO 仅补齐配置未填写的字段。 + if configSEO, ok := playletser.FirstHomeConfigSEOByType(homeConfigData, 3); ok { + seo = playletser.MergeSeoInfo(configSEO, seo) + } + title := "专题剧单 - 黄果短剧" + if seo.Title != "" { + title = seo.Title + } + HTMLOK(c, "topic.html", gin.H{ + "title": title, + "navActive": "topic", + "seoInfo": seo, + "routeConfigSEO": true, + "homeConfigData": homeConfigData, + "seoSelections": seoSelections, + "list": list, + "totalCount": total, + "pageNum": pageNum, + "pageSize": pageSize, + "sortType": q.SortType, + "pagination": playletser.BuildTopicListPagination(q, total), + "canonicalPath": q.URL(), + }) +} diff --git a/app/api/web/topicDetail.go b/app/api/web/topicDetail.go new file mode 100644 index 0000000..d87d858 --- /dev/null +++ b/app/api/web/topicDetail.go @@ -0,0 +1,97 @@ +package web + +import ( + "fmt" + "videoweb/app/service/playletser" + + "github.com/gin-gonic/gin" +) + +// TopicDetail 专题详情页:GET /api/app/playlet/topic/detail。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_detail +func TopicDetail(c *gin.Context) { + q := playletser.ParseTopicDetailQuery(c) + playletser.ResolveTopicDetailID(c, &q) + raw := playletser.FetchTopicDetailOrEmpty(c, q) + if raw != nil { + if raw.Slug != "" { + q.Slug = raw.Slug + } + if raw.ID > 0 { + q.ID = raw.ID + } + } + + name := "" + if raw != nil { + name = raw.Name + } + title := "专题 - 黄果短剧" + if raw != nil && raw.SeoInfo.Title != "" { + title = raw.SeoInfo.Title + } else if name != "" { + title = name + " - 黄果短剧" + } + + list := []playletser.CommonData(nil) + more := []playletser.PlayletTopicPageItem(nil) + total := int64(0) + playletCount := int64(0) + pageNum, pageSize := q.PageNum, q.PageSize + var seo playletser.SeoInfo + desc := "" + heroCover := "" + covers := []string(nil) + if raw != nil { + list = raw.List + more = raw.MoreTopics + total = raw.TotalCount + playletCount = raw.PlayletCount + seo = raw.SeoInfo + desc = raw.Desc + heroCover = playletser.TopicDetailHeroCover(*raw) + covers = playletser.TopicDetailCoverURLs(*raw) + if raw.PageNum > 0 { + pageNum = raw.PageNum + } + if raw.PageSize > 0 { + pageSize = raw.PageSize + } + } + countText := "持续更新" + if playletCount > 0 { + countText = fmt.Sprintf("%d 部 持续更新", playletCount) + } else if total > 0 { + countText = fmt.Sprintf("%d 部 持续更新", total) + } + var seoSelections []playletser.SEOSelection + if q.SortType != 1 { + seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: playletser.TopicDetailSortLabel(q.SortType)}} + } + + HTMLOK(c, "topicDetail.html", gin.H{ + "title": title, + "navActive": "topic", + "seoInfo": seo, + "seoSelections": seoSelections, + "topic": raw, + "name": name, + "desc": desc, + "slug": q.Slug, + "topicId": q.ID, + "list": list, + "moreTopics": more, + "totalCount": total, + "playletCount": playletCount, + "countText": countText, + "pageNum": pageNum, + "pageSize": pageSize, + "sortType": q.SortType, + "sortLabel": playletser.TopicDetailSortLabel(q.SortType), + "seoEntityValid": q.ID > 0 && (name != "" || total > 0), + "heroCover": heroCover, + "covers": covers, + "pagination": playletser.BuildTopicDetailPagination(q, total), + "canonicalPath": q.URL(), + }) +} diff --git a/app/api/web/tracker_page.go b/app/api/web/tracker_page.go new file mode 100644 index 0000000..c3c422b --- /dev/null +++ b/app/api/web/tracker_page.go @@ -0,0 +1,80 @@ +package web + +import ( + "net/http" + "strings" +) + +// attachTrackerPage 为所有 HTML 页面补齐稳定的 pageKey/pageName,避免事件落入 unknown。 +func attachTrackerPage(path string, code int, data map[string]any) { + if data == nil { + return + } + key, name := trackerPageFromRequest(path, code) + if current, _ := data["pageKey"].(string); strings.TrimSpace(current) == "" { + data["pageKey"] = key + } + if current, _ := data["pageName"].(string); strings.TrimSpace(current) == "" { + data["pageName"] = name + } +} + +func trackerPageFromRequest(path string, code int) (string, string) { + if code >= http.StatusInternalServerError { + return "server_error", "服务异常页面" + } + if code >= http.StatusBadRequest { + return "not_found", "404页面" + } + return trackerPageFromPath(path) +} + +func trackerPageFromPath(path string) (string, string) { + p := strings.TrimSuffix(strings.TrimSpace(path), "/") + if p == "" { + return "home", "首页" + } + rules := []struct { + prefix string + key string + name string + }{ + {"/series-play", "series_play", "AI成人剧集播放页"}, + {"/series", "series", "AI成人剧集"}, + {"/play", "play", "短剧播放页"}, + {"/detail", "detail", "短剧详情"}, + {"/list", "list", "短剧列表"}, + {"/recently-added", "recently_added", "最新短剧"}, + {"/popular-recommend", "popular_recommend", "热门推荐"}, + {"/rank", "rank", "排行榜"}, + {"/topic-detail", "topic_detail", "专题详情"}, + {"/topic", "topic", "专题列表"}, + {"/tags", "tags", "标签详情"}, + {"/more-series-list", "more_series_list", "成人剧集列表"}, + {"/more-list", "more_list", "短剧扩展列表"}, + {"/search-mobile", "search", "搜索结果页"}, + {"/search", "search", "搜索结果页"}, + {"/history", "history", "观看历史"}, + {"/favorites", "favorites", "我的收藏"}, + {"/purchased", "purchased", "已购剧集"}, + {"/profile", "profile", "个人中心"}, + {"/setting", "setting", "设置"}, + {"/recharge", "recharge", "充值中心"}, + {"/download-app", "download_app", "下载APP"}, + {"/feedback", "feedback", "问题反馈"}, + {"/help", "help", "帮助中心"}, + {"/about", "about", "关于我们"}, + {"/terms", "terms", "用户协议"}, + {"/privacy", "privacy", "隐私政策"}, + {"/2257", "compliance_2257", "2257合规声明"}, + {"/dmca", "dmca", "DMCA版权声明"}, + {"/login", "login", "登录"}, + {"/register", "register", "注册"}, + } + for _, rule := range rules { + if p == rule.prefix || strings.HasPrefix(p, rule.prefix+"/") { + return rule.key, rule.name + } + } + return "internal_other", "站内其他页面" +} diff --git a/app/api/web/videoCollect.go b/app/api/web/videoCollect.go new file mode 100644 index 0000000..b911572 --- /dev/null +++ b/app/api/web/videoCollect.go @@ -0,0 +1,64 @@ +package web + +import ( + "net/http" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" +) + +// VideoCollectSet BFF:代理 POST /api/app/video/collect/set。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_collect_set +func VideoCollectSet(c *gin.Context) { + var body struct { + ID int64 `json:"id"` + IsCollect *bool `json:"isCollect"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ID <= 0 || body.IsCollect == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "id/isCollect required", + "tip": "缺少视频 ID 或收藏状态", + "data": nil, + }) + return + } + + env, err := videoser.CollectSet(c, videoser.VideoCollectSetReq{ + ID: body.ID, + IsCollect: *body.IsCollect, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/api/web/videoLike.go b/app/api/web/videoLike.go new file mode 100644 index 0000000..50226c0 --- /dev/null +++ b/app/api/web/videoLike.go @@ -0,0 +1,64 @@ +package web + +import ( + "net/http" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" +) + +// VideoLikeSet BFF:代理 POST /api/app/video/like/set。 +// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_like_set +func VideoLikeSet(c *gin.Context) { + var body struct { + ID int64 `json:"id"` + IsLike *bool `json:"isLike"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": "invalid body", + "tip": "参数错误", + "data": nil, + }) + return + } + if body.ID <= 0 || body.IsLike == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "id/isLike required", + "tip": "缺少视频 ID 或点赞状态", + "data": nil, + }) + return + } + + env, err := videoser.LikeSet(c, videoser.VideoLikeSetReq{ + ID: body.ID, + IsLike: *body.IsLike, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": err.Error(), + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + if env == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "empty response", + "tip": "操作失败,请稍后重试", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": env.Code, + "msg": env.Msg, + "tip": env.Tip, + "data": env.Data, + }) +} diff --git a/app/appg/appg.go b/app/appg/appg.go new file mode 100644 index 0000000..7a7d43c --- /dev/null +++ b/app/appg/appg.go @@ -0,0 +1,46 @@ +package appg + +import ( + "strings" + "videoweb/pub/pubdef" + + "github.com/patrickmn/go-cache" +) + +const EsIndex = "sz" + +var ( + Conf GlobalCfgEx + Cache *cache.Cache +) + +type GlobalCfgEx struct { + ServerAddr string `yaml:"serverAddr"` + ListenAddr string `yaml:"listenAddr"` + DHListenAddr string `yaml:"dhListenAddr"` + RunMod string `yaml:"runMod"` + TsAuthKey string `yaml:"tsAuthKey"` + SEO SEOConf `yaml:"seo"` + Tracker TrackerConf `yaml:"tracker"` +} + +// SEOConf 站长平台验证与 SEO 相关配置。 +type SEOConf struct { + BaiduSiteVerification string `yaml:"baiduSiteVerification"` + BaiduHTMLFile string `yaml:"baiduHtmlFile"` // 如 baidu_verify_codeva-xxx.html + IndexNowKey string `yaml:"indexNowKey"` // Bing IndexNow 密钥,提供 /{key}.txt +} + +// TrackerConf 数据上报 SDK 本地兜底(上游 reportSdkConfig 优先)。 +type TrackerConf struct { + AppID string `yaml:"appId"` + EncryptedConfig string `yaml:"encryptedConfig"` +} + +// TrackerAppID 返回页面 SDK 与 BFF 透传校验共用的可信应用 ID。 +func TrackerAppID() string { + if id := strings.TrimSpace(Conf.Tracker.AppID); id != "" { + return id + } + return pubdef.TrackerAppId() +} diff --git a/app/conf/app.yaml b/app/conf/app.yaml new file mode 100644 index 0000000..47b019f --- /dev/null +++ b/app/conf/app.yaml @@ -0,0 +1,11 @@ +serverAddr: "http://172.104.181.99:25319" +listenAddr: "25121" +dhListenAddr: "26813" +runMod: "dev" +tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN" + +# 数据上报 SDK(web-sdk)。encryptedConfig 优先用上游 config/info.reportSdkConfig; +# 若上游暂未下发,可在此填写密文兜底。 +tracker: + appId: "JHA-1045" + encryptedConfig: "" diff --git a/app/conf/m3u8.key b/app/conf/m3u8.key new file mode 100644 index 0000000..6cce2a7 --- /dev/null +++ b/app/conf/m3u8.key @@ -0,0 +1 @@ +—�ˆȒ š ÁC�„ \ No newline at end of file diff --git a/app/deploy_prod.sh b/app/deploy_prod.sh new file mode 100755 index 0000000..3468753 --- /dev/null +++ b/app/deploy_prod.sh @@ -0,0 +1,28 @@ +#!/bin/bash +FILE_NAME="web_cgdj_h5" +REMOTE_FILE_PATH="/home/server2/web_cgdj/h5/" +TAG_NAME="web_cgdj_h5.tar.gz" + +set -e +set -o +function send_msg() { + msg="@jscdsndj @xiuyuan_Lee @steven0yu %0A服务:SERVER2  站点-cgdj-H5 %0A更新内容:$content%0A创建时间: $(date +%F_%X)%0A操作人:$(git config user.name)" + curl -X POST "https://api.telegram.org/bot6357085965:AAEmXfjZnZwnDTicL7Wjbd7cXvYv_G98w8A/sendMessage" -d "chat_id=$1&text=$msg" >/dev/null 2>&1 +} + +read -p "输入yes, 开始上传: " word +if [[ ! "yes" =~ $word ]]; then + echo "终止" +else + read -e -p "本次更新内容(输入q退出编辑): " -r content + echo "开始" + rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt + GOOS=linux GOARCH=amd64 go build -o $FILE_NAME + md5sum $FILE_NAME | cut -d ' ' -f1 >yinsemd5.txt + tar zvcf $TAG_NAME ./$FILE_NAME yinsemd5.txt + md5sum $TAG_NAME | cut -d ' ' -f1 >yinsemd5.txt + scp -i ~/.ssh/id_rsa -P 22889 ./yinsemd5.txt server2@116.212.120.2:$REMOTE_FILE_PATH + scp -i ~/.ssh/id_rsa -P 22889 ./$TAG_NAME server2@116.212.120.2:$REMOTE_FILE_PATH + send_msg -4791758991 #服务器小组 +fi +rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt diff --git a/app/innercache/.gitkeep b/app/innercache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..d4f1b68 --- /dev/null +++ b/app/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "os" + "videoweb/app/server" +) + +func main() { + server.Run(os.Args) +} diff --git a/app/middleware/.gitkeep b/app/middleware/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/router/client_headers.go b/app/router/client_headers.go new file mode 100644 index 0000000..986f476 --- /dev/null +++ b/app/router/client_headers.go @@ -0,0 +1,16 @@ +package router + +import ( + "videoweb/pub/common/useragent" + + "github.com/gin-gonic/gin" +) + +// applyClientForwardHeaders 向上游透传客户端 IP / UA(与 API 上游一致): +// X-Real-IP、User-Agent、X-User-Agent。 +func applyClientForwardHeaders(header map[string]string, c *gin.Context) { + if header == nil { + return + } + useragent.ApplyToHeader(header, c, useragent.DefaultVer) +} diff --git a/app/router/gin.go b/app/router/gin.go new file mode 100644 index 0000000..9c55ece --- /dev/null +++ b/app/router/gin.go @@ -0,0 +1,264 @@ +package router + +import ( + "encoding/json" + "html/template" + "net/http" + "os" + "strings" + "videoweb/app/api/web" + "videoweb/app/appg" + "videoweb/app/service/commentser" + "videoweb/app/service/playletser" + "videoweb/app/service/seoser" + "videoweb/app/service/translationser" + "videoweb/app/service/videoser" + staticassets "videoweb/app/static" + + "github.com/gin-gonic/gin" +) + +// Init 初始化 Gin,注册静态资源与业务路由。 +func Init() *gin.Engine { + setGinMode() + engine := gin.Default() + engine.HandleMethodNotAllowed = true + setupEngine(engine) + registerM3U8Routes(engine) + registerSitemapRoutes(engine) + registerRoutes(engine) + engine.NoMethod(web.NotFound) + return engine +} + +// DHInit 导航站独立端口:仅暴露官方导航页及静态资源。 +func DHInit() *gin.Engine { + setGinMode() + engine := gin.Default() + engine.HandleMethodNotAllowed = true + setupEngine(engine) + engine.GET("/", web.Nav) + engine.GET("/nav/", web.Nav) + engine.GET("/dh/", web.Nav) + engine.NoRoute(web.NotFound) + engine.NoMethod(web.NotFound) + return engine +} + +func setGinMode() { + mode := strings.ToLower(strings.TrimSpace(appg.Conf.RunMod)) + if envMode := strings.ToLower(strings.TrimSpace(os.Getenv(gin.EnvGinMode))); envMode != "" { + mode = envMode + } + switch mode { + case "test": + gin.SetMode(gin.TestMode) + case "prod", "production", "release": + gin.SetMode(gin.ReleaseMode) + default: + gin.SetMode(gin.DebugMode) + } +} + +func setupEngine(engine *gin.Engine) { + tplFuncs := playletser.TemplateFuncs() + for name, fn := range commentser.TemplateFuncs() { + tplFuncs[name] = fn + } + tplFuncs["InJSONScript"] = inJSONScript + tplFuncs["navHref"] = translationser.NavHref + tplFuncs["navIsDownload"] = translationser.NavIsDownload + tplFuncs["navIsActive"] = translationser.NavIsActive + tplFuncs["navDrawerIcon"] = translationser.NavDrawerIcon + tplFuncs["navHasMegaMenu"] = translationser.NavHasMegaMenu + tplFuncs["listThemeSegment"] = translationser.ListThemeSegmentFromPath + tplFuncs["listUpdateStatus"] = translationser.ListUpdateStatusFromPath + tplFuncs["seriesListURL"] = videoser.SeriesListURL + tplFuncs["videoDetailURL"] = videoser.VideoDetailURL + tplFuncs["videoPlayURL"] = videoser.VideoPlayURL + tplFuncs["mediaTypeLabel"] = videoser.MediaTypeLabel + tplFuncs["episodeLabel"] = videoser.EpisodeLabel + engine.SetFuncMap(tplFuncs) + engine.SetHTMLTemplate(mustHTMLTemplate(tplFuncs)) + engine.Use(strictTransportSecurity(), staticCacheHeaders(), crawlerHeaders()) + engine.StaticFS("/static", http.FS(staticassets.FS)) +} + +// strictTransportSecurity 仅在请求实际通过 HTTPS 到达,或可信代理标记首个协议为 HTTPS 时启用 HSTS。 +// 不声明 includeSubDomains/preload,避免把尚未确认支持 HTTPS 的子域一并纳入。 +func strictTransportSecurity() gin.HandlerFunc { + return func(c *gin.Context) { + if isHTTPSRequest(c.Request) { + c.Header("Strict-Transport-Security", "max-age=31536000") + } + c.Next() + } +} + +func isHTTPSRequest(r *http.Request) bool { + if r == nil { + return false + } + if r.TLS != nil { + return true + } + proto := r.Header.Get("X-Forwarded-Proto") + if i := strings.IndexByte(proto, ','); i >= 0 { + proto = proto[:i] + } + return strings.EqualFold(strings.TrimSpace(proto), "https") +} + +// crawlerHeaders 防止接口和媒体工具响应进入搜索索引。 +func crawlerHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/h5/") || + path == "/enkey" || path == "/newenkey" || path == "/healthz" { + c.Header("X-Robots-Tag", "noindex, nofollow") + } + c.Next() + } +} + +// inJSONScript 将任意数据序列化为可嵌入 +{{end}} +{{end}} diff --git a/app/template/base/downloadPop.html b/app/template/base/downloadPop.html new file mode 100644 index 0000000..cb51084 --- /dev/null +++ b/app/template/base/downloadPop.html @@ -0,0 +1,29 @@ +{{define "downloadPop"}} +{{/* 下载弹窗:链接取值与首页 app-cta 一致 → .config.AppAddr */}} +{{$appAddr := .config.AppAddr}} + + + +{{end}} diff --git a/app/template/base/eighteen.html b/app/template/base/eighteen.html new file mode 100644 index 0000000..0c870f6 --- /dev/null +++ b/app/template/base/eighteen.html @@ -0,0 +1,27 @@ +{{define "eighteen"}} + + + + +{{end}} diff --git a/app/template/base/footer.html b/app/template/base/footer.html new file mode 100644 index 0000000..26cbc83 --- /dev/null +++ b/app/template/base/footer.html @@ -0,0 +1,82 @@ +{{define "footer"}} + +
    + +
    + + + +{{end}} diff --git a/app/template/base/header.html b/app/template/base/header.html new file mode 100644 index 0000000..ad62799 --- /dev/null +++ b/app/template/base/header.html @@ -0,0 +1,294 @@ +{{define "header"}} +{{$appAddr := .appConfig.AppAddr}} + + +
    +
    + +
    + + + + + + + + + +{{template "eighteen" .}} +{{template "dialogAd" .}} +{{template "loginPop" .}} +{{template "registerPop" .}} +{{template "toast" .}} + +{{end}} diff --git a/app/template/base/loginPop.html b/app/template/base/loginPop.html new file mode 100644 index 0000000..df60c3b --- /dev/null +++ b/app/template/base/loginPop.html @@ -0,0 +1,93 @@ +{{define "loginPop"}} + + +{{end}} diff --git a/app/template/base/pagination.html b/app/template/base/pagination.html new file mode 100644 index 0000000..e778dd3 --- /dev/null +++ b/app/template/base/pagination.html @@ -0,0 +1,41 @@ +{{define "pagination"}} + +{{with .pagination}} +{{if .Visible}} + +{{end}} +{{end}} +{{end}} diff --git a/app/template/base/playComments.html b/app/template/base/playComments.html new file mode 100644 index 0000000..6f251f8 --- /dev/null +++ b/app/template/base/playComments.html @@ -0,0 +1,115 @@ +{{define "playComments"}} + +{{/* 播放页评论区:首屏 4 条,超出显示「查看更多评论」客户端翻页 */}} +{{$c := .chapter}} +{{$pageSize := .commentSize}} +{{$total := .commentTotal}} +{{$shown := len .comments}} +
    +
    +

    评论

    + {{if gt $total 0}}{{$total}} 条{{else if and $c (gt $c.CountComment 0)}}{{$c.CountComment}} 条{{else if and .video (gt .video.CountComment 0)}}{{.video.CountComment}} 条{{else}}暂无评论{{end}} +
    + +
    + +
    + + 0 / 200 +
    + {{if .isLoggedIn}} + + {{else}} + + {{end}} +
    + +
      + {{range .comments}} +
    • + {{$avatar := commentAvatar .UserAvatar}} + {{if .UserAvatar}} + 评论用户头像 + {{else}} + 评论用户头像 + {{end}} +
      +
      + {{if .UserName}}{{.UserName}}{{else}}匿名用户{{end}} + {{formatCommentTime .CreatedAt}} +
      +

      {{.Text}}

      + {{range .Children}} +
      + {{if .UserName}}{{.UserName}}{{else}}用户{{end}}{{if .ReplyUserName}} 回复 {{.ReplyUserName}}{{end}}:{{.Text}} +
      + {{end}} +
      + + +
      +
      +
    • + {{else}} +
    • +

      暂无评论

      +
    • + {{end}} +
    + + +
    + +{{end}} diff --git a/app/template/base/playLinks.html b/app/template/base/playLinks.html new file mode 100644 index 0000000..853ef2a --- /dev/null +++ b/app/template/base/playLinks.html @@ -0,0 +1,43 @@ +{{define "playLinks"}} +{{/* 官方入口:GET /api/app/config/info → config / newAddressInfo(经 appConfig 合并) */}} +{{$cfg := .appConfig}} + + +{{end}} diff --git a/app/template/base/rechargeModal.html b/app/template/base/rechargeModal.html new file mode 100644 index 0000000..248f65c --- /dev/null +++ b/app/template/base/rechargeModal.html @@ -0,0 +1,87 @@ +{{define "rechargeModal"}} +{{/* 金币充值弹窗:GET /api/app/recharge/package/list → coinList */}} + +{{if .rechargePackageResponse}} +{{end}} +{{end}} diff --git a/app/template/base/registerPop.html b/app/template/base/registerPop.html new file mode 100644 index 0000000..787d90b --- /dev/null +++ b/app/template/base/registerPop.html @@ -0,0 +1,124 @@ +{{define "registerPop"}} + + +{{end}} diff --git a/app/template/base/seoCommon.html b/app/template/base/seoCommon.html new file mode 100644 index 0000000..b514c87 --- /dev/null +++ b/app/template/base/seoCommon.html @@ -0,0 +1,30 @@ +{{define "seoCommon"}} + +{{if .metaKeywords}}{{end}} + + + + + + +{{if .canonicalURL}}{{end}} +{{if .paginationPrevURL}}{{end}} +{{if .paginationNextURL}}{{end}} + + + + + +{{if .canonicalURL}}{{end}} +{{if .ogImage}}{{end}} + + + +{{if .ogImage}}{{end}} + + + +{{if .jsonLD}}{{end}} + +{{template "trackerInit" .}} +{{end}} diff --git a/app/template/base/subpageHeader.html b/app/template/base/subpageHeader.html new file mode 100644 index 0000000..b6466bf --- /dev/null +++ b/app/template/base/subpageHeader.html @@ -0,0 +1,7 @@ +{{define "subpageHeader"}} +
    + 返回 + {{.pageHeading}} + 充值帮助 +
    +{{end}} diff --git a/app/template/base/toast.html b/app/template/base/toast.html new file mode 100644 index 0000000..556172b --- /dev/null +++ b/app/template/base/toast.html @@ -0,0 +1,19 @@ +{{define "toast"}} + + + +{{end}} diff --git a/app/template/base/trackerInit.html b/app/template/base/trackerInit.html new file mode 100644 index 0000000..c3e56a0 --- /dev/null +++ b/app/template/base/trackerInit.html @@ -0,0 +1,36 @@ +{{define "trackerInit"}} + + + + + + + + + + + +{{end}} diff --git a/app/template/base/userCenterShell.html b/app/template/base/userCenterShell.html new file mode 100644 index 0000000..07e4ebe --- /dev/null +++ b/app/template/base/userCenterShell.html @@ -0,0 +1,46 @@ +{{define "userCenterHero"}} +
    +
    +
    + 个人头像 +
    +
    {{if .userName}}{{.userName}}{{else}}未登录{{end}}
    +

    {{if .userID}}ID {{.userID}}{{else}}登录后查看账户资料{{end}}

    + + + 编辑资料 + +
    +
    +
    +
    +

    金币金币余额

    + {{if .balance}}{{.balance}}{{else}}—{{end}} +

    按集解锁付费剧集  永久观看

    +
    + +
    +
    +
    +
    {{if .historyCount}}{{.historyCount}}{{else}}—{{end}}观看
    +
    {{if .favoriteCount}}{{.favoriteCount}}{{else}}—{{end}}收藏
    +
    {{if .chasingCount}}{{.chasingCount}}{{else}}—{{end}}追剧中
    +
    {{if .boughtChapterCount}}{{.boughtChapterCount}}{{else}}—{{end}}已购剧集
    +
    +
    +{{end}} + +{{define "userCenterTabs"}} + +{{end}} diff --git a/app/template/fs.go b/app/template/fs.go new file mode 100644 index 0000000..4d52246 --- /dev/null +++ b/app/template/fs.go @@ -0,0 +1,6 @@ +package template + +import "embed" + +//go:embed all:base all:pages +var FS embed.FS diff --git a/app/template/pages/2257.html b/app/template/pages/2257.html new file mode 100644 index 0000000..429d79c --- /dev/null +++ b/app/template/pages/2257.html @@ -0,0 +1,63 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} +
    + 返回 + 2257 合规声明 + +
    + +
    + + +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/404.html b/app/template/pages/404.html new file mode 100644 index 0000000..086ed4e --- /dev/null +++ b/app/template/pages/404.html @@ -0,0 +1,34 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} + +
    +
    + +

    页面走丢了

    +

    你访问的页面不存在、已下线,或者链接输错了。试试回到首页,或从热门榜单里挑一部。

    + +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/about.html b/app/template/pages/about.html new file mode 100644 index 0000000..f71fb0b --- /dev/null +++ b/app/template/pages/about.html @@ -0,0 +1,95 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} +
    + 返回 +
    关于我们
    + +
    +
    +
    +
    +

    关于我们

    +

    不是所有短剧,都能被称作作品。

    +

    黄果短剧,因为专业,所以领先。

    +
    +
    +
    +

    别人拼命追赶风口,我们定义创造标准。每一支作品都经过精心制作,分镜反复推敲,为一句台词的语气重新生成十次,字幕与剪辑的表情调整到满意为止。这就是我们的匠人精神,也是黄果品质唯一的定义。

    +

    我们用 AI 帮助提升作品的品质,把不可能的画面化为可能。题材多元,都会、悬疑、古装、奇幻同步推进;每日更新,精彩不断。短剧与影集双轨并行,仿真与动画齐发。我们只做一件事:把每一支作品,做到值得被记得。

    +

    我们用 AI 帮助提升作品的品质,把不可能的画面化为可能。题材多元,每日更新,短剧与影集双轨并行,仿真与动画齐发。我们只做一件事:把每一支作品,做到值得被记得。

    +

    黄果一开,全射出来,每一帧都能解你的色。

    + +
    +
    黄果短剧作品画面拼贴
    +
    +
    +
    +
    +

    把最好看的留给你

    + 黄果特色 +
    +
    +
    + 01 +
    +

    多元齐全

    +

    多元的题材、海量的短剧与影集,我们把最好看的留给你。

    +
    +
    +
    + 02 +
    +

    天天上新

    +

    持续更新的内容节奏,每天打开都有新惊喜,追剧永不断档。

    +
    +
    +
    + 03 +
    +

    沉浸体验

    +

    高清画质、震撼音效,每个动作、每个画面,全心投入剧情。

    +
    +
    +
    +
    +
    +
    +
    +

    黄果短剧 App

    +

    把黄果装进口袋,随时随地追剧

    +

    iOS / Android 双端  竖屏沉浸播放  离线缓存随时看

    +

    竖屏沉浸播放  离线缓存  弹幕互动

    +

    手机扫码会自动识别设备并推荐安装方式  iOS 商店版 / iOS 轻量版 / Android 版

    +
    +
    下载黄果短剧 App 二维码
    + {{$appAddr := .appConfig.AppAddr}} + +

    已识别为 iOS 设备:推荐商店版前往 App Store,也可选轻量版用 Safari 安装描述文件

    +
    +
    +
    + {{template "footer" .}} + + diff --git a/app/template/pages/dmca.html b/app/template/pages/dmca.html new file mode 100644 index 0000000..8078091 --- /dev/null +++ b/app/template/pages/dmca.html @@ -0,0 +1,68 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} +
    + 返回 + DMCA 版权声明 + +
    + +
    + + +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/favorites.html b/app/template/pages/favorites.html new file mode 100644 index 0000000..057c3ec --- /dev/null +++ b/app/template/pages/favorites.html @@ -0,0 +1,69 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + + + + {{template "header" .}} + {{template "userCenterHero" .}} + {{template "userCenterTabs" .}} +
    +

    我的收藏

    +
    + + {{if .favorites}}{{end}} +
    +
    + {{range .favorites}} + + {{end}} +
    +
    + +

    {{if .favoritesUnavailable}}收藏暂时无法加载{{else if ne .filter "all"}}暂无相关收藏{{else}}还没有收藏的作品{{end}}

    +

    在播放页点击「加入收藏」,更新时第一时间提醒你在播放页点击「收藏」,更新时第一时间提醒你

    + 前往首页去首页逛逛 +
    + {{if and (not .favorites) .recommended}} +
    +
    +

    大家都在看

    + 查看全部全部前往下一项 +
    +
    + {{range .recommended}}{{template "searchCard" .}}{{end}} +
    +
    + {{end}} + {{template "accountPagination" .}} +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/feedback.html b/app/template/pages/feedback.html new file mode 100644 index 0000000..36ad8db --- /dev/null +++ b/app/template/pages/feedback.html @@ -0,0 +1,159 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} + +
    + + 返回 + +
    意见反馈
    + +
    + + + + {{template "footer" .}} + + + diff --git a/app/template/pages/help.html b/app/template/pages/help.html new file mode 100644 index 0000000..b76f334 --- /dev/null +++ b/app/template/pages/help.html @@ -0,0 +1,81 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} +
    + 返回 +
    常见问题
    + +
    + +
    +
    +

    常见问题

    +

    关于黄果,你想知道的

    +

    找不到答案?欢迎通过意见反馈或邮箱联系我们。

    +
    + +
    +
    +
    + 黄果短剧网页版可以看全集吗? +

    可以。免费集直接观看,付费集可用金币按集解锁,解锁后永久有效。

    +
    +
    + 解锁过的剧集还需要再付费吗? +

    不需要。已解锁的剧集可在登录后继续观看,无需重复付费。

    +
    +
    + 如何充值金币? +

    登录后进入个人中心,选择金币充值,即可查看可用的充值方式。

    +
    +
    + 支持哪些支付方式? +

    可用的支付方式会在充值页面列出,请以页面显示为准。

    +
    +
    + 忘记密码怎么办? +

    请通过意见反馈联系我们,提交账号信息后由客服协助处理。

    +
    +
    + 内容多久更新一次? +

    黄果短剧持续更新内容,欢迎关注首页的新剧和最近上新。

    +
    +
    + 如何联系客服或洽谈合作? +

    请发送邮件至 support@huangguo.com,我们会尽快回复。

    +
    +
    + 如何注销账号? +

    请通过意见反馈或客服邮箱提交注销申请。

    +
    +
    + + +
    +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/history.html b/app/template/pages/history.html new file mode 100644 index 0000000..1b50693 --- /dev/null +++ b/app/template/pages/history.html @@ -0,0 +1,79 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + + + + {{template "header" .}} + {{template "userCenterHero" .}} + {{template "userCenterTabs" .}} +
    +

    观看历史

    +
    +
    + + + +
    + {{if .groups}} + + {{end}} +
    + {{range .groups}} +
    +

    {{.Title}}

    +
    + {{range .Entries}} +
    + {{if .URL}}{{else}}{{end}} +
    +

    {{if .URL}}{{.Title}}{{else}}{{.Title}}{{end}}

    +
    + {{if .EpisodeText}}{{.EpisodeText}}{{else if .Current}}看至第 {{.Current}} 集{{else}}观看记录{{end}}{{if and (not .EpisodeText) .Total}} / 共 {{.Total}} 集{{end}} + {{if .Genre}}{{.Genre}}{{end}} + {{.Time}} +
    + {{if .Progress}}{{end}} +
    + {{.Time}} + {{if .URL}} + 播放 + 继续观看继续 + {{end}} + +
    + {{end}} +
    +
    + {{end}} + + {{template "accountPagination" .}} +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/index.html b/app/template/pages/index.html new file mode 100644 index 0000000..8685cf0 --- /dev/null +++ b/app/template/pages/index.html @@ -0,0 +1,342 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} +

    黄果短剧

    + + + {{if .homeBannerData}} + {{$banners := .homeBannerData}} + {{$first := index $banners 0}} +
    + {{if $first.PlayName}}{{$first.PlayName}}{{else}}{{$first.Title}}{{end}}横幅 + + + + +
    + {{range $i, $item := $banners}} +
    +
    +
    +

    + + {{if $item.IsNew}}本周主打 新剧速递{{else if $item.IsFeatured}}本周主打 独家首发{{else if $item.IsRecommend}}本周主打 热播推荐{{else}}本周主打 口碑佳作{{end}} +

    + 18+ +
    +

    {{if $item.PlayName}}{{$item.PlayName}}{{else}}{{$item.Title}}{{end}}

    +

    + {{range $ti, $t := $item.ThemeNames}}{{if $ti}} {{end}}{{$t}}{{end}} + {{if $item.TotalEpisode}} 全 {{$item.TotalEpisode}} 集{{end}} + {{if $item.Anchor}} 主演 {{$item.Anchor}}{{end}} +

    +

    {{$item.Desc}}

    + +
    +
    + + {{if $item.PlayName}}{{$item.PlayName}}{{else}}{{$item.Title}}{{end}} + + + 播放 + + {{if eq $item.OperateType 2}}漫剧{{else}}短剧{{end}} +
    +
    + {{end}} +
    + +
    +
    + {{range $i, $item := $banners}} + + {{end}} +
    +
    + 本周主打 {{len $banners}} 部 + + 01 / {{printf "%02d" (len $banners)}} +
    +
    +
    + + {{end}} + +
    + +
    + + {{if .announcements}} +
    +
    + 更新提醒 + 公告 +
    +
    +
    +
    + {{range $i, $a := .announcements}} + {{if $i}}{{end}} +

    {{if $a.Title}}{{$a.Title}}:{{end}}{{$a.Content}}

    + {{end}} + +
    + +
    +
    +
    + {{end}} + + + + + + {{if .continueWatch}} +
    +
    +
    +

    继续观看

    +

    上次看到

    +
    + + 观看历史 + 前往下一项 + +
    +
    + {{range $item := .continueWatch}} + + {{end}} +
    +
    + {{end}} + + + {{$adIdx := 0}} + {{range $cfg := .homeConfigData}} + {{if and (eq $cfg.Type 1) (eq $cfg.Name "热播榜") $.hotRankPlaylets}} +
    +
    +
    +

    {{$cfg.Name}}

    +
    + + 完整榜单 + 前往下一项 + +
    + +
    + {{range $i, $item := $.hotRankPlaylets}} + {{$rank := add $i 1}} + + {{end}} +
    +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdx 2)}} + {{$adIdx = add $adIdx 2}} + + {{else if eq $cfg.Type 1}} + + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdx 2)}} + {{$adIdx = add $adIdx 2}} + + {{else if eq $cfg.Type 2}} +
    +
    +
    +

    {{$cfg.Name}}

    + {{if $cfg.Desc}}

    {{$cfg.Desc}}

    {{end}} +
    + + 查看全部 + 前往下一项 + +
    + {{if $cfg.VideoList}} +
    + {{range $item := $cfg.VideoList}} + + {{end}} +
    + {{else if $cfg.List}} + + {{end}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdx 2)}} + {{$adIdx = add $adIdx 2}} + + {{else if eq $cfg.Type 3}} +
    +
    +
    +

    {{$cfg.Name}}

    + {{if $cfg.Desc}}

    {{$cfg.Desc}}

    {{end}} +
    + + 全部专题 + 前往下一项 + +
    + {{if $cfg.ThemeList}} +
    + {{range $theme := $cfg.ThemeList}} + + {{end}} +
    + {{end}} +
    + {{end}} + {{end}} + + +
    +
    +

    + + 黄果短剧 App +

    +

    在 App 上继续追剧,离线缓存随时看

    +

    竖屏沉浸播放 离线缓存 弹幕互动

    +

    点击后前往黄果短剧下载页,手机打开会自动识别设备并推荐安装方式

    +
    + {{$appAddr := .appConfig.AppAddr}} + + + 点击下载 + +
    +
    +
    + + {{template "footer" .}} + + + diff --git a/app/template/pages/list.html b/app/template/pages/list.html new file mode 100644 index 0000000..b9e8021 --- /dev/null +++ b/app/template/pages/list.html @@ -0,0 +1,110 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} + +
    +
    +
    +
    +

    + + 短剧 +

    +

    {{if .pageHeading}}{{.pageHeading}}{{else}}全部短剧{{end}}

    +

    海量竖屏原创短剧,每日 18:00 上新

    +
    +
    + +
    +
    + 题材 +
    + 全部 + {{range .themeList}} + {{.Name}} + {{end}} +
    +
    +
    + 状态 + +
    +
    + 排序 + +
    +
    + +
    +

    短剧列表

    +

    {{.listCount}}

    + {{if .list}} + {{$adIdxPc := 0}} + {{$adIdxH5 := 0}} + {{$total := len .list}} +
    + {{range $i, $item := .list}} + + {{$n := add $i 1}} + {{if and $.advitise $.advitise.DetailBannerList (lt $n $total)}} + {{if eq (mod $n 12) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxPc 2)}} +
    + {{$adIdxPc = add $adIdxPc 2}} + {{end}} + {{if eq (mod $n 6) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxH5 2)}} +
    + {{$adIdxH5 = add $adIdxH5 2}} + {{end}} + {{end}} + {{end}} +
    + {{else}} +

    暂无短剧

    + {{end}} + + {{template "pagination" .}} +
    + +
    + {{template "ads" (adSlice .advitise.DetailBannerList 2 2)}} +
    +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/login.html b/app/template/pages/login.html new file mode 100644 index 0000000..e266b69 --- /dev/null +++ b/app/template/pages/login.html @@ -0,0 +1,121 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + +
    + + 返回 + +

    登录

    + +
    + +
    + +
    + + + + + + + diff --git a/app/template/pages/moreList.html b/app/template/pages/moreList.html new file mode 100644 index 0000000..63394ec --- /dev/null +++ b/app/template/pages/moreList.html @@ -0,0 +1,114 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + +
    + + 返回 + +
    {{if .configName}}{{.configName}}{{else}}更多短剧{{end}}
    + + 搜索 + +
    + +
    +
    +
    +
    +

    + + 首页精选 +

    +

    {{if .configName}}{{.configName}}{{else}}更多短剧{{end}}

    +

    {{if .configDesc}}{{.configDesc}}{{else}}精选短剧合集,持续更新{{end}}

    +
    +
    + +
    +
    + 排序 +
    + {{range .timeTabs}} + {{.Label}} + {{end}} +
    +
    +
    + 题材 +
    + 全部 + {{range .themeList}} + {{.Name}} + {{end}} +
    +
    +
    + +
    +

    {{if .configName}}{{.configName}}{{else}}精选短剧{{end}}作品列表

    +

    {{.listCount}}

    + {{if .list}} + {{$adIdxPc := 0}} + {{$adIdxH5 := 0}} + {{$total := len .list}} +
    + {{range $i, $item := .list}} + + {{$n := add $i 1}} + {{if and $.advitise $.advitise.DetailBannerList (lt $n $total)}} + {{if eq (mod $n 12) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxPc 2)}} +
    + {{$adIdxPc = add $adIdxPc 2}} + {{end}} + {{if eq (mod $n 6) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxH5 2)}} +
    + {{$adIdxH5 = add $adIdxH5 2}} + {{end}} + {{end}} + {{end}} +
    + {{template "pagination" .}} + {{else}} +

    暂无短剧

    + {{end}} +
    + + {{if .advitise}} +
    + {{template "ads" (adSlice .advitise.DetailBannerList 0 2)}} +
    + {{end}} +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/moreSeriesList.html b/app/template/pages/moreSeriesList.html new file mode 100644 index 0000000..4bb3ebb --- /dev/null +++ b/app/template/pages/moreSeriesList.html @@ -0,0 +1,113 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + + {{template "header" .}} + +
    + + 返回 + +
    {{if .configName}}{{.configName}}{{else}}更多视频{{end}}
    + + 搜索 + +
    + +
    +
    +
    +
    +

    + + 视频 +

    +

    {{if .configName}}{{.configName}}{{else}}更多视频{{end}}

    +

    {{if .configDesc}}{{.configDesc}}{{else}}横版观看的影集作品,持续更新{{end}}

    +
    +
    + +
    +
    + 排序 +
    + {{range .timeTabs}} + {{.Label}} + {{end}} +
    +
    +
    + 题材 +
    + 全部 + {{range .themeList}} + {{.Name}} + {{end}} +
    +
    +
    + +
    +

    {{if .configName}}{{.configName}}{{else}}精选视频{{end}}作品列表

    +

    {{.listCount}}

    + {{if .list}} + {{$adIdxPc := 0}} + {{$adIdxH5 := 0}} + {{$total := len .list}} +
    + {{range $i, $item := .list}} + + {{$n := add $i 1}} + {{if and $.advitise $.advitise.DetailBannerList (lt $n $total)}} + {{if eq (mod $n 12) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxPc 2)}} +
    + {{$adIdxPc = add $adIdxPc 2}} + {{end}} + {{if eq (mod $n 6) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxH5 2)}} +
    + {{$adIdxH5 = add $adIdxH5 2}} + {{end}} + {{end}} + {{end}} +
    + + {{template "pagination" .}} + {{else}} +

    暂无视频

    + {{end}} +
    + + {{if and .advitise .advitise.DetailBannerList}} +
    + {{template "ads" (adSlice .advitise.DetailBannerList 2 2)}} +
    + {{end}} +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/nav.html b/app/template/pages/nav.html new file mode 100644 index 0000000..0948a4c --- /dev/null +++ b/app/template/pages/nav.html @@ -0,0 +1,104 @@ + + + + + + {{if .appConfig.SeoNavInfo.DhTitle}}{{.appConfig.SeoNavInfo.DhTitle}}{{else if .appConfig.SeoNavInfo.DhSiteName}}{{.appConfig.SeoNavInfo.DhSiteName}}{{else if .title}}{{.title}}{{else}}黄果短剧 · 官方导航{{end}} + {{template "seoCommon" .}} + + + + +
    + + + +
    + + + diff --git a/app/template/pages/play.html b/app/template/pages/play.html new file mode 100644 index 0000000..359137c --- /dev/null +++ b/app/template/pages/play.html @@ -0,0 +1,536 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + {{/* 夸克 / QQ(X5):页内播放适配。不要写 x5-fullscreen,否则 QQ 会接管成系统播放器 */}} + + + + + + + + + + + + + + + + + + {{$p := .playlet}} + {{$c := .chapter}} + {{$name := playletDisplayName $p.PlayName $p.Title}} + {{$ep := .episode}} + {{$activeID := .chapterID}} + {{if le $activeID 0}} + {{range $p.PlayletChapterInfo}} + {{if and (le $activeID 0) (eq .CurrentEpisode 1)}}{{$activeID = .ID}}{{end}} + {{end}} + {{if le $activeID 0}}{{with index $p.PlayletChapterInfo 0}}{{$activeID = .ID}}{{end}}{{end}} + {{end}} + {{template "header" .}} + +
    + + 返回 + +
    {{$name}}
    + +
    + +
    +
    + + +
    + {{$startPreview := and (not .canWatch) .hasPreview}} +
    + {{if or .mainURL .hasPreview}} +
    + {{end}} + + {{$name}} + + + + + + {{if not .canWatch}} + {{$coinShort := lt .balance .unlockCost}} +
    + +

    {{if gt $ep 0}}第 {{$ep}} 集{{end}}{{if and (gt $ep 0) $p.TotalEpisode}} {{end}}{{if $p.TotalEpisode}}共 {{$p.TotalEpisode}} 集{{end}}

    + {{if $coinShort}} +

    金币不足

    +

    本集需 {{.unlockCost}} 金币,当前余额 {{.balance}} 金币

    +
    + + {{$appAddr := .appConfig.AppAddr}} + + 下载 + 下载 App 免费看 + +
    + {{else}} +

    使用金币解锁本集

    +

    + {{if gt .unlockCost 0}}本集需 {{.unlockCost}} 金币,{{end}}解锁后可永久观看。 + {{if gt .unlockCost 0}}当前余额 {{.balance}} 金币。{{end}} +

    +
    + + {{$appAddr := .appConfig.AppAddr}} + + 下载 + 下载 App 免费看 + +
    + {{end}} +
    + {{end}} + +
    + {{if $startPreview}} + 预告片 免费 + 预告片 + {{else}} + 正片{{if gt $ep 0}} 第 {{$ep}} 集{{end}}{{if $p.TotalEpisode}} 共 {{$p.TotalEpisode}} 集{{end}} + {{if gt $ep 0}}第 {{$ep}} 集{{end}}{{if $p.TotalEpisode}} 共 {{$p.TotalEpisode}} 集{{end}} + {{end}} +
    + + {{if not .canWatch}} +
    + +
    +
    + + {{if .nextChapterID}} + + 前往下一项 + + {{else}} + + {{end}} + 00:00{{if $c}} / {{formatMediaTime $c.MediaTime}}{{end}} +
    +
    + + + + +
    +
    +
    + {{end}} +
    + +
    + + {{if .hasPreview}} + +

    预告片免费播放,无需登录与解锁

    + {{end}} +
    +
    + +
    +
    +
    +

    {{$name}}

    + {{if $p.IsFeatured}}爆剧{{else if eq $p.Anchor "原创"}}原创{{end}} +
    +
    + {{if $p.TotalEpisode}}全 {{$p.TotalEpisode}} 集{{end}} + {{updateStatusText $p.UpdateStatus}} + {{with playletYearText $p.OnlineAt}}{{if ne . "年份未知"}}{{.}}{{end}}{{end}} + 1080P +
    +

    + {{with usableAnchor $p.Anchor}}{{if and (ne . "原创") (ne . "已注销")}}主演 {{.}}{{end}}{{end}} + {{$themes := namedThemeTags $p.ThemeIDs $p.ThemeNames $p.ThemeSlugs}} + {{if $themes}} + + 题材 + {{range $ti, $tag := $themes}}{{if $ti}} {{end}}{{if $tag.URL}}{{$tag.Name}}{{else}}{{$tag.Name}}{{end}}{{end}} + + {{end}} + 热度 {{formatLikeCount $p.HeatValue}} + 收藏 {{formatLikeCount $p.CountCollect}} +

    +

    + {{with usableAnchor $p.Anchor}}{{if and (ne . "原创") (ne . "已注销")}}主演 {{.}}{{end}}{{end}} + {{$themesH5 := namedThemeTags $p.ThemeIDs $p.ThemeNames $p.ThemeSlugs}} + {{if $themesH5}} + + {{range $ti, $tag := $themesH5}}{{if $ti}} {{end}}{{if $tag.URL}}{{$tag.Name}}{{else}}{{$tag.Name}}{{end}}{{end}} + + {{end}} + 热度 {{formatLikeCount $p.HeatValue}} + 收藏 {{formatLikeCount $p.CountCollect}} +

    +
    + +
    + + 播放 + {{if .canWatch}}立即播放{{else if $startPreview}}播放预告片{{else}}解锁播放{{end}} + + + + + + 评论 + 反馈 + +
    + +
    +
    +

    简介

    + +
    +

    {{if $p.Desc}}{{$p.Desc}}{{else if $p.Description}}{{$p.Description}}{{else}}暂无简介{{end}}

    +
    +
    + +
    +
    +
    +

    选集

    + + {{if $p.TotalEpisode}}共 {{$p.TotalEpisode}} 集{{else}}共 {{len $p.PlayletChapterInfo}} 集{{end}} + {{if eq $p.UpdateStatus 2}} 已完结{{else}} {{updateStatusText $p.UpdateStatus}}{{end}} + + +
    + +
    + +
    + {{range $i, $ch := $p.PlayletChapterInfo}} + {{if $ch.CurrentEpisode}}{{$ch.CurrentEpisode}}{{else}}{{add $i 1}}{{end}} + {{else}} + 1 + {{end}} +
    + +
    + + + 免费集 无需登录直接播放 + 免费集直接播放 + + + + {{if gt .unlockCost 0}} + {{.unlockCost}} 金币 / 集 解锁后永久观看 + {{.unlockCost}} 金币 / 集 永久观看 + {{else}} + 金币解锁后永久观看 + 金币解锁 永久观看 + {{end}} + +
    +
    + + {{template "playLinks" .}} + +
    + {{if .advitise}}{{template "ads" (adSlice .advitise.DetailBannerList 0 2)}}{{end}} +
    + + {{if .related}} + + {{end}} + +
    + {{template "playComments" .}} + + +
    + + {{if .guessLike}} +
    +
    +
    +

    猜你喜欢

    + 根据本剧题材推荐 +
    + +
    +
    + {{range .guessLike}} + + {{end}} +
    +
    + {{end}} + +
    + {{if .advitise}}{{template "ads" (adSlice .advitise.DetailBannerList 2 2)}}{{end}} +
    +
    +
    + + {{template "footer" .}} + + + + {{template "rechargeModal" .}} + + + + + diff --git a/app/template/pages/popularRecommend.html b/app/template/pages/popularRecommend.html new file mode 100644 index 0000000..b53db43 --- /dev/null +++ b/app/template/pages/popularRecommend.html @@ -0,0 +1,99 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + +
    + + 返回 + +
    {{if .tabTitle}}{{.tabTitle}}{{else}}热播榜{{end}}
    + + 搜索 + +
    + +
    +
    +
    +
    +

    + + 热播榜 +

    +

    {{if .tabTitle}}{{.tabTitle}}{{else}}热播榜{{end}}

    +

    {{if .seoInfo.Description}}{{.seoInfo.Description}}{{else}}根据播放、收藏与解锁综合排序{{end}}

    +
    +
    + +
    +
    + 榜单 +
    + {{range .rankTabs}} + {{.Label}} + {{end}} +
    +
    +
    + +
    +

    {{if .tabTitle}}{{.tabTitle}}{{else}}热播榜{{end}}作品列表

    +

    {{.listCount}}

    + {{if .list}} + {{$adIdxPc := 0}} + {{$adIdxH5 := 0}} + {{$total := len .list}} +
    + {{range $i, $item := .list}} + + {{$n := add $i 1}} + {{if and $.appConfig.DetailBannerList (lt $n $total)}} + {{if eq (mod $n 12) 0}} +
    + {{template "ads" (adSlice $.appConfig.DetailBannerList $adIdxPc 2)}} +
    + {{$adIdxPc = add $adIdxPc 2}} + {{end}} + {{if eq (mod $n 6) 0}} +
    + {{template "ads" (adSlice $.appConfig.DetailBannerList $adIdxH5 2)}} +
    + {{$adIdxH5 = add $adIdxH5 2}} + {{end}} + {{end}} + {{end}} +
    + {{template "pagination" .}} + {{else}} +

    暂无榜单数据

    + {{end}} +
    +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/privacy.html b/app/template/pages/privacy.html new file mode 100644 index 0000000..7baf1bb --- /dev/null +++ b/app/template/pages/privacy.html @@ -0,0 +1,90 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} +
    + 返回 +
    隐私政策
    + +
    + +
    + + + +
    + + {{template "footer" .}} + + + diff --git a/app/template/pages/profile.html b/app/template/pages/profile.html new file mode 100644 index 0000000..3d1b0ea --- /dev/null +++ b/app/template/pages/profile.html @@ -0,0 +1,62 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + {{template "userCenterHero" .}} + {{template "userCenterTabs" .}} +
    +

    个人中心

    + {{if .history}} +
    +
    +

    最近观看

    上次看到
    + 全部历史 前往下一项 +
    +
    + {{range .history}} + + {{end}} +
    +
    + {{end}} +
    +
    +

    我的收藏

    {{if .favoriteCount}}{{.favoriteCount}}{{else}}—{{end}} 部
    + 全部收藏 前往下一项 +
    +
    + {{range .favorites}} + + {{else}} +

    {{if $.favoritesUnavailable}}收藏暂时无法加载{{else}}暂无收藏{{end}}

    + {{end}} +
    +
    +
    + {{template "footer" .}} + + diff --git a/app/template/pages/purchased.html b/app/template/pages/purchased.html new file mode 100644 index 0000000..619dada --- /dev/null +++ b/app/template/pages/purchased.html @@ -0,0 +1,51 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + + + + {{template "header" .}} + {{template "userCenterHero" .}} + {{template "userCenterTabs" .}} +
    +

    已购剧集

    + {{if .purchased}} +

    金币解锁的剧集永久有效,可随时回看

    +
    + {{range .purchased}} +
    + {{.Title}} +
    +

    {{.Title}}

    +

    {{.Info}}{{if .Date}} · {{.Date}}{{end}}

    + +
    + + 播放继续观看继续 +
    + {{end}} +
    + {{else}} + + {{end}} + {{template "accountPagination" .}} +
    + {{template "footer" .}} + + diff --git a/app/template/pages/rank.html b/app/template/pages/rank.html new file mode 100644 index 0000000..3fbecfc --- /dev/null +++ b/app/template/pages/rank.html @@ -0,0 +1,98 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + +
    + + 返回 + +
    {{if .tabTitle}}{{.tabTitle}}{{else}}热播榜{{end}}
    + + 搜索 + +
    + +
    +
    +
    +
    +

    + + 排行榜 +

    +

    {{if .tabTitle}}{{.tabTitle}}{{else}}热播榜{{end}}

    +

    {{if .seoInfo.Description}}{{.seoInfo.Description}}{{else}}每小时更新一次,数据来自全站播放、收藏与解锁{{end}}

    +

    每小时更新一次

    +
    + + +
    + +
    +

    {{if .tabTitle}}{{.tabTitle}}{{else}}热播榜{{end}}作品列表

    + {{if .list}} +
    + {{range $i, $item := .list}} + {{$rank := add $.rankOffset (add $i 1)}} +
    +
    + {{rankNo $rank}} +
    +
    + + {{if eq $item.UpdateStatus 1}} + 连载中 + {{else if $item.TotalEpisode}} + 全 {{$item.TotalEpisode}} 集 + {{else}} + {{updateStatusText $item.UpdateStatus}} + {{end}} + +
    +

    + {{if $item.PlayName}}{{$item.PlayName}}{{else}}{{$item.Title}}{{end}} +

    +

    + {{$tags := cardTags $item}} + {{range $ti, $tag := $tags}}{{if $ti}} {{end}}{{if $tag.URL}}{{$tag.Name}}{{else}}{{$tag.Name}}{{end}}{{end}} + {{if $tags}} {{end}} + {{if eq $item.UpdateStatus 1}}连载中{{else if $item.TotalEpisode}}全 {{$item.TotalEpisode}} 集{{else}}{{updateStatusText $item.UpdateStatus}}{{end}} +

    +

    + 热门趋势 + {{if $.isBinge}}{{formatLikeCount $item.CountCollect}}{{else}}{{formatLikeCount $item.HeatValue}}{{end}} +

    +
    +
    + + 播放 + 播放 + +
    + {{end}} +
    + {{template "pagination" .}} + {{else}} +

    暂无榜单数据

    + {{end}} +
    +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/recentlyAdded.html b/app/template/pages/recentlyAdded.html new file mode 100644 index 0000000..bf86789 --- /dev/null +++ b/app/template/pages/recentlyAdded.html @@ -0,0 +1,88 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + +
    + + 返回 + +
    最近上新
    + + 搜索 + +
    + +
    +
    +
    +
    +

    + + 最近上新 +

    +

    最近上新

    +

    {{if .seoInfo.Description}}{{.seoInfo.Description}}{{else}}按上线时间排序,每日 18:00 更新{{end}}

    +
    +
    + +
    +

    最近上新作品列表

    +

    {{.listCount}}

    + {{if .list}} + {{$adIdxPc := 0}} + {{$adIdxH5 := 0}} + {{$total := len .list}} +
    + {{range $i, $item := .list}} + + {{$n := add $i 1}} + {{if and $.appConfig.DetailBannerList (lt $n $total)}} + {{if eq (mod $n 12) 0}} +
    + {{template "ads" (adSlice $.appConfig.DetailBannerList $adIdxPc 2)}} +
    + {{$adIdxPc = add $adIdxPc 2}} + {{end}} + {{if eq (mod $n 6) 0}} +
    + {{template "ads" (adSlice $.appConfig.DetailBannerList $adIdxH5 2)}} +
    + {{$adIdxH5 = add $adIdxH5 2}} + {{end}} + {{end}} + {{end}} +
    + {{template "pagination" .}} + {{else}} +

    暂无上新短剧

    + {{end}} +
    +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/recharge.html b/app/template/pages/recharge.html new file mode 100644 index 0000000..198ec96 --- /dev/null +++ b/app/template/pages/recharge.html @@ -0,0 +1,103 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + {{template "subpageHeader" .}} +
    + +
    + 钱包 +

    金币充值

    +

    金币按集解锁付费剧集,解锁后永久有效

    +
    + +
    +
    + +
    + 当前余额 + {{if .balanceText}}{{.balanceText}}{{else if .balance}}{{.balance}}{{else}}0{{end}} 金币 +

    按剧集原定价按集解锁  解锁后永久有效按集解锁,解锁后永久有效

    +
    +
    +
    +

    金币怎么用

    +
    按集解锁
    解锁单集内容按剧集标定价消耗金币
    +
    快速到账
    奖励金币优先使用解锁剧集先自动优先扣除任务获得的奖励金币
    +
    永久有效
    已解锁剧集不再扣费已解锁的剧集可随时回看,不会重复消耗金币
    +
    +
    + +
    +

    选择套餐

    按集解锁,自由使用
    +
    + {{range .packages}} + + {{end}} +
    + {{if not .packages}}

    {{if .packageUnavailable}}套餐加载失败,请稍后重试{{else}}暂无可购买的金币套餐{{end}}

    {{end}} +

    支付方式和到账金币以套餐接口信息为准

    +
    + +
    +

    支付方式

    +
    + {{range .paymentMethods}} + + {{end}} +
    +

    当前套餐暂无可用支付方式

    +

    已选 — 金币  支付即表示同意《充值协议》

    + +
    + +
    +

    交易明细

    +
    + + + + +
    + + +
    +
    + +
    + {{range $index, $tx := .transactions}} +
    + {{$tx.Kind}} + {{$tx.Description}} + {{$tx.AmountText}} +
    + {{end}} +
    +

    {{if .transactionsUnavailable}}交易明细加载失败{{else}}暂无记录{{end}}

    +
    + +
    +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/register.html b/app/template/pages/register.html new file mode 100644 index 0000000..c1c2fd9 --- /dev/null +++ b/app/template/pages/register.html @@ -0,0 +1,146 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + +
    + + 返回 + +

    注册账号

    + +
    + +
    +
    +
    + +
    + +
    +
    +

    创建账号

    +

    注册后即可解锁付费剧集并收藏作品

    +
    + +
    + + + + + + + + + + +
    + +

    + 已有账号? + 立即登录 +

    +
    +
    +
    + + + + + + + diff --git a/app/template/pages/robots.txt b/app/template/pages/robots.txt new file mode 100644 index 0000000..c23e04c --- /dev/null +++ b/app/template/pages/robots.txt @@ -0,0 +1,14 @@ +User-agent: * +Allow: / + +# 非 HTML 接口与播放器工具无需被搜索引擎抓取。 +Disallow: /api/ +Disallow: /h5/ +Disallow: /ui/ +Disallow: /healthz +Disallow: /enkey +Disallow: /newenkey +Disallow: /cdn-cgi/ + +# 搜索、登录和账户 HTML 保持可抓取,以便爬虫读取页面内 noindex。 +Sitemap: /sitemap.xml diff --git a/app/template/pages/search.html b/app/template/pages/search.html new file mode 100644 index 0000000..a757614 --- /dev/null +++ b/app/template/pages/search.html @@ -0,0 +1,111 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} +
    + + 返回 + + + 取消 +
    +

    搜索“{{.query}}”

    +
    + {{if .hasResults}} +
    +
    +

    搜索

    +

    “{{.query}}”

    +

    找到 {{.resultCount}} 部相关作品

    +
    +
    +
    +

    找到 {{.resultCount}} 部相关作品

    +
    + {{range .works}}{{template "searchCard" .}}{{end}} +
    + {{template "pagination" .}} +
    + {{else}} +
    +
    +

    搜索

    +

    “{{.query}}”

    +

    没有找到相关作品,试试更短的关键词或从热门推荐里挑一部

    +
    +
    +
    +
    搜索
    +

    没有找到相关作品

    +

    换个关键词试试,或按题材浏览全部短剧

    + 浏览全部短剧 +
    + {{if .recommended}} +
    +
    +

    大家都在看

    + 查看全部全部前往下一项 +
    +
    + {{range .recommended}}{{template "searchCard" .}}{{end}} +
    +
    + {{end}} + {{end}} +
    + {{template "footer" .}} + + + + +{{define "searchCard"}} + +{{end}} diff --git a/app/template/pages/searchMobile.html b/app/template/pages/searchMobile.html new file mode 100644 index 0000000..6e34217 --- /dev/null +++ b/app/template/pages/searchMobile.html @@ -0,0 +1,91 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + +
    + + 返回 + + + 取消 +
    + +
    +

    搜索短剧

    +
    + + + {{if .trends}} + + {{end}} + + {{if .recommended}} +
    +

    猜你想搜

    +
    + {{range .recommended}} + {{$name := playletDisplayName .PlayName .Title}} + + {{end}} +
    +
    + {{end}} +
    + +
    +
    +
    +
    + + + + diff --git a/app/template/pages/series.html b/app/template/pages/series.html new file mode 100644 index 0000000..6c98d7d --- /dev/null +++ b/app/template/pages/series.html @@ -0,0 +1,112 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + +
    +
    +
    +
    +

    + + 剧集 +

    +

    {{if .pageHeading}}{{.pageHeading}}{{else}}剧集与动画{{end}}

    +

    {{if .seoInfo.Description}}{{.seoInfo.Description}}{{else}}横版观看的影集与动画作品{{end}}

    +
    +
    + +
    +
    + 类型 +
    + 全部 + 影集 + 动画 +
    +
    +
    + 题材 +
    + 全部 + {{range .topicList}} + {{.DisplayName}} + {{end}} +
    +
    +
    + 排序 + +
    +
    + +
    +

    剧集与动画作品列表

    +

    {{.listCount}}

    + {{if .list}} + {{$adIdxPc := 0}} + {{$adIdxH5 := 0}} + {{$total := len .list}} +
    + {{range $i, $item := .list}} + + {{$n := add $i 1}} + {{if and $.advitise $.advitise.DetailBannerList (lt $n $total)}} + {{if eq (mod $n 8) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxPc 2)}} +
    + {{$adIdxPc = add $adIdxPc 2}} + {{end}} + {{if eq (mod $n 4) 0}} +
    + {{template "ads" (adSlice $.advitise.DetailBannerList $adIdxH5 2)}} +
    + {{$adIdxH5 = add $adIdxH5 2}} + {{end}} + {{end}} + {{end}} +
    + + {{template "pagination" .}} + {{else}} +

    暂无剧集

    + {{end}} +
    + + {{if and .advitise .advitise.DetailBannerList}} +
    + {{template "ads" (adSlice .advitise.DetailBannerList 2 2)}} +
    + {{end}} +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/seriesPlay.html b/app/template/pages/seriesPlay.html new file mode 100644 index 0000000..b78fda5 --- /dev/null +++ b/app/template/pages/seriesPlay.html @@ -0,0 +1,342 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + {{/* 夸克 / QQ(X5):页内播放适配。不要写 x5-fullscreen,否则 QQ 会接管成系统播放器 */}} + + + + + + + + + + + + + + + + + + {{template "header" .}} + +
    + + 返回 + +
    {{if .video.Title}}{{.video.Title}}{{else}}视频播放{{end}}
    + +
    + +
    +
    + + +
    +
    + {{if and .canWatch .videoURL}} +
    + {{end}} + + {{if .video.Title}}{{.video.Title}}{{else}}视频{{end}} + + + + + + {{if not .canWatch}} +
    + +

    {{if .video.Title}}{{.video.Title}}{{else}}视频{{end}}

    +

    暂无法播放

    +

    当前视频地址不可用,请稍后再试。

    +
    + {{$appAddr := .appConfig.AppAddr}} + + 下载 + 下载 App 免费看 + +
    +
    + {{end}} + +
    + 正片{{if .video.Video.VideoTime}} {{formatMediaTime .video.Video.VideoTime}}{{end}} + {{if .video.Video.VideoTime}}{{formatMediaTime .video.Video.VideoTime}}{{else}}正片{{end}} +
    + + {{if not .canWatch}} +
    + +
    +
    + + 00:00{{if .video.Video.VideoTime}} / {{formatMediaTime .video.Video.VideoTime}}{{end}} +
    +
    +
    + {{end}} +
    + +
    + 正片 + +
    +
    + +
    +
    +
    +

    {{if .video.Title}}{{.video.Title}}{{else}}视频{{end}}

    + 影集 +
    +
    + {{if .video.Video.VideoTime}}{{.video.Video.VideoTime}} 分钟{{end}} + {{if and .video.Video.Width .video.Video.Height}}{{.video.Video.Width}}×{{.video.Video.Height}}{{end}} + 18+ +
    +

    + {{if .video.TagsName}}标签 {{range $i, $t := .video.TagsName}}{{if $i}} {{end}}{{$t}}{{end}} {{end}} + {{if .video.WatchTimes}}热度 {{formatLikeCount .video.WatchTimes}}{{end}} +

    +

    + {{range $i, $t := .video.TagsName}}{{if $i}} {{end}}{{$t}}{{end}} + {{if .video.WatchTimes}} 热度 {{formatLikeCount .video.WatchTimes}}{{end}} +

    +
    + +
    + + + + + +
    + +
    +
    +

    简介

    + +
    +

    {{if .seoInfo.Description}}{{.seoInfo.Description}}{{else if .video.Title}}{{.video.Title}}{{else}}暂无简介{{end}}

    +
    +
    + + {{template "playLinks" .}} + +
    + {{if .advitise}}{{template "ads" (adSlice .advitise.DetailBannerList 0 2)}}{{end}} +
    + +
    + {{template "playComments" .}} + + +
    + + {{if .guessLike}} +
    +
    +
    +

    猜你喜欢

    + 根据本剧题材推荐 +
    + +
    +
    + {{range .guessLike}} + + {{end}} +
    +
    + {{end}} + +
    + {{if .advitise}}{{template "ads" (adSlice .advitise.DetailBannerList 2 2)}}{{end}} +
    +
    +
    + + {{template "footer" .}} + + + + {{template "rechargeModal" .}} + + + + + diff --git a/app/template/pages/setting.html b/app/template/pages/setting.html new file mode 100644 index 0000000..7426a14 --- /dev/null +++ b/app/template/pages/setting.html @@ -0,0 +1,86 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + + {{template "header" .}} + {{template "userCenterHero" .}} + {{template "userCenterTabs" .}} +
    +

    账号设置

    + {{with .setting}} +
    +
    +
    +

    基本资料

    +
    + +
    + 账号 + {{if .HasAccount}} + + 账号绑定后不可修改 + {{else}} + + {{end}} +
    +
    + 登录密码 + {{if .HasPassword}} +
    + + +
    + {{else}} + + {{end}} +
    +
    + +
    +
    + + + 注销账号暂未开放 +
    +

    +
    + + {{if .HasPassword}} + + {{end}} +
    + {{else}} + + {{end}} +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/tags.html b/app/template/pages/tags.html new file mode 100644 index 0000000..c67dbec --- /dev/null +++ b/app/template/pages/tags.html @@ -0,0 +1,99 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + +
    + + 返回 + +
    {{if .tagName}}{{.tagName}}{{else}}标签{{end}}
    + + 搜索 + +
    + +
    +
    +
    + + +
    +

    + + 标签 +

    +

    {{if .tagName}}{{.tagName}}{{else}}标签短剧{{end}}

    +

    {{if .heroDesc}}{{.heroDesc}}{{else if .seoInfo.Description}}{{.seoInfo.Description}}{{end}}

    +
    +
    + +
    + {{if .relatedTags}} +
    + 相关 +
    + {{range .relatedTags}} + {{.Name}} + {{end}} +
    +
    + {{end}} +
    + 排序 + +
    +
    + +
    +

    {{if .tagName}}{{.tagName}}{{else}}标签短剧{{end}}作品列表

    +

    {{.listCount}}

    + {{if .list}} + + {{template "pagination" .}} + {{else}} +

    暂无作品

    + {{end}} +
    +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/terms.html b/app/template/pages/terms.html new file mode 100644 index 0000000..426d612 --- /dev/null +++ b/app/template/pages/terms.html @@ -0,0 +1,87 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + {{template "header" .}} +
    + 返回 + 用户条款 + +
    + +
    + + +
    + {{template "footer" .}} + + + diff --git a/app/template/pages/topic.html b/app/template/pages/topic.html new file mode 100644 index 0000000..db2811d --- /dev/null +++ b/app/template/pages/topic.html @@ -0,0 +1,82 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + {{template "header" .}} + +
    + + 返回 + +
    专题剧单
    + + 搜索 + +
    + +
    +
    +
    +
    +

    + + 专题 +

    +

    专题剧单

    +

    {{if .seoInfo.Description}}{{.seoInfo.Description}}{{else}}按口味打包的剧单,编辑每周更新{{end}}

    +
    +
    + +
    +
    + 排序 +
    + 最新 + 最热 +
    +
    +
    + +
    +

    专题列表

    + {{if .list}} + + {{else}} +

    暂无专题

    + {{end}} + + {{template "pagination" .}} +
    +
    +
    + + {{template "footer" .}} + + diff --git a/app/template/pages/topicDetail.html b/app/template/pages/topicDetail.html new file mode 100644 index 0000000..e83eda4 --- /dev/null +++ b/app/template/pages/topicDetail.html @@ -0,0 +1,204 @@ + + + + + + {{.metaTitle}} + {{template "seoCommon" .}} + + + + + + + {{template "header" .}} + +
    + {{if .name}}{{.name}}{{else}}专题{{end}}背景 + + +
    + + 返回 + +
    专题
    + +
    + +
    +
    + +

    + + 专题 +

    +

    {{if .name}}{{.name}}{{else}}专题剧单{{end}}

    + {{if or .desc .seoInfo.Description}} +

    {{if .desc}}{{.desc}}{{else}}{{.seoInfo.Description}}{{end}}

    + {{end}} +

    {{if .playletCount}}{{.playletCount}} 部作品{{else if .totalCount}}{{.totalCount}} 部作品{{else}}专题作品{{end}} 持续更新

    +

    {{if .playletCount}}{{.playletCount}} 部{{else if .totalCount}}{{.totalCount}} 部{{else}}0 部{{end}} 持续更新

    +
    + {{if .list}} + {{$first := index .list 0}} + + 播放 + 从第 1 部开始看 + + {{else}} + + 播放 + 浏览专题 + + {{end}} + +
    +
    + {{if .covers}} + + {{end}} +
    +
    + +
    +
    +
    +
    +
    +

    专题作品

    + {{if .playletCount}}{{.playletCount}} 部{{else if .totalCount}}{{.totalCount}} 部{{else}}0 部{{end}} + +
    +
    + + +
    +
    + {{if .list}} + + {{template "pagination" .}} + {{else}} +

    暂无作品

    + {{end}} +
    + + {{if .moreTopics}} +
    +
    +
    +

    更多专题

    +
    + + 全部专题 + 前往下一项 + +
    + +
    + {{end}} +
    +
    + + {{template "footer" .}} + + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c303874 --- /dev/null +++ b/go.mod @@ -0,0 +1,91 @@ +module videoweb + +go 1.24.0 + +toolchain go1.24.3 + +require ( + github.com/aws/aws-sdk-go v1.34.28 + github.com/ddliu/go-httpclient v0.6.9 + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 + github.com/elastic/go-elasticsearch/v7 v7.17.10 + github.com/gin-gonic/gin v1.10.1 + github.com/go-playground/validator/v10 v10.27.0 + github.com/go-redis/redis v6.15.9+incompatible + github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible + github.com/gorilla/sessions v1.4.0 + github.com/grafov/m3u8 v0.11.1 + github.com/karrick/godirwalk v1.10.3 + github.com/mitchellh/mapstructure v1.4.1 + github.com/nyaruka/phonenumbers v1.0.68 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/robfig/cron/v3 v3.0.1 + github.com/satori/go.uuid v1.2.0 + github.com/shopspring/decimal v1.2.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.0 + github.com/xxjwxc/gowp v0.0.0-20200603141413-57c3ba7108be + go.mongodb.org/mongo-driver v1.17.3 + go.uber.org/zap v1.16.0 + golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/swaggo/swag v1.16.1 // indirect + github.com/technoweenie/multipartstreamer v1.0.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xxjwxc/public v0.0.0-20200603141144-4001846f9957 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/atomic v1.6.0 // indirect + go.uber.org/multierr v1.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/eapache/queue.v1 v1.1.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..293c6de --- /dev/null +++ b/go.sum @@ -0,0 +1,442 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/ant0ine/go-json-rest v3.3.2+incompatible/go.mod h1:q6aCt0GfU6LhpBsnZ/2U+mwe+0XB5WStbmwyoPfc+sk= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPIk= +github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ddliu/go-httpclient v0.6.9 h1:/3hsBVpcgCJwqm1dkVlnAJ9NWuYInbRc+i9FyUXX/LE= +github.com/ddliu/go-httpclient v0.6.9/go.mod h1:zM9P0OxV4OGGz1pt/ibuj0ooX2SWH9a6MvXZLbT0JMc= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 h1:AqeKSZIG/NIC75MNQlPy/LM3LxfpLwahICJBHwSMFNc= +github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3/go.mod h1:hEfFauPHz7+NnjR/yHJGhrKo1Za+zStgwUETx3yzqgY= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo= +github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= +github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA= +github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jander/golog v0.0.0-20150917071935-954a5be801fc/go.mod h1:uWhWXOR4dpfk9J8fegnMY7sP2GFXxe3PFI9Ps+TRXJs= +github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jroimartin/gocui v0.4.0/go.mod h1:7i7bbj99OgFHzo7kB2zPb8pXLqMBSQegY7azfqXMkyY= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/service v1.0.0/go.mod h1:8CzDhVuCuugtsHyZoTvsOBuvonN/UDBvl0kH+BUxvbo= +github.com/karrick/godirwalk v1.10.3 h1:lOpSw2vJP0y5eLBW906QwKsUK/fe/QDyoqM5rnnuPDY= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/muesli/cache2go v0.0.0-20200423001931-a100c5aac93f/go.mod h1:414R+qZrt4f9S2TO/s6YVQMNAXR2KdwqQ7pW+O4oYzU= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nicksnyder/go-i18n/v2 v2.0.3/go.mod h1:oDab7q8XCYMRlcrBnaY/7B1eOectbvj6B1UPBT+p5jo= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +github.com/nsqio/go-nsq v1.0.8/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY= +github.com/nyaruka/phonenumbers v1.0.68 h1:HM+zMsS0iOwREnRKieB+RmK3Sgthwf1Kftgi3GxIp7U= +github.com/nyaruka/phonenumbers v1.0.68/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olivere/elastic v6.2.31+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +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/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/xxjwxc/gowp v0.0.0-20200603130651-4d7368b0e285/go.mod h1:yJ/fY5BorWARfDDsxBU/MyQTHc5MVyNcqBQQYD6MN0k= +github.com/xxjwxc/gowp v0.0.0-20200603141413-57c3ba7108be h1:v4Ws2Pd0HNxegMWiZTgaSBfeLtfyFP/eWc50o2CFFX8= +github.com/xxjwxc/gowp v0.0.0-20200603141413-57c3ba7108be/go.mod h1:mstQ3GY2ZYdP9fCUJJmsq5hwhP0LKoR4ZVTq0XaxK0U= +github.com/xxjwxc/public v0.0.0-20200603115833-341beff27850/go.mod h1:fp3M+FEQrCgWD1fZ/PLwZkCTglf086OEhC9LcydAUnc= +github.com/xxjwxc/public v0.0.0-20200603141144-4001846f9957 h1:1JDmDFVQFpty205Kh9lxBAjjn9rG1eTYtdF/Q0dTh5w= +github.com/xxjwxc/public v0.0.0-20200603141144-4001846f9957/go.mod h1:0BFWVHqt7nKW8MtIx7R7bOkoGQFFnKsaJeeVbkzY88E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= +golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/eapache/queue.v1 v1.1.0 h1:EldqoJEGtXYiVCMRo2C9mePO2UUGnYn2+qLmlQSqPdc= +gopkg.in/eapache/queue.v1 v1.1.0/go.mod h1:wNtmx1/O7kZSR9zNT1TTOJ7GLpm3Vn7srzlfylFbQwU= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-with/wxpay.v1 v1.3.0/go.mod h1:12lWy92n19pAUSSE3BrOiEZbWRkl+9tneOd/aU/LU6g= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/make.sh b/make.sh new file mode 100755 index 0000000..9d2ef5a --- /dev/null +++ b/make.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +export GOPATH=$(cd $(dirname $0)/../;pwd) +export PATH=${PATH}:${GOPATH}/bin + +curDir=$(cd $(dirname $0);pwd) +cd ${curDir} + +which swag +if [ $? -ne 0 ]; then + echo "未发现swag,拉取最新swag" + go get github.com/swaggo/swag/cmd/swag +fi + +prepare=( + "github.com/swaggo/gin-swagger" + "github.com/swaggo/gin-swagger/swaggerFiles" +) +for goget in ${prepare[@]};do + go get ${goget} + if [ $? -ne 0 ]; then + echo "拉取${goget}失败,终止构建" + exit 1 + fi +done + +echo "package main;func main() {}" > main.go + +swag init --exclude web --output app/docs +if [ $? -ne 0 ]; then + echo "执行swag init失败,终止构建" + rm main.go + exit 1 +fi + +swag init --exclude app --output web/docs +if [ $? -ne 0 ]; then + echo "执行swag init失败,终止构建" + rm main.go + exit 1 +fi + +rm main.go + diff --git a/outputs/seo-audit-20260908/indexation-analysis-20260908.md b/outputs/seo-audit-20260908/indexation-analysis-20260908.md new file mode 100644 index 0000000..f10beb6 --- /dev/null +++ b/outputs/seo-audit-20260908/indexation-analysis-20260908.md @@ -0,0 +1,187 @@ +# 黄果短剧全页面索引策略分析 + +## 输入信息与假设 + +- 分析范围:当前本地 Go 项目的全部页面路由、模板 `meta robots`、公共渲染层、`robots.txt` 和 sitemap 路由。 +- 未提供线上域名,无法验证生产环境最终 HTML、HTTP 状态、真实 canonical 和 sitemap 内容;以下结论以代码为准。 +- “需要索引”表示页面有独立搜索意图、稳定公开内容和唯一 canonical,建议输出 `index,follow` 并进入 sitemap。 +- “不需要索引”表示登录/用户私有页、站内搜索、操作页、接口、重复 URL、错误页或低价值工具页,建议输出 `noindex` 或返回正确的 3xx/4xx。 +- 分页页只要内容真实存在、URL 唯一并使用自引用 canonical,建议保持可索引。Google 官方也明确建议分页 URL 各自使用 canonical,而不是全部 canonical 到第一页。 + +## 总体判断 + +当前索引方向属于 **Mixed(基本方向正确,但存在明显一致性问题)**: + +- 公开内容页基本为 `index,follow`。 +- 搜索、账户、反馈、404 基本为 `noindex`。 +- 但全站模板普遍输出两条 `meta robots`;登录/注册页更同时输出 `noindex,follow` 和 `index,follow`。 +- `robots.txt` 又禁止抓取登录、搜索和账户页面,搜索引擎可能无法读取页面里的 `noindex`。Google 官方说明:被 `robots.txt` 阻止的页面,爬虫看不到 `noindex`,URL 仍可能只凭外链被索引。 +- 空标签、空分类、无效专题、应用空列表和上游接口失败时,多个页面仍会返回 `200 + index,follow`,存在 soft 404 和索引膨胀风险。 + +## 一、明确需要索引的页面 + +以下页面有明确、稳定的搜索价值,建议 `index,follow`,只把 canonical URL 放入 sitemap。 + +| 页面类型 | 路由 | 建议 | 当前代码 | 条件与说明 | +|---|---|---|---|---| +| 首页 | `/` | 索引 | `index,follow` | 核心入口,必须索引 | +| 短剧主列表 | `/list/ai-short-play/` 及合法筛选页 | 索引 | `index,follow` | 合法的有限筛选页可索引;任意 slug 会被 canonical 重定向 | +| AI 成人剧集 | `/series/`、合法筛选及分页 | 索引 | `index,follow` | 内容真实存在时索引 | +| 标签聚合页 | `/tag/` | 索引 | `index,follow` | 应展示标签导航或有价值的聚合内容 | +| 有效标签详情 | `/tag/:slug/` 及有效分页 | 索引 | `index,follow` | 标签存在、列表非空、TDK/正文可区分时索引 | +| 排行榜 | `/rank/`、合法榜单类型及分页 | 索引 | `index,follow` | 有独立榜单意图;分页使用自引用 canonical | +| 今日更新 | `/today-update/` 及有效分页 | 索引 | `index,follow` | 有稳定的“今日更新/最新短剧”搜索意图 | +| 有效分类详情 | `/category/:slug/` 及有效分页 | 索引 | `index,follow` | 分类必须真实存在且有内容 | +| 专题总览 | `/topic/` 及有效分页 | 索引 | `index,follow` | 作为专题导航入口 | +| 有效专题详情 | `/topic/:slug/` 及有效分页 | 索引 | `index,follow` | 专题必须存在,具有独立介绍与内容集合 | +| 下载 APP | `/download-app/` | 索引 | `index,follow` | 官方下载意图明确;仅保留一个官方 canonical URL | +| 福利应用 | `/application/` | 索引 | `index,follow` | 当前有分类介绍和应用说明;空列表时不应索引 | +| 短剧详情 | `/detail/:slug/` | 索引 | `index,follow` | 最重要的内容承接页;不存在时已经返回 404 | +| 关于我们 | `/about-us/` | 索引 | `index,follow` | 品牌实体与可信度页面 | +| 帮助中心 | `/help-center/` | 索引 | `index,follow` | FAQ/帮助内容真实且公开时有搜索价值 | + +路由证据见 `app/router/gin.go:127-255`;公开页面模板 robots 见 `app/templates/page/*.html`。 + +## 二、需要条件判断后再索引的页面 + +这些页面不应无条件 `index,follow`,需要 handler 根据内容状态设置 `noindex`、404、410 或 503。 + +| 页面类型 | 路由 | 推荐规则 | 当前风险 | +|---|---|---|---| +| 播放/章节页 | `/play/:slug/`、`/play/:slug/:chapterId/` | 章节真实存在、页面有唯一标题、可播放视频和足够正文时索引;无效章节返回 404;只有付费壳、无视频或与详情页高度重复时 `noindex,follow` | 当前只验证短剧存在,章节请求失败时仍可能用短剧数据渲染 `200 + index,follow`;所有章节描述还复用短剧简介 | +| 回家的路 | `/home-road/` | 若它是唯一官方最新地址发布页且内容长期维护,可索引;多个镜像域名只索引一个 canonical,其余 canonical/noindex | 多域名/导航站场景容易形成重复或 doorway 页面 | +| 官方导航 | `/nav/`;导航独立服务的 `/`、`/nav/`、`/dh/` | 只索引一个官方导航 URL;内容站副本和独立导航站副本之间必须明确 canonical,其余 noindex 或 301 | 当前各入口都可能输出 `index,follow`,代码未体现跨域唯一索引版本 | +| 通用富文本 | `/richtext/:title/` | 仅白名单中的公开、唯一、完整内容索引;协议、内部说明、重复帮助内容等 noindex 或 301 到固定语义路由 | 当前 `IsIndexablePath` 将全部 `/richtext/*` 视为可索引,容易暴露任意 CMS 内容和重复页 | +| 用户协议 | `/user-agreement/` | 一般不需要搜索流量,建议 `noindex,follow`;若业务要求公开检索也可保留索引 | 当前 `index,follow` | +| 隐私政策 | `/privacy-policy/` | 一般建议 `noindex,follow`,保留用户访问但不占索引配额 | 当前 `index,follow` | +| 标签/分类/专题第 1 页 | `/tag/:slug/`、`/category/:slug/`、`/topic/:slug/` | 不存在必须 404;存在但内容过少可 `noindex,follow`;达到内容阈值再 index | 当前只对“分页越界”处理 404,第 1 页空结果仍可能 200 可索引 | +| 所有公开列表空状态 | list/series/rank/today-update/topic/application | 合法但暂时为空可 noindex;上游故障应优先返回 503,不能把接口异常伪装成长期 `200` 空页面 | 多个 `Fetch...OrEmpty` 吞掉上游错误并继续输出可索引页面 | + +## 三、明确不需要索引的 HTML 页面 + +| 页面类型 | 路由 | 建议 | 当前代码 | +|---|---|---|---| +| 登录 | `/login/`、`/login/return/*` | `noindex,follow`,不进 sitemap | handler 设置 `noindex=true`,但 `index.html` 又固定输出 `index,follow`;且 robots.txt 禁止抓取 | +| 注册 | `/register/` | `noindex,follow`,不进 sitemap | 同登录页,存在冲突 robots 标签 | +| 个人中心 | `/user-center/` | `noindex,nofollow`,不进 sitemap | 已输出 `noindex,nofollow`,但 robots.txt 同时禁止抓取 | +| 充值 | `/deposit/`、`/deposit/:tab/` | `noindex,nofollow` | 已 noindex;robots.txt 同时禁止抓取 | +| 收藏 | `/collect/...` | `noindex,nofollow` | 已 noindex;robots.txt 同时禁止抓取 | +| 观看历史 | `/history/...` | `noindex,nofollow` | 已 noindex;robots.txt 同时禁止抓取 | +| 账号设置 | `/setting/` | `noindex,nofollow` | 已 noindex;robots.txt 同时禁止抓取 | +| 编辑资料 | `/edit-profile/` | `noindex,nofollow` | 已 noindex;robots.txt 同时禁止抓取 | +| 修改密码 | `/edit-password/` | `noindex,nofollow` | 已 noindex;robots.txt 同时禁止抓取 | +| 站内搜索 | `/search/`、`/search/:keyword/...` | `noindex,follow` | 模板已 noindex,但 robots.txt 禁止抓取,爬虫可能看不到 noindex | +| 问题反馈 | `/feedback/` | `noindex,nofollow` | 已 noindex;robots.txt 同时禁止抓取 | +| 404/无效页面 | 未匹配路由、无效详情/分页 | 正确返回 HTTP 404/410,并 `noindex,follow` | 统一 404 模板已 noindex;部分无效聚合页仍可能返回 200 | +| 空富文本入口 | `/richtext/` | 返回 404 并禁止索引 | 当前因标题为空而进入统一 404,方向正确 | +| UI 弹窗片段 | `/ui/dialog/:name` | 不索引;建议响应头 `X-Robots-Tag: noindex`,并从 robots.txt 禁止抓取 | 当前未设置索引响应头,robots.txt 也没有 `/ui/` 规则 | +| 健康检查 | `/healthz` | 不索引;建议 `X-Robots-Tag: noindex` | 当前只是 200 JSON,无索引响应头,也未在 robots.txt 禁止 | + +## 四、重复 URL:自身不索引,只让目标 URL 索引 + +| 重复/历史路由 | 当前行为 | 建议 | +|---|---|---| +| `/tag-list/...` | handler 301 到 `/tag/...` | 正确;不要进入 sitemap | +| `/more/...` | handler 301 到 `/topic/...` | 正确;不要进入 sitemap | +| `/list/ai-series/...` | 301 到 `/series/...` | 正确;不要进入 sitemap | +| 查询参数版分页/筛选 URL | 多数 handler 会重定向到路径式 canonical | 保持 301,只提交最终 canonical | +| 非规范 slug/ID URL | detail/category/topic 等尝试跳转规范 slug | 保持 301;旧 URL 不进入 sitemap | + +## 五、非 HTML 路由和资源的索引策略 + +| 路由/资源 | 是否需要搜索索引 | 推荐控制方式 | +|---|---|---| +| `/api/*` | 不需要 | 保持 robots.txt 限制抓取;对可直接访问的 JSON 响应统一增加 `X-Robots-Tag: noindex` | +| `/h5/*`、`/enkey`、`/newenkey` | 不需要 | 保持 robots.txt 禁止;视频/密钥响应可加 `X-Robots-Tag: noindex` | +| `/robots.txt`、`/sitemap.xml`、`/sitemaps/*.xml` | 需要允许抓取,但不作为普通网页参与排名 | 返回正确状态和 Content-Type;无需放进 sitemap | +| Google/百度/Bing/IndexNow 验证文件 | 需要允许验证爬虫访问,不需要普通搜索索引 | 保持可访问;可选 `X-Robots-Tag: noindex`,不得放入 sitemap | +| `/favicon.*`、`/static/*` | 允许抓取以支持页面渲染和搜索展示,不作为 HTML 页面索引 | 不要整体 Disallow 静态 CSS/JS/图片 | + +## 六、分页页是否索引 + +建议有效分页页继续 `index,follow`,包括: + +- `/list/.../page/:page/` +- `/series/.../page/:page/` +- `/tag/.../page/:page/` +- `/rank/.../page/:page/` +- `/today-update/page/:page/` +- `/category/.../page/:page/` +- `/topic/.../page/:page/` + +必须满足: + +1. 每一页有唯一 URL。 +2. 每一页使用自引用 canonical,不能全部 canonical 到第 1 页。 +3. 分页越界返回 404。 +4. 上一页/下一页使用真实 ``,保证爬虫发现深层内容。 +5. 不需要为了分页强制写完全不同的 title/description;Google 可以识别分页序列,但加“第 N 页”更利于人工排查和结果辨识。 + +## 七、当前实现的优先级问题 + +### 高优先级 + +1. **robots.txt 与 noindex 冲突** + - 范围:登录、注册、搜索、账户、反馈页面。 + - 证据:`app/templates/page/robots.txt:14-25` 同时禁止这些路径,而页面模板又输出 `noindex`。 + - 影响:爬虫被挡住后无法读取 `noindex`,URL 仍可能以无摘要形式出现在结果中。 + - 修复:若目标是“绝不进入搜索结果”,允许抓取这些 HTML URL并保留 `noindex`;私密内容本身必须通过鉴权保护,不能依赖 robots.txt。 + +2. **登录/注册页存在相反 robots 指令** + - 范围:`/login/*`、`/register/`。 + - 证据:`app/api/web/index.go:35-36` 设置 `noindex`,`seoCommon.html:12` 输出 `noindex,follow`,但 `index.html:10` 又输出 `index,follow`。 + - 影响:虽然 Google通常采用更严格规则,但重复冲突不利于跨搜索引擎一致性和测试。 + - 修复:全站只保留一个由公共模板控制的 robots 标签。 + +3. **空内容和接口故障页仍可索引** + - 范围:标签、分类、专题、榜单、今日更新、应用等聚合页。 + - 影响:soft 404、低质量索引、抓取预算浪费。 + - 修复:区分“不存在”“合法为空”“上游故障”;分别返回 404、`200 + noindex`、503。 + +### 中优先级 + +4. **播放页无条件索引**:应按章节存在性、可播放性和内容唯一性控制。 +5. **任意富文本均可索引**:应建立可索引白名单或后台 `indexable` 字段。 +6. **导航页可能多域重复**:主站 `/nav/` 与独立导航站需确定唯一 canonical。 +7. **法律页面默认索引**:若无自然搜索目标,用户协议和隐私政策改为 `noindex,follow`。 + +### 低优先级 + +8. **全站重复 meta robots**:`seoCommon.html` 与每个页面模板各输出一条,应统一到公共模板。 +9. **本地未发现 sitemap XML 文件**:路由读取 `./templates/page/sitemaps`,当前仓库扫描未找到生成产物;需确认部署流程会生成并正确挂载,否则 `/sitemap.xml` 可能 404。 + +## 八、建议的最终索引矩阵 + +| 策略 | 页面 | +|---|---| +| `index,follow` | 首页、有效短剧/剧集列表、有效标签、排行榜、今日更新、有效分类、专题总览与有效专题、下载 APP、非空福利应用、短剧详情、关于我们、帮助中心、有效分页 | +| 条件 `index` | 播放章节、回家的路、官方导航、通用富文本、稀疏标签/分类/专题 | +| `noindex,follow` | 登录、注册、站内搜索、用户协议、隐私政策、合法但暂时空的公开聚合页 | +| `noindex,nofollow` | 个人中心、充值、收藏、历史、设置、编辑资料、修改密码、反馈 | +| 301 | `/tag-list/*`、`/more/*`、`/list/ai-series/*`、查询参数和旧 slug 变体 | +| 404/410 | 不存在的内容、无效 slug、越界分页 | +| 503 | 上游故障导致无法生成正常内容的公开页面 | +| `X-Robots-Tag: noindex` | `/ui/dialog/*`、`/healthz`、可直接访问的 API/非 HTML 工具响应 | + +## 参考依据 + +- [Google:使用 noindex 阻止编入索引](https://developers.google.com/search/docs/crawling-indexing/block-indexing) +- [Google:robots meta 与 X-Robots-Tag 规范](https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag) +- [Google:robots.txt 不是阻止网页进入索引的机制](https://developers.google.com/search/docs/crawling-indexing/robots/intro) +- [Google:分页与增量加载最佳实践](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading) + +## 实施结果(2026-09-08) + +已按上述矩阵完成以下代码调整: + +- 全站 robots meta 收口到 `seoCommon.html`,每个 HTML 页面只输出一条规则。 +- 登录、注册、搜索、账户、充值、收藏、历史、设置、编辑资料、修改密码、反馈和 404 已明确设置 noindex。 +- 用户协议、隐私政策和任意通用富文本默认 `noindex,follow`;关于我们、帮助中心继续索引。 +- `/richtext/关于我们/` 等重复地址会 301 到固定语义路由;页脚也直接生成语义路由。 +- 空短剧列表、空剧集、空标签、空分类、空榜单、空今日更新、空专题和空福利应用自动 `noindex,follow`。 +- 明确请求但不存在的播放章节返回 404;无法取得默认章节的播放页设置 noindex。 +- `/api/*`、`/ui/*`、`/healthz`、媒体代理与密钥响应增加 `X-Robots-Tag: noindex, nofollow`。 +- `robots.txt` 不再阻止登录、搜索和账户 HTML,使爬虫能够读取页面的 noindex;继续限制非 HTML 接口与媒体工具路由。 +- `robots.txt` 合并为一个 `User-agent: *` 规则组,避免原先的 Disallow 只落在最后一个特定爬虫分组下。 + +仍需由部署配置决定的事项:主站 `/nav/`、独立导航站首页和 `/home-road/` 跨域时,哪一个域名作为唯一 canonical;代码无法在没有目标域名配置的情况下安全替用户做这个选择。 diff --git a/outputs/seo-audit-20260908/seo优化清单-91av-wz-20260908.xlsx b/outputs/seo-audit-20260908/seo优化清单-91av-wz-20260908.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..cc785b7cb5f33b589a6ab88996efd4e40198b6e4 GIT binary patch literal 31158 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 literal 0 HcmV?d00001 diff --git a/outputs/seo-audit-20260908/tdk-source-analysis-20260908.md b/outputs/seo-audit-20260908/tdk-source-analysis-20260908.md new file mode 100644 index 0000000..444ec4f --- /dev/null +++ b/outputs/seo-audit-20260908/tdk-source-analysis-20260908.md @@ -0,0 +1,98 @@ +# 黄果短剧页面 TDK 来源分析 + +## 分析范围与口径 + +- 分析对象:当前本地 Go 项目的页面路由、SSR handler、公共渲染层和页面模板。 +- 未提供线上域名,因此结论代表代码的运行逻辑,不代表某一线上环境当前接口里一定配置了 TDK。 +- “动态获取”指 TDK 至少可从上游接口返回的 `seoInfo`、公共配置、导航 SEO 或内容字段生成。 +- “写死”指模板内直接写固定文本,或 handler 传入固定常量;列表数据虽然动态,但未用于 TDK 的页面仍算写死。 +- “混合”指部分字段动态、部分字段写死,或接口失败后回退到模板常量。 + +## 总体结论 + +项目不是简单的“动态 / 写死”两类,而是四种实现: + +1. **后台 TDK 动态优先,模板常量兜底**:首页、标签、排行榜、今日更新、分类详情、专题详情等。 +2. **依赖公共导航 SEO,页面业务接口本身不返回 TDK**:短剧列表、AI 剧集、福利应用、回家的路。 +3. **根据内容或路由参数动态拼装**:短剧详情、播放、搜索、富文本。 +4. **模板固定写死**:账户相关页、反馈页、下载页默认配置、404 的 D/K 等。 + +公共渲染层会读取 `/api/app/translation/category/list` 的导航项 `seoInfo`,按当前路径或 `navActive` 覆盖页面原有 `seoInfo`;字段级优先级是“导航 SEO > 页面 SEO > 模板兜底”。证据见 `app/api/web/render.go:25-54`、`app/service/translationser/list.go:13-55` 和 `app/service/translationser/view.go:154-185`。 + +## 一、完整 TDK 可动态获取的页面 + +这些页面的 T、D、K 都存在动态来源;动态字段为空或接口失败时,模板会回退到固定文案。 + +| 页面 / 路由 | T 来源 | D 来源 | K 来源 | 主要接口与说明 | +|---|---|---|---|---| +| 首页 `/` | 首页 `seoInfo.title`,导航 SEO 可覆盖;再回退公共配置和固定标题 | 首页 `seoInfo.description`,导航 SEO可覆盖;再回退公共配置/固定文案 | 首页 `seoInfo.keywords`,导航 SEO 可覆盖;再回退公共配置/固定词 | `/api/app/playlet/home`;`/api/app/config/info`;`/api/app/translation/category/list`。见 `app/api/web/index.go:20-38`、`app/templates/page/index.html:7-9` | +| 登录 `/login/`、注册 `/register/` | 与首页相同 | 与首页相同 | 与首页相同 | 两者复用 `index.html` 和首页接口,并没有登录/注册专属 TDK。handler 虽设置内部 `noindex` 标志,但模板的 robots 仍固定输出 `index,follow`。见 `app/api/web/index.go:15-38`、`app/templates/page/index.html:10` | +| 标签列表/详情 `/tag/...` | 标签接口 SEO 标题或标签名 + 分页 | 标签接口 SEO 描述;否则用标签名拼装 | 标签接口 SEO 关键词;否则标签名 + 固定词 | `/api/app/playlet/tag/list`;导航 SEO 仍可覆盖。见 `app/api/web/tag_list.go`、`app/templates/page/tagList.html:7-9` | +| 排行榜 `/rank/...` | 排行榜接口 SEO + 榜单筛选值 | 排行榜接口 SEO + 榜单筛选值 | 排行榜接口 SEO + 榜单筛选值 | `/api/app/playlet/rank`;导航 SEO 优先。见 `app/api/web/rank.go`、`app/templates/page/rank.html:7-9` | +| 今日更新 `/today-update/...` | 接口 SEO 或固定标题 | 接口 SEO 或固定文案 | 接口 SEO 或固定词 | `/api/app/playlet/today-update`;导航 SEO 优先。见 `app/api/web/today_update.go`、`app/templates/page/todayUpdate.html:7-9` | +| 分类详情 `/category/:slug/...` | 分类接口 SEO;否则分类名 + 分页 | 分类接口 SEO;否则 handler 固定分类介绍 | 分类接口 SEO;否则分类名 + 固定词 | `/api/app/playlet/recommend/list`;导航 SEO 可覆盖。见 `app/api/web/category_detail.go`、`app/templates/page/categoryDetail.html:7-9` | +| 专题详情 `/topic/:slug/...` | 专题列表接口 SEO;否则专题名称 | 专题列表接口 SEO;否则固定专题介绍 | 专题列表接口 SEO;否则专题名 + 固定词 | `/api/app/playlet/list` 提供 `seoInfo`,`/api/app/theme/list` 提供专题名;导航 SEO 可覆盖。见 `app/api/web/more.go`、`app/templates/page/more.html:7-18` | +| 官方导航 `/nav/`;导航独立服务的 `/`、`/nav/`、`/dh/` | `seoNavInfo.dhTitle` / `dhSiteName` | `seoNavInfo.dhDescription` | `seoNavInfo.dhKeyworld` | `/api/app/config/info`;失败时固定文案。见 `app/templates/page/nav.html:7-9`、`app/service/configser/types.go:118-124` | + +## 二、条件动态:只依赖导航 SEO 的页面 + +这些页面不是从自身内容接口取得 TDK,而是依靠公共导航接口中与当前路由匹配的 `seoInfo`。如果后台导航没有配置 SEO,最终就是模板固定文案。 + +| 页面 / 路由 | 实际情况 | 动态来源 | 固定兜底 | +|---|---|---|---| +| 短剧列表 `/list/...` | 三个字段都支持动态;筛选项还会追加到 T/D/K | `/api/app/translation/category/list` 的导航 SEO | `短剧` 标题及模板内固定描述、关键词。业务接口 `/api/app/playlet/operate/list` 的响应结构没有 `seoInfo`,handler 也显式传入空 `SeoInfo`。见 `app/api/web/list.go:34-53`、`app/service/playletser/types.go:168-176` | +| AI 成人剧集 `/series/...` | 三个字段都支持动态;筛选项会追加到 T/D/K | 导航 SEO | 业务接口同样没有页面级 `seoInfo`,handler 没有传 `seoInfo`。见 `app/api/web/series.go:20-39`、`app/templates/page/series.html:7-9` | +| 福利应用 `/application/` | 三个字段都支持动态,但应用列表接口不参与 TDK | 导航 SEO | `福利应用` + 模板固定 D/K。见 `app/api/web/application.go`、`app/templates/page/application.html:7-14` | +| 回家的路 `/home-road/` | 三个字段都支持动态,但 `config/info.seoInfo` 并未传给模板的 `.seoInfo` | 导航 SEO | handler 固定标题,模板固定 D/K。见 `app/api/web/home_road.go`、`app/templates/page/homeRoad.html:7-9` | +| 下载 APP `/download-app/` | 模板具备 `.seoInfo` 动态入口,但 handler 不传;默认导航项被标记为下载项,会被导航 SEO 匹配逻辑跳过 | 仅当后台把它配置成可匹配的普通站内导航 URL 时才可能动态 | 通常落到模板固定的下载 APP TDK。见 `app/router/gin.go:202-207`、`app/templates/page/downloadApp.html:7-12`、`app/service/translationser/view.go:159-166` | + +## 三、内容字段或路由规则动态拼装的页面 + +这些页面会变化,但不一定使用后台专门配置的完整 TDK。 + +| 页面 / 路由 | T | D | K | 判定 | +|---|---|---|---|---| +| 短剧详情 `/detail/:slug/` | 优先 `detail.SeoInfo.Title`,否则短剧名;站点名也可由接口 SEO/公共配置动态取得 | **不读取 `detail.SeoInfo.Description`**;只要 `detail` 存在,就使用短剧 `Desc`,为空则按短剧名拼固定句式 | **不读取 `detail.SeoInfo.Keywords`**;由短剧名、题材名、标签名和固定词拼装 | 内容动态拼装,不是完整后台 TDK。见 `app/api/web/detail.go:17-30`、`app/templates/page/detail.html:7-11` | +| 播放 `/play/:slug/:chapterId?/` | 优先章节 SEO 标题,其次短剧 SEO 标题/短剧名,并拼集数 | **不读取已经合并好的 `.seoInfo.Description`**;只要 `playlet` 存在,就使用短剧 `Desc` 或固定播放句式 | **不读取已经合并好的 `.seoInfo.Keywords`**;由短剧名、题材、标签和固定词拼装 | 标题动态取 SEO,D/K 是内容字段拼装。见 `app/api/web/play.go:37-60`、`app/service/playletser/seo.go:252-277`、`app/templates/page/play.html:7-17` | +| 搜索 `/search/:keyword/...` | 搜索词拼装 | 搜索词拼装固定句式 | 搜索词 + 固定词 | 路由参数动态,不读搜索接口的 SEO;页面为 `noindex,follow`。见 `app/api/web/search.go`、`app/templates/page/search.html:7-19` | +| 通用富文本 `/richtext/:title/` | 上游富文本 `desc/title` | 用动态标题套固定句式 | 动态标题 + 固定词 | `/api/app/config/richtext/list` 只映射 `id/title/desc/content`,没有映射 `seoInfo`,所以不是后台独立 TDK。见 `app/service/configser/types.go:133-145`、`app/service/configser/richtext.go:48-82`、`app/templates/page/richtext.html:7-18` | +| 专题列表 `/topic/`、分页 | handler 固定基础标题并按页码追加;公共导航 SEO 命中时也可覆盖标题 | 固定 | 固定 | T 动态/规则生成,D/K 写死。见 `app/api/web/theme_index.go`、`app/templates/page/themeIndex.html:7-9` | +| 404 / 未找到页面 | handler 的错误标题可变化 | 固定 | 固定 | 只有 T 动态,D/K 写死;`noindex,follow`。见 `app/api/web/notfound.go`、`app/templates/page/404.html:7-13` | + +## 四、路由层“动态富文本优先、静态模板兜底”的页面 + +以下四个固定路由会先请求 `/api/app/config/richtext/list`:有内容时使用 `richtext.html`,按上一节的规则动态拼装;接口失败或找不到内容时,改用各自的静态模板,TDK 全部写死。 + +| 路由 | 动态成功时 | 接口失败时静态模板 | +|---|---|---| +| `/about-us/` | 富文本标题驱动的规则 TDK | `aboutUs.html`:TDK 写死 | +| `/user-agreement/` | 富文本标题驱动的规则 TDK | `userAgreement.html`:TDK 写死 | +| `/privacy-policy/` | 富文本标题驱动的规则 TDK | `privacyPolicy.html`:TDK 写死 | +| `/help-center/` | 富文本标题驱动的规则 TDK | `helpCenter.html`:TDK 写死 | + +路由切换逻辑见 `app/api/web/richtext.go:26-50`。 + +## 五、TDK 写死的页面 + +这些模板不读取 `.seoInfo`,即使公共渲染层取得导航 SEO,也不会改变最终 TDK。 + +| 页面 / 路由 | TDK 情况 | 模板 | +|---|---|---| +| 个人中心 `/user-center/` | 全部写死 | `app/templates/page/userCenter.html:7-12` | +| 金币/会员充值 `/deposit/...` | T 由 handler 固定传入,D/K 写死;整体仍属固定 TDK | `app/templates/page/deposit.html:7-9` | +| 我的收藏 `/collect/...` | T 由 handler 固定传入,D/K 写死 | `app/templates/page/collect.html:7-9` | +| 观看历史 `/history/...` | 全部写死 | `app/templates/page/history.html:7-9` | +| 账号设置 `/setting/` | 全部写死 | `app/templates/page/setting.html:7-12` | +| 编辑资料 `/edit-profile/` | 全部写死 | `app/templates/page/editProfile.html:7-12` | +| 修改密码 `/edit-password/` | 全部写死 | `app/templates/page/editPassword.html:7-12` | +| 问题反馈 `/feedback/` | 全部写死 | `app/templates/page/feedback.html:7-12` | + +以上账户页多数设置了 `noindex,nofollow`,固定 TDK 对自然搜索影响较小。 + +## 关键风险与建议 + +1. **详情页和播放页丢弃后台 D/K**:handler 已经拿到 `seoInfo.description/keywords`,模板在存在 `detail/playlet` 时却绕过它们。若后台运营会配置详情和章节 SEO,这部分配置当前不会生效。 +2. **短剧列表和剧集列表没有页面接口 SEO**:二者完全依赖导航 SEO;导航未配置时所有筛选/分页页面主要使用相同兜底内容,仅筛选值可能由公共层追加。 +3. **富文本 `seoInfo` 未映射**:数据模型中虽然服务端领域模型可见 `SeoInfo`,Web 上游响应结构 `AppRichTextItemResp` 没有该字段,当前只能用标题套固定句式。 +4. **专题列表 D/K 固定**:分页页标题会变化,但 description 和 keywords 始终一致。 +5. **登录/注册复用首页 TDK,且 robots 标志不一致**:handler 设置了 `noindex=true`,但 `index.html` 固定输出 `index,follow`。这两个页面不仅 TDK 与页面功能不一致,还可能被搜索引擎索引。 diff --git a/outputs/seo-audit-20260908/~$seo优化清单-91av-wz-20260908.xlsx b/outputs/seo-audit-20260908/~$seo优化清单-91av-wz-20260908.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..10205df365d91c1adfc330dcbbd10c9e87de30a8 GIT binary patch literal 165 ocmdL!GE^~SFeEbMF{CqOF=T>S3JkP@0K+U2#{d8T literal 0 HcmV?d00001 diff --git a/outputs/seo-audit-jha-1045-hgdjnet-web-20260922.md b/outputs/seo-audit-jha-1045-hgdjnet-web-20260922.md new file mode 100644 index 0000000..dc7645d --- /dev/null +++ b/outputs/seo-audit-jha-1045-hgdjnet-web-20260922.md @@ -0,0 +1,365 @@ +# jha-1045-hgdjnet-web SEO 收录审计报告 + +审计日期:2026-09-22 +审计范围:`app/template/pages`、公共模板、页面路由、TDK 注入链路、canonical/robots、JSON-LD、sitemap、独立导航服务,以及本地 SSR 最终响应。 +目标搜索引擎:Google、Bing、Yandex。 + +## 一、结论 + +当前项目已经具备较完整的 SSR SEO 基础设施,但**暂不建议按现状直接上线并开放全站抓取**。 + +- 32/32 个页面模板均有 `lang="zh-CN"`、`charset`、`viewport`、唯一 ``、统一 SEO 公共片段、`<main>` 和一个 `<h1>`。 +- 主站公开页能输出绝对 canonical、robots、Open Graph、Twitter Card 和 JSON-LD;搜索、账户页与 404 的 noindex 策略基本正确。 +- 路径型筛选和分页 URL 已经取代页面链接中的查询参数,分页第 2 页以后具有独立 title、description 与 canonical。 +- 本轮已经阻止软 404、无限分页和重复筛选 URL 进入索引;仍有 5 项高优先级内容质量/结构化数据问题:跨栏目重复 TDK、品牌信息冲突、动态 URL 未进入 sitemap、Organization logo 结构化数据失效、`series-play` 播放页没有 VideoObject。 + +综合判断: + +| 引擎 | 当前判断 | 主要风险 | +|---|---|---| +| Google | 收录边界已明确,但富媒体与页面质量仍需优化 | 重复标题、结构化数据与页面不一致、视频页标记缺失 | +| Bing | 重复 URL 已禁止索引,但元数据区分度仍不足 | 相同 TDK、动态 sitemap 覆盖不足 | +| Yandex | 与其他搜索引擎使用统一 robots 策略 | 非主域名统一 noindex;sitemap 覆盖仍不足 | + +## 本轮收录策略优化结果 + +已将页面收录规则改为公开页白名单;未明确归类的新页面默认 `noindex,follow`,避免后续新增路由意外进入索引。 + +| 状态 | 页面类型与路由 | +|---|---| +| `index,follow` | 首页;关于、帮助与法律合规页;主列表默认排序;有效题材/媒体/专题/标签/榜单页;有效分页;短剧及视频播放详情页 | +| `noindex,follow` | 站内搜索;独立导航页;列表/专题/标签排序变体;首页配置列表的时间/题材筛选变体;播放页推荐翻页;重复的默认参数路径;无效标签、专题、配置页;越界分页;404 | +| `noindex,nofollow` | 登录、注册、个人中心、历史、收藏、已购、设置、充值、反馈等用户或交易页面 | +| HTTP `X-Robots-Tag: noindex, nofollow` | API、M3U8、密钥和健康检查等非 HTML 工具接口 | + +主域名使用配置接口动态返回的 `foreverUrlAddr`,不使用当前请求 Host、SEO canonical 或最新地址作为主域名回退。只有永久域名 Host 的公开白名单页面允许索引;CloudFront、旧域名及其他备用 Host 对所有搜索引擎统一输出: + +```html +<meta name="robots" content="noindex,nofollow"> +``` + +非主域名及主域名自身的 noindex 页面均不输出 canonical 或 JSON-LD。所有 noindex HTML 页面同时发送对应的 `X-Robots-Tag` 响应头。 + +## 二、审计方法与边界 + +本次完成: + +1. 静态扫描 422 个项目文件和 32 个页面模板。 +2. 检查所有页面模板的 title、description、robots、canonical、H1–H6、main、图片 alt 和外链 rel。 +3. 跟踪 `seoInfo → 导航 SEO → 筛选条件 → 本地兜底` 的 TDK 获取与合并流程。 +4. 检查 JSON-LD 的生成分支及页面可见内容一致性。 +5. 临时启动本地 `25121` 主站和 `26813` 导航站,抽查最终 SSR 响应,而非只检查模板源码。 +6. 解析代表页面 JSON-LD;抽查 sitemap XML 通过 `xmllint` 语法校验。 + +限制:当前站点尚未上线,因此没有做搜索引擎控制台、线上 CDN、真实爬虫日志与 Google Rich Results Test 的线上 URL 校验。本轮使用配置接口动态返回的永久域名及不同 Host 请求本地 SSR 服务,验证了主域名、CloudFront 和旧域名的最终输出。 + +## 三、页面类型矩阵 + +| 页面类型 | 代表路由 | robots/canonical | H 标签 | JSON-LD | 结论 | +|---|---|---|---|---|---| +| 首页 | `/` | `index,follow`;绝对自 canonical | 1 个 H1 | Organization、WebSite、WebPage、ItemList | 基础通过;品牌/H1 与 TDK 不一致 | +| 短剧列表 | `/list/...` | 默认排序与有效筛选 index;排序变体、无效实体和越界分页 noindex | 1 个 H1 | CollectionPage、BreadcrumbList、ItemList | 收录边界已清理;仍需解决 TDK 重复 | +| 视频列表 | `/series/...` | 默认排序与有效筛选 index;排序变体、无效实体和越界分页 noindex | 1 个 H1 | CollectionPage、BreadcrumbList、ItemList | 收录边界已清理;JSON-LD 名称仍有双品牌风险 | +| 专题/榜单/标签 | `/topic`、`/topic-detail/...`、`/rank/...`、`/tags/...` | 有效实体/榜单 index;纯排序、无效实体和越界分页 noindex | 1 个 H1 | CollectionPage、BreadcrumbList、ItemList | 收录边界已清理;部分 TDK 仍重复 | +| 更多列表 | `/more-list/...`、`/more-series-list/...` | 有效配置基础页及分页 index;筛选变体、无效配置 noindex | 1 个 H1 | WebPage、ItemList | 无效 config ID 已 noindex;仍建议改为 CollectionPage | +| 短剧播放 | `/play/:slug/:chapterID` | index;内容 canonical | 1 个 H1 | WebPage、BreadcrumbList、TVSeries、VideoObject、TVEpisode | 标记较完整;title 可能被通用上游 SEO 覆盖 | +| 视频播放 | `/series-play/:slug` | index;内容 canonical | 1 个 H1 | 仅 Organization、WebSite、WebPage | 缺少 VideoObject 和结构化面包屑 | +| 关于/帮助/法律页 | `/about`、`/help`、`/terms` 等 | index;自 canonical | 1 个 H1 | WebPage、BreadcrumbList;帮助页另有 FAQPage | 品牌文案与动态站点名冲突 | +| 搜索页 | `/search/:q/...` | `noindex,follow`;不输出 canonical/JSON-LD | 1 个 H1 | 无 | 策略正确 | +| 登录/账户/充值/反馈 | `/login`、`/profile`、`/favorites` 等 | `noindex,nofollow`;不输出 canonical/JSON-LD | 1 个 H1 | 无 | 策略正确 | +| 404 | 任意不存在内容页 | HTTP 404;`noindex,follow` | 1 个 H1 | 无 | 正确 | +| 独立导航站 | `26813 /`、`/nav/`、`/dh/` | 统一 `noindex,follow`;无 canonical | 1 个 H1 | 无 | 已统一禁止索引 | + +## 四、高优先级问题 + +### P0-1:越界分页和不存在的聚合对象仍返回 200,但已禁止索引 + +本轮已在统一 SEO 层接入 `PageOutOfRange`,并为标签、专题、首页配置和分类实体增加有效性标记。越界分页、无效实体及纯排序变体现在均返回 `noindex,follow`、`X-Robots-Tag: noindex, follow`,且不输出 canonical/JSON-LD,不再进入索引。 + +这些 URL 目前仍可能返回 HTTP 200;后续若需要进一步消除软 404,建议对已确认不存在的实体和越界分页直接返回 HTTP 404。 + +优化前实际响应: + +| URL | HTTP | robots | canonical | +|---|---:|---|---| +| `/list/all/0/1/page/999999` | 200 | index,follow | 自指向第 999999 页 | +| `/series/all/all/1/page/999999` | 200 | index,follow | 自指向第 999999 页 | +| `/topic/page/999999` | 200 | index,follow | 自指向第 999999 页 | +| `/tags/biaoqianceshi/page/999999` | 200 | index,follow | 自指向第 999999 页 | +| `/tags/definitely-not-existing-seo-audit` | 200 | index,follow | 自 canonical | +| `/topic-detail/definitely-not-existing-seo-audit` | 200 | index,follow | 自 canonical | +| `/more-list/999999999` | 200 | index,follow | 自 canonical | +| `/list/definitely-not-existing-seo-audit` | 200 | index,follow | 自 canonical | + +其中越界分页的 UI 页码会被分页组件钳制到真实末页,但 canonical 仍保留请求中的极大页码。例如标签第 999999 页最终 title 变为“第2页”,canonical 却仍是 `/page/999999`。这会生成任意数量的近重复、软 404 URL。 + +正确行为: + +1. 数据拉取后,调用现有 `PageOutOfRange(page, pageSize, totalCount)`。 +2. 第 2 页及以后越界时直接返回 HTTP 404 + `noindex,follow`,不输出 canonical 和 JSON-LD。 +3. tag/topic/config/theme 无法解析或上游明确返回无数据时返回 404。 +4. 枚举型路径参数必须白名单校验;无效筛选不能自 canonical 成新的可收录页面。 +5. `/play/不存在` 和 `/series-play/不存在` 当前已正确返回 404,可沿用同一模式。 + +### P0-2:多个公开栏目输出完全相同的 TDK + +当前优先级是:页面接口 `seoInfo` 优先,导航 SEO 补空,筛选条件追加,最后才使用本地兜底。这个模型本身合理,但没有判断上游返回的是否只是全站通用 SEO。 + +本地最终响应中,下列页面输出了相同的 title 和 description: + +- `/` +- `/list` +- `/topic` +- `/rank` +- `/recently-added` +- `/popular-recommend` + +它们均使用当前上游返回的“麻豆官网|麻豆传媒官方站 最新华语AV免费看”及同一段首页描述。另有: + +- `/rank/2` 与 `/popular-recommend/2` 的 title 和 description 完全相同,仅 canonical 不同。 +- `/play/chongchongceshiduanju` 的 H1 是“虫虫测试短剧”,但 `<title>` 被章节 `seoInfo.title` 覆盖成全站通用标题并追加数字。 + +Google 明确建议每个页面使用简洁、描述性、非模板化重复的 title;Bing 也会使用 title、OG 和显著正文综合生成结果标题;Yandex 的标题与摘要同样取自 title、description、正文和结构化数据。当前重复与冲突会导致搜索引擎重写标题、降低页面区分度。 + +建议: + +1. 非首页页面不要无条件接受“全站通用 title/description”。增加通用 SEO 检测:若与首页 SEO 完全相同,则回退到页面类型 TDK。 +2. 每个主栏目设置独立模板:栏目名/筛选名/页码 + 简短站点名;description 描述该栏目实际内容。 +3. 播放页将 `ResolvePlayPageTitle` 的结果写回最终 `seoInfo.Title`,避免随后又被原始章节 SEO 覆盖。 +4. `/rank/:type` 与 `/popular-recommend/:type` 必须体现不同页面意图,不能只追加相同榜单名称。 +5. 对上游 SEO 建立验收条件:非空、包含当前实体或栏目主题、不是首页 TDK、长度合理、与可见 H1 不矛盾。 + +### P0-3:动态品牌与硬编码“黄果短剧”混用 + +当前上游配置的站点名是“麻豆传媒”,但项目中仍有 141 处“黄果/黄果短剧”硬编码。最终页面出现多套品牌信号: + +- 首页 `<title>`、Organization、WebSite 是“麻豆传媒”,H1 是“黄果短剧”。 +- `/series` `<title>` 为“剧集与动画 - 黄果短剧”,JSON-LD `WebPage.name` 被再次追加成“剧集与动画 - 黄果短剧 - 麻豆传媒”。 +- `/about`、`/help`、法律页的 title 使用“麻豆传媒”,description/H1/正文仍为“黄果短剧”。 +- 独立导航页根路径:`<title>` 是“麻豆官网…”,OG/Twitter 是“黄果短剧 · 官方导航”,H1 是“麻豆传媒官方导航站…”,JSON-LD WebPage 又使用“黄果短剧”。 + +这不只是品牌展示问题,也会让 title、H1、OG、JSON-LD 互相矛盾。Google 和 Bing 都会综合这些来源重新判断页面标题与站点实体。 + +建议: + +1. 先明确该部署到底是“黄果短剧”还是“麻豆传媒”。 +2. 可复用白标项目应统一使用 `siteName`/配置品牌变量,避免在 handler、模板、fallback、JSON-LD 中写死品牌。 +3. 法律文本、帮助 FAQ、邮箱域名、图片 alt、App 文案也必须跟随同一品牌配置,不能只改 head。 +4. JSON-LD 不应对已经带旧品牌的名称再次追加新品牌。 + +### P0-4:sitemap 仅包含 13 个静态栏目 URL + +当前 sitemap XML 语法有效、使用绝对 URL、robots.txt 也正确声明了 Sitemap,但只列出 13 个固定地址:主页、主栏目和静态说明页。 + +缺失: + +- 所有 `/play/...` 和 `/series-play/...` 内容页; +- `/tags/...`、`/topic-detail/...`; +- 可收录的 `/more-list/...`、`/more-series-list/...`; +- 有独立搜索意图的筛选页与分页页; +- `<lastmod>`; +- 视频 sitemap 或视频扩展字段。 + +对以动态视频详情为核心的网站,只依赖站内链接发现会降低新内容发现速度,并让孤立或深层内容更难被抓取。Google 建议 sitemap 放入希望出现在搜索结果中的 canonical URL;Yandex 也建议 Sitemap 反映当前站点结构。 + +建议按类型生成 sitemap index: + +- `sitemap-static.xml` +- `sitemap-playlets.xml` +- `sitemap-videos.xml` +- `sitemap-topics-tags.xml` + +只收录 200、index、canonical 的真实 URL;`lastmod` 必须来自真实更新时间。视频内容可增加 Google 支持的视频 sitemap 扩展。 + +### P0-5:Organization.logo 指向 404,且声明尺寸与文件不符 + +JSON-LD 当前生成: + +```text +https://{site}/favicon.png +``` + +实际本地响应: + +- `/favicon.png` → 404 HTML +- `/static/favicon.png` → 200 `image/png` +- 实际 PNG 尺寸为 132×132,但 JSON-LD 声明 512×512 + +Google 要求 Organization logo URL 可抓取、可索引,且至少 112×112。现有文件尺寸达最低要求,但 URL 与声明尺寸错误。 + +建议将 `url`/`contentUrl` 改为真实 `/static/favicon.png`,宽高写真实值;更理想的是提供专用 512×512 品牌 logo 文件,并确认生产 CDN 返回 200 和正确 MIME。 + +### P0-6:`series-play` 视频播放页没有 VideoObject + +短剧播放页能在数据完整时输出: + +- WebPage +- BreadcrumbList +- TVSeries +- VideoObject +- TVEpisode + +但 `/series-play/:slug` 实际只输出 Organization、WebSite、WebPage,即使页面存在可见播放器、封面、标题、时长和视频地址,也没有 VideoObject。 + +Google 的视频富媒体文档要求 VideoObject 至少包含唯一 `name`、`thumbnailUrl` 和 `uploadDate`;推荐提供 `contentUrl` 或 `embedUrl`、description、duration。当前 `VideoInfoResp` 没有可靠发布时间字段,因此需要先补齐上游字段或数据映射,再生成合规 VideoObject,不能虚构日期。 + +短剧 VideoObject 的现状较好,但锁定内容的实测对象未输出 `contentUrl`/`embedUrl`。这不是必填错误,但会降低 Google 获取视频文件与视频搜索展示的能力。 + +## 五、中优先级问题 + +### P1-1:独立导航服务入口已统一禁止索引 + +`DHInit` 暴露 `/`、`/nav/`、`/dh/`。本轮通过页面数据中的 `isNavPage` 统一覆盖为 `noindex,follow`,不再输出 canonical 和 JSON-LD;即使请求 Host 恰好为主域名也不会误收录。 + +优化前行为为: + +- `/`:200、index,但 canonical 指向主站首页,而不是导航站自身; +- `/nav/`、`/dh/`:200、index,却没有 canonical 和 JSON-LD; +- 导航服务的 `/robots.txt`、`/sitemap.xml` 返回 404。 + +如果导航站需要独立收录,应为导航域名配置独立 origin、统一 title/description/OG/H1/JSON-LD,并只保留一个规范入口;未上线阶段可直接删除多余别名或令其 404。若导航站不需要收录,则三个入口统一 noindex,不要一部分 index、一部分无 canonical。 + +### P1-2:公共 header 的 H2 出现在页面 H1 之前 + +32 个页面模板自身都只有一个 H1,页面内部 H1→H2→H3 层级基本合理。但公共 header 在正文 H1 之前渲染“搜索历史”“热门搜索”两个 H2,因此多数最终 HTML 的第一个标题标签是 H2,而不是页面主题 H1。 + +这些只是下拉菜单标签,建议改为 `<p>`/`<div>`,保留可访问性属性即可。这样最终 heading outline 从页面 H1 开始,更符合 Bing 对逻辑标题层级的建议,也避免搜索引擎误判页面主题。 + +### P1-3:Breadcrumb JSON-LD 与可见面包屑不完全一致 + +- 多数列表/法律/帮助页没有可见面包屑,却生成 BreadcrumbList。 +- 短剧播放页可见路径是“首页 → 短剧 → 剧名 → 第 N 集”,JSON-LD 缺少“短剧”层级。 +- `series-play` 有可见面包屑,却没有 BreadcrumbList。 + +Google 的通用结构化数据规则要求标记代表页面真实、可见的主要内容。建议由同一个 breadcrumb ViewModel 同时渲染 HTML 与 JSON-LD,防止两份逻辑漂移。 + +### P1-4:`more-*` 集合页仍标记为普通 WebPage + +`/more-list/...` 与 `/more-series-list/...` 有真实列表和 ItemList,但 `isCollectionPath` 没包含这两个前缀,因此输出 WebPage。不是语法错误,但改为 CollectionPage 更准确。 + +## 六、已通过项 + +### 模板与语义标签 + +- 32/32 页面模板有 `<!doctype html>`、`lang="zh-CN"`、charset、viewport、title、统一 SEO 片段、main、H1。 +- 每个页面模板静态结构只有一个 H1。 +- 290 个 `<img>` 均有 alt 属性。 +- 44 个 `target="_blank"` 链接均包含 `rel="sponsored nofollow noopener"`。 +- 主要内容由 SSR 输出,不依赖客户端 JavaScript 才能产生 title、description、H1、canonical 或 JSON-LD。 + +### robots 与 canonical + +- 主站 `robots.txt` 返回 200,允许抓取 HTML,同时屏蔽 API、播放器工具和健康检查路径。 +- 搜索页 `noindex,follow`,账户/登录/充值等私有页 `noindex,nofollow`。 +- 404 返回真实 HTTP 404 与 `noindex,follow`。 +- 所有 noindex 页面均不输出 canonical 和 JSON-LD,避免信号冲突。 +- 正常公开页使用绝对 canonical,去除 query 和 fragment。 +- 路径型分页第 2 页具有自 canonical、独立页码 TDK、prev/next。 +- API/媒体工具响应统一带 `X-Robots-Tag: noindex, nofollow`。 + +### JSON-LD + +- 使用 Google 推荐的 JSON-LD 格式,并由 `json.Marshal` 生成;抽查页面均可正常解析为 JSON。 +- 首页与列表页能输出 WebSite/Organization/WebPage 或 CollectionPage/ItemList。 +- 帮助页 FAQPage 的问题和答案与页面可见内容一致。 +- 短剧播放页仅在有真实封面和发布日期时输出 VideoObject,没有伪造必填字段。 +- 结构化数据中的 URL 基本使用绝对 canonical URL。 + +### URL 结构 + +- 页面筛选、搜索与分页主链接已经使用二级路径形式。 +- 模板中未发现业务 HTML 链接继续使用 `?filter=`、`?page=` 等查询式页面 URL;发现的 `?v=` 均为静态资源版本号。 + +## 七、TDK 获取链路评价 + +当前实现: + +```text +接口 seoInfo + ↓ 缺失字段补齐 +导航配置 SEO + ↓ +当前筛选/排序维度差异化 + ↓ +页面类型本地 fallback + ↓ +追加品牌、页码 +``` + +优点:入口集中、字段归一化、分页差异化明确,避免模板各自生成冲突标签。 + +主要缺口: + +1. 上游字段只判断“是否非空”,不判断“是否属于当前页面”。 +2. `seoInfo.CanonicalURL` 被解析和保留,但最终 canonical 完全由本地路由重建;如果这是刻意的安全策略,应删除或注释清楚该未生效字段,避免后台运营误以为可配置页面 canonical。 +3. 品牌 fallback 写死“黄果短剧”,与动态 `WebSiteName` 不一致。 +4. 部分 handler 先生成了正确页面 title,随后仍把原始 `seoInfo` 交给公共层,导致正确 title 被再次覆盖。 +5. meta keywords 已输出,但 Google 不使用它作为排名信号;不能用 keywords 的完整度代替 title/description 的唯一性与准确性。 + +建议把最终 TDK 规则收口为: + +```text +实体/栏目专属 SEO(通过相关性校验) + > 页面本地语义模板 + 动态品牌 + > 全站配置兜底 +``` + +首页允许使用全站 SEO;非首页如果上游 TDK 与首页完全相同,应自动降级到页面本地模板。 + +## 八、建议实施顺序 + +### 上线前必须完成 + +1. 所有无效实体、无效 slug/config ID、越界分页返回 404/noindex。 +2. 修复跨栏目重复 TDK 与播放页 title 覆盖。 +3. 统一品牌来源,清理 head、H1、正文、OG、JSON-LD 的多品牌冲突。 +4. 修复 Organization logo URL 和尺寸。 +5. 为 `series-play` 添加合规 VideoObject;先补真实 uploadDate。 +6. sitemap 纳入动态 canonical 内容 URL。 + +### 上线前建议完成 + +7. 已完成:导航站全部入口统一 `noindex,follow`,不输出 canonical/JSON-LD。 +8. 调整公共 header 的 H2,保证正文 H1 是页面第一个主题标题。 +9. 统一 HTML 与 JSON-LD 面包屑数据源。 +10. 将 `more-*` 页面标记为 CollectionPage。 + +### 上线后验证 + +1. Google Search Console:URL Inspection、Page indexing、Video indexing、Rich results。 +2. Bing Webmaster Tools:Site Scan、URL Inspection、Sitemaps、IndexNow。 +3. Yandex Webmaster:Page check、Searchable pages、Sitemap、Duplicate pages。 +4. 持续监控 404/软 404、重复 title/description、已发现未收录、视频缩略图抓取失败。 + +## 九、验收标准 + +- 任意不存在实体或越界分页:HTTP 404,robots noindex,无 canonical/JSON-LD。 +- 每个可收录 URL:唯一且与 H1/正文一致的 title、description。 +- 同一页面:title、H1、OG、Twitter、JSON-LD 使用同一品牌与页面主题。 +- 每个 canonical:200、可抓取、可索引、无跳转链、同域绝对 URL。 +- sitemap:只包含真实 200/index/canonical URL,并覆盖所有核心内容页。 +- Organization logo:200 图片响应、正确 MIME、可抓取、尺寸声明真实。 +- 所有视频观看页:满足 VideoObject 必填属性;可用时提供 contentUrl 或 embedUrl。 +- 仅主站公开白名单页面可索引;导航站全部入口稳定输出 noindex。 + +## 十、官方规则参考 + +- [Google:标题链接最佳实践](https://developers.google.com/search/docs/appearance/title-link) +- [Google:结构化数据通用规范](https://developers.google.com/search/docs/appearance/structured-data/sd-policies) +- [Google:VideoObject 结构化数据](https://developers.google.com/search/docs/appearance/structured-data/video) +- [Google:视频 SEO 最佳实践](https://developers.google.com/search/docs/appearance/video) +- [Google:Organization 结构化数据](https://developers.google.com/search/docs/appearance/structured-data/organization) +- [Google:创建和提交 Sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) +- [Bing Webmaster Guidelines](https://www.bing.com/webmasters/help/webmaster-guidelines-30fba23a) +- [Bing:标题生成与一致性建议](https://blogs.bing.com/webmaster/June-2014/How-Does-Bing-Choose-The-Title-For-My-Web-Page) +- [Bing:重复内容与搜索可见性](https://blogs.bing.com/webmaster/December-2025/Does-Duplicate-Content-Hurt-SEO-and-AI-Search-Visibility) +- [Yandex:Canonical URLs](https://yandex.com/support/webmaster/en/robot-workings/canonical) +- [Yandex:Title and description](https://yandex.com/support/webmaster/en/search-results/title-and-description) +- [Yandex:Sitemap files](https://yandex.com/support/webmaster/en/indexing-options/sitemap) + +## 十一、构建与校验记录 + +- `go test ./app/...`:通过;项目 app、router、web、SEO service 等包可编译。 +- `go test ./...`:失败,原因是仓库既有 `pub/models` 缺少 `videoweb/app/api/proto`,以及 `pub/storage` 引用未定义的 proxy 符号;与本次 SEO 审计无关。 +- sitemap XML:`xmllint --noout` 通过。 +- 本轮已完成页面收录白名单、无效实体/越界分页 noindex、导航页 noindex,以及基于动态永久域名的非主域名统一 noindex 规则。 diff --git a/project.md b/project.md new file mode 100644 index 0000000..95bd14e --- /dev/null +++ b/project.md @@ -0,0 +1,83 @@ +--- +项目名称: 91ly +一级部门: 北斗 +二级部门: 瑞升 + +DMP编码: + +项目类型: PC + # 可选值: +# H5 / APP / PC / 服务 / 组件 / 工具 / AI / 中间件 + +开发语言: GO + # 可选值: +# GO / Java / PHP / Node + +项目负责人: DN1425 # 员工编号, 多个用逗号隔开,这里的负责是这套代码的负责人 + +运维负责人: DN9460, DN9651, DN4792 # 负责部署、监控、K8S、发布、证书等 + +产品负责人: YY1166 # 项目产品经理 + + +项目成员: DN7208 DN2185 + +项目状态: 维护中 + # 可选值: + # 开发中 / 维护中 / 已停止 / 已归档 + +项目级别: 中 + # 可选值: + # 高 / 中 / 低 , 北斗体系 s和a为 高 b为中 c d为低 + +核心分支: main + # 即部署时候使用的分支,可选值: + # master / main / dev / release + +代码仓库: + - ssh://git@bd-git-sh.jsyyds.com:8082/ruisheng/jha-1021-91jhs/frontend/jha-1045-hgdjnet-web.git + +最后审计人: + +最后审计时间: + +告警群: + +备注: + +--- + +# 1. 项目说明 + +- hgdj_server +- app 包含前端API及页面 +- web 包含管理后端API + + +# 2. 基础设施与中间件 + +- MongoDB 主从 +- Redis 单节点 +- Elasticsearch 搜索(v7) + + +# 3. 运维部署 + +- Shell脚本部署(scp + supervisorctl) +- Telegram Bot 部署通知 + + +# 4. 风险说明 + +- Redis 热Key风险 + + +# 5. 历史事故(可选) + +- + + +# 6. 交接说明(可选) + +- appid 代码固定:1045 +- diff --git a/pub/common/comm/comm.go b/pub/common/comm/comm.go new file mode 100644 index 0000000..4f87259 --- /dev/null +++ b/pub/common/comm/comm.go @@ -0,0 +1,138 @@ +package comm + +import ( + "encoding/json" + "sync" + "time" + "videoweb/pub/common/godef" + "videoweb/pub/common/lock" + "videoweb/pub/common/log" + + "github.com/go-redis/redis" + "go.uber.org/zap" +) + +type MessageType int64 + +const ( + MessageTypeError MessageType = iota // 心跳消息 + MessageTypeAlive + MessageTypeMediaAdd // 添加一个媒体 + MessageTypeMediaDel // 删除一个媒体 + MessageTypePostAdd // 添加一个帖子 + MessageTypePostDel // 删除一个帖子 +) + +type message struct { + Type MessageType + Payload []byte +} + +type OnReceive func(data []byte) + +type Communicate struct { + sendChannel string // 发送消息的频道 + recvChannel string // 接收消息的频道 + client *redis.Client // Redis的客户端 + lock sync.RWMutex + callbacks map[MessageType][]OnReceive + publish *lock.Queue[*message] +} + +func NewCommunicate(client *redis.Client, sendChannel string, recvChannel string) *Communicate { + return &Communicate{ + sendChannel: sendChannel, + recvChannel: recvChannel, + client: client, + callbacks: make(map[MessageType][]OnReceive), + publish: lock.NewQueue[*message](), + } +} + +func (c *Communicate) RegisterReceiver(mType MessageType, receive OnReceive) { + c.lock.Lock() + defer c.lock.Unlock() + c.callbacks[mType] = append(c.callbacks[mType], receive) +} + +func (c *Communicate) findEvent(mType MessageType) []OnReceive { + c.lock.RLock() + defer c.lock.RUnlock() + return c.callbacks[mType] +} + +func (c *Communicate) dispatchMessage(mType MessageType, payload []byte) { + events := c.findEvent(mType) + for _, event := range events { + event(payload) + } +} + +func (c *Communicate) handleRecv(data *redis.Message) { + if data.Channel == "error" { + log.Error("recv from channel %s failed", zap.Any("error", data.Payload)) + return + } + if data.Channel == c.recvChannel { + msg := &message{} + err := json.Unmarshal([]byte(data.Payload), msg) + if err != nil { + log.Error("recv from channel %s failed", zap.Any("error", err)) + } + c.dispatchMessage(msg.Type, msg.Payload) + } +} + +func (c *Communicate) Send(mType MessageType, payload []byte) error { + msg := &message{ + Type: mType, + Payload: payload, + } + c.publish.Enqueue(msg) + return nil +} + +func (c *Communicate) Start() { + // 启动接收线程 + godef.Go(func() { + sub := c.client.Subscribe(c.recvChannel) + defer sub.Close() + // 循环接收消息 + for { + msg, err := sub.ReceiveMessage() + if err != nil { + msg = &redis.Message{ + Channel: "error", + Payload: err.Error(), + } + c.handleRecv(msg) + time.Sleep(time.Second) + } else { + c.handleRecv(msg) + } + } + }) + // 启动发送线程 + godef.Go(func() { + for { + time.Sleep(time.Second) + for !c.publish.IsEmpty() { + msg, ok := c.publish.Dequeue() + if !ok { + break + } + data, err := json.Marshal(msg) + if err != nil { + log.Error("Communicate msg marshal failed", zap.Any("error", err)) + break + } + err = c.client.Publish(c.sendChannel, data).Err() + if err != nil { + c.publish.Enqueue(msg) // 再写回队列 + log.Error("Communicate send msg failed", zap.Any("error", err)) + break + } + } + } + }) +} diff --git a/pub/common/common.go b/pub/common/common.go new file mode 100644 index 0000000..f95f6a0 --- /dev/null +++ b/pub/common/common.go @@ -0,0 +1,131 @@ +package common + +import ( + "errors" + "fmt" + "net/http" + "os" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +var ( + cryptSecret string + env consts.RunMod + //routineCount int64 +) + +// InitGinSecret InitResponseParam +func InitGinSecret(secret string, env_ consts.RunMod) { + cryptSecret = secret + env = env_ +} + +// ServerJSON 返回数据并处理多语言 +func ServerJSON(c *gin.Context, codeOrErr srvdef.ErrInfoer, data interface{}) { + code := codeOrErr.GetCode() + tip := codeOrErr.GetTip() + msg := codeOrErr.GetMsg() + var hash bool + if code != srvdef.Success { //发生错误,记录日志 + var version, sysType, devType string + id := GetUserId(c) + ua, _ := GetUA(c) + if ua.Ver != "" { + version = ua.Ver + } + if ua.SysType != "" { + sysType = ua.SysType + } + if devType != "" { + devType = ua.DevType + } + log.Warn("Error:", + log.Any("UID", strconv.FormatInt(id, 10)), + log.Any("IP", c.ClientIP()), + log.Any("Version", version), + log.Any(consts.CtxSysType, sysType), + log.Any("DevType", devType), + log.Any("Router", c.Request.RequestURI), + log.Any("PID", os.Getpid()), + log.Any("Error", data), + log.Any("Code", code)) + } + c.JSON(http.StatusOK, gin.H{ + "code": code, + "hash": hash, + "msg": msg, + "tip": tip, + "data": data, + "time": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + }) +} + +// GetUID 获取用户uid +// oauth.Auth执行后uid有效 +func GetUID(ctx *gin.Context) (uid uint64, err error) { + val, exists := ctx.Get(consts.CtxUserID) + if !exists { + err = errors.New("userId not exist") + return + } + uid, ok := val.(uint64) + if !ok { + return 0, errors.New("userId type error") + } + return +} + +func GetUserId(ctx *gin.Context) int64 { + return ctx.GetInt64(consts.CtxUserID) +} + +// GetUA +func GetUA(ctx *gin.Context) (u useragent.UA, err error) { + val, exists := ctx.Get(consts.CtxUA) + if !exists { + err = errors.New("user-agent not exists") + return + } + u, ok := val.(useragent.UA) + if !ok { + err = errors.New("user-agent type error") + return + } + return u, nil +} + +// GetIP 获取真实IP +func GetIP(ctx *gin.Context) string { + relIP, exists := ctx.Get(consts.CtxIP) + if !exists { + if relIP == "" { + relIP = ctx.ClientIP() + } + } + v, ok := relIP.(string) + if !ok { + return "" + } + log.Info(fmt.Sprintf("[IP-ROUTER] %s,PID %d", ctx.GetHeader("X-Forwarded-For"), os.Getpid())) + return v +} + +func IsNilOrEmpty(in interface{}) bool { + if in == nil || in == "" { + return true + } + return false +} + +func AlphabeticColl() []string { + return []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", + "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"} +} \ No newline at end of file diff --git a/pub/common/convUtil.go b/pub/common/convUtil.go new file mode 100644 index 0000000..0a99f82 --- /dev/null +++ b/pub/common/convUtil.go @@ -0,0 +1,135 @@ +package common + +import ( + "encoding/json" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ID = primitive.ObjectID + +// ToJsonM 将struct转换为 Json Map +func ToJsonM(obj interface{}) (map[string]interface{}, error) { + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + m := make(map[string]interface{}) + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +func ToBsonM(s interface{}) (bson.M, error) { + data, err := bson.Marshal(s) + if err != nil { + return nil, err + } + m := make(bson.M) + if err := bson.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func MapToJsonString(m map[string]string) (string, error) { + if data, err := json.Marshal(m); err != nil { + return "", err + } else { + return string(data), nil + } +} + +// MapToStruct s is struct ptr +func MapToStruct(s interface{}, m map[string]interface{}) error { + bsonBytes, err := bson.Marshal(m) + if err != nil { + return err + } + return bson.Unmarshal(bsonBytes, s) +} + +// JSONStruct2Map 将struct转换为Map +func JSONStruct2Map(obj interface{}) (map[string]interface{}, error) { + m := make(map[string]interface{}) + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +// Map2JSONStruct 将map转换为Json struct +func Map2JSONStruct(v interface{}, m map[string]interface{}) error { + j, err := json.Marshal(m) + if err != nil { + return err + } + if err := json.Unmarshal(j, v); err != nil { + return err + } + return err +} + +func IDArray(ids []string) ([]ID, error) { + var idArray []ID + for _, id := range ids { + _id, err := primitive.ObjectIDFromHex(id) + if err != nil { + return nil, err + } + idArray = append(idArray, _id) + } + + return idArray, nil +} + +func ObjectIDs2String(obj []primitive.ObjectID) []string { + arr := []string{} + if len(obj) == 0 { + return arr + } + for _, o := range obj { + arr = append(arr, o.Hex()) + } + return arr +} + +func String2ObjectID(obj []string) []primitive.ObjectID { + arr := []primitive.ObjectID{} + for _, o := range obj { + oid, err := primitive.ObjectIDFromHex(o) + if err != nil { + continue + } + arr = append(arr, oid) + } + return arr +} + +// PercentOf - calculate what percent [number1] is of [number2]. +// ex. 300 is 12.5% of 2400 +func PercentOf(part int, total int) float64 { + return (float64(part) * float64(100)) / float64(total) +} + +func MergeMap(src, dest map[string]interface{}) map[string]interface{} { + if len(src) == 0 { + return dest + } + if len(dest) == 0 { + return src + } + if len(src) == 0 && len(dest) == 0 { + return make(map[string]interface{}) + } + for k, v := range dest { + src[k] = v + } + return src +} diff --git a/pub/common/cronmgr/crommgr.go b/pub/common/cronmgr/crommgr.go new file mode 100644 index 0000000..2494c63 --- /dev/null +++ b/pub/common/cronmgr/crommgr.go @@ -0,0 +1,119 @@ +package cronmgr + +import ( + "context" + "errors" + "sync" + "videoweb/pub/common/log" + + "github.com/robfig/cron/v3" +) + +var c *cron.Cron + +// runIngs 正在运行中的任务 +var runIngs map[*context.Context]struct{} +var r_lock sync.Mutex + +// RegisterCron 注册一个定时任务 +func RegisterCron(spec string, cmd func(), errfunc func(err interface{})) (cron.EntryID, error) { + if len(spec) == 0 || cmd == nil { + return -1, errors.New("spec or cmd is nil") + } + return registerCron(spec, func() { + defer func() { + if err := recover(); err != nil { + if errfunc != nil { + errfunc(err) + } + log.Errorf("cron task error.%v", err) + } + }() + cmd() + }) +} + +// RegisterCronAsyncOne 注册一个协程执行的定时任务 限制了cmd同时只有一个协程在执行 +func RegisterCronAsyncOne(spec string, cmd func(), errfunc func(err interface{})) (cron.EntryID, error) { + if len(spec) == 0 || cmd == nil { + return -1, errors.New("spec or cmd is nil") + } + ctx := context.Background() + return registerCron(spec, func() { + go func(c *context.Context) { + defer func() { + if err := recover(); err != nil { + if errfunc != nil { + errfunc(err) + } + log.Errorf("cron task error.%v", err) + } + }() + if taskRunIng(c) { + cmd() + taskRunEnd(c) + } else { + log.Warnf("Limits cmd to only one coroutine executing at the same time") + } + }(&ctx) + }) +} + +// RegisterCronAsync 注册一个协程执行的定时任务 +func RegisterCronAsync(spec string, cmd func(), errfunc func(err interface{})) (cron.EntryID, error) { + if len(spec) == 0 || cmd == nil { + return -1, errors.New("spec or cmd is nil") + } + return registerCron(spec, func() { + go func() { + defer func() { + if err := recover(); err != nil { + if errfunc != nil { + errfunc(err) + } + log.Errorf("cron task error.%v", err) + } + }() + cmd() + }() + }) +} +func registerCron(spec string, cmd func()) (cron.EntryID, error) { + if c == nil { + c = cron.New(cron.WithSeconds()) + runIngs = make(map[*context.Context]struct{}) + } + id, err := c.AddFunc(spec, cmd) + if err != nil { + log.Warnf("add cron [%s] fail", spec) + return -1, err + } + c.Start() + log.Infof("add cron [%d] spec:[%s].", id, spec) + return id, nil +} + +// UnregisterCron 注销一个定时任务 +func UnregisterCron(id cron.EntryID) bool { + if id > 0 { + c.Remove(id) + log.Infof("remove cron id:[%d]", id) + } + log.Infof("remove cron [%d] not found", id) + return false +} + +func taskRunIng(ctx *context.Context) bool { + r_lock.Lock() + defer r_lock.Unlock() + if _, ok := runIngs[ctx]; ok { + return false + } + runIngs[ctx] = struct{}{} + return true +} +func taskRunEnd(ctx *context.Context) { + r_lock.Lock() + defer r_lock.Unlock() + delete(runIngs, ctx) +} diff --git a/pub/common/cronx/cron.go b/pub/common/cronx/cron.go new file mode 100644 index 0000000..5228ccf --- /dev/null +++ b/pub/common/cronx/cron.go @@ -0,0 +1,148 @@ +package cronx + +import ( + "context" + "sync" + "time" + "videoweb/pub/common/log" + + "github.com/robfig/cron/v3" + "go.uber.org/zap" +) + +type CronJob interface { + Run() error + Desc() string + Name() string +} + +type CronJobAndSpec struct { + CronJob CronJob + Spec string +} + +type BaseJob struct { + ctx context.Context + spec string + name string + desc string + l *zap.SugaredLogger +} + +type cronFunc struct { + *BaseJob + fn func(ctx context.Context, l *zap.SugaredLogger) error +} + +func addError(fn func(ctx context.Context, l *zap.SugaredLogger)) func(context.Context, *zap.SugaredLogger) error { + return func(ctx context.Context, l *zap.SugaredLogger) error { + l.Info("start") + defer l.Info("end") + fn(ctx, l) + return nil + } +} + +func (f cronFunc) Run() error { return f.fn(f.ctx, f.l) } +func NewCronFunc(ctx context.Context, name, desc string, fn func(ctx context.Context, l *zap.SugaredLogger)) cronFunc { + return cronFunc{ + BaseJob: NewBaseJob(ctx, name, desc), + fn: addError(fn), + } +} + +func NewCronFuncWithError(ctx context.Context, name, desc string, fn func(ctx context.Context, l *zap.SugaredLogger) error) cronFunc { + return cronFunc{ + BaseJob: NewBaseJob(ctx, name, desc), + fn: fn, + } +} + +func NewBaseJob(ctx context.Context, name, desc string) *BaseJob { + return &BaseJob{ + ctx: ctx, + name: name, + desc: desc, + l: log.ZapLogS.Named(name), + } +} + +func (j *BaseJob) Name() string { return j.name } +func (j *BaseJob) Desc() string { return j.desc } +func (j *BaseJob) L() *zap.SugaredLogger { return j.l } +func (j *BaseJob) Context() context.Context { return j.ctx } + +type CronLogger struct { + l *zap.SugaredLogger +} + +func NewCronLogger() *CronLogger { + return &CronLogger{ + l: log.ZapLogS.Named("cron"), + } +} + +func (l *CronLogger) Info(msg string, kvs ...interface{}) { + l.l.Infow(msg, kvs) +} +func (l *CronLogger) Error(err error, msg string, kvs ...interface{}) { + l.l.Errorw(err.Error()+msg, kvs) +} + +func StartJobs(jobs ...CronJobAndSpec) (closer func()) { + var once sync.Once + l := NewCronLogger() + c := cron.New(cron.WithSeconds(), cron.WithLocation(time.Local), cron.WithChain(cron.Recover(l))) + for i := range jobs { + j := jobs[i].CronJob + spec := jobs[i].Spec + name := j.Name() + _, err := c.AddFunc(spec, func() { + defer log.Infof("%s end", name) + log.Infof("%s start", name) + if err := j.Run(); err != nil { + log.Errorf("%s run failed: %s", name, err.Error()) + } + }) + if err != nil { + log.Fatalf("%s add failed: %s", err) + } + } + c.Start() + return func() { + once.Do(func() { + c.Stop() + }) + } +} + +type CronFuncType func(ctx context.Context, l *zap.SugaredLogger) + +func TraceTime(fn CronFuncType) CronFuncType { + return func(ctx context.Context, l *zap.SugaredLogger) { + now := time.Now() + defer func() { + elapse := time.Since(now) + if elapse > time.Second { + l.Infof("takes %s", elapse.Truncate(time.Millisecond)) + } else { + l.Infof("takes %d ms", elapse/time.Millisecond) + } + }() + l.Info("start run") + fn(ctx, l) + l.Info("end run") + } +} + +// MustSecondSpec is a helper method to validate spec at compile time +func MustSecondSpec(spec string) string { + parser := cron.NewParser( + cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, + ) + _, err := parser.Parse(spec) + if err != nil { + panic(err) + } + return spec +} diff --git a/pub/common/crypt/aesecb.go b/pub/common/crypt/aesecb.go new file mode 100644 index 0000000..a75f817 --- /dev/null +++ b/pub/common/crypt/aesecb.go @@ -0,0 +1,43 @@ +package crypt + +import ( + "bytes" + "crypto/aes" +) + +func PKCS7PaddingEx(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padtext...) +} + +func PKCS7UnPaddingEx(origData []byte) []byte { + length := len(origData) + unpadding := int(origData[length-1]) + return origData[:(length - unpadding)] +} + +func EcbDecrypt(data, key []byte) []byte { + block, _ := aes.NewCipher(key) + decrypted := make([]byte, len(data)) + size := block.BlockSize() + + for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size { + block.Decrypt(decrypted[bs:be], data[bs:be]) + } + + return PKCS7UnPaddingEx(decrypted) +} + +func EcbEncrypt(data, key []byte) []byte { + block, _ := aes.NewCipher(key) + data = PKCS7PaddingEx(data, block.BlockSize()) + decrypted := make([]byte, len(data)) + size := block.BlockSize() + + for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size { + block.Encrypt(decrypted[bs:be], data[bs:be]) + } + + return decrypted +} diff --git a/pub/common/crypt/crypt.go b/pub/common/crypt/crypt.go new file mode 100644 index 0000000..68bb269 --- /dev/null +++ b/pub/common/crypt/crypt.go @@ -0,0 +1,566 @@ +package crypt + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "hash/crc32" + "io" + "net/url" + "os" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/dgrijalva/jwt-go" +) + +// CreateToken 生成Token算法 +func CreateToken(secret string, tokenClaims map[string]interface{}) (tokeness string, err error) { + if secret == "" { + return "", errors.New("secret is empty") + } + claims := jwt.MapClaims(tokenClaims) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokeness, err = token.SignedString([]byte(secret)) + if err != nil { + return "", err + } + return tokeness, nil +} + +func keyFunc(secret string) jwt.Keyfunc { + return func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + } +} + +// ParseToken 解析Token +func ParseToken(secret string, tokeness string) (map[string]interface{}, error) { + if secret == "" || tokeness == "" { + return nil, errors.New("secret or tokeness is empty") + } + token, err := jwt.Parse(tokeness, keyFunc(secret)) + if err != nil { + return nil, err + } + //验证token,如果token被修改过则为false + if !token.Valid { + return nil, errors.New("token is invalid") + } + claimsToken, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("cannot convert claim to MapClaim") + } + return claimsToken, nil +} + +// StructToStr 结构体转json 先对结构体按照字典顺序排序 并返回字符串 +func StructToStr(obj interface{}) (string, error) { + if obj == nil { + return "", errors.New("obj is not nil") + } + data := make(map[string]interface{}) + bytes, err := json.Marshal(obj) + if err != nil { + return "", err + } + err = json.Unmarshal(bytes, &data) + if err != nil { + return "", err + } + str, err := MapToStr(data) + if err != nil { + return "", err + } + return str, nil +} + +// MapToStr 对map按照字典顺序排序 并返回字符串 +func MapToStr(data map[string]interface{}) (string, error) { + if data == nil || len(data) == 0 { + return "", errors.New("data is nil or empty") + } + newData := make(map[string]interface{}) + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + newData[k] = data[k] + } + b, err := json.Marshal(newData) + if err != nil { + return "", err + } + str := string(b) + return str, nil +} + +// JSON2Struct json转struct +func JSON2Struct(str string, obj interface{}) { + if err := json.Unmarshal([]byte(str), &obj); err != nil { + return + } +} + +// JSONArray2Struct json转struct +func JSONArray2Struct(str []string, obj interface{}) []interface{} { + var data []interface{} + for _, v := range str { + JSON2Struct(v, &obj) + data = append(data, obj) + } + return data +} + +// JSON2Map json转map +func JSON2Map(str string) (map[string]interface{}, error) { + var mapResult map[string]interface{} + if err := json.Unmarshal([]byte(str), &mapResult); err != nil { + return nil, err + } + return mapResult, nil +} + +// StructToStrNormal 结构体转str +func StructToStrNormal(obj interface{}) (string, error) { + str, err := json.Marshal(obj) + if err != nil { + return "", err + } + return string(str), nil +} + +func UrlValueToStr(values url.Values, exclude string) (string, string) { + if values == nil { + return "", "" + } + var buf bytes.Buffer + var sign string + keys := make([]string, 0, len(values)) + for k, _ := range values { + if k == exclude { + sign = strings.Join(values[k], "") + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i != 0 { + buf.WriteString("&") + } + buf.WriteString(k) + buf.WriteString("=") + buf.WriteString(strings.Join(values[k], "")) + } + return buf.String(), sign +} + +// 对参数进行排序后 拼接成url字符串 只对一级字符串类型的参数做处理 +func MapToUrlOnlyStr(data map[string]interface{}) string { + if data == nil || len(data) == 0 { + return "" + } + var buf bytes.Buffer + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i != 0 { + buf.WriteString("&") + } + buf.WriteString(k) + buf.WriteString("=") + if v, ok := data[k].(string); ok { + buf.WriteString(v) + } + } + return buf.String() +} + +// MapToStr 对map按照字典顺序排序 并返回字符串url 拼接的字段 +func MapToUrlStr(data map[string]interface{}) string { + if data == nil || len(data) == 0 { + return "" + } + var buf bytes.Buffer + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i != 0 { + buf.WriteString("&") + } + buf.WriteString(k) + buf.WriteString("=") + if v, ok := data[k].(string); ok { + buf.WriteString(v) + } else { + t := reflect.TypeOf(data[k]) + if t != nil && t.Kind() == reflect.Map { + if v, ok := data[k].(map[string]interface{}); ok { + buf.WriteString("{") + str := MapToUrlStr(v) + buf.WriteString(str) + buf.WriteString("}") + } + } + if t != nil && (t.Kind() == reflect.Slice || t.Kind() == reflect.Array) { + arraybyte, _ := json.Marshal(data[k]) + buf.WriteString(string(arraybyte)) + } + } + } + return buf.String() +} + +// MapToURL 集合转URL +func MapToURL(m map[string]string) string { + if m == nil || len(m) == 0 { + return "" + } + v := url.Values{} + for k := range m { + v.Set(k, string(m[k])) + } + return v.Encode() +} + +func Strcut2UrlValue(obj interface{}) (string, error) { + t := reflect.TypeOf(obj) + v := reflect.ValueOf(obj) + if t.Kind() != reflect.Struct { + return "", errors.New("obj must be struct type") + } + u := url.Values{} + for i := 0; i < t.NumField(); i++ { + var value string + tag := t.Field(i).Tag.Get("json") + switch t.Field(i).Type.Kind() { + case reflect.String: + value = v.Field(i).String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + value = strconv.Itoa(int(v.Field(i).Int())) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + value = strconv.FormatUint(v.Field(i).Uint(), 10) + case reflect.Slice, reflect.Array: + buf := bytes.Buffer{} + b, _ := json.Marshal(v.Field(i).Interface()) + t := strings.TrimRight(strings.TrimLeft(string(b), "["), "]") + array := strings.Split(t, ",") + if len(array) == 0 { + value = "" + } + if len(array) == 1 { + value = array[0] + } else { + for i, v := range array { + if i == 0 { + buf.WriteString(v) + continue + } + buf.WriteString("&") + buf.WriteString(tag) + buf.WriteString("=") + buf.WriteString(v) + value = buf.String() + } + } + } + u.Add(tag, value) + } + dcodeurl, err := url.QueryUnescape(u.Encode()) + return dcodeurl, err +} + +// StrToMd5 字符串转md5 +func StrToMd5(str string) string { + data := []byte(str) + return ByteToMd5(data) +} + +// ByteToMd5 数组转md5 +func ByteToMd5(data []byte) string { + md5Ctx := md5.New() + md5Ctx.Write(data) + cipherStr := md5Ctx.Sum(nil) + return hex.EncodeToString(cipherStr) +} + +// FileToMd5 文件转MD5 +func FileToMd5(fileName string) string { + file, err := os.Open(fileName) + if err != nil { + return "" + } + defer file.Close() + md5Ctx := md5.New() + if _, err := io.Copy(md5Ctx, file); err != nil { + return "" + } + cipherStr := md5Ctx.Sum(nil) + return hex.EncodeToString(cipherStr) +} + +// HashCode 对一个字符串生成唯一的hasHcode 码 +func HashCode(src string) int { + v := int(crc32.ChecksumIEEE([]byte(src))) + if v > 0 { + return v + } + if -v > 0 { + return -v + } + return 0 +} + +// HexToString converts +func HexToString(data []byte) string { + return hex.EncodeToString(data) +} + +// StrToSha256 字符串转sha256 +func StrToSha256(str string) string { + shaCtx := sha256.New() + shaCtx.Write([]byte(str)) + shaBytes := shaCtx.Sum(nil) + cipherStr := hex.EncodeToString(shaBytes[:]) + return cipherStr +} + +// StrToSha1 字符串转sha1 +func StrToSha1(str string, secret string) []byte { + h := hmac.New(sha1.New, []byte(secret)) + h.Write([]byte(str)) + return h.Sum(nil) +} + +// StrToSha1 字符串转sha1 转16进制后 +func StrToHmacSha1(str, secret string) string { + return hex.EncodeToString(StrToSha1(str, secret)) +} + +// StrToHmacSha256 加密 字符串转sha256加密字符串 +func StrToHmacSha256(str string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(str)) + cipherStr := hex.EncodeToString(h.Sum(nil)) + return cipherStr +} + +func PKCS7Padding(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padtext...) +} + +func PKCS7UnPadding(origData []byte) []byte { + length := len(origData) + unpadding := int(origData[length-1]) + return origData[:(length - unpadding)] +} + +// AesEncrypt AES加密 +func AesEncrypt(origData, key string) ([]byte, error) { + origDataByte := []byte(origData) + return CoreAesEncrypt(origDataByte, key) +} + +// AesDecrypt AES解密 +func AesDecrypt(crypted, key string) (string, error) { + cryptedByte := []byte(crypted) + return CoreAesDecrypt(cryptedByte, key) +} + +// AesDecrypt AES解密 +func AesDecryptByte(cryptedByte []byte, key string) (string, error) { + return CoreAesDecrypt(cryptedByte, key) +} + +// CoreAesEncrypt AES加密 +func CoreAesEncrypt(origData []byte, key string) ([]byte, error) { + keybyte := []byte(key) + block, err := aes.NewCipher(keybyte) + if err != nil { + return nil, err + } + blockSize := block.BlockSize() + origData = PKCS7Padding(origData, blockSize) + blockMode := cipher.NewCBCEncrypter(block, keybyte[:blockSize]) + crypted := make([]byte, len(origData)) + blockMode.CryptBlocks(crypted, origData) + return crypted, nil +} + +// CoreAesDecrypt AES解密 +func CoreAesDecrypt(crypted []byte, key string) (string, error) { + keybyte := []byte(key) + block, err := aes.NewCipher(keybyte) + if err != nil { + return "", err + } + blockSize := block.BlockSize() + blockMode := cipher.NewCBCDecrypter(block, keybyte[:blockSize]) + origData := make([]byte, len(crypted)) + blockMode.CryptBlocks(origData, crypted) + origData = PKCS7UnPadding(origData) + return string(origData), nil +} + +// XorEnc +func XorEnc(src string, xorKey string) string { + var result string + j := 0 + bt := []rune(src) + xor := []rune(xorKey) + for i := 0; i < len(bt); i++ { + s := strconv.FormatInt(int64(bt[i]^xor[j]), 10) + result = result + s + j = 1 % len(xor) + } + return result +} + +// XorDec +func XorDec(src string, xorKey string) string { + var result string + j := 0 + bt := []rune(src) + xor := []rune(xorKey) + for i := 0; i < len(bt); i++ { + s := strconv.FormatInt(int64(bt[i]^xor[j]), 10) + result = result + s + j = i % len(xor) + } + return result +} + +func XorDecode(src, key string) string { + srcByte, _ := base64.StdEncoding.DecodeString(src) + keyByte, _ := base64.StdEncoding.DecodeString(key) + srcByte = xor(srcByte, keyByte) + return base64.StdEncoding.EncodeToString(srcByte) +} + +func XorEncode(src, key string) string { + return XorDecode(src, key) +} + +func xor(src []byte, key []byte) []byte { + for i := 0; i < len(src); i++ { + src[i] ^= key[i%len(key)] + } + return src +} + +func XORLength(src []byte, key []byte, length int) []byte { + for i := 0; i < length; i++ { + src[i] ^= key[i%len(key)] + } + return src[:length] +} + +// https://core.telegram.org/api/end-to-end#sending-and-receiving-messages-in-a-secret-chat +func CoreAesEncryptEx(plain []byte, nonceLen int, key string) ([]byte, error) { + nonce := make([]byte, nonceLen) + _, err := io.ReadFull(rand.Reader, nonce) + if err != nil { + return nil, err + } + var largeShaRaw []byte + largeShaRaw = append(largeShaRaw, []byte(key)...) + largeShaRaw = append(largeShaRaw, nonce...) + largeShaRawMid := len(largeShaRaw) / 2 + msgKeyLarge := sha256.Sum256(largeShaRaw) + msgKey := msgKeyLarge[8:24] //16 bytes + + var shaRawA []byte + shaRawA = append(shaRawA, msgKey...) + shaRawA = append(shaRawA, largeShaRaw[:largeShaRawMid]...) + sha256a := sha256.Sum256(shaRawA) //32 bytes + + var shaRawB []byte + shaRawB = append(shaRawB, largeShaRaw[largeShaRawMid:]...) + shaRawB = append(shaRawB, msgKey...) + sha256b := sha256.Sum256(shaRawB) //32 bytes + + var aesKey []byte //32 bytes AES-256 + aesKey = append(aesKey, sha256a[:8]...) //a: 8 bytes + aesKey = append(aesKey, sha256b[8:24]...) //b: 16 bytes + aesKey = append(aesKey, sha256a[24:32]...) //a: 8 bytes + + var aesIV []byte // 16 bytes + aesIV = append(aesIV, sha256b[:4]...) //b: 4 bytes + aesIV = append(aesIV, sha256a[12:20]...) //a: 8 bytes + aesIV = append(aesIV, sha256b[28:]...) //b: 4 bytes + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + blockSize := block.BlockSize() + ciphertext := PKCS7Padding(plain, blockSize) + mode := cipher.NewCBCEncrypter(block, aesIV) + cipher := make([]byte, nonceLen+len(ciphertext)) + copy(cipher, nonce) + mode.CryptBlocks(cipher[nonceLen:], ciphertext) + return cipher, nil +} + +func CoreAesDecryptEx(crypt []byte, nonceLen int, key string) ([]byte, error) { + nonce := make([]byte, nonceLen) + copy(nonce, crypt) + var largeShaRaw []byte + largeShaRaw = append(largeShaRaw, []byte(key)...) + largeShaRaw = append(largeShaRaw, nonce...) + largeShaRawMid := len(largeShaRaw) / 2 + msgKeyLarge := sha256.Sum256(largeShaRaw) + msgKey := msgKeyLarge[8:24] //16 bytes + + var shaRawA []byte + shaRawA = append(shaRawA, msgKey...) + shaRawA = append(shaRawA, largeShaRaw[:largeShaRawMid]...) + sha256a := sha256.Sum256(shaRawA) //32 bytes + + var shaRawB []byte + shaRawB = append(shaRawB, largeShaRaw[largeShaRawMid:]...) + shaRawB = append(shaRawB, msgKey...) + sha256b := sha256.Sum256(shaRawB) //32 bytes + + var aesKey []byte //32 bytes AES-256 + aesKey = append(aesKey, sha256a[:8]...) //a: 8 bytes + aesKey = append(aesKey, sha256b[8:24]...) //b: 16 bytes + aesKey = append(aesKey, sha256a[24:32]...) //a: 8 bytes + + var aesIV []byte // 16 bytes + aesIV = append(aesIV, sha256b[:4]...) //b: 4 bytes + aesIV = append(aesIV, sha256a[12:20]...) //a: 8 bytes + aesIV = append(aesIV, sha256b[28:]...) //b: 4 bytes + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + realData := crypt[nonceLen:] + blockMode := cipher.NewCBCDecrypter(block, aesIV) + origData := make([]byte, len(realData)) + blockMode.CryptBlocks(origData, realData) + return PKCS7UnPadding(origData), nil +} diff --git a/pub/common/crypt/rsa.go b/pub/common/crypt/rsa.go new file mode 100644 index 0000000..0ae93f7 --- /dev/null +++ b/pub/common/crypt/rsa.go @@ -0,0 +1,145 @@ +package crypt + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "videoweb/pub/common/log" +) + +func splitBytesSlice(data []byte, n int) [][]byte { + var chunk []byte + chunks := make([][]byte, 0, len(data)/n+1) + for len(data) >= n { + chunk, data = data[:n], data[n:] + chunks = append(chunks, chunk) + } + if len(data) > 0 { + chunks = append(chunks, data[:]) + } + return chunks +} + +func rsaBytesToPublicKey(pub []byte) (*rsa.PublicKey, error) { + // fmt.Printf("pubKey is %s", string(pub)) + block, _ := pem.Decode(pub) + enc := x509.IsEncryptedPEMBlock(block) + b := block.Bytes + var err error + if enc { + b, err = x509.DecryptPEMBlock(block, nil) + if err != nil { + log.Error("RSABytesToPublicKey DecryptPEMBlock err", log.E(err)) + return nil, err + } + } + + pk, err := x509.ParsePKIXPublicKey(b) + if err != nil { + log.Error("RSABytesToPublicKey ParsePKIXPublicKey err", log.E(err)) + return nil, err + } + if pk_, ok := pk.(*rsa.PublicKey); ok { + return pk_, nil + } + log.Error("RSABytesToPublicKey error input not PublicKey") + return nil, errors.New("Not RSA PublicKey format") +} + +func rsaBytesToPrivateKey(priv []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(priv) + enc := x509.IsEncryptedPEMBlock(block) + b := block.Bytes + var err error + if enc { + b, err = x509.DecryptPEMBlock(block, nil) + if err != nil { + log.Error("RSABytesToPrivateKey DecryptPEMBlock err", log.E(err)) + return nil, err + } + } + key, err := x509.ParsePKCS8PrivateKey(b) + if err != nil { + log.Error("RSABytesToPrivateKey ParsePKCS8PrivateKey err", log.E(err)) + return nil, err + } + if pk_, ok := key.(*rsa.PrivateKey); ok { + return pk_, nil + } + log.Error("RSABytesToPrivateKey error input not PrivateKey") + return nil, errors.New("Not RSA PrivateKey format") +} + +// 公钥加密 +func RSAEncryptByPublicKey(pemPubKey string, data []byte) ([]byte, error) { + pubKey, err := rsaBytesToPublicKey([]byte(pemPubKey)) + if err != nil { + log.Error("RSAEncryptByPublicKey rsaBytesToPublicKey err", log.E(err)) + return nil, err + } + partLen := pubKey.N.BitLen()/8 - 11 + chunks := splitBytesSlice(data, partLen) + result := bytes.Buffer{} + for _, chunk := range chunks { + cipherSilce, err := rsa.EncryptPKCS1v15(rand.Reader, pubKey, chunk) + if err != nil { + log.Error("RSAEncryptByPublicKey EncryptPKCS1v15 err", log.E(err)) + return nil, err + } + result.Write(cipherSilce) + } + return result.Bytes(), nil +} + +// 私玥签名 +func RSASignByPrivateKey(pemPrivateKey string, data []byte, hash crypto.Hash) ([]byte, error) { + privateKey, err := rsaBytesToPrivateKey([]byte(pemPrivateKey)) + if err != nil { + log.Error("RSASignByPrivateKey rsaBytesToPrivateKey err", log.E(err)) + return nil, err + } + h := hash.New() + h.Write(data) + hashed := make([]byte, 0) + hashed = h.Sum(hashed) + + return rsa.SignPKCS1v15(rand.Reader, privateKey, hash, hashed) + +} +func RSAVerifyByPublicKey(publicKey string, data, sign []byte, hash crypto.Hash) (err error) { + pubKey, err := rsaBytesToPublicKey([]byte(publicKey)) + if err != nil { + log.Error("RSAEncryptByPublicKey rsaBytesToPublicKey err", log.E(err)) + return err + } + h := hash.New() + h.Write(data) + hashed := make([]byte, 0) + hashed = h.Sum(hashed) + return rsa.VerifyPKCS1v15(pubKey, hash, hashed, sign) +} + +// 公钥解密 +func RSADecryptByPrivateKey(pemPrivateKey string, cipherData []byte) ([]byte, error) { + privateKey, err := rsaBytesToPrivateKey([]byte(pemPrivateKey)) + if err != nil { + log.Error("RSADecryptByPrivateKey rsaBytesToPrivateKey err", log.E(err)) + return nil, err + } + blockLen := privateKey.N.BitLen() / 8 + chunks := splitBytesSlice(cipherData, blockLen) + result := bytes.Buffer{} + for _, chunk := range chunks { + plain, err := rsa.DecryptPKCS1v15(rand.Reader, privateKey, chunk) + if err != nil { + log.Error("RSADecryptByPrivateKey DecryptPKCS1v15 err", log.E(err)) + return nil, err + } + result.Write(plain) + } + return result.Bytes(), nil +} diff --git a/pub/common/enum/consts/consts.go b/pub/common/enum/consts/consts.go new file mode 100644 index 0000000..13cdd03 --- /dev/null +++ b/pub/common/enum/consts/consts.go @@ -0,0 +1,126 @@ +package consts + +import ( + "strings" + "time" +) + +type RunMod string + +const ( + RunModeDev RunMod = "dev" + RunModeTest RunMod = "test" + RunModeProd RunMod = "prod" + RunModRelease RunMod = "release" +) + +// gin中ctx使用的key +const ( + CtxUserID = "USER_ID" + CtxUpUserID = "UP_USER_ID" + CtxUpInfo = "UP_INFO" + CtxUA = "UA" + CtxUAH5 = "UAH5" + CtxIP = "IP" + CtxDevId = "DEVID" + CtxDevType = "DEVTYPE" + CtxSysType = "SYSTYPE" + CtxVersion = "VERSION" + CtxBuildId = "BUILDID" + CtxToken = "TOKEN" + CtxOnlineTime = "ONLINE_TIME" +) + +const ( + AppSysType_And = "" + AppSysType_Ios = "" +) + +// Etcd 的Key +const ( + EtcdWarmupDB = "/micro/config/com.sz.api.server/warmupdb" + EtcdSzDB = "/micro/config/com.sz.api.server/db" + EtcdRds = "/micro/config/com.sz.api.server/redis" +) + +const ( + AdminJwtTokenExpire = 30 * 24 * time.Hour + UserJwtTokenExpire = 30 * 24 * time.Hour + UserSignInExpire = 60 * 24 * time.Hour +) + +const ( + UAHeader = "User-Agent" +) + +const ( + IOS = "ios" + Android = "android" + H5 = "h5" + PC = "pc" + H5IOS = "h5_ios" + H5Android = "h5_android" + H5PC = "h5_pc" + Light = "light" +) + +const ( + CaptchaLen = 6 //验证码长度 +) + +const ( + TypeIOS = "ios" + TypeAndroid = "and" + TypeWeb = "h5" +) + +// 机型 +const ( + WebTypeIOS = "ios" + WebTypeAndroid = "android" + WebSysTypeH5 = "h5" +) + +// func HandleSysType(sysType string) (sys string) { +// if strings.Contains(strings.ToLower(sysType), TypeIOS) { +// sys = IOS +// } +// +// if strings.Contains(strings.ToLower(sysType), TypeAndroid) { +// sys = Android +// } +// +// if strings.Contains(strings.ToLower(sysType), TypeWeb) { +// sys = H5 +// } +// return +// } + +func ToWebSysType(sysType string) (sys string) { + if strings.Contains(strings.ToLower(sysType), TypeIOS) { + sys = WebTypeIOS + } + + if strings.Contains(strings.ToLower(sysType), TypeAndroid) { + sys = WebTypeAndroid + } + + if strings.Contains(strings.ToLower(sysType), TypeWeb) { + sys = WebSysTypeH5 + } + return +} +func ToNewSysType(sysType string) (sys string) { + if strings.Contains(strings.ToLower(sysType), TypeIOS) { + sys = WebTypeIOS + } + + if strings.Contains(strings.ToLower(sysType), TypeAndroid) { + sys = WebTypeAndroid + } + + if strings.Contains(strings.ToLower(sysType), TypeWeb) { + sys = WebSysTypeH5 + } + return +} \ No newline at end of file diff --git a/pub/common/enum/redisconst/const.go b/pub/common/enum/redisconst/const.go new file mode 100644 index 0000000..4c0496a --- /dev/null +++ b/pub/common/enum/redisconst/const.go @@ -0,0 +1,100 @@ +package redisconst + +import ( + "fmt" + "strings" + "time" +) + +const ( + visitLogFmt = "bd_visitlog:%s:%d" + FiveMVisit = "bd_visitFmt:%s" + BloomUser = "bd_bloom_user:%d" + AppUserToken = "bd_app_user_token:%d" + captchaKey = "bd_captcha_mobile_%s" + CaptchaExpire = 5 * time.Minute + replayNonceKey = "bd_replay:nonce:_%s" + mediaM3U8Fmt = "m3u8cache:%s" + MediaM3U8CacheExpire = time.Hour * 24 * 7 + + //禁止登陆设置 + userForbit = "userForbit:%d" + ipForbit = "ipForbit:%s" + + //每分钟Ip注册次数 + registerLimist = "registerLimit:%s" + RegisterLimistExpire = time.Minute * 30 + + //用户关注限制 + careLimitKey = "carelimit:%d" + CareLimitKeyExpire = 15 * time.Second + + //用户评论限制 + commentLimitKey = "commentlimit:%s" + CommentLimitKeyExpire = 1 * time.Minute + CommentDayLimitKeyExpire = 10 * time.Hour + + //每用户访问次数限制 + userCallLimit = "userCall:%d" + ipCallLimit = "ipCall:%s" + CallLimitExpire = time.Second * 10 +) + +func BloomUserKey(userId int64) string { + return fmt.Sprintf(BloomUser, userId) +} + +func FiveMVisitKey(time string) string { + return fmt.Sprintf(FiveMVisit, time) +} + +func UserVisitLogKey(uid int64, t time.Time) string { + return fmt.Sprintf(visitLogFmt, t.Format("20060102"), uid) +} + +func MobileCaptchaKey(mobile string) string { + return fmt.Sprintf(captchaKey, mobile) +} + +func AppUserTokenKey(userId int64) string { + return fmt.Sprintf(AppUserToken, userId) +} + +func MediaM3U8Key(name string) string { + return fmt.Sprintf(mediaM3U8Fmt, name) +} +func UserForbitKey(userId int64) string { + return fmt.Sprintf(userForbit, userId) +} + +func RegisterLimitKey(ip string) string { + ip = strings.ReplaceAll(ip, ":", "-") + return fmt.Sprintf(registerLimist, ip) +} + +func CareLimitKey(userId int64) string { + return fmt.Sprintf(careLimitKey, userId) +} +func ReplayNonceKey(nonce string) string { + return fmt.Sprintf(replayNonceKey, nonce) +} + +func CommentLimitKey(ip string) string { + return fmt.Sprintf(commentLimitKey, ip) +} +func CommentOneDayLimitKey(ip string) string { + return fmt.Sprintf("commentdaylimit:%s", ip) +} + +func UserCallLimitKey(userId int64) string { + return fmt.Sprintf(userCallLimit, userId) +} + +func IpCallLimitKey(ip string) string { + return fmt.Sprintf(ipCallLimit, ip) +} + +func IpForbitKey(ip string) string { + ip = strings.ReplaceAll(ip, ":", "-") + return fmt.Sprintf(ipForbit, ip) +} \ No newline at end of file diff --git a/pub/common/enum/video/video.go b/pub/common/enum/video/video.go new file mode 100644 index 0000000..219758d --- /dev/null +++ b/pub/common/enum/video/video.go @@ -0,0 +1,27 @@ +package video + +type VideoType int64 +type videoStatus int64 + +// 0-上架、1-未上架 +const ( + Enable videoStatus = iota + Disable +) + +// 1是AV、2是SP +const ( + _ VideoType = iota + AV + SP +) + +const AVLimit = 1 + +type recordType int64 + +const ( + _ recordType = iota + Watch + Downlaoad +) diff --git a/pub/common/errors/code.go b/pub/common/errors/code.go new file mode 100644 index 0000000..6af873c --- /dev/null +++ b/pub/common/errors/code.go @@ -0,0 +1,169 @@ +package errors + +type CodeType int64 + +const NotFind = "mongo: no documents in result" + +// 错误级别 +const ( + ParamLevel = 1000 // 参数级 + StoreLevel = 3000 // 存储级 + ServerLevel = 4000 // 服务级 + BusinessLevel = 6000 // 业务逻辑级 +) + +// app 端用到了 +// 1000, 重新登陆 +// 4002 短信发送失败 +// 6017 手机号码已绑定 +// 6023 user_video操作错误 +// 这里作出兼容 + +func (c CodeType) Error() string { + return "" +} + +const ( + InvalidateToken CodeType = ParamLevel + iota // 不可用的token,客户端需要重新登陆 + MethodNotFound // 方法为找到,指gin方法未找到 + ApiNotFound // api未找到 + Limiting // 限流 + NoToken // 无token 1004 +) + +const ( + DBQuery CodeType = StoreLevel + iota + DBInsert + DBUpdate + DBDelete + RedisSet + RedisGet + RedisDel + ProxyPutObject + EtcdGet + EtcdPut + RedisRPush + RedisLPop +) + +const ( + HttpClient CodeType = ServerLevel + iota // httpclient + TypePointer // 指针类型 + SmsLimit // 短信限制 + SmsSend // 短信发送失败 + SmsTmp // 短信模版 + SmsTmpParse // 短信模版解析 + UserId // 用户ID + OpenFile // 打开文件 + FsErr // seaweedfs + CtxTimeout // context超时 + UnmarshalJson // json + FSFiler // fs filer + HttpGet // + ReadData // read data + IOCopy // io copy + Pass // pass + PassConfID // pass config id + Tips // 提示错误 +) + +const ( + // 用户权限 + UserPermission CodeType = BusinessLevel + iota + Unknown // 未知错误 + InvalidKeyword // 无效的关键字 + UserName // 用户名称 + Mobile // 手机号码 + Captcha // 验证码 + CaptchaErr // 验证码错误 + Psw // 密码 + PayCode // 用户支付密码 + Number // 数字类型 + String // 字符串类型 + VideoId // 视频ID + Progress // 进度 + Ids // id数组 + CatagoryExist // 视频种类已存在 + MobileBinded // 手机号码已绑定 + Rate // 返佣金比例 + Brokerage // 返佣 + Logo // 用户logo + UploadFile // + EmptyFile // + Operate // user_video操作错误 + UserDisable // + NickName // + Gender // + InviteCode // + Coat // + ReLogin // + PopType // + AgentBinded // + M3u8FileName // + M3u8Read // + VideoName // + ExchangeCodeErr // 兑换码错误 + ChanAcount // + Recharge // + Withdraw // + ExportErr // 导出参数错误 + DomainErr // 主域名错误 + ForbiddenWord // 禁言 + ForbiddenLogin // 禁止登陆 + DuplicateLike // 重复点赞 + AliPayUnBind // 支付宝未绑定 + BankCardUnBind // 银行卡未绑定 + VideoUploadExist // 视频重复上传 + Task // + PostsNewArgsInvalid // 帖子参数错误 + PostDupi // 帖子重复发送 + PostNotExisted // 帖子不存在 + PostLikeOnLiked // 重复点赞帖子 + PostUndoLikeOnUnliked // 尚未点赞,不可取消点赞 + PostFavOnFaved // 重复 收藏 + PostUndoFavOnUnfaved // 尚未收藏,不可取消收藏 + PostDisLikeOnDisLiked // 重复屏蔽帖子 + PostUndoDisLikeOnUnliked // 尚未屏蔽,不可取消屏蔽 + UserDoNotForSelf // 用户不能对自己进行此项操作 + UserWatchDupi // 不可重复关注同一用户 + UserNoWatch // 没有关注该用户,不可取消关注 + PostRepleNewArgsInvalid // 帖子 回复 参数错误 + PostReplyNotExisted // 评论不存在 + PostReplyAlready // 评论不存在 + NoWatchableUsers // 没有可以关注的用户池 + PostReplyLikeOnLiked // 重复点赞帖子 + PostReplyUndoLikeOnUnliked // 尚未点赞,不可取消点赞 + PostPriceInvalid // 帖子定价异常,不可购买 + PostIsBuyed // 帖子已购买,不可重复购买 + NoBalance // 余额不足 + BalanceErr // 编辑余额错误 + NoAccount // 没账户 + AccountDisabled // 账户已被封禁 + BuySelf // 自己帖子,不用购买 + RedisError // 服务器繁忙 + Button // 落地页按钮 + ReplyTimeShort // 回复间隔时间太短 + OperateLog // 日志出错 + RoleErr // 角色权限不够 + DocExists // 查找的文档已存在 + PostUnknownError // 未知错误 + SeekScoreConfirm // 商人追分确认 + GetOrderInfo // 商人充值订单报错 + PromSeq // 商区错误 + SignErr // 签名错误 + BadRequest // 传参错误 + ReplyPoolExisted // 评论已存在 + LimitIPErr // 限制IP登陆 +) + +// 代充专用 +const ( + TakeChatFail CodeType = iota + 18000 //发起代充聊天失败 18000 + GetOrderInfoFail //获取代理充值订单信息失败 18001 + AppIDNotExist //代充AppID 不存在 18002 + TraderNotExist //此商人不存在 18003 + TraderBalanceNotEnough //商人余额不足 18004 + TraderHasLocked //商人被禁止登陆 18005 + TraderSeekScoreFail //商人追分失败 18006 + TraderSeekScoreFailDueNotEnough //商人追分失败 18007 +) diff --git a/pub/common/errors/error.go b/pub/common/errors/error.go new file mode 100644 index 0000000..1421715 --- /dev/null +++ b/pub/common/errors/error.go @@ -0,0 +1,39 @@ +package errors + +import ( + "fmt" +) + +// SrvErr 错误定义 +type SrvErr struct { + Code CodeType `json:"code"` + Msg string `json:"msg"` + Tips string `json:"tip,omitempty"` + Cause string `json:"-"` +} + +// New create an error +func New(code CodeType, msg string, cause string) *SrvErr { + return &SrvErr{ + Code: code, + Msg: msg, + Cause: cause, + } +} + +// New create an error +func NewTips(code CodeType, tips string, cause string) *SrvErr { + return &SrvErr{ + Code: code, + Msg: tips, + Tips: tips, + Cause: cause, + } +} + +func (s *SrvErr) Error() string { + if s == nil { + return "" + } + return fmt.Sprintf("code = %d, msg = %s ,case = %v", s.Code, s.Msg, s.Cause) +} diff --git a/pub/common/errors/make.go b/pub/common/errors/make.go new file mode 100644 index 0000000..4d6cf54 --- /dev/null +++ b/pub/common/errors/make.go @@ -0,0 +1,24 @@ +package errors + +import ( + "fmt" + "runtime" +) + +func MakeWithErr(code CodeType, err error) error { + pc, fn, line, _ := runtime.Caller(1) + cause := fmt.Sprintf("[error] in %s[%s:%d] %v", runtime.FuncForPC(pc).Name(), fn, line, err.Error()) + return New(code, err.Error(), cause) +} + +func MakeWithMsg(code CodeType, msg string) error { + pc, fn, line, _ := runtime.Caller(1) + cause := fmt.Sprintf("[error] in %s[%s:%d] %v", runtime.FuncForPC(pc).Name(), fn, line, msg) + return New(code, msg, cause) +} + +func MakeWithTips(code CodeType, tips string) error { + pc, fn, line, _ := runtime.Caller(1) + cause := fmt.Sprintf("[error] in %s[%s:%d] %v", runtime.FuncForPC(pc).Name(), fn, line, tips) + return NewTips(code, tips, cause) +} diff --git a/pub/common/errors/msg_app.go b/pub/common/errors/msg_app.go new file mode 100644 index 0000000..a74ccfe --- /dev/null +++ b/pub/common/errors/msg_app.go @@ -0,0 +1,19 @@ +package errors + +// error tips +const ( + + //基础错误提示 + ET_Store = "存储错误" + ET_DBOp = "DB操作错误" + ET_RdsOp = "Rds操作错误" + ET_Param = "参数错误" + ET_DBQury = "DB查询错误" + ET_DBNoResult = "DB查询数据不存在" + ET_TransactionsErr = "事务运行错误" + //---------------------------------------------- + ET_UserNoExist = "用户不存在" + ET_BloggerApply = "博主申请条件不满足" + ET_BloggerAlreadyIs = "用户已经是博主" + ET_BalanceNotEnough = "余额不足" +) diff --git a/pub/common/fconst/fconst.go b/pub/common/fconst/fconst.go new file mode 100644 index 0000000..e100ba5 --- /dev/null +++ b/pub/common/fconst/fconst.go @@ -0,0 +1,6 @@ +package fconst + +const ( + FileDir = "file-tmp" + FsDir = "/fs" +) diff --git a/pub/common/godef/godef.go b/pub/common/godef/godef.go new file mode 100644 index 0000000..4a1f48e --- /dev/null +++ b/pub/common/godef/godef.go @@ -0,0 +1,26 @@ +package godef + +import ( + "runtime" + "videoweb/pub/common/log" + "videoweb/pub/common/tgbot" +) + +func Go(f func()) { + //atomic.AddInt64(&routineCount, 1) + go func() { + defer func() { + //atomic.AddInt64(&routineCount, -1) + if r := recover(); r != nil { + buf := make([]byte, 2048) + length := runtime.Stack(buf, false) + info := string(buf[:length]) + log.Error("cat a unkown excetion", log.Any("excetion :", info), log.Any("e", r)) + //发送tg消息 + msg := "goroutine crash,the stack:\n" + info + tgbot.SendToProgram("程序异常", msg) + } + }() + f() + }() +} diff --git a/pub/common/googauth/googauth.go b/pub/common/googauth/googauth.go new file mode 100644 index 0000000..3c086b6 --- /dev/null +++ b/pub/common/googauth/googauth.go @@ -0,0 +1,34 @@ +package googauth + +import ( + "github.com/dgryski/dgoogauth" +) + +const windowSize = 30 +const hotpWindowSize = 5 + +var HotpCfg = dgoogauth.OTPConfig{ + Secret: "MRSHM6LTMFYHAMJSGM2DKNQ=", + HotpCounter: 1, + WindowSize: hotpWindowSize, +} + +func New(secret string, name string) string { + cfg := &dgoogauth.OTPConfig{ + Secret: secret, + WindowSize: windowSize, + } + return cfg.ProvisionURI(name) +} + +func Verify(secret string, pwd string) (bool, error) { + cfg := &dgoogauth.OTPConfig{ + Secret: secret, + WindowSize: windowSize, + } + return cfg.Authenticate(pwd) +} + +func VerifyHotp(cfg *dgoogauth.OTPConfig, authCode string, counter int) (bool, error) { + return cfg.Authenticate(authCode) +} diff --git a/pub/common/googauth/htop.go b/pub/common/googauth/htop.go new file mode 100644 index 0000000..fa1e02b --- /dev/null +++ b/pub/common/googauth/htop.go @@ -0,0 +1,67 @@ +package googauth + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "strconv" +) + +// HOTP implementation +type HOTP struct { + secret []byte + digits int +} + +// At generate code +func (h HOTP) At(counter uint64) string { + counterBytes := make([]byte, 8) + binary.BigEndian.PutUint64(counterBytes, counter) + hash := hmac.New(sha1.New, h.secret) + hash.Write(counterBytes) + hs := hash.Sum(nil) + offset := hs[19] & 0x0f + binCodeBytes := make([]byte, 4) + binCodeBytes[0] = hs[offset] & 0x7f + binCodeBytes[1] = hs[offset+1] & 0xff + binCodeBytes[2] = hs[offset+2] & 0xff + binCodeBytes[3] = hs[offset+3] & 0xff + binCode := binary.BigEndian.Uint32(binCodeBytes) + mod := uint32(1) + for i := 0; i < h.digits; i++ { + mod *= 10 + } + code := binCode % mod + codeString := strconv.FormatUint(uint64(code), 10) + if len(codeString) < h.digits { + paddingByteLength := h.digits - len(codeString) + paddingBytes := make([]byte, paddingByteLength) + for i := 0; i < paddingByteLength; i++ { + paddingBytes[i] = '0' + } + codeString = string(paddingBytes) + codeString + } + return codeString +} + +// Verify verify OTP code +func (h HOTP) VerifyCode(code string, counter uint64) bool { + realCode := h.At(counter) + if realCode == code { + return true + } + return false +} + +// Verify verify OTP code +func (h HOTP) GenerateCode(counter uint64) string { + return h.At(counter) +} + +// NewHOTP generate new HOTP instance +func NewHOTP(secret []byte, digits int) (h *HOTP) { + h = new(HOTP) + h.secret = secret + h.digits = digits + return +} diff --git a/pub/common/http/client_headers.go b/pub/common/http/client_headers.go new file mode 100644 index 0000000..749d670 --- /dev/null +++ b/pub/common/http/client_headers.go @@ -0,0 +1,27 @@ +package http + +import "strings" + +// ApplyClientForwardHeaders 向上游透传客户端 IP 与浏览器 UA。 +func ApplyClientForwardHeaders(header map[string]string, clientIP, userAgent string) { + if header == nil { + return + } + if ip := strings.TrimSpace(clientIP); ip != "" { + header["X-Real-IP"] = ip + } + if ua := strings.TrimSpace(userAgent); ua != "" { + header["User-Agent"] = ua + } +} + +// ApplyClientForwardHeadersWithXUA 透传 IP、浏览器 UA,以及可选的 X-User-Agent。 +func ApplyClientForwardHeadersWithXUA(header map[string]string, clientIP, userAgent, xUserAgent string) { + ApplyClientForwardHeaders(header, clientIP, userAgent) + if header == nil { + return + } + if xua := strings.TrimSpace(xUserAgent); xua != "" { + header["X-User-Agent"] = xua + } +} diff --git a/pub/common/http/forward_headers.go b/pub/common/http/forward_headers.go new file mode 100644 index 0000000..a89d16a --- /dev/null +++ b/pub/common/http/forward_headers.go @@ -0,0 +1,24 @@ +package http + +// ForwardExcludedHeaders 是本服务转发请求到上游 serverAddr 时必须剔除的请求头集合。 +// key 使用 net/http.Header 的规范形式 (textproto.CanonicalMIMEHeaderKey), +// 调用方可直接用 http.Header 的 key 做查找。 +// +// 包含两类: +// 1. RFC 7230 §6.1 定义的 hop-by-hop 头,不允许跨代理转发。 +// 2. 本进程内部注入的控制标志,只对本进程的中间件有意义,不能让上游看见。 +var ForwardExcludedHeaders = map[string]bool{ + // hop-by-hop + "Connection": true, + "Keep-Alive": true, + "Proxy-Authenticate": true, + "Proxy-Authorization": true, + "Te": true, + "Trailers": true, + "Transfer-Encoding": true, + "Upgrade": true, + + // 内部 DoS 防护标志,由 server.headToGet 注入, + // 仅供 templatecache / filecache 判断是否回填缓存。 + "X-Origin-Head": true, +} diff --git a/pub/common/http/http.go b/pub/common/http/http.go new file mode 100644 index 0000000..a7c4ccb --- /dev/null +++ b/pub/common/http/http.go @@ -0,0 +1,242 @@ +package http + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "reflect" + "time" +) + +var ( + client = http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConnsPerHost: 5, + MaxConnsPerHost: 100, + }, + } +) + +type ByteSize int64 + +const ( + _ = iota // ignore first value by assigning to blank identifier + KB ByteSize = 1 << (10 * iota) + MB +) + +var ( + outOfBodySizeErr = errors.New("out of body size limit") +) + +type ResponseBody struct { + StatusCode int + Data []byte +} +type ResponseBody2 struct { + StatusCode int + Data []byte + Header http.Header +} + +type RequestFormData map[string]string + +// Post +// Note: 只会读取小于1M的数据 +func Post(url string, header map[string]string, body io.Reader) (fr *ResponseBody, err error) { + fr, err = do(http.MethodPost, url, header, body, int64(1024*MB)) + return +} + +func PostJson(url string, body io.Reader) (fr *ResponseBody, err error) { + header := map[string]string{} + header["Content-Type"] = "application/json" + fr, err = do(http.MethodPost, url, header, body, int64(1024*KB)) + return +} + +// PostFormData 提交标准的form表单 +func PostFormData(url string, fromdata RequestFormData) (fr *ResponseBody, err error) { + var b bytes.Buffer + writer := multipart.NewWriter(&b) + + for k, v := range fromdata { + if err = writer.WriteField(k, v); err != nil { + return + } + } + if err = writer.Close(); err != nil { + return + } + + header := map[string]string{"Content-Type": writer.FormDataContentType()} + fr, err = do(http.MethodPost, url, header, &b, 2*int64(KB)) + return +} +func DoHost(method string, url string, header map[string]string, body io.Reader, host string) (fr *ResponseBody2, err error) { + fr, err = doHost(method, url, header, body, int64(2*MB), host) + return +} + +// Get +// Note: 只会读取小于1M的数据 +func Get(url string, header map[string]string) (fr *ResponseBody, err error) { + fr, err = do(http.MethodGet, url, header, nil, int64(KB)) + return +} + +// GetLargeFile 获取大文件。2M +func GetLargeFile(url string, header map[string]string) (fr *ResponseBody, err error) { + fr, err = do(http.MethodGet, url, header, nil, int64(20*MB)) + return +} +func doHost(method string, url string, header map[string]string, body io.Reader, size int64, host string) (fr *ResponseBody2, err error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return + } + for k, v := range header { + req.Header.Set(k, v) + } + req.Host = host + + resp, err := client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.ContentLength > size { + err = outOfBodySizeErr + return + } + + data, err := ioutil.ReadAll(io.LimitReader(resp.Body, size)) + if err != nil { + return + } + fr = &ResponseBody2{ + StatusCode: resp.StatusCode, + Data: data, + Header: resp.Header, + } + return +} +func do(method string, url string, header map[string]string, body io.Reader, size int64) (fr *ResponseBody, err error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return + } + for k, v := range header { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.ContentLength > size { + err = outOfBodySizeErr + return + } + + data, err := ioutil.ReadAll(io.LimitReader(resp.Body, size)) + if err != nil { + return + } + fr = &ResponseBody{ + StatusCode: resp.StatusCode, + Data: data, + } + return +} + +// 推送对象到文件服务器 +func PostObject(ctx context.Context, url string, header map[string]string, body []byte, objName string) (string, error) { + base64data := base64.StdEncoding.EncodeToString(body) + + req := struct { + Name string `json:"fileName"` + Data string `json:"fileData"` + }{ + Name: objName, + Data: base64data, + } + + jsonBytes, _ := json.Marshal(req) + + resp, err := Post(url, header, bytes.NewReader(jsonBytes)) + if err != nil { + return "", err + } + + type Obj struct { + Domain string `json:"domain"` + Name string `json:"fileName"` + } + + fsResp := struct { + Code int `json:"code"` + Object Obj `json:"data"` + Msg string `json:"msg"` + }{} + + if err = json.Unmarshal(resp.Data, &fsResp); err != nil { + return "", err + } + + if fsResp.Code != 200 || fsResp.Object.Name == "" { + return "", fmt.Errorf("上传fs失败") + } + + return fsResp.Object.Name, nil +} + +func PostWithBind(ctx context.Context, bind interface{}, url string, body io.Reader) error { + if err := verifyBind(bind); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, "POST", url, body) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("response status code:%d", resp.StatusCode) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(data, bind) +} + +func verifyBind(bind interface{}) error { + bType := reflect.TypeOf(bind) + if bType.Kind() != reflect.Ptr { + return errors.New("bind must be a Ptr") + } + return nil +} + +func GetWithBody(url string, header map[string]string, body io.Reader) (fr *ResponseBody, err error) { + fr, err = do(http.MethodGet, url, header, body, int64(20*MB)) + return +} \ No newline at end of file diff --git a/pub/common/httputil/httputil.go b/pub/common/httputil/httputil.go new file mode 100644 index 0000000..bfadd58 --- /dev/null +++ b/pub/common/httputil/httputil.go @@ -0,0 +1,321 @@ +package httputil + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "videoweb/pub/common/log" + + "github.com/ddliu/go-httpclient" +) + +// HTTPClient htttp客户端 +type HTTPClient struct { + client *httpclient.HttpClient +} + +type ProxyCfg struct { + Host string + Timeout int //秒 + Source string +} + +func (p *ProxyCfg) Build(rawurl string, rawHeaders map[string]string) (string, map[string]string, error) { + u, err := url.Parse(rawurl) + if err != nil { + return "", nil, err + } + target := url.URL{Scheme: u.Scheme, Host: u.Host} + u.Host = p.Host + u.Scheme = "http" + m := make(map[string]string) + m["X-Proxy-Target-Host"] = target.String() + m["X-Proxy-Source"] = "ys" + if p.Timeout > 0 { + m["X-Proxy-Timeout"] = strconv.Itoa(p.Timeout) + } + if len(rawHeaders) > 0 { + for k, v := range rawHeaders { + m[k] = v + } + } + return u.String(), m, nil +} + +type httpClientPool struct { + cliPool map[string]*httpclient.HttpClient + poolSize int + offset int +} + +// HTTPResponse http响应 +type HTTPResponse struct { + StatusCode int `json:"statusCode"` + Body *httpclient.Response `json:"boby"` +} + +var httpCliPool = httpClientPool{cliPool: map[string]*httpclient.HttpClient{}, poolSize: 50} + +func New() *HTTPClient { + h := httpclient.NewHttpClient().Defaults(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 120, + }) + return &HTTPClient{client: h} +} + +func NewCtx(ctx context.Context) *HTTPClient { + h := httpclient.NewHttpClient().WithOptions(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 10, + httpclient.OPT_CONTEXT: ctx, + }) + return &HTTPClient{client: h.Begin()} +} + +func Begin() *HTTPClient { + h := httpclient.NewHttpClient().Defaults(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 60, + }) + return &HTTPClient{client: h.Begin()} +} + +// GetHTTPClient 获取httpclient +func Client() *HTTPClient { + cliID := "CLIENT-ID-" + if len(httpCliPool.cliPool) < httpCliPool.poolSize { + h := httpclient.NewHttpClient().Defaults(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 120, + }) + cliID = cliID + strconv.FormatInt(int64(len(httpCliPool.cliPool)+1), 10) + httpCliPool.cliPool[cliID] = h + httpCliPool.offset = httpCliPool.poolSize + return &HTTPClient{client: h} + } + if httpCliPool.offset == httpCliPool.poolSize+1 { + httpCliPool.offset = 1 + } + cliID = cliID + strconv.FormatInt(int64(httpCliPool.offset), 10) + httpCliPool.offset = httpCliPool.offset + 1 + h, ok := httpCliPool.cliPool[cliID] + if !ok || h == nil { + h = httpclient.NewHttpClient().WithOption(httpclient.OPT_TIMEOUT, 60) + httpCliPool.cliPool[cliID] = h + return &HTTPClient{client: h} + } + return &HTTPClient{client: h} +} + +func jsonUnmarshalResp(bind interface{}, resp *HTTPResponse) error { + defer func() { _ = resp.Body.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + b, err := resp.Body.ReadAll() + if err != nil { + log.Error("httputil jsonUnmarshalResp readall err", log.E(err)) + return err + } + err = json.Unmarshal(b, bind) + if err != nil { + log.Error("httputil jsonUnmarshalResp Unmarshal err", log.E(err)) + } + return err +} + +// Get Get方法 +func (h *HTTPClient) Get(url string, headers map[string]string, params ...interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Get(url, params...) + if err != nil { + log.Error(fmt.Sprintf("http client Get method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +func (h *HTTPClient) PGet(p *ProxyCfg, url string, headers map[string]string, params ...interface{}) (*HTTPResponse, error) { + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return nil, err + } + return h.Get(purl, pheaders, params) +} + +// Get GetBytes +func (h *HTTPClient) GetBytes(url string, headers map[string]string, params ...interface{}) ([]byte, error) { + h.client.WithHeaders(headers) + response, err := h.client.Get(url, params...) + if err != nil { + log.Error(fmt.Sprintf("http client Get method failed %+v:", err)) + return nil, err + } + data, err := response.ReadAll() + if err != nil { + log.ZapLog.Error("resp readAll errror", log.Any("Error", err)) + return data, err + } + defer func() { + _ = response.Body.Close() + }() + return data, nil +} + +// GetWithJResp 结果json.Unmarshal到bind中 +func (h *HTTPClient) GetWithJResp(bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + resp, err := h.Get(url, headers, params...) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +func (h *HTTPClient) PGetWithJResp(p *ProxyCfg, bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return 0, err + } + return h.GetWithJResp(bind, purl, pheaders, params...) +} + +// Get Get方法 +func (h *HTTPClient) Delete(url string, headers map[string]string, params ...interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Delete(url, params...) + if err != nil { + log.Error(fmt.Sprintf("http client Delete method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// DeleteWithJResp 结果json.Unmarshal到bind中 +func (h *HTTPClient) DeleteWithJResp(bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + resp, err := h.Delete(url, headers, params...) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +// Post Post方法 +func (h *HTTPClient) Post(url string, headers map[string]string, params interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Post(url, params) + if err != nil { + log.Error(fmt.Sprintf("http client Post method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +func (h *HTTPClient) PPost(p *ProxyCfg, bind interface{}, url string, headers map[string]string, params interface{}) (*HTTPResponse, error) { + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return nil, err + } + return h.Post(purl, pheaders, params) +} + +// POSTJson PostJson方法 +func (h *HTTPClient) POSTJson(url string, headers map[string]string, data interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.PostJson(url, data) + if err != nil { + log.Error(fmt.Sprintf("http client POSTJson method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// POSTJson PostJson方法, bind json.Unmarshal +func (h *HTTPClient) POSTJsonWithJResp(bind interface{}, url string, headers map[string]string, data interface{}) (int, error) { + resp, err := h.POSTJson(url, headers, data) + if err != nil { + log.Error("POSTJsonWithResp err", log.E(err)) + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +func (h *HTTPClient) PPOSTJsonWithJResp(p *ProxyCfg, bind interface{}, url string, headers map[string]string, data interface{}) (int, error) { + if p == nil { + return 0, fmt.Errorf("ProxyCfg is nil") + } + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return 0, err + } + return h.POSTJsonWithJResp(bind, purl, pheaders, data) +} + +// PostMultipart PostMultipart 上传文件 +func (h *HTTPClient) PostMultipart(url string, headers map[string]string, params interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.PostMultipart(url, params) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// Put 流式传输 可用于上传文件 +func (h *HTTPClient) Put(url string, headers map[string]string, body io.Reader) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Put(url, body) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// PutJSON PutJSON方法 +func (h *HTTPClient) PutJSON(url string, headers map[string]string, data interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.PutJson(url, data) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// Header Header +func (h *HTTPClient) Header(url string) (*HTTPResponse, error) { + response, err := h.client.Head(url) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// POSTWithJResp 结果json.Unmarshal到bind中 用于上传文件, content-type :"application/x-www-form-urlencoded" 如参数以@开头 则为上传文件 +func (h *HTTPClient) POSTWithJResp(bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + resp, err := h.Post(url, headers, params) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +// POSTWithJResp 结果json.Unmarshal到bind中 用于上传文件, content-type :"application/x-www-form-urlencoded" 如参数以@开头 则为上传文件 +func (h *HTTPClient) PPOSTWithJResp(p *ProxyCfg, bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + resp, err := h.PPost(p, bind, url, headers, params) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} diff --git a/pub/common/linode/linode.go b/pub/common/linode/linode.go new file mode 100644 index 0000000..b43230c --- /dev/null +++ b/pub/common/linode/linode.go @@ -0,0 +1,212 @@ +package linode + +import ( + "bytes" + "errors" + "io" + "mime" + "os" + "path" + "path/filepath" + "videoweb/pub/common/fconst" + "videoweb/pub/common/log" + + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/karrick/godirwalk" + "github.com/xxjwxc/gowp/workpool" +) + +const ( + //region = "default" + //endpoint = "https://ap-south-1.linodeobjects.com" + //accessKey = "0RALF74H45ANVYPRQA1Y" + //secretKey = "S1yCKUqxFJHqlFPZXJ3BbD8T6P0Yz6loUDntUrsS" + //bucket = "fengshang" + M = 1024 * 1024 + G = M * 1024 + defaultConcurrency = 150 + defaultTryMax = 5 + defaultContentType = "application/octet-stream" +) + +type Client struct { + session *session.Session + cfg Config +} +type UploadReq struct { + Key string + ContentType string + Data []byte +} + +func (u *UploadReq) Valid() bool { + return u.Key != "" && len(u.Data) != 0 +} + +type Config struct { + Region string + Endpoint string + AccessKey string + SecretKey string + Bucket string + Concurrency int + TryMax int +} + +func traverseDir(d string) ([]string, error) { + var fileNames []string + fn := func(name string, entry *godirwalk.Dirent) error { + if !entry.IsRegular() { + return nil + } + fileNames = append(fileNames, name) + return nil + } + pathName := d + err := godirwalk.Walk(pathName, &godirwalk.Options{ + Unsorted: true, + Callback: fn, + }) + if err != nil { + log.Info("list files failed", log.Any("path", pathName), log.E(err)) + return nil, err + } + return fileNames, nil +} + +func New(cfg Config) (*Client, error) { + if cfg.TryMax <= 0 { + cfg.TryMax = defaultTryMax + } + if cfg.Concurrency <= 0 { + cfg.Concurrency = defaultConcurrency + } + s, err := session.NewSession(&aws.Config{ + Region: aws.String(cfg.Region), + Endpoint: aws.String(cfg.Endpoint), + Credentials: credentials.NewStaticCredentials(cfg.AccessKey, cfg.SecretKey, ""), + }) + if err != nil { + log.Error("Linode NewSession err", log.E(err)) + return nil, err + } + return &Client{session: s, cfg: cfg}, nil +} + +//func WithDefault() (*Client, error) { +// return New(&Config{Concurrency: defaultConcurrency, TryMax: defaultTryMax}) +//} + +func (c *Client) UploadFolder(folderName string) error { + absFiles, err := traverseDir(folderName) + if err != nil { + return err + } + if len(absFiles) == 0 { + return nil + } + wp := workpool.New(c.cfg.Concurrency) + for _, absFilename := range absFiles { + var absName = absFilename + wp.Do(func() error { + var err error + for i := 0; i < c.cfg.TryMax; i++ { + start := time.Now() + err = c.uploadToLinNode(absName) + diff := time.Now().Sub(start) + if diff < time.Second { + time.Sleep(time.Second - diff) + } + if err == nil { + break + } + } + return err + }) + } + return wp.Wait() +} + +func (c *Client) UploadRaw(key, contentType string, body io.Reader) (string, error) { + if contentType == "" { + contentType = defaultContentType + } + input := &s3manager.UploadInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(key), + ACL: aws.String("public-read"), + Body: body, + ContentType: aws.String(contentType), + } + result, err := s3manager.NewUploader(c.session, func(uploader *s3manager.Uploader) { + uploader.PartSize = 1 * G + }).Upload(input) + if err != nil { + log.Info("UpdateRaw Upload Err", log.Any("key", key), log.E(err)) + return "", err + } + log.Info("UpdateRaw Upload OK", log.Any("key", key), log.Any("Location", result.Location)) + return "", nil +} +func (c *Client) Upload(req UploadReq) error { + if !req.Valid() { + return errors.New("invalid upload request") + } + if req.ContentType == "" { + req.ContentType = defaultContentType + ext := path.Ext(req.Key) + if ext != "" { + req.ContentType = mime.TypeByExtension(ext) + } + } + var err error + for i := 0; i < defaultTryMax; i++ { + buf := bytes.NewReader(req.Data) + input := &s3.PutObjectInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(req.Key), + ACL: aws.String("public-read"), + Body: buf, + ContentType: aws.String(req.ContentType), + } + + _, err := s3.New(c.session).PutObject(input) + if err == nil { + log.Info("Linode Upload OK", log.Any("key", req.Key)) + return nil + } + time.Sleep(time.Millisecond) //出让CPU + log.Error("Linode Upload Try err", log.Any("key", req.Key), log.Any("tryCount", i+1), log.E(err)) + } + log.Error("Linode Upload err", log.Any("key", req.Key), log.E(err)) + return err +} + +// 多线程调用 +func (m *Client) uploadToLinNodeWithKey(fileName, key string) error { + f, err := os.OpenFile(fileName, os.O_RDONLY, 0644) + if err != nil { + log.Info("uploadToLinNodeWithKey OpenFile", log.Any("fileName", fileName), log.E(err)) + return err + } + defer func() { _ = f.Close() }() + ext := filepath.Ext(fileName) + contentType := "" + if ext != "" { + contentType = mime.TypeByExtension(ext) + } + _, err = m.UploadRaw(key, contentType, f) + return err +} + +func (m *Client) uploadToLinNode(absFilename string) error { + key := strings.TrimPrefix(absFilename, fconst.FsDir) + return m.uploadToLinNodeWithKey(absFilename, key) +} diff --git a/pub/common/lock/cache.go b/pub/common/lock/cache.go new file mode 100644 index 0000000..5d8a314 --- /dev/null +++ b/pub/common/lock/cache.go @@ -0,0 +1,86 @@ +package lock + +import ( + "sync" +) + +type IIdent interface { + GetID() int64 +} + +type Cache[T IIdent] struct { + dict map[int64]T + lock sync.RWMutex +} + +func NewCache[T IIdent]() *Cache[T] { + return &Cache[T]{ + dict: make(map[int64]T), + } +} + +func (c *Cache[T]) Get(Id int64) (T, bool) { + c.lock.RLock() + defer c.lock.RUnlock() + t, ok := c.dict[Id] + return t, ok +} + +func (c *Cache[T]) Keys() []int64 { + c.lock.RLock() + defer c.lock.RUnlock() + keys := make([]int64, 0, len(c.dict)) + for k := range c.dict { + keys = append(keys, k) + } + return keys +} + +func (c *Cache[T]) Set(items ...T) { + c.lock.Lock() + defer c.lock.Unlock() + for _, item := range items { + c.dict[item.GetID()] = item + } +} + +func (c *Cache[T]) Del(items ...T) { + c.lock.Lock() + defer c.lock.Unlock() + for _, item := range items { + delete(c.dict, item.GetID()) + } +} + +func (c *Cache[T]) Update(dict map[int64]T) { + c.lock.Lock() + defer c.lock.Unlock() + c.dict = dict +} + +func (c *Cache[T]) SetItems(items []T) { + c.lock.Lock() + defer c.lock.Unlock() + for _, item := range items { + c.dict[item.GetID()] = item + } +} + +func (c *Cache[T]) Has(Id int64) bool { + c.lock.RLock() + defer c.lock.RUnlock() + _, ok := c.dict[Id] + return ok +} + +func (c *Cache[T]) GetItems(Ids []int64) []T { + ret := make([]T, 0) + c.lock.RLock() + defer c.lock.RUnlock() + for _, id := range Ids { + if item, ok := c.dict[id]; ok { + ret = append(ret, item) + } + } + return ret +} diff --git a/pub/common/lock/dict.go b/pub/common/lock/dict.go new file mode 100644 index 0000000..10b7979 --- /dev/null +++ b/pub/common/lock/dict.go @@ -0,0 +1,143 @@ +package lock + +import ( + "sync" +) + +type Dict[K comparable, V any] struct { + dict map[K]V + lock sync.RWMutex +} + +func NewDict[K comparable, V any]() *Dict[K, V] { + return &Dict[K, V]{ + dict: make(map[K]V), + } +} + +// Count get dict key count +func (d *Dict[K, V]) Count() int { + d.lock.RLock() + defer d.lock.RUnlock() + return len(d.dict) +} + +// Keys get dict keys +func (d *Dict[K, V]) Keys() []K { + d.lock.RLock() + defer d.lock.RUnlock() + keys := make([]K, 0, len(d.dict)) + for k := range d.dict { + keys = append(keys, k) + } + return keys +} + +// Set set k v to dict, overwrite old value if exists +func (d *Dict[K, V]) Set(key K, value V) { + d.lock.Lock() + defer d.lock.Unlock() + d.dict[key] = value +} + +// Has if exists, return true +func (d *Dict[K, V]) Has(key K) bool { + d.lock.RLock() + defer d.lock.RUnlock() + _, exists := d.dict[key] + return exists +} + +// Get if exists, return value and true +func (d *Dict[K, V]) Get(key K) (V, bool) { + d.lock.RLock() + defer d.lock.RUnlock() + value, exists := d.dict[key] + return value, exists +} + +// Del if exists and del success, return true +func (d *Dict[K, V]) Del(key K) bool { + d.lock.Lock() + defer d.lock.Unlock() + _, exists := d.dict[key] + delete(d.dict, key) + return exists +} + +// Remove if exists and remove success, return old value and true +func (d *Dict[K, V]) Remove(key K) (V, bool) { + d.lock.Lock() + defer d.lock.Unlock() + value, exists := d.dict[key] + delete(d.dict, key) + return value, exists +} + +// SetIfNotExists if not exists and set success, return true +func (d *Dict[K, V]) SetIfNotExists(key K, value V) bool { + d.lock.Lock() + defer d.lock.Unlock() + _, exists := d.dict[key] + if !exists { + d.dict[key] = value + return true + } + return false +} + +// GetOrSetIfNotExists if key exists return old:true, or, set new value, and return new:false +func (d *Dict[K, V]) GetOrSetIfNotExists(key K, new V) (V, bool) { + d.lock.Lock() + defer d.lock.Unlock() + old, exists := d.dict[key] + if !exists { + d.dict[key] = new + return new, false + } + return old, true +} + +// Travel travels all dict keys and values +func (d *Dict[K, V]) Travel(travel func(key K, value V) bool) { + d.lock.RLock() + defer d.lock.RUnlock() + for key, value := range d.dict { + cont := travel(key, value) + if !cont { + break + } + } +} + +// Clear dict directly +func (d *Dict[K, V]) Clear() { + d.lock.Lock() + defer d.lock.Unlock() + d.dict = map[K]V{} +} + +// Update replace dict directly +func (d *Dict[K, V]) Update(dict map[K]V) { + d.lock.Lock() + defer d.lock.Unlock() + d.dict = dict +} + +// Replace replace dict directly and return old dict +func (d *Dict[K, V]) Replace(dict map[K]V) map[K]V { + d.lock.Lock() + defer d.lock.Unlock() + old := d.dict + d.dict = dict + return old +} + +// Append dict directly +func (d *Dict[K, V]) Append(dict map[K]V) { + d.lock.Lock() + defer d.lock.Unlock() + for k, v := range dict { + d.dict[k] = v + } +} diff --git a/pub/common/lock/queue.go b/pub/common/lock/queue.go new file mode 100644 index 0000000..531d557 --- /dev/null +++ b/pub/common/lock/queue.go @@ -0,0 +1,75 @@ +package lock + +import "sync" + +// Queue 定义一个泛型队列结构 +type Queue[T any] struct { + items []T + lock sync.RWMutex // 添加读写锁 +} + +// NewQueue 创建一个新的队列 +func NewQueue[T any]() *Queue[T] { + return &Queue[T]{ + items: make([]T, 0), + } +} + +// Enqueue 入队操作 - 将元素添加到队列末尾 +func (q *Queue[T]) Enqueue(item T) { + q.lock.Lock() // 写操作需要写锁 + defer q.lock.Unlock() + + q.items = append(q.items, item) +} + +// Dequeue 出队操作 - 从队列头部移除并返回元素 +func (q *Queue[T]) Dequeue() (T, bool) { + q.lock.Lock() // 写操作需要写锁 + defer q.lock.Unlock() + + var zero T + if len(q.items) == 0 { + return zero, false + } + + item := q.items[0] + q.items = q.items[1:] + return item, true +} + +// Peek 查看队列头部元素但不移除 +func (q *Queue[T]) Peek() (T, bool) { + q.lock.RLock() // 读操作使用读锁 + defer q.lock.RUnlock() + + var zero T + if len(q.items) == 0 { + return zero, false + } + return q.items[0], true +} + +// IsEmpty 检查队列是否为空 +func (q *Queue[T]) IsEmpty() bool { + q.lock.RLock() // 读操作使用读锁 + defer q.lock.RUnlock() + + return len(q.items) == 0 +} + +// Size 返回队列中的元素个数 +func (q *Queue[T]) Size() int { + q.lock.RLock() // 读操作使用读锁 + defer q.lock.RUnlock() + + return len(q.items) +} + +// Clear 清空队列 +func (q *Queue[T]) Clear() { + q.lock.Lock() // 写操作需要写锁 + defer q.lock.Unlock() + + q.items = make([]T, 0) +} diff --git a/pub/common/log/log.go b/pub/common/log/log.go new file mode 100644 index 0000000..89cf271 --- /dev/null +++ b/pub/common/log/log.go @@ -0,0 +1,135 @@ +package log + +import ( + "context" + "fmt" + "log" + "os" + "time" + "videoweb/app/appg" + "videoweb/pub/common/log/zapcorex" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var ( + zapLogger *zap.Logger + zapLoggerS *zap.SugaredLogger + ZapLog *zap.Logger + ZapLogS *zap.SugaredLogger + Debug = zapLogger.Debug + Info = zapLogger.Info + Warn = zapLogger.Warn + Error = zapLogger.Error + Panic = zapLogger.Panic + Fatal = zapLogger.Fatal + Debugf = zapLoggerS.Debugf + Infof = zapLoggerS.Infof + Warnf = zapLoggerS.Warnf + Errorf = zapLoggerS.Errorf + Fatalf = zapLoggerS.Fatalf + Debugw = zapLoggerS.Debugw + Infow = zapLoggerS.Infow + Warnw = zapLoggerS.Warnw + Errorw = zapLoggerS.Errorw + Fatalw = zapLoggerS.Fatalw + TestLog = func(msg string, fields ...Field) { + if appg.Conf.RunMod == "prod" { + return + } + zapLogger.Info(msg, fields...) + } +) + +func newEncoderConfig() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + // Keys can be anything except the empty string. + TimeKey: "T", + LevelKey: "L", + NameKey: "N", + CallerKey: "C", + MessageKey: "M", + StacktraceKey: "S", + FunctionKey: "F", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalColorLevelEncoder, + EncodeTime: timeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + ConsoleSeparator: " ", + } +} + +func init() { + core := zapcore.NewCore( + zapcorex.NewConsoleEncoder(newEncoderConfig()), + zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout)), + zap.DebugLevel, + ) + zapLogger = zap.New(core, zap.AddCaller()) + zapLoggerS = zapLogger.Sugar() + ZapLog = zapLogger + ZapLogS = zapLoggerS + Debug = zapLogger.Debug + Info = zapLogger.Info + Warn = zapLogger.Warn + Error = zapLogger.Error + Panic = zapLogger.Panic + Fatal = zapLogger.Fatal + Debugf = zapLoggerS.Debugf + Infof = zapLoggerS.Infof + Warnf = zapLoggerS.Warnf + Errorf = zapLoggerS.Errorf + Fatalf = zapLoggerS.Fatalf + Debugw = zapLoggerS.Debugw + Infow = zapLoggerS.Infow + Warnw = zapLoggerS.Warnw + Errorw = zapLoggerS.Errorw + Fatalw = zapLoggerS.Fatalw +} + +func timeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.In(time.Local).Format("2006-01-02 15:04:05")) +} + +type Field = zap.Field + +func Any(key string, value interface{}) Field { + return zap.Any(key, value) +} + +func E(err error) Field { + return Any("err", err) +} + +type contextKey int64 + +var ( + loggerContextKey contextKey +) + +func ContextWithLogger(ctx context.Context, l *zap.SugaredLogger) context.Context { + return context.WithValue(ctx, loggerContextKey, l) +} + +func LoggerFromContext(ctx context.Context) *zap.SugaredLogger { + v := ctx.Value(loggerContextKey) + if v == nil { + return ZapLogS + } + return v.(*zap.SugaredLogger) +} + +type Logger struct{} + +// Info logs routine messages about cron's operation. +func (s *Logger) Info(msg string, keysAndValues ...interface{}) { + _ = log.Default().Output(2, msg+" "+fmt.Sprint(keysAndValues...)) +} + +// Error logs an error condition. +func (s *Logger) Error(err error, msg string, keysAndValues ...interface{}) { + line := fmt.Sprintf("%s %v %s", msg, err, fmt.Sprint(keysAndValues...)) + _ = log.Default().Output(2, line) +} diff --git a/pub/common/log/zapcorex/bufferpool/bufferpool.go b/pub/common/log/zapcorex/bufferpool/bufferpool.go new file mode 100644 index 0000000..dad583a --- /dev/null +++ b/pub/common/log/zapcorex/bufferpool/bufferpool.go @@ -0,0 +1,31 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package bufferpool houses zap's shared internal buffer pool. Third-party +// packages can recreate the same functionality with buffers.NewPool. +package bufferpool + +import "go.uber.org/zap/buffer" + +var ( + _pool = buffer.NewPool() + // Get retrieves a buffer from the pool, creating one if necessary. + Get = _pool.Get +) diff --git a/pub/common/log/zapcorex/color.go b/pub/common/log/zapcorex/color.go new file mode 100644 index 0000000..6b8faa7 --- /dev/null +++ b/pub/common/log/zapcorex/color.go @@ -0,0 +1,44 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package color adds coloring functionality for TTY output. +package zapcorex + +import "fmt" + +// Foreground colors. +const ( + Black Color = iota + 30 + Red + Green + Yellow + Blue + Magenta + Cyan + White +) + +// Color represents a text color. +type Color uint8 + +// Add adds the coloring to the given string. +func (c Color) Add(s string) string { + return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s) +} diff --git a/pub/common/log/zapcorex/console_encoder.go b/pub/common/log/zapcorex/console_encoder.go new file mode 100644 index 0000000..51ea796 --- /dev/null +++ b/pub/common/log/zapcorex/console_encoder.go @@ -0,0 +1,150 @@ +package zapcorex + +import ( + "fmt" + "strings" + "sync" + + "videoweb/pub/common/log/zapcorex/bufferpool" + + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" +) + +var _sliceEncoderPool = sync.Pool{ + New: func() interface{} { + return &sliceArrayEncoder{elems: make([]interface{}, 0, 2)} + }, +} + +func getSliceEncoder() *sliceArrayEncoder { + return _sliceEncoderPool.Get().(*sliceArrayEncoder) +} + +func putSliceEncoder(e *sliceArrayEncoder) { + e.elems = e.elems[:0] + _sliceEncoderPool.Put(e) +} + +type consoleEncoder struct { + *jsonEncoder +} + +// NewConsoleEncoder creates an encoder whose output is designed for human - +// rather than machine - consumption. It serializes the core log entry data +// (message, level, timestamp, etc.) in a plain-text format and leaves the +// structured context as JSON. +// +// Note that although the console encoder doesn't use the keys specified in the +// encoder configuration, it will omit any element whose key is set to the empty +// string. +func NewConsoleEncoder(cfg zapcore.EncoderConfig) zapcore.Encoder { + if cfg.ConsoleSeparator == "" { + // Use a default delimiter of '\t' for backwards compatibility + cfg.ConsoleSeparator = "\t" + } + return consoleEncoder{newJSONEncoder(cfg, true)} +} + +func (c consoleEncoder) Clone() zapcore.Encoder { + return consoleEncoder{c.jsonEncoder.Clone().(*jsonEncoder)} +} + +func (c consoleEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + line := bufferpool.Get() + + // We don't want the entry's metadata to be quoted and escaped (if it's + // encoded as strings), which means that we can't use the JSON encoder. The + // simplest option is to use the memory encoder and fmt.Fprint. + // + // If this ever becomes a performance bottleneck, we can implement + // ArrayEncoder for our plain-text format. + arr := getSliceEncoder() + if c.TimeKey != "" && c.EncodeTime != nil { + c.EncodeTime(ent.Time, arr) + } + if c.LevelKey != "" && c.EncodeLevel != nil { + c.EncodeLevel(ent.Level, arr) + } + if ent.LoggerName != "" && c.NameKey != "" { + nameEncoder := c.EncodeName + + if nameEncoder == nil { + // Fall back to FullNameEncoder for backward compatibility. + nameEncoder = zapcore.FullNameEncoder + } + + nameEncoder(ent.LoggerName, arr) + } + + for i := range arr.elems { + if i > 0 { + line.AppendString(c.ConsoleSeparator) + } + fmt.Fprint(line, arr.elems[i]) + } + + putSliceEncoder(arr) + + // Add the message itself. + if c.MessageKey != "" { + c.addSeparatorIfNecessary(line) + line.AppendString(ent.Message) + } + + // Add any structured context. + c.writeContext(line, fields) + + if ent.Caller.Defined { + if c.FunctionKey != "" { + c.addSeparatorIfNecessary(line) + if idx := strings.LastIndex(ent.Caller.Function, "/"); idx > 0 { + line.AppendString(White.Add(ent.Caller.Function[idx+1:])) + } else { + line.AppendString(White.Add(ent.Caller.Function)) + } + } + + line.AppendString(c.ConsoleSeparator) + if c.CallerKey != "" && c.EncodeCaller != nil { + line.AppendString(White.Add(ent.Caller.TrimmedPath())) + } + } + + // If there's no stacktrace key, honor that; this allows users to force + // single-line output. + if ent.Stack != "" && c.StacktraceKey != "" { + line.AppendByte('\n') + line.AppendString(ent.Stack) + } + + line.AppendString(c.LineEnding) + return line, nil +} + +func (c consoleEncoder) writeContext(line *buffer.Buffer, extra []zapcore.Field) { + context := c.jsonEncoder.Clone().(*jsonEncoder) + defer func() { + // putJSONEncoder assumes the buffer is still used, but we write out the buffer so + // we can free it. + context.buf.Free() + putJSONEncoder(context) + }() + + addFields(context, extra) + context.closeOpenNamespaces() + if context.buf.Len() == 0 { + return + } + + c.addSeparatorIfNecessary(line) + line.AppendByte('{') + line.Write(context.buf.Bytes()) + line.AppendByte('}') +} + +func (c consoleEncoder) addSeparatorIfNecessary(line *buffer.Buffer) { + if line.Len() > 0 { + line.AppendString(c.ConsoleSeparator) + } +} diff --git a/pub/common/log/zapcorex/field.go b/pub/common/log/zapcorex/field.go new file mode 100644 index 0000000..d5f91e8 --- /dev/null +++ b/pub/common/log/zapcorex/field.go @@ -0,0 +1,9 @@ +package zapcorex + +import "go.uber.org/zap/zapcore" + +func addFields(enc zapcore.ObjectEncoder, fields []zapcore.Field) { + for i := range fields { + fields[i].AddTo(enc) + } +} diff --git a/pub/common/log/zapcorex/json_encoder.go b/pub/common/log/zapcorex/json_encoder.go new file mode 100644 index 0000000..95f8975 --- /dev/null +++ b/pub/common/log/zapcorex/json_encoder.go @@ -0,0 +1,518 @@ +package zapcorex + +import ( + "encoding/base64" + "encoding/json" + "math" + "sync" + "time" + "unicode/utf8" + + "videoweb/pub/common/log/zapcorex/bufferpool" + + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" +) + +// For JSON-escaping; see jsonEncoder.safeAddString below. +const _hex = "0123456789abcdef" + +var _jsonPool = sync.Pool{New: func() interface{} { + return &jsonEncoder{} +}} + +func getJSONEncoder() *jsonEncoder { + return _jsonPool.Get().(*jsonEncoder) +} + +func putJSONEncoder(enc *jsonEncoder) { + if enc.reflectBuf != nil { + enc.reflectBuf.Free() + } + enc.EncoderConfig = nil + enc.buf = nil + enc.spaced = false + enc.openNamespaces = 0 + enc.reflectBuf = nil + enc.reflectEnc = nil + _jsonPool.Put(enc) +} + +type jsonEncoder struct { + *zapcore.EncoderConfig + buf *buffer.Buffer + spaced bool // include spaces after colons and commas + openNamespaces int + + // for encoding generic values by reflection + reflectBuf *buffer.Buffer + reflectEnc *json.Encoder +} + +// NewJSONEncoder creates a fast, low-allocation JSON encoder. The encoder +// appropriately escapes all field keys and values. +// +// Note that the encoder doesn't deduplicate keys, so it's possible to produce +// a message like +// +// {"foo":"bar","foo":"baz"} +// +// This is permitted by the JSON specification, but not encouraged. Many +// libraries will ignore duplicate key-value pairs (typically keeping the last +// pair) when unmarshaling, but users should attempt to avoid adding duplicate +// keys. +func NewJSONEncoder(cfg zapcore.EncoderConfig) zapcore.Encoder { + return newJSONEncoder(cfg, false) +} + +func newJSONEncoder(cfg zapcore.EncoderConfig, spaced bool) *jsonEncoder { + return &jsonEncoder{ + EncoderConfig: &cfg, + buf: bufferpool.Get(), + spaced: spaced, + } +} + +func (enc *jsonEncoder) AddArray(key string, arr zapcore.ArrayMarshaler) error { + enc.addKey(key) + return enc.AppendArray(arr) +} + +func (enc *jsonEncoder) AddObject(key string, obj zapcore.ObjectMarshaler) error { + enc.addKey(key) + return enc.AppendObject(obj) +} + +func (enc *jsonEncoder) AddBinary(key string, val []byte) { + enc.AddString(key, base64.StdEncoding.EncodeToString(val)) +} + +func (enc *jsonEncoder) AddByteString(key string, val []byte) { + enc.addKey(key) + enc.AppendByteString(val) +} + +func (enc *jsonEncoder) AddBool(key string, val bool) { + enc.addKey(key) + enc.AppendBool(val) +} + +func (enc *jsonEncoder) AddComplex128(key string, val complex128) { + enc.addKey(key) + enc.AppendComplex128(val) +} + +func (enc *jsonEncoder) AddDuration(key string, val time.Duration) { + enc.addKey(key) + enc.AppendDuration(val) +} + +func (enc *jsonEncoder) AddFloat64(key string, val float64) { + enc.addKey(key) + enc.AppendFloat64(val) +} + +func (enc *jsonEncoder) AddInt64(key string, val int64) { + enc.addKey(key) + enc.AppendInt64(val) +} + +func (enc *jsonEncoder) resetReflectBuf() { + if enc.reflectBuf == nil { + enc.reflectBuf = bufferpool.Get() + enc.reflectEnc = json.NewEncoder(enc.reflectBuf) + + // For consistency with our custom JSON encoder. + enc.reflectEnc.SetEscapeHTML(false) + } else { + enc.reflectBuf.Reset() + } +} + +var nullLiteralBytes = []byte("null") + +// Only invoke the standard JSON encoder if there is actually something to +// encode; otherwise write JSON null literal directly. +func (enc *jsonEncoder) encodeReflected(obj interface{}) ([]byte, error) { + if obj == nil { + return nullLiteralBytes, nil + } + enc.resetReflectBuf() + if err := enc.reflectEnc.Encode(obj); err != nil { + return nil, err + } + enc.reflectBuf.TrimNewline() + return enc.reflectBuf.Bytes(), nil +} + +func (enc *jsonEncoder) AddReflected(key string, obj interface{}) error { + valueBytes, err := enc.encodeReflected(obj) + if err != nil { + return err + } + enc.addKey(key) + _, err = enc.buf.Write(valueBytes) + return err +} + +func (enc *jsonEncoder) OpenNamespace(key string) { + enc.addKey(key) + enc.buf.AppendByte('{') + enc.openNamespaces++ +} + +func (enc *jsonEncoder) AddString(key, val string) { + enc.addKey(key) + enc.AppendString(val) +} + +func (enc *jsonEncoder) AddTime(key string, val time.Time) { + enc.addKey(key) + enc.AppendTime(val) +} + +func (enc *jsonEncoder) AddUint64(key string, val uint64) { + enc.addKey(key) + enc.AppendUint64(val) +} + +func (enc *jsonEncoder) AppendArray(arr zapcore.ArrayMarshaler) error { + enc.addElementSeparator() + enc.buf.AppendByte('[') + err := arr.MarshalLogArray(enc) + enc.buf.AppendByte(']') + return err +} + +func (enc *jsonEncoder) AppendObject(obj zapcore.ObjectMarshaler) error { + enc.addElementSeparator() + enc.buf.AppendByte('{') + err := obj.MarshalLogObject(enc) + enc.buf.AppendByte('}') + return err +} + +func (enc *jsonEncoder) AppendBool(val bool) { + enc.addElementSeparator() + enc.buf.AppendBool(val) +} + +func (enc *jsonEncoder) AppendByteString(val []byte) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddByteString(val) + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendComplex128(val complex128) { + enc.addElementSeparator() + // Cast to a platform-independent, fixed-size type. + r, i := float64(real(val)), float64(imag(val)) + enc.buf.AppendByte('"') + // Because we're always in a quoted string, we can use strconv without + // special-casing NaN and +/-Inf. + enc.buf.AppendFloat(r, 64) + enc.buf.AppendByte('+') + enc.buf.AppendFloat(i, 64) + enc.buf.AppendByte('i') + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendDuration(val time.Duration) { + cur := enc.buf.Len() + if e := enc.EncodeDuration; e != nil { + e(val, enc) + } + if cur == enc.buf.Len() { + // User-supplied EncodeDuration is a no-op. Fall back to nanoseconds to keep + // JSON valid. + enc.AppendInt64(int64(val)) + } +} + +func (enc *jsonEncoder) AppendInt64(val int64) { + enc.addElementSeparator() + enc.buf.AppendInt(val) +} + +func (enc *jsonEncoder) AppendReflected(val interface{}) error { + valueBytes, err := enc.encodeReflected(val) + if err != nil { + return err + } + enc.addElementSeparator() + _, err = enc.buf.Write(valueBytes) + return err +} + +func (enc *jsonEncoder) AppendString(val string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddString(val) + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendTimeLayout(time time.Time, layout string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.buf.AppendTime(time, layout) + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendTime(val time.Time) { + cur := enc.buf.Len() + if e := enc.EncodeTime; e != nil { + e(val, enc) + } + if cur == enc.buf.Len() { + // User-supplied EncodeTime is a no-op. Fall back to nanos since epoch to keep + // output JSON valid. + enc.AppendInt64(val.UnixNano()) + } +} + +func (enc *jsonEncoder) AppendUint64(val uint64) { + enc.addElementSeparator() + enc.buf.AppendUint(val) +} + +func (enc *jsonEncoder) AddComplex64(k string, v complex64) { enc.AddComplex128(k, complex128(v)) } +func (enc *jsonEncoder) AddFloat32(k string, v float32) { enc.AddFloat64(k, float64(v)) } +func (enc *jsonEncoder) AddInt(k string, v int) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddInt32(k string, v int32) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddInt16(k string, v int16) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddInt8(k string, v int8) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddUint(k string, v uint) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUint32(k string, v uint32) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUint16(k string, v uint16) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUint8(k string, v uint8) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUintptr(k string, v uintptr) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AppendComplex64(v complex64) { enc.AppendComplex128(complex128(v)) } +func (enc *jsonEncoder) AppendFloat64(v float64) { enc.appendFloat(v, 64) } +func (enc *jsonEncoder) AppendFloat32(v float32) { enc.appendFloat(float64(v), 32) } +func (enc *jsonEncoder) AppendInt(v int) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendInt32(v int32) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendInt16(v int16) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendInt8(v int8) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendUint(v uint) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUint32(v uint32) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUint16(v uint16) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUint8(v uint8) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUintptr(v uintptr) { enc.AppendUint64(uint64(v)) } + +func (enc *jsonEncoder) Clone() zapcore.Encoder { + clone := enc.clone() + clone.buf.Write(enc.buf.Bytes()) + return clone +} + +func (enc *jsonEncoder) clone() *jsonEncoder { + clone := getJSONEncoder() + clone.EncoderConfig = enc.EncoderConfig + clone.spaced = enc.spaced + clone.openNamespaces = enc.openNamespaces + clone.buf = bufferpool.Get() + return clone +} + +func (enc *jsonEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + final := enc.clone() + final.buf.AppendByte('{') + + if final.LevelKey != "" { + final.addKey(final.LevelKey) + cur := final.buf.Len() + final.EncodeLevel(ent.Level, final) + if cur == final.buf.Len() { + // User-supplied EncodeLevel was a no-op. Fall back to strings to keep + // output JSON valid. + final.AppendString(ent.Level.String()) + } + } + if final.TimeKey != "" { + final.AddTime(final.TimeKey, ent.Time) + } + if ent.LoggerName != "" && final.NameKey != "" { + final.addKey(final.NameKey) + cur := final.buf.Len() + nameEncoder := final.EncodeName + + // if no name encoder provided, fall back to FullNameEncoder for backwards + // compatibility + if nameEncoder == nil { + nameEncoder = zapcore.FullNameEncoder + } + + nameEncoder(ent.LoggerName, final) + if cur == final.buf.Len() { + // User-supplied EncodeName was a no-op. Fall back to strings to + // keep output JSON valid. + final.AppendString(ent.LoggerName) + } + } + if ent.Caller.Defined { + if final.CallerKey != "" { + final.addKey(final.CallerKey) + cur := final.buf.Len() + final.EncodeCaller(ent.Caller, final) + if cur == final.buf.Len() { + // User-supplied EncodeCaller was a no-op. Fall back to strings to + // keep output JSON valid. + final.AppendString(ent.Caller.String()) + } + } + if final.FunctionKey != "" { + final.addKey(final.FunctionKey) + final.AppendString(ent.Caller.Function) + } + } + if final.MessageKey != "" { + final.addKey(enc.MessageKey) + final.AppendString(ent.Message) + } + if enc.buf.Len() > 0 { + final.addElementSeparator() + final.buf.Write(enc.buf.Bytes()) + } + addFields(final, fields) + final.closeOpenNamespaces() + if ent.Stack != "" && final.StacktraceKey != "" { + final.AddString(final.StacktraceKey, ent.Stack) + } + final.buf.AppendByte('}') + if final.LineEnding != "" { + final.buf.AppendString(final.LineEnding) + } else { + final.buf.AppendString(zapcore.DefaultLineEnding) + } + + ret := final.buf + putJSONEncoder(final) + return ret, nil +} + +func (enc *jsonEncoder) truncate() { + enc.buf.Reset() +} + +func (enc *jsonEncoder) closeOpenNamespaces() { + for i := 0; i < enc.openNamespaces; i++ { + enc.buf.AppendByte('}') + } +} + +func (enc *jsonEncoder) addKey(key string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddString(key) + enc.buf.AppendByte('"') + enc.buf.AppendByte(':') + if enc.spaced { + enc.buf.AppendByte(' ') + } +} + +func (enc *jsonEncoder) addElementSeparator() { + last := enc.buf.Len() - 1 + if last < 0 { + return + } + switch enc.buf.Bytes()[last] { + case '{', '[', ':', ',', ' ': + return + default: + enc.buf.AppendByte(',') + if enc.spaced { + enc.buf.AppendByte(' ') + } + } +} + +func (enc *jsonEncoder) appendFloat(val float64, bitSize int) { + enc.addElementSeparator() + switch { + case math.IsNaN(val): + enc.buf.AppendString(`"NaN"`) + case math.IsInf(val, 1): + enc.buf.AppendString(`"+Inf"`) + case math.IsInf(val, -1): + enc.buf.AppendString(`"-Inf"`) + default: + enc.buf.AppendFloat(val, bitSize) + } +} + +// safeAddString JSON-escapes a string and appends it to the internal buffer. +// Unlike the standard library's encoder, it doesn't attempt to protect the +// user from browser vulnerabilities or JSONP-related problems. +func (enc *jsonEncoder) safeAddString(s string) { + for i := 0; i < len(s); { + if enc.tryAddRuneSelf(s[i]) { + i++ + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if enc.tryAddRuneError(r, size) { + i++ + continue + } + enc.buf.AppendString(s[i : i+size]) + i += size + } +} + +// safeAddByteString is no-alloc equivalent of safeAddString(string(s)) for s []byte. +func (enc *jsonEncoder) safeAddByteString(s []byte) { + for i := 0; i < len(s); { + if enc.tryAddRuneSelf(s[i]) { + i++ + continue + } + r, size := utf8.DecodeRune(s[i:]) + if enc.tryAddRuneError(r, size) { + i++ + continue + } + enc.buf.Write(s[i : i+size]) + i += size + } +} + +// tryAddRuneSelf appends b if it is valid UTF-8 character represented in a single byte. +func (enc *jsonEncoder) tryAddRuneSelf(b byte) bool { + if b >= utf8.RuneSelf { + return false + } + if 0x20 <= b && b != '\\' && b != '"' { + enc.buf.AppendByte(b) + return true + } + switch b { + case '\\', '"': + enc.buf.AppendByte('\\') + enc.buf.AppendByte(b) + case '\n': + enc.buf.AppendByte('\\') + enc.buf.AppendByte('n') + case '\r': + enc.buf.AppendByte('\\') + enc.buf.AppendByte('r') + case '\t': + enc.buf.AppendByte('\\') + enc.buf.AppendByte('t') + default: + // Encode bytes < 0x20, except for the escape sequences above. + enc.buf.AppendString(`\u00`) + enc.buf.AppendByte(_hex[b>>4]) + enc.buf.AppendByte(_hex[b&0xF]) + } + return true +} + +func (enc *jsonEncoder) tryAddRuneError(r rune, size int) bool { + if r == utf8.RuneError && size == 1 { + enc.buf.AppendString(`\ufffd`) + return true + } + return false +} diff --git a/pub/common/log/zapcorex/memory_encoder.go b/pub/common/log/zapcorex/memory_encoder.go new file mode 100644 index 0000000..646a142 --- /dev/null +++ b/pub/common/log/zapcorex/memory_encoder.go @@ -0,0 +1,183 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package zapcorex + +import ( + "time" + + "go.uber.org/zap/zapcore" +) + +// MapObjectEncoder is an ObjectEncoder backed by a simple +// map[string]interface{}. It's not fast enough for production use, but it's +// helpful in tests. +type MapObjectEncoder struct { + // Fields contains the entire encoded log context. + Fields map[string]interface{} + // cur is a pointer to the namespace we're currently writing to. + cur map[string]interface{} +} + +// NewMapObjectEncoder creates a new map-backed ObjectEncoder. +func NewMapObjectEncoder() *MapObjectEncoder { + m := make(map[string]interface{}) + return &MapObjectEncoder{ + Fields: m, + cur: m, + } +} + +// AddArray implements ObjectEncoder. +func (m *MapObjectEncoder) AddArray(key string, v zapcore.ArrayMarshaler) error { + arr := &sliceArrayEncoder{elems: make([]interface{}, 0)} + err := v.MarshalLogArray(arr) + m.cur[key] = arr.elems + return err +} + +// AddObject implements ObjectEncoder. +func (m *MapObjectEncoder) AddObject(k string, v zapcore.ObjectMarshaler) error { + newMap := NewMapObjectEncoder() + m.cur[k] = newMap.Fields + return v.MarshalLogObject(newMap) +} + +// AddBinary implements ObjectEncoder. +func (m *MapObjectEncoder) AddBinary(k string, v []byte) { m.cur[k] = v } + +// AddByteString implements ObjectEncoder. +func (m *MapObjectEncoder) AddByteString(k string, v []byte) { m.cur[k] = string(v) } + +// AddBool implements ObjectEncoder. +func (m *MapObjectEncoder) AddBool(k string, v bool) { m.cur[k] = v } + +// AddDuration implements ObjectEncoder. +func (m MapObjectEncoder) AddDuration(k string, v time.Duration) { m.cur[k] = v } + +// AddComplex128 implements ObjectEncoder. +func (m *MapObjectEncoder) AddComplex128(k string, v complex128) { m.cur[k] = v } + +// AddComplex64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddComplex64(k string, v complex64) { m.cur[k] = v } + +// AddFloat64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddFloat64(k string, v float64) { m.cur[k] = v } + +// AddFloat32 implements ObjectEncoder. +func (m *MapObjectEncoder) AddFloat32(k string, v float32) { m.cur[k] = v } + +// AddInt implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt(k string, v int) { m.cur[k] = v } + +// AddInt64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt64(k string, v int64) { m.cur[k] = v } + +// AddInt32 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt32(k string, v int32) { m.cur[k] = v } + +// AddInt16 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt16(k string, v int16) { m.cur[k] = v } + +// AddInt8 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt8(k string, v int8) { m.cur[k] = v } + +// AddString implements ObjectEncoder. +func (m *MapObjectEncoder) AddString(k string, v string) { m.cur[k] = v } + +// AddTime implements ObjectEncoder. +func (m MapObjectEncoder) AddTime(k string, v time.Time) { m.cur[k] = v } + +// AddUint implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint(k string, v uint) { m.cur[k] = v } + +// AddUint64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint64(k string, v uint64) { m.cur[k] = v } + +// AddUint32 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint32(k string, v uint32) { m.cur[k] = v } + +// AddUint16 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint16(k string, v uint16) { m.cur[k] = v } + +// AddUint8 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint8(k string, v uint8) { m.cur[k] = v } + +// AddUintptr implements ObjectEncoder. +func (m *MapObjectEncoder) AddUintptr(k string, v uintptr) { m.cur[k] = v } + +// AddReflected implements ObjectEncoder. +func (m *MapObjectEncoder) AddReflected(k string, v interface{}) error { + m.cur[k] = v + return nil +} + +// OpenNamespace implements ObjectEncoder. +func (m *MapObjectEncoder) OpenNamespace(k string) { + ns := make(map[string]interface{}) + m.cur[k] = ns + m.cur = ns +} + +// sliceArrayEncoder is an ArrayEncoder backed by a simple []interface{}. Like +// the MapObjectEncoder, it's not designed for production use. +type sliceArrayEncoder struct { + elems []interface{} +} + +func (s *sliceArrayEncoder) AppendArray(v zapcore.ArrayMarshaler) error { + enc := &sliceArrayEncoder{} + err := v.MarshalLogArray(enc) + s.elems = append(s.elems, enc.elems) + return err +} + +func (s *sliceArrayEncoder) AppendObject(v zapcore.ObjectMarshaler) error { + m := NewMapObjectEncoder() + err := v.MarshalLogObject(m) + s.elems = append(s.elems, m.Fields) + return err +} + +func (s *sliceArrayEncoder) AppendReflected(v interface{}) error { + s.elems = append(s.elems, v) + return nil +} + +func (s *sliceArrayEncoder) AppendBool(v bool) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendByteString(v []byte) { s.elems = append(s.elems, string(v)) } +func (s *sliceArrayEncoder) AppendComplex128(v complex128) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendComplex64(v complex64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendDuration(v time.Duration) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendFloat64(v float64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendFloat32(v float32) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt(v int) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt64(v int64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt32(v int32) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt16(v int16) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt8(v int8) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendString(v string) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendTime(v time.Time) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint(v uint) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint64(v uint64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint32(v uint32) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint16(v uint16) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint8(v uint8) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUintptr(v uintptr) { s.elems = append(s.elems, v) } diff --git a/pub/common/m3u8/enkey.go b/pub/common/m3u8/enkey.go new file mode 100644 index 0000000..28e798f --- /dev/null +++ b/pub/common/m3u8/enkey.go @@ -0,0 +1,44 @@ +package m3u8 + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "sync" +) + +var ( + cacheEnkey []byte + initOnce sync.Once + initErr error +) + +// 安全初始化 m3u8 key(文件缺失时不再 panic,交由调用方降级) +func initEnkey() { + initOnce.Do(func() { + data, err := ioutil.ReadFile("conf/m3u8.key") + if err != nil { + initErr = err + return + } + if len(data) == 0 { + initErr = fmt.Errorf("conf/m3u8.key is empty") + return + } + cacheEnkey = data + }) +} + +// Enkey 密匙 key +func Enkey(ctx context.Context) (buffer *bytes.Buffer, err error) { + initEnkey() + if initErr != nil { + return nil, initErr + } + if len(cacheEnkey) == 0 { + return nil, fmt.Errorf("m3u8 key not loaded") + } + buffer = bytes.NewBuffer(cacheEnkey) + return +} diff --git a/pub/common/m3u8/media_play_list.go b/pub/common/m3u8/media_play_list.go new file mode 100644 index 0000000..cf26ae8 --- /dev/null +++ b/pub/common/m3u8/media_play_list.go @@ -0,0 +1,42 @@ +package m3u8 + +import ( + //"sz-app-server/pub/common/utils" + + "time" + "videoweb/pub/common/utils" + + "github.com/grafov/m3u8" +) + +// makeMediaPlayList 根据from,重新生成播放列表 +func makeMediaPlayList(from *m3u8.MediaPlaylist, cdnDomain string, dirPath string, keyUri string, authKey string) (media *m3u8.MediaPlaylist, err error) { + media, err = m3u8.NewMediaPlaylist(0, from.Count()) + if err != nil { + return + } + base := utils.JoinUrlPath(cdnDomain, dirPath) + now := time.Now() + + for _, segment := range from.Segments { + if segment == nil { + continue + } + sg := new(m3u8.MediaSegment) + sg.URI = utils.JoinUrlPath(base, segment.URI) + if len(authKey) > 0 { + //生成鉴权url + urlPath := utils.JoinUrlPath(dirPath, segment.URI) + urlAuth := generateUrlAuth(now, urlPath, authKey) + sg.URI = sg.URI + urlAuth + } + sg.Title = segment.Title + sg.Duration = segment.Duration + if err = media.AppendSegment(sg); err != nil { + return + } + } + _ = media.SetDefaultKey("AES-128", keyUri, "", "", "") + media.Close() + return +} diff --git a/pub/common/m3u8/play_list.go b/pub/common/m3u8/play_list.go new file mode 100644 index 0000000..ebf8a34 --- /dev/null +++ b/pub/common/m3u8/play_list.go @@ -0,0 +1,146 @@ +package m3u8 + +import ( + "bytes" + "fmt" + "io" + "strings" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/errors" + "videoweb/pub/common/http" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + "videoweb/pub/redis" + "videoweb/pub/storage/proxy" + + "github.com/grafov/m3u8" +) + +const ( + keyUri = "/enkey" + webkeyUri = "/api/web/enkey" + h5KeyUri = "/enkey" + AuthKey = "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN" +) + +// resolveTsAuthKey 优先使用传入的 tsAuthKey,为空则回退默认 AuthKey。 +func resolveTsAuthKey(tsAuthKey string) string { + if key := strings.TrimSpace(tsAuthKey); key != "" { + return key + } + return AuthKey +} + +var headerMediaPlayList *m3u8.MediaPlaylist + +// TransFormWithSafely 被检查权限的播放列表 +// 主要检查用户是否有观看该视频的权限 +func TransFormWithSafely(req *Req, cdnDomain, origDomain string, web bool, tsAuthKey string) (*m3u8.MediaPlaylist, *bytes.Buffer, error) { + if req.Name == "" { + return nil, nil, errors.MakeWithMsg(errors.M3u8FileName, "file name is empty") + } + if cdnDomain == "" || origDomain == "" { + return nil, nil, errors.MakeWithMsg(errors.DomainErr, "cdnDomain or origDomain is empty") + } + + return transform(req.Name, cdnDomain, origDomain, web, tsAuthKey) +} + +// transform m3u8文件转换,将不正确的m3u8文件,替换为正确的m3u8文件 +func transform(name string, cdnDomain, origDomain string, web bool, tsAuthKey string) (media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + m3u8reader, err := readFromRedis(name, origDomain) + if err != nil { + return + } + p, t, err := m3u8.DecodeFrom(m3u8reader, false) + if err != nil { + err = errors.MakeWithErr(errors.M3u8Read, err) + return + } + + // 处理媒资库 桶1(v1) 桶2(v2) 桶3(v3) + mediaVerson := proxy.AppendsMediaS3Version(name) + if len(mediaVerson) > 0 { + name = mediaVerson + name + } + + authKey := resolveTsAuthKey(tsAuthKey) + keyurl := keyUri + if web { + keyurl = webkeyUri + } + + switch t { + case m3u8.MEDIA: + pl := p.(*m3u8.MediaPlaylist) + dirPath := utils.PathForInfo(name).Dir + media, err = makeMediaPlayList(pl, cdnDomain, dirPath, keyurl, authKey) + if err != nil { + return + } + buff = media.Encode() + case m3u8.MASTER: + mpl := p.(*m3u8.MasterPlaylist) + buff = mpl.Encode() + default: + err = errors.MakeWithMsg(errors.M3u8FileName, fmt.Sprintf("invalid m3u8 type %d", t)) + } + return +} + +func readFromCDN(name, domain string) (reader io.Reader, err error) { + requestUrl := utils.JoinUrlPath(domain, name) + resp, err := http.GetLargeFile(requestUrl, nil) + if err != nil { + return + } + if resp.StatusCode != 200 { + msg := fmt.Sprintf("read m3u8 fail, url: %s, status code %d", requestUrl, resp.StatusCode) + err = errors.MakeWithMsg(errors.M3u8Read, msg) + return + } + reader = bytes.NewReader(resp.Data) + return +} + +// 从redis缓存中读取 +func readFromRedis(name, domain string) (reader io.Reader, err error) { + m3u8Key := redisconst.MediaM3U8Key(name) + data, err := redis.GetBytes(m3u8Key) + if err == nil && len(data) > 0 { + reader = bytes.NewReader(data) + return + } + requestUrl := utils.JoinUrlPath(domain, name) + resp, err := http.GetLargeFile(requestUrl, nil) + if err != nil { + fmt.Println("requestUrl111", requestUrl) + return + } + fmt.Println("requestUrl", requestUrl, resp.StatusCode) + if resp.StatusCode != 200 { + msg := fmt.Sprintf("read m3u8 fail, url: %s, status code %d", requestUrl, resp.StatusCode) + err = errors.MakeWithMsg(errors.M3u8Read, msg) + return + } + if err := redis.Set(m3u8Key, resp.Data, + redisconst.MediaM3U8CacheExpire); err != nil { + log.Info("m3u8:readFromRedis redis set err", log.E(err)) + } + reader = bytes.NewReader(resp.Data) + return +} + +func GetHeaderMediaSegUrls() []string { + urls := make([]string, 0) + if headerMediaPlayList == nil { + return urls + } + for _, segment := range headerMediaPlayList.Segments { + if segment == nil { + continue + } + urls = append(urls, segment.URI) + } + return urls +} diff --git a/pub/common/m3u8/transformer.go b/pub/common/m3u8/transformer.go new file mode 100644 index 0000000..1096553 --- /dev/null +++ b/pub/common/m3u8/transformer.go @@ -0,0 +1,133 @@ +package m3u8 + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "fmt" + "strconv" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/common/utils" + "videoweb/pub/pubdef" + "videoweb/pub/storage/proxy" + + "github.com/grafov/m3u8" +) + +// transform m3u8文件转换,将不正确的m3u8文件,替换为正确的m3u8文件 +// apiType(1:app,2:h5app,3:web) +func TransformEx(name string, cdnDomain, origDomain string, apiType int, authKey string) ( + media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + + if name == "" { + err = errors.MakeWithMsg(errors.M3u8FileName, "m3u8 file name is empty") + return + } + if cdnDomain == "" || origDomain == "" { + err = errors.MakeWithMsg(errors.DomainErr, "cdnDomain or origDomain is empty") + return + } + // 处理媒资库 桶1(v1) 桶2(v2) 桶3(v3) + mediaVerson := proxy.AppendsMediaS3Version(name) + if len(mediaVerson) > 0 { + name = mediaVerson + name + } + authKey = resolveTsAuthKey(authKey) + + //log.Info("Test", log.Any("name", name), log.Any("cdnDomain", cdnDomain), log.Any("origDomain", origDomain), log.Any("authKey", authKey)) + m3u8reader, err := readFromRedis(name, origDomain) + if err != nil { + return + } + p, t, err := m3u8.DecodeFrom(m3u8reader, false) + if err != nil { + err = errors.MakeWithErr(errors.M3u8Read, err) + return + } + apiPath := keyUri + if apiType == 2 { + apiPath = h5KeyUri + } else if apiType == 3 { + apiPath = webkeyUri + } + + switch t { + case m3u8.MEDIA: + pl := p.(*m3u8.MediaPlaylist) + dirPath := utils.PathForInfo(name).Dir + media, err = makeTSList(pl, cdnDomain, dirPath, apiPath, authKey) + if err != nil { + return + } + buff = media.Encode() + case m3u8.MASTER: + mpl := p.(*m3u8.MasterPlaylist) + buff = mpl.Encode() + default: + err = errors.MakeWithMsg(errors.M3u8FileName, fmt.Sprintf("invalid m3u8 type %d", t)) + } + return +} + +// makeTSList 根据from,重新生成播放列表 +func makeTSList(from *m3u8.MediaPlaylist, cdnDomain string, + dirPath string, apiPath string, authKey string) ( + media *m3u8.MediaPlaylist, err error) { + media, err = m3u8.NewMediaPlaylist(from.WinSize(), from.Count()) + if err != nil { + return + } + now := time.Now() + base := utils.JoinUrlPath(cdnDomain, dirPath) + for _, segment := range from.Segments { + if segment == nil { + continue + } + sg := new(m3u8.MediaSegment) + sg.URI = utils.JoinUrlPath(base, segment.URI) + if len(authKey) > 0 { + //生成鉴权url + urlPath := utils.JoinUrlPath(dirPath, segment.URI) + urlAuth := generateUrlAuth(now, urlPath, authKey) + sg.URI = sg.URI + urlAuth + } + sg.Title = segment.Title + sg.Duration = segment.Duration + if err = media.AppendSegment(sg); err != nil { + return + } + } + _ = media.SetDefaultKey("AES-128", apiPath, "", "", "") + media.Close() + return +} + +// 生成鉴权url +func generateUrlAuth(now time.Time, path string, authKey string) string { + timestamp := now.Unix() + signStr := fmt.Sprintf("%s-%d-0-0-%s", path, timestamp, authKey) + md5Str := getMD5Sign(signStr) + // TS CDN 鉴权参数后追加应用标识,便于流量统计。 + urlAuth := fmt.Sprintf("?md=%d-0-0-%s&c=%d", timestamp, md5Str, pubdef.AppId) + return urlAuth +} + +// 生成鉴权url 七牛云 +func generateUrlAuthQiniu(now time.Time, path string, authKey string) string { + timestamp := strconv.FormatInt(now.Add(2*time.Hour).Unix(), 16) + base64 := path + signStr := authKey + base64 + timestamp + md5Str := getMD5Sign(signStr) + urlAuth := fmt.Sprintf("?sign=%s&t=%s", md5Str, timestamp) + return urlAuth +} + +// getMD5Sign 得到签名 +func getMD5Sign(buf string) string { + md5Ctx := md5.New() + md5Ctx.Write([]byte(buf)) + cipherStr := md5Ctx.Sum(nil) + nsign := hex.EncodeToString(cipherStr) + return nsign +} diff --git a/pub/common/m3u8/types.go b/pub/common/m3u8/types.go new file mode 100644 index 0000000..97a1614 --- /dev/null +++ b/pub/common/m3u8/types.go @@ -0,0 +1,13 @@ +package m3u8 + +type Req struct { + Token string `json:"token" form:"token"` + VId int64 `json:"vId" form:"vId"` + Name string `json:"name" form:"name"` + NeedHeader bool `json:"needHeader" form:"needHeader"` +} + +type PostVideoReq struct { + Token string `json:"token" form:"token"` + Name string `json:"name" form:"name" binding:"required" comment:"must be set"` +} diff --git a/pub/common/m3u8/validate.go b/pub/common/m3u8/validate.go new file mode 100644 index 0000000..a7b4927 --- /dev/null +++ b/pub/common/m3u8/validate.go @@ -0,0 +1,18 @@ +package m3u8 + +//sErr "sz-app-server/pub/common/errors" + +// func CheckParams(req *Req) (err error) { +// if req.Token == "" { +// err = sErr.MakeWithMsg(sErr.NoToken, "no token") +// return +// } +// if req.VId <= 0 { +// err = sErr.MakeWithMsg(sErr.VideoId, "invalid video id") +// return +// } +// if req.Name == "" { +// err = sErr.MakeWithMsg(sErr.VideoName, "invalid name") +// } +// return +// } diff --git a/pub/common/maths/maths.go b/pub/common/maths/maths.go new file mode 100644 index 0000000..523f9bd --- /dev/null +++ b/pub/common/maths/maths.go @@ -0,0 +1,68 @@ +package maths + +import ( + "math/big" + "strings" + + uuid "github.com/satori/go.uuid" +) + +const CODE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +const CodeLenth = 62 + +/** + * 10进制 为 62进制 字符串。大数字类型 + * + * param: *big.Int number 待转换的数字,非常大的 + * return: string + */ +func From10To62Big(number *big.Int) string { + tmpNumber := big.NewInt(0) + var result []byte + for number.Cmp(big.NewInt(0)) != 0 { + var tmp []byte + tmpNumber.Rem(number, big.NewInt(CodeLenth)) + tmp = append(tmp, CODE62[tmpNumber.Int64()]) + result = append(tmp, result...) + number.Div(number, big.NewInt(CodeLenth)) + } + return string(result) +} + +/** + * 10进制 为 62进制 字符串。大数字类型 + * + * param: int64 num 待转换的数字 + * return: string + */ +func From10To62(number int64) string { + var result []byte + for number != 0 { + var tmp []byte + number1 := number % CodeLenth + tmp = append(tmp, CODE62[number1]) + result = append(tmp, result...) + number = number / CodeLenth + } + return string(result) +} + +func GenUUID() string { + u1 := uuid.Must(uuid.NewV4(), nil).String() + uuidStr := strings.Replace(u1, "-", "", -1) + number, _ := new(big.Int).SetString(uuidStr, 16) + return From10To62Big(number) +} + +func Abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +func Gen32String() string { + u1 := uuid.Must(uuid.NewV4(), nil).String() + uuidStr := strings.Replace(u1, "-", "", -1) + return uuidStr +} diff --git a/pub/common/netutil/netutil.go b/pub/common/netutil/netutil.go new file mode 100644 index 0000000..a2b68b8 --- /dev/null +++ b/pub/common/netutil/netutil.go @@ -0,0 +1,52 @@ +package netutil + +import ( + "net" + "net/url" + "path" + "strings" +) + +func GetLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, address := range addrs { + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + return "" +} + +func MergeURL(baseURL, relativePath string) string { + // 解析基础URL + base, err := url.Parse(baseURL) + if err != nil { + return "" + } + + // 如果相对路径是绝对URL,直接返回 + if strings.HasPrefix(relativePath, "http://") || strings.HasPrefix(relativePath, "https://") { + return relativePath + } + + // 合并路径 + mergedPath := path.Join(base.Path, relativePath) + + // 处理路径中的 ./ 和 ../ + mergedPath = path.Clean(mergedPath) + + // 确保路径以/开头(如果原始base.Path以/结尾) + if strings.HasSuffix(base.Path, "/") && !strings.HasPrefix(mergedPath, "/") { + mergedPath = "/" + mergedPath + } + + // 更新URL的Path部分 + base.Path = mergedPath + + return base.String() +} diff --git a/pub/common/pageopt/match.go b/pub/common/pageopt/match.go new file mode 100644 index 0000000..e6185af --- /dev/null +++ b/pub/common/pageopt/match.go @@ -0,0 +1,580 @@ +package pageopt + +import ( + "reflect" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// A A from Array +type A = []interface{} + +// M M from Map +type M = bson.M + +// *******************************************// +// Matcher +type Matcher interface { + Filter() M +} + +// MergeM +func MergeM(mats []Matcher) M { + merge := M{} + for _, m := range mats { + if m == nil { + continue + } + ql := m.Filter() + for k, v := range ql { + merge[k] = v + } + } + return merge +} + +// *******************************************// +type MergeMatch struct { + merge M +} + +func NewMergeMatch(mats ...Matcher) Matcher { + mergeM := MergeM(mats) + return &MergeMatch{mergeM} +} + +func (m *MergeMatch) Filter() M { + return m.merge +} + +// *******************************************// +// AssignMatch +type AssignMatch struct { + Key string + Val interface{} +} + +func NewAssignMatch(key string, val interface{}) Matcher { + return &AssignMatch{ + key, + val, + } +} + +func (m *AssignMatch) Filter() M { + if IsEmpt(m.Val) { + return M{} + } + return M{ + m.Key: m.Val, + } +} + +// *******************************************// + +type Sort = bson.D + +func SortDesc(Key string) Sort { + return Sort{{Key, -1}} +} + +func SortAsc(Key string) Sort { + return Sort{{Key, 1}} +} + +// *******************************************// +// RegexMatch +type RegexMatch struct { + Key string + Val interface{} + Options string +} + +func NewRegexMatch(key string, val interface{}, opt string) Matcher { + return &RegexMatch{ + Key: key, + Val: val, + Options: opt, + } +} + +func (r *RegexMatch) Filter() M { + if IsEmpt(r.Val) { + return M{} + } + return M{ + r.Key: M{"$regex": r.Val, "$options": r.Options}, + } +} + +// *******************************************// +func IsNil(i interface{}) bool { + vi := reflect.ValueOf(i) + return vi.IsNil() +} + +func IsEmpt(i interface{}) bool { + v := reflect.ValueOf(i) + switch v.Kind() { + case reflect.Ptr: + return v.IsNil() + case reflect.Slice, reflect.Array: + return v.Len() == 0 + } + return false +} + +// *******************************************// + +type NEMatch struct { + Key string + NE interface{} +} + +func (m *NEMatch) Filter() M { + if IsEmpt(m.NE) { + return M{} + } + return M{m.Key: M{"$ne": m.NE}} +} + +func NewNEMatch(Key string, ne interface{}) Matcher { + return &NEMatch{ + Key: Key, + NE: ne, + } +} + +// *******************************************// +// GEMatch +type GEMatch struct { + Key string + GT interface{} +} + +func NewGEMatch(Key string, gt interface{}) Matcher { + return &GEMatch{ + Key: Key, + GT: gt, + } +} + +func (m *GEMatch) Filter() M { + if IsEmpt(m.GT) { + return M{} + } + return M{m.Key: M{"$gt": m.GT}} +} + +// *******************************************// +// GTEMatch +type GTEMatch struct { + Key string + GTE interface{} +} + +func NewGTEMatch(Key string, gte interface{}) Matcher { + return >EMatch{ + Key: Key, + GTE: gte, + } +} + +func (m *GTEMatch) Filter() M { + if IsEmpt(m.GTE) { + return M{} + } + return M{m.Key: M{"$gte": m.GTE}} +} + +// *******************************************// + +// GTMatch +type GTMatch struct { + Key string + GT interface{} +} + +func NewGTMatch(Key string, gt interface{}) Matcher { + return >Match{ + Key: Key, + GT: gt, + } +} + +func (m *GTMatch) Filter() M { + if IsEmpt(m.GT) { + return M{} + } + return M{m.Key: M{"$gt": m.GT}} +} + +// *******************************************// +// LTMatch +type LTMatch struct { + Key string + LT interface{} +} + +func NewLTMatch(Key string, lt interface{}) Matcher { + return <Match{ + Key: Key, + LT: lt, + } +} + +func (m *LTMatch) Filter() M { + if IsEmpt(m.LT) { + return M{} + } + return M{m.Key: M{"$lt": m.LT}} +} + +// *******************************************// + +// LTEMatch +type LTEMatch struct { + Key string + LTE interface{} +} + +func NewLTEMatch(Key string, lte interface{}) Matcher { + return <EMatch{ + Key: Key, + LTE: lte, + } +} + +func (m *LTEMatch) Filter() M { + if IsEmpt(m.LTE) { + return M{} + } + return M{m.Key: M{"$lte": m.LTE}} +} + +// *******************************************// +// GTEAndLTEMatch +type GTEAndLTEMatch struct { + Key string + GTE interface{} + LTE interface{} +} + +func NewGTEAndLTEMatch(Key string, gte, lte interface{}) Matcher { + return >EAndLTEMatch{ + Key, + gte, + lte, + } +} + +func (m *GTEAndLTEMatch) Filter() M { + if IsEmpt(m.GTE) || IsEmpt(m.LTE) { + return M{} + } + + return M{ + m.Key: M{ + "$gte": m.GTE, + "$lte": m.LTE, + }, + } +} + +// *******************************************// +// GTEAndLTMatch +type GTEAndLTMatch struct { + Key string + GTE interface{} + LT interface{} +} + +func NewGTEAndLTMatch(key string, gte, lt interface{}) Matcher { + return >EAndLTMatch{ + key, + gte, + lt, + } +} + +func (m *GTEAndLTMatch) Filter() M { + if IsEmpt(m.GTE) || IsEmpt(m.LT) { + return M{} + } + + return M{ + m.Key: M{ + "$gte": m.GTE, + "$lt": m.LT, + }, + } +} + +// *******************************************// + +// ExistsMatch +type ExistsMatch struct { + Key string + IsExisted *bool +} + +func NewExistsMatch(key string, isExisted *bool) Matcher { + return &ExistsMatch{ + Key: key, + IsExisted: isExisted, + } +} + +func (e *ExistsMatch) Filter() M { + if IsEmpt(e.IsExisted) { + return M{} + } + return M{ + e.Key: M{"$exists": e.IsExisted}, + } +} + +// *******************************************// +// InMatch +type InMatch struct { + Key string + Slice interface{} +} + +func NewInMatch(key string, slice interface{}) Matcher { + return &InMatch{ + Key: key, + Slice: slice, + } +} + +func (i *InMatch) Filter() M { + if IsNil(i.Slice) { + return M{} + } + return M{ + i.Key: M{"$in": i.Slice}, + } +} + +// *******************************************// +// NinMatch +type NinMatch struct { + Key string + Slice interface{} +} + +func NewNinMatch(key string, slice interface{}) Matcher { + return &NinMatch{ + Key: key, + Slice: slice, + } +} + +// *******************************************// +func (n *NinMatch) Filter() M { + if IsNil(n.Slice) { + return M{} + } + return M{ + n.Key: M{"$nin": n.Slice}, + } +} + +// *******************************************// +// ManualOrMatch +type ManualOrMatch struct { + Key string + Mats []Matcher +} + +func NewManualOrMatch(key string, mats []Matcher) Matcher { + return &ManualOrMatch{ + Key: key, + Mats: mats, + } +} + +func (o *ManualOrMatch) Filter() M { + if len(o.Mats) == 0 { + return M{} + } + fs := []interface{}{} + for _, v := range o.Mats { + f := v.Filter() + if len(f) != 0 { + fs = append(fs, v.Filter()) + } + } + if len(fs) == 0 { + return M{} + } + return M{"$or": fs} +} + +// *******************************************// +// BitsAllSetMatch +type BitsAllSetMatch struct { + Key string + Bits []int +} + +func NewBitsAllSetMatch(key string, bits []int) Matcher { + return &BitsAllSetMatch{ + Key: key, + Bits: bits, + } +} + +func (b *BitsAllSetMatch) Filter() M { + if len(b.Bits) == 0 { + return M{} + } + return M{ + b.Key: M{"$bitsAllSet": b.Bits}, + } +} + +// BitsAllClearMatch +type BitsAllClearMatch struct { + Key string + Bits []int +} + +// *******************************************// +func NewBitsAllClearMatch(key string, bits []int) Matcher { + return &BitsAllClearMatch{ + Key: key, + Bits: bits, + } +} + +func (b *BitsAllClearMatch) Filter() M { + if len(b.Bits) == 0 { + return M{} + } + return M{ + b.Key: M{"$bitsAllClear": b.Bits}, + } +} + +// *******************************************// + +// UIDMatch +type UIDMatch struct { + UID *int64 +} + +func (u *UIDMatch) New() Matcher { + return NewAssignMatch("userId", u.UID) +} + +// UIDInMatch +type UIDInMatch struct { + UIDS []int64 +} + +func (u *UIDInMatch) New() Matcher { + return NewInMatch("userId", u.UIDS) +} + +// *******************************************// +// UIDMatch +type UserUIDMatch struct { + UID *int64 +} + +func (u *UserUIDMatch) New() Matcher { + return NewAssignMatch("id", u.UID) +} + +// UIDInMatch +type UserUIDInMatch struct { + UIDS []int64 +} + +func (u *UserUIDInMatch) New() Matcher { + return NewInMatch("id", u.UIDS) +} + +// *******************************************// + +// IDMatch +type IDMatch struct { + ID *primitive.ObjectID +} + +func (i *IDMatch) New() Matcher { + return NewAssignMatch("_id", i.ID) +} + +// IDMatch +type IDint64Match struct { + Id *int64 +} + +func (i *IDint64Match) New() Matcher { + return NewAssignMatch("id", i.Id) +} + +// *******************************************// +// IDInMatch +type IDInMatch struct { + IDs []primitive.ObjectID +} + +func (c *IDInMatch) New() Matcher { + return NewInMatch("_id", c.IDs) +} + +// *******************************************// +// CreatedAtGTEMatch +type CreatedAtGTEMatch struct { + GTE *time.Time +} + +func (c *CreatedAtGTEMatch) New() Matcher { + return NewGTEMatch("createdAt", c.GTE) +} + +// *******************************************// +// CreatedAtLTMatch +type CreatedAtLTMatch struct { + LT *time.Time +} + +func (c *CreatedAtLTMatch) New() Matcher { + return NewLTMatch("createdAt", c.LT) +} + +// *******************************************// +// CreatedAtGTEAndLTMatch +type CreatedAtGTEAndLTMatch struct { + GTE *time.Time + LT *time.Time +} + +func (c *CreatedAtGTEAndLTMatch) New() Matcher { + return NewGTEAndLTMatch("createdAt", c.GTE, c.LT) +} + +// // *******************************************// +// type Int32Match struct { +// key string //值 +// value *int32 +// } +// +// func (i *Int32Match) New() Matcher { +// return NewAssignMatch("_id", i.ID) +// } +// +// type Int32InMatch struct { +// key string //值 +// values []int32 +// } +// +// func (c *Int32InMatch) New() Matcher { +// return NewInMatch("_id", c.IDs) +// } diff --git a/pub/common/pointers/pointers.go b/pub/common/pointers/pointers.go new file mode 100644 index 0000000..908c79d --- /dev/null +++ b/pub/common/pointers/pointers.go @@ -0,0 +1,83 @@ +package pointers + +import "time" + +func Bool(v bool) *bool { return &v } +func Int(v int) *int { return &v } +func Int32(v int32) *int32 { return &v } +func Int64(v int64) *int64 { return &v } +func Uint32(v uint32) *uint32 { return &v } +func Uint64(v uint64) *uint64 { return &v } +func Float32(v float32) *float32 { return &v } +func Float64(v float64) *float64 { return &v } +func String(v string) *string { return &v } +func Time(v time.Time) *time.Time { return &v } +func StringSlice(v []string) *[]string { return &v } +func Int64Slice(v []int64) *[]int64 { return &v } + +func GetBool(v *bool) bool { + if v != nil { + return *v + } + return false +} +func GetInt(v *int) int { + if v != nil { + return *v + } + return 0 +} +func GetInt32(v *int32) int32 { + if v != nil { + return *v + } + return 0 +} +func GetInt64(v *int64) int64 { + if v != nil { + return *v + } + return 0 +} +func GetInt64Slice(v *[]int64) []int64 { + if v != nil { + return *v + } + return []int64{} +} +func GetUint32(v *uint32) uint32 { + if v != nil { + return *v + } + return 0 +} +func GetUint64(v *uint64) uint64 { + if v != nil { + return *v + } + return 0 +} +func GetFloat32(v *float32) float32 { + if v != nil { + return *v + } + return 0 +} +func GetFloat64(v *float64) float64 { + if v != nil { + return *v + } + return 0 +} +func GetString(v *string) string { + if v != nil { + return *v + } + return "" +} +func GetStringSlice(v *[]string) []string { + if v != nil { + return *v + } + return []string{} +} diff --git a/pub/common/random/number.go b/pub/common/random/number.go new file mode 100644 index 0000000..af0d5c3 --- /dev/null +++ b/pub/common/random/number.go @@ -0,0 +1,53 @@ +package random + +import ( + "fmt" + "math/rand" + "time" +) + +const letterBytesAll = "0123456789abcdefghijklmnopqrstvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func init() { + rand.Seed(time.Now().Unix()) +} + +func Int(n int) int { + return rand.Intn(n) +} + +func SliceRand(ids []int64) (result []int64) { + result = make([]int64, 0) + if len(ids) <= 0 { + return + } + copy := make([]int64, 0) + for _, id := range ids { + copy = append(copy, id) + } + num := Int(len(copy)) + 1 + for i := 0; i < num; i++ { + index := Int(len(copy)) + result = append(result, copy[index]) + copy = append(copy[:index], copy[index+1:]...) + } + return +} + +func RandDigits(n uint) string { + s := "" + for n != 0 { + s += fmt.Sprintf("%d", rand.Intn(10)) + n-- + } + return s +} + +// RandStrRandrAll 生成指定位数的随机数 +func RandStrRandrAll(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytesAll[rand.Int63()%int64(len(letterBytesAll))] + } + return string(b) +} diff --git a/pub/common/recomapi/define.go b/pub/common/recomapi/define.go new file mode 100644 index 0000000..11969a4 --- /dev/null +++ b/pub/common/recomapi/define.go @@ -0,0 +1,42 @@ +package recomapi + +import "time" + +const ( + FBT_Star string = "star" + FBT_Like string = "like" + FBT_Read string = "read" +) + +type RSItemInfo struct { + Comment string `json:"Comment"` + ItemId string `json:"ItemId"` + Labels []string `json:"Labels"` + Timestamp time.Time `json:"Timestamp"` +} + +type RSItemInfoList []*RSItemInfo + +type RSUserInfo struct { + Comment string `json:"Comment"` + UserId string `json:"UserId"` + Labels []string `json:"Labels"` + Subscribe []string `json:"Subscribe"` + Timestamp time.Time `json:"Timestamp"` +} + +type RSUserInfoList []*RSUserInfo + +type APIResult struct { + RowAffected int64 `json:"RowAffected"` +} + +type FeedbackInfo struct { + Comment string `json:"Comment"` + FeedbackType string `json:"FeedbackType"` + ItemId string `json:"ItemId"` + Timestamp time.Time `json:"Timestamp"` + UserId string `json:"UserId"` +} + +type FeedbackInfoList []*FeedbackInfo diff --git a/pub/common/recomapi/feedback.go b/pub/common/recomapi/feedback.go new file mode 100644 index 0000000..c933a08 --- /dev/null +++ b/pub/common/recomapi/feedback.go @@ -0,0 +1,45 @@ +package recomapi + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" +) + +// AddOneFeedback 添加一个推荐反馈 +func AddOneFeedback(url string, key string, + userId int64, mediaId int64, fbType string, comment string) ( + int64, *srvdef.SrvErr) { + feedBack := &FeedbackInfo{ + UserId: strconv.FormatInt(userId, 10), + ItemId: strconv.FormatInt(mediaId, 10), + FeedbackType: fbType, + Comment: comment, + Timestamp: time.Now(), + } + return AddManyFeedbacks(url, key, FeedbackInfoList{feedBack}) +} + +func AddManyFeedbacks(url string, key string, feedbacks FeedbackInfoList) ( + int64, *srvdef.SrvErr) { + reqUrl := url + "/api/feedback" + resp := &APIResult{} + reqBody, _ := json.Marshal(feedbacks) + header := map[string]string{"X-API-Key": key} + code, err := utils.POSTJsonWithJResp(resp, reqUrl, header, reqBody) + if err != nil { + log.Error("recomapi:AddManyFeedbacks POSTJsonWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return 0, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:AddManyFeedbacks POSTJsonWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return 0, srvdef.New(srvdef.Err, "", "") + } + return resp.RowAffected, nil +} diff --git a/pub/common/recomapi/itemapi.go b/pub/common/recomapi/itemapi.go new file mode 100644 index 0000000..51578a7 --- /dev/null +++ b/pub/common/recomapi/itemapi.go @@ -0,0 +1,106 @@ +package recomapi + +import ( + "encoding/json" + "fmt" + "net/http" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" +) + +func InsertOneItem(url string, key string, itemInfo *RSItemInfo) *srvdef.SrvErr { + reqUrl := url + "/api/item" + resp := &APIResult{} + reqBody, _ := json.Marshal(itemInfo) + header := map[string]string{"X-API-Key": key} + code, err := utils.POSTJsonWithJResp(resp, reqUrl, header, reqBody) + if err != nil { + log.Error("recomapi:InsertOneItem POSTJsonWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:InsertOneItem POSTJsonWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return srvdef.New(srvdef.Err, "", "") + } + return nil +} + +func InsertManyItems(url string, key string, itemInfos RSItemInfoList) *srvdef.SrvErr { + reqUrl := url + "/api/items" + reqBody, _ := json.Marshal(itemInfos) + header := map[string]string{"X-API-Key": key} + code, err := utils.POSTJsonWithJResp(nil, reqUrl, header, reqBody) + if err != nil { + log.Error("recomapi:InsertManyItems POSTJsonWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:InsertManyItems POSTJsonWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return srvdef.New(srvdef.Err, "", "") + } + return nil +} + +func GetOneItem(url string, key string, itemId string) (*RSItemInfo, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/item/%s", url, itemId) + resp := &RSItemInfo{} + header := map[string]string{"X-API-Key": key} + code, err := utils.GetWithJResp(resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:GetOneItem GetWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return nil, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:GetOneItem GetWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return nil, srvdef.New(srvdef.Err, "", "") + } + log.Info("Test", log.Any("resp", resp)) + return resp, nil +} + +func GetManyItems(url string, key string, cursor string, size int64) (RSItemInfoList, string, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/items?n=%d&cursor=%s", url, size, cursor) + type Resp struct { + Cursor string `json:"Cursor"` + Items RSItemInfoList `json:"Items"` + } + var resp Resp + header := map[string]string{"X-API-Key": key} + code, err := utils.GetWithJResp(&resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:GetOneItem GetWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return nil, "", srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:GetOneItem GetWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return nil, "", srvdef.New(srvdef.Err, "", "") + } + return resp.Items, resp.Cursor, nil +} + +func DeleteOneItem(url string, key string, itemId string) (int64, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/item/%s", url, itemId) + resp := &APIResult{} + header := map[string]string{"X-API-Key": key} + code, err := utils.DeleteWithJResp(resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:DeleteOneItem DeleteWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return 0, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:DeleteOneItem DeleteWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return 0, srvdef.New(srvdef.Err, "", "") + } + return resp.RowAffected, nil +} diff --git a/pub/common/recomapi/recomapi.go b/pub/common/recomapi/recomapi.go new file mode 100644 index 0000000..b4356a6 --- /dev/null +++ b/pub/common/recomapi/recomapi.go @@ -0,0 +1,36 @@ +package recomapi + +import ( + "fmt" + "net/http" + "strconv" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" +) + +func GetRecomItems(url string, key string, userId int64, writeBack string, size int64) ( + []int64, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/recommend/%d?n=%d&write-back=%s", url, userId, size, writeBack) + var resp []string + header := map[string]string{"X-API-Key": key} + code, err := utils.GetWithJResp(&resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:GetRecomItems GetWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return nil, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:GetRecomItems GetWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return nil, srvdef.New(srvdef.Err, "", "") + } + itemIds := make([]int64, 0) + for _, v := range resp { + id, err := strconv.ParseInt(v, 10, 64) + if err == nil { + itemIds = append(itemIds, id) + } + } + return itemIds, nil +} diff --git a/pub/common/recomapi/usersapi.go b/pub/common/recomapi/usersapi.go new file mode 100644 index 0000000..e8699bd --- /dev/null +++ b/pub/common/recomapi/usersapi.go @@ -0,0 +1 @@ +package recomapi diff --git a/pub/common/regapi.go b/pub/common/regapi.go new file mode 100644 index 0000000..aba3d64 --- /dev/null +++ b/pub/common/regapi.go @@ -0,0 +1,26 @@ +package common + +import ( + "github.com/gin-gonic/gin" +) + +// type FunHandle func(*gin.Context) (proto.Message, string, string, pkt_pub.EType) + +type ApiOpType int + +const ( + ApiGet ApiOpType = 1 + ApiPost ApiOpType = 2 + ApiPut ApiOpType = 3 +) + +// API接口设置 +type ApiSet struct { + Path string // 路径 + OpType ApiOpType // 操作类型 + Fun gin.HandlerFunc // 函数句柄 + IsWhite bool // 是否白名单 + Desc string // 描述 +} + +var ApiSetMaps = map[string]ApiSet{} diff --git a/pub/common/sms/fengniao/fengniao.go b/pub/common/sms/fengniao/fengniao.go new file mode 100644 index 0000000..5d11c13 --- /dev/null +++ b/pub/common/sms/fengniao/fengniao.go @@ -0,0 +1,76 @@ +package fengniao + +import ( + "crypto/sha1" + "errors" + "fmt" + "net/http" + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" +) + +type requestBody struct { + MercId string `json:"mercId"` + Secret string `json:"secret"` + TemplateId string `json:"templateId"` + Code string `json:"smsCode"` + Mobile string `json:"mobile"` + SignType string `json:"signType"` // 固定sha1 + Sign string `json:"sign"` // 签名 +} + +type msg struct { + Status string `json:"status"` + TaskId string `json:"taskId"` +} + +type responseBody struct { + Code int `json:"code"` + Msg msg `json:"msg"` + Err string `json:"err"` +} + +type FengNiao struct { + Url string + MercId string + Secret string + Tmpl string +} + +// Send 发送, 目前蜂鸟貌似只支持数字 +func (f *FengNiao) Send(mobile string, code string) error { + content := fmt.Sprintf(f.Tmpl, code) + data := fmt.Sprintf("mercId=%s&templateId=%s&smsCode=%s&mobile=%s&secret=%s", f.MercId, f.Tmpl, code, mobile, f.Secret) + + sha := sha1.New() + _, err := sha.Write([]byte(data)) + if err != nil { + log.Error("fengniao send error", log.Any("mobile", mobile), log.Any("content", content), log.E(err)) + return err + } + + body := &requestBody{ + MercId: f.MercId, + Secret: f.Secret, + TemplateId: f.Tmpl, + Code: code, + Mobile: mobile, + SignType: "sha1", + Sign: fmt.Sprintf("%x", sha.Sum(nil)), + } + rp := responseBody{} + statusCode, err := httputil.Client().POSTJsonWithJResp(&rp, f.Url, nil, body) + if err != nil { + log.Error("fengniao POSTJsonWithResp err", log.E(err)) + return err + } + if statusCode != http.StatusOK { + log.Error("fengniao status code not 200") + return errors.New("fengniao statusCode not 200") + } + if rp.Code != 200 { + log.Error("fengniao code not 200", log.Any("errdesc", rp.Err)) + err = errors.New(rp.Err) + } + return err +} diff --git a/pub/common/sms/sms.go b/pub/common/sms/sms.go new file mode 100644 index 0000000..e0c5514 --- /dev/null +++ b/pub/common/sms/sms.go @@ -0,0 +1,45 @@ +package sms + +import ( + "strings" + "videoweb/pub/common/sms/fengniao" +) + +type Provider = int + +const ( + tmplID1 = "6029f19a99fd8b404062e4d2" // 大陆 + tmplID2 = "5f8412c39396d16f787e561b" // 非大陆 +) + +const ( + FengNiao Provider = 1 //蜂鸟 + YunPian Provider = 2 //云片 +) + +func sendByFengNiao(mobile string, code string) error { + var tmplID string + if strings.HasPrefix(mobile, "+86") { + tmplID = tmplID1 + return (&fengniao.FengNiao{ + Url: "https://t9x6k.cc/api/sms/sendSms", + MercId: "10177", + Secret: "2dfb7ec44ed59ee3c1fe8dc533384e16", + Tmpl: tmplID, + }).Send(mobile, code) + } else { + tmplID = tmplID2 + return (&fengniao.FengNiao{ + Url: "https://t9x6k.cc/api/sms/sendSms", + MercId: "10228", + Secret: "3ea58a27a162dad4ed3e18c076648742", + Tmpl: tmplID, + }).Send(mobile, code) + } + // return +} + +// Send 发送短信 +func Send(mobile string, code string) error { + return sendByFengNiao(mobile, code) +} diff --git a/pub/common/srvdef/codedef.go b/pub/common/srvdef/codedef.go new file mode 100644 index 0000000..e56b9a4 --- /dev/null +++ b/pub/common/srvdef/codedef.go @@ -0,0 +1,328 @@ +package srvdef + +import "fmt" + +type CodeType int + +const NotFind = "mongo: no documents in result" + +// 错误级别 +const ( + ParamLevel = 1000 // 参数级 + StoreLevel = 3000 // 存储级 + DBLevel = 5000 // 数据库 + ServerLevel = 4000 // 服务级 + BusinessLevel = 6000 // 业务逻辑级 + NewErrorLevelV2 = 7000 // 新版错误 + NewErrorLevelV3 = 8000 // 新版错误 +) + +// 通用code +const ( + Success CodeType = 200 //Success 通用成功 + Failure CodeType = 400 //Failure 通用失败 +) + +func (c CodeType) Msg() string { + msg, ok := codeAndMsg[c] + if ok { + return msg + } + return "" +} + +func (c CodeType) Tip() string { + tip, ok := codeAndTip[c] + if ok { + return tip + } + return "" +} + +func (c CodeType) Error() string { + return fmt.Sprintf("code=%d,msg=%v,tip=%v", c, c.Msg(), c.Tip()) +} + +type ErrInfoer interface { + GetMsg() string + GetTip() string + GetCode() CodeType +} + +// GetMsg alias of Msg() +func (c CodeType) GetMsg() string { return c.Msg() } + +// GetTip alias of Tip() +func (c CodeType) GetTip() string { return c.Tip() } +func (c CodeType) GetCode() CodeType { return c } + +const ( + InvalidToken CodeType = ParamLevel + iota // 框架错误 + ErrParamError // 参数错误 + NoToken // 无token + RoleErr // 角色错误 + ReLogin // 重复登陆 + UploadToFileSrvFailed // 文件上传失败 + ErrCaptcha // 验证码为空 + ErrCaptchaInvalid // 验证码非法 + ErrCaptchaGet // 验证码获取失败 + ErrCaptchaResend // 验证码重复发送 + ErrCaptchaSendFail // 验证码发送失败 + InvalidateToken // 非法token + NoUA // 没有UA信息 + UploadFile // 文件上传失败 + AlreadyRegisered // 已经组册 + TokenRemoved // 你已经挤下线,请重新登陆 + PostCommentNotUse //发送失败,服务器错误,请稍后再试 + DBTransaction // 事务错误 + ErrCrawlerUrl // 爬虫URL错误 + ErrCrawler // 爬取数据错误 +) + +const ( + DBQuery CodeType = StoreLevel + iota + DBInsert + DBUpdate + DBDelete + RedisSet // 设置redis错误 + RedisGet // 获取redis值错误 + RedisDel // 删除redis值错误 + EtcdGet // 获取etcd错误 + MongoDBQueryErr // 数据库查询失败 +) + +const ( + ServerError CodeType = ServerLevel + iota // 用户相关错误 +) + +const ( + _ CodeType = DBLevel + iota + ErrDbQueryError //ErrDbQueryError 数据库查询错误 5001 + ErrDbInsertError //ErrDbInsertError 数据库插入错误 5002 + ErrDbUpdateError //ErrDbUpdateError 数据库修改错误 5003 + ErrDbDeleteError //ErrDbDeleteError 数据库删除错误 5004 + ErrDbInputExist //ErrDbInputExist 数据库输入值已经存在 5005 + ErrRdsOperError //ErrRdsOperError redis操作错误 5006 + ErrElsOperError //ErrElsOperError redis操作错误 5007 + ErrTransactionsError //ErrTransactionsError 数据库事务操作错误 + ErrNoData //无数据 +) +const ( + ErrUserPermission CodeType = BusinessLevel + iota // 用户权限 + ErrUserNotExsit // 用户不存在 + ErrUserTypeNotUP // 用户不是UP主 + AccountIsNotExists // 账户不存在 + AccountErrAccessForbid // 账户禁止登陆 + AccountErrPasswordError // 账户密码错误 + GenerateTokenFailed // 生成二维码错误 + ErrCardNotExists // 没有会员卡信息 + ErrMadouCoinNotExists // 没有钻石信息 + ErrMediaNotExsit // 视频不存在 + ErrTopicInfoNotExsit // 视频专题不存在 + ErrData // 数据错误 + Err // 一般错误 + ErrRchgOrderNotExist // 充值订单不存在 + ErrSubmitRchgOrder // 发起充值订单错误 + InvalidRequestReplayAttack // 非法请求 + ErrBuyVIPCardFail // 购买会员卡失败 + ErrOperationTooFrequently // 用户操作太频繁 + ErrMediaSyncServer // 视频同步服务器错误 + ErrUserType // 用户无改权限 + DownloadM3u8File // 文件服务器访问失败 + MediaSyncing // 视频同步中 + MediaConnectServerFaild // 媒资库链接失败 + AtressNotExist // 演员不存在 + ErrAnnouncementNotExsit // 公告不存在 + ErrContactDetailsNotExsit // 联系方式不存在 + ErrPostCommentNotExsit // 评论不存在 + HistoryNotExist // 记录不存在 + ErrHotsearchNotExsit // 热搜不存在 + ActivityDoesNotExist // 活动不存在 + UserWatchNumNotEnough // 免费观看次数不足 + MediaIsVip // 用户不是会员 + MediaIsNeedPay // 视频需要付费 + RepeatBuy // 重复购买 + MovieTicketsNotExits // 观影券不足 + MediaIsNotNeedPay // 视频不需要支付 + PostNoNeed2Pay // 帖子不需要支付 + SystemMsgNotExist // 消息不存在 + ForbidlogNotExist // 封禁消息不存在 + AttachmentDoesNotExist // 导出记录 + UserDownloadNotEnough // 下载次数不足 + UserDownloadNeedPay // 付费视频需要先购买 + RedeemCodeNotExists // 兑换码不存在 + RedeemCodeTimesUsedUp // 兑换码次数使用完了 + RedeemCodeAlreadyUsed // 兑换码你已经使用过兑换码了 + RedeemCodeRecordNotExists // 兑换记录不存在 + MediaIsNotNeedPreSale // 视频不支持预售 + MediaIsPreSale // 预售视频 + QRcodeError // 二维码错误 + ModifyUserNameError // 修改 + ErrSkdRecordNotExsit // 任务不存在 + ErrContainIllegalWord // 包含非法字符 + DatingOrderNotExist // 约会购买记录不存在 + DatingHasBuy // 重复购买 + DatingCollectNotExist // 收藏记录不存在 + DatingBossNotExist // 经纪人不存在 + DatingReportNotExist // 验证报告不存在 + DatingReportDouble // 重复验证 + DatingAlreadyComplaint // 已经提交举报 + SortNotExist // 视频排序不存在 + MessageDialogNotExist // 私信对话框不存在 + VideoUploadFaild // 文件上传失败 + PosoStatusFaild // 帖子状态错误 + WtdrFrequencyLimits // 提现频率限制 + WtdrErroTip1 // 提现不是100整数倍 + WtdrErroTip2 // 赠送3元金额要满500才能提现 + WtdrErroTip3 // 请填写银行名字或升级App版本 + GameWtdrErroTip7 // 银行卡必须与您以前的提现卡一致,如需更改请联系客服 + ProxyUserNotExist // 代理不存在 + ErrPostReportNotExsit // 举报信息不存在 + FileUploadDouble // 文件重复上传 + UserRepeatSubmitRchgOrder // 未支付订单太多,有问题请联系客服 + Limiting // 限流 + ErrIpForbid // Ip禁止 + DeviceIdInvalid // 设备id无效 + UserScoreNotEnough // 用户积分不足 + UserSocreLVLimit // 用户积分等级限制 + UploaderNeedActive // Up主没有激活 + UploaderNeedBind // 后台用户没有up主绑定 + PostCollectionNotExists // 图文专辑不存在 + MediaCollectionNotExist // 该合集或选集不存在 + ContestNotExists // 该比赛不存在 + CannotJoinContest // 该比赛无法参加 + ContestIsVip // 该比赛只限VIP参加 + AppInfoNotExist // 该App不存在 + MediaNotVerified // 视频未上架 + PCCategoryUnmatch // 专辑的分类和作品的分类不一致 + ErrMediaRecommendNotExsit // 推荐不存在 + DatingBuyHistoryNotExist // 购买记录不存在 + DatingWatchHistoryNotExist // 观看记录不存在 + CollectIsExist // 收藏已经存在 + CollectNotExist // 收藏不存在 + DiscussRankDoesNotExist // 话题视频排名 + DiscussActivityDoesNotExist // 话题活动不存在 + UpDayStatDoesNotExist // 创作者收益 + DiscussObjectRankDoesNotExist // 话题视频 + DiscussCategoryDoesNotExist // 话题分类不存在 + ErrCareNotExist // 取消关注对象不存在 + ErrUpMasterTaskNotCompleted // up主申请任务未完成 + HGameNotExist // 游戏不存在 + HGameIsVip // 游戏需要会员 + HGameIsNeedPay // 游戏需要付费 + ErrApplyUpIsVip // UP主申请需要vip + UserLevelCardNotExist // 用户等级卡不存在 + UserCheckinSetupNotExist // 签到配置不存在 + PriceMediaNotCancel // 钻石视频不能下架 + PriceMediaHasBuy // 钻石视频已经被购买 + GiftWeekIsExist // 礼包已领取 + UserLevelPointTooLow // 用户等级积分太低 + UserGiftHaveReceive // 任务奖励已经领取 + UserGiftExistNoReceive // 没有领取资格 + DatingUpMasterNotExist // up主不存在 + UserIsUpMaster // 用户已经是up主 + UserUpCerting // up主认证中 + MissionSetupNotExist // 任务配置不存在 + UserIsUpMasterBind // 用户已被up主绑定 + ErrCareIsExist // 不可重复关注 + ObjectDayStatDoesNotExist // 作品统计记录不存在 + ObjectDayViewIncStatDoesNotExist // 作品增量记录不存在 + UserNotEnoughFreeLotteryCount // 免费抽奖次数不足 + PrizeOutOfStore // 奖品库存不足 + MediaSubscribedNotNeedPay // 订阅的视频不需要支付 + DatingComplaintNotExist // 举报帖子不存在 + DatingUserNotExist // 楼凤不存在 + UserDauInfoNotExist // 搜寻日期没有资料 + ParseJsonError // 数据解析错误 + AlreadyRefund // 已退款 + AiCountTypeNotSupport // ai内容不支持 + TodayPontNotExist // 积分记录不存在 + RedeemPrizeHistoryNotExist // 奖品兑换记录不存在 + PrizeNotExist // 奖品不存在 + ErrUserPointsNotExist // 积分不足 + AIFreeCountNotEnough // ai免费次使用数不足 + AIFreeCountNOtSupportRefund // AI免费使用次数不支持退款 + CrowdfundStatusFail // 众筹女神的状态不在可修改的范围内 + CrowdfundNotOpenPrizeFail // 众筹女神的订单开奖失败 + CrowdfundPrizeNumberError // 众筹女神的开奖号码不对 + RedeemFail // 积分兑换失败 + CrowdfundNotBuy // 当前女神状态不能购买 + CrowdfundNumberHaveBuy // 这个号码已经被其他人购买了 + UserCheckinDisabled // 用户签到功能已关闭 + PrizeTypeUnsupported // 奖品类型不支持自动发放 + CouponExpired // 卡券已过期 + CouponUsed // 卡券已被使用 + CouponNotFound // 没有卡券信息 + JumpTabNotExist // 首页跳转tab不存在 + CouponCanNotUse // 卡券无法使用 + SysConfigError //系统配置错误 + ComicsCateNotExist // 漫画分类不存在 + ComicsTagNotExist // 漫画标签不存在 + ComicsTopicNotExist // 漫画主题不存在 + ErrComicsChapterNotExist // 漫画章节不存在 + ComicsNotExist // 漫画不存在 + ComicsCVNotExist // 漫画CV不存在 + ComicsMediaNotExist // 漫画视频不存在 + ComicsRankNotExist // 排行榜不存在 + VomicsTopicNotExist // 有声漫不存在 + ComicsIsBlack // 漫画在黑名单中 + CommentTextIsEmpty // 评论内容为空 + ComicsIsVip // 漫画需要会员 + ComicsIsNeedPay // 漫画需要付费 + ComicsChapterExist // 漫画章节已存在 + ObjectNotExist // 数据不存在 + GameWtdrErroTip3 // 请填写银行名字或升级App版本 + UserDoTaskErr // 用户任务出错 + GetPrizeFail // 获取奖品失败 + NovelIsNeedPay // 小说需要付费 + NovelIsVip // 小说需要vip + LotteryActivityNotEnable // 抽奖活动没有启用 + CurrTimeNotActivity // 当前时间没有活动 + AIOrderStripsCountLimit // 今天AI脱衣提交次数达到上限 + NotWtdrChannel // 没有匹配到提现通过 + ExcelExportErr // 文件导出错误 + ComicsNotJoinActivity // 这本漫画没有参与活动 + NoGiftToReceive // 没有奖品可领取 + UserRepeatDrawDiscountCard // 用户重复翻牌 + NoUseDiscountCard // 不可使用折扣卡 + AIOrderNotDel // 未完成的订单不能删除 + AIWaitOrderTooMuch // 排队中的AI订单太多,请稍后再试 + ActorNotExist // 演员不存在 + MediaFSServerHandleIng // 文件服务器处理中 + VideoRepeat // 视频重复 + ErrReqForbidden // 请求被拒 + APPIDError // APPID异常 +) +const ( + ErrRecharge CodeType = iota + 18000 // 充值相关错误 + ErrGetRechargeType // 充值方式获取错误 + UserPhoneNotBind // 手机号码未注册 + PostCommentPermissionDenied // 禁止评论 + UserLoginPasswordErr // 密码错误 + ErrMediaRecordNotExsit // 视频记录不存在 + ErrThressServerHttpErr // 第三方服务错误 + UserPhoneBeenBound // 手机号码已被绑定 + WeekCommentNumNotEnough // 评论条数不足 + ErrAccessForbid //ErrAccessForbid 没有访问权限 1000 + MediaAlreadyInEpisode // 该视频已经有选集 + MediaNotInEpisode // 选集中不包含该视频 + MediaCollectNotNull // 合集视频不为空 +) + +const ( + // + CircleErrCode CodeType = iota + 20000 // 圈子错误码 20000 + PublistNotVIPPermissionDenied // 不是会员禁止发布 + PublistContentErrDenied // 包含敏感词汇 + PostRepeatBuy // 帖子重复购买 + UserNotEnoughMoney // 用户的钻石数量不足 + PostStatusError // 订单状态错误 + PostNotAllowComplaint // 不允许举报 + UserNotEnoughScore // 用户的钻石数量不足 + ErrUserAccExist // 账号已存在 + UserAccountNotBind // 账号未注册 + VerifyOKPostCountLimit //上架的帖子数量达到限制 + ErrUserBeBanned //ErrUserBeBanned 用户已经禁言 1004 + ErrCustomerBanned //ErrCustomerBanned 客服已休息 1005 + ErrVersionUpdate //ErrVersionUpdate 版本强制更新 1006 +) \ No newline at end of file diff --git a/pub/common/srvdef/msgdef.go b/pub/common/srvdef/msgdef.go new file mode 100644 index 0000000..98bcf53 --- /dev/null +++ b/pub/common/srvdef/msgdef.go @@ -0,0 +1,58 @@ +package srvdef + +// error tips +const ( + Ok = "成功" + //基础错误提示 + ET_Store = "存储错误" + ET_DBOp = "DB操作错误" + ET_DBUpdate = "DB更新失败" + ET_RdsOp = "Rds操作错误" + ET_ESOp = "ES操作错误" + ET_Param = "参数错误" + ET_NoDocment = "无数据不存在" + ET_NoCache = "缓存数据不存在" + ET_MoneyNotEnough = "余额不足" + //---------------------------------------------- + ET_UserNoExist = "用户不存在" + ET_IsntBlogger = "用户不是博主" + ET_BloggerApply = "博主申请条件不满足" + ET_BloggerAlreadyIs = "用户已经是博主" + ET_VipLevel2Low = "用户的VIP等级不够" + //----------------------care------------------------ + ET_StatusEqual = "已经是请求状态" + ET_AlreadyCare = "已关注" + ET_AlreadyJoinFG = "您已加入了该粉丝团" +) + +var codeAndMsg = map[CodeType]string{ + Success: "success", + Failure: "Failed", + ErrDbQueryError: "database query error", + ErrDbInsertError: "database insert error", + ErrDbUpdateError: "database update error", + ErrDbDeleteError: "database delete error", + ErrDbInputExist: "current input has already exists", + ErrRdsOperError: "redis operate error", + ErrElsOperError: "elastic operate error", + BusinessLevel: "business error", + ErrCardNotExists: "card does not exist", + ErrMadouCoinNotExists: "madou coin does not exist", + ErrGetRechargeType: "get recharge type error", + ErrRchgOrderNotExist: "recharge order does not exist", + ErrBuyVIPCardFail: "buy vip card failed", + ErrOperationTooFrequently: "user operation is too frequent", + UserNotEnoughMoney: "user's coin is not enough", + RedeemCodeNotExists: "code does not exist", + RedeemCodeTimesUsedUp: "code times used up", + RedeemCodeAlreadyUsed: "you have used this code", + RedeemCodeRecordNotExists: "code use record does not exist", + ErrNoData: "no data", + UserPhoneBeenBound: "phone number has been bound", + WtdrFrequencyLimits: "wtdr frequency limits,please try again later", + MediaIsNeedPay: "You have to buy the video.", + UserWatchNumNotEnough: "More than three times", + UserScoreNotEnough: "user score is not enough", + UserSocreLVLimit: "user score level limit", + AlreadyRefund: "already refund", +} diff --git a/pub/common/srvdef/srvdef.go b/pub/common/srvdef/srvdef.go new file mode 100644 index 0000000..70382d4 --- /dev/null +++ b/pub/common/srvdef/srvdef.go @@ -0,0 +1,67 @@ +package srvdef + +import ( + "fmt" +) + +// SrvErr 代替/common/error中的定义,逐渐在代码中弃掉(common/error模块) +type SrvErr struct { + Code CodeType `json:"code"` + Msg string `json:"msg"` + Tips string `json:"tips"` +} + +// New create an error +func New(code CodeType, msgAndTips ...string) *SrvErr { + msg := code.Msg() + tips := code.Tip() + if len(msgAndTips) == 1 { + msg = msgAndTips[0] + } else if len(msgAndTips) == 2 { + msg, tips = msgAndTips[0], msgAndTips[1] + } + return &SrvErr{ + Code: code, + Msg: msg, + Tips: tips, + } +} + +// SrvErr 返回字符串 +func (s *SrvErr) Error() string { + if s == nil { + return "" + } + return fmt.Sprintf("code = %d, msg = %s ,tips = %v", s.Code, s.Msg, s.Tips) +} + +// SrvErr 返回error +func (s *SrvErr) ToError() error { + if s == nil { + return nil + } + return fmt.Errorf("code = %d, msg = %s ,tips = %v", s.Code, s.Msg, s.Tips) +} + +func (c *SrvErr) GetMsg() string { + // 有自定义的msg就用自定义的msg + if c.Msg != "" { + return c.Msg + } + return c.Code.Msg() +} +func (c *SrvErr) GetTip() string { + // 有自定义的tip就用自定义的tip + if c.Tips != "" { + return c.Tips + } + return c.Code.Tip() +} +func (c *SrvErr) GetCode() CodeType { return c.Code } + +var ( + ErrDbInsert = New(ErrDbInsertError, "", "") + ErrDbQuery = New(ErrDbQueryError, "", "") + ErrDbUpdate = New(ErrDbUpdateError, "", "") + ErrDbDelete = New(ErrDbDeleteError, "", "") +) diff --git a/pub/common/srvdef/tipdef.go b/pub/common/srvdef/tipdef.go new file mode 100644 index 0000000..5a849f1 --- /dev/null +++ b/pub/common/srvdef/tipdef.go @@ -0,0 +1,162 @@ +package srvdef + +var codeAndTip = map[CodeType]string{ + Success: "成功", + Failure: "失敗", + ErrCardNotExists: "沒有會員卡信息", + ErrMadouCoinNotExists: "沒有金幣信息", + ErrGetRechargeType: "充值方式獲取錯誤", + ErrRchgOrderNotExist: "充值訂單不存在", + ErrBuyVIPCardFail: "購買會員卡失敗", + ErrOperationTooFrequently: "用戶操作太頻繁", + UserNotEnoughMoney: "用户余额不足", + UserPhoneNotBind: "手机号码未注册", + PostCommentPermissionDenied: "非会员禁止发贴和评论", + UserLoginPasswordErr: "密码错误", + ErrUserTypeNotUP: "用户不是UP主", + AccountIsNotExists: "账户不存在", + AccountErrAccessForbid: "账户禁止登陆", + AccountErrPasswordError: "账户密码错误", + GenerateTokenFailed: "生成二维码错误", + ErrMediaNotExsit: "视频不存在", + ErrTopicInfoNotExsit: "视频专题不存在", + ErrData: "数据错误", + Err: "一般错误", + ErrSubmitRchgOrder: "发起充值订单错误", + InvalidRequestReplayAttack: "非法请求", + ErrMediaSyncServer: "视频同步服务器错误", + ErrUserType: "用户无改权限", + DownloadM3u8File: "文件服务器访问失败", + MediaSyncing: "视频同步中", + MediaConnectServerFaild: "媒资库链接失败", + AtressNotExist: "演员不存在", + ErrAnnouncementNotExsit: "公告不存在", + ErrContactDetailsNotExsit: "联系方式不存在", + ErrPostCommentNotExsit: "评论不存在", + HistoryNotExist: "记录不存在", + ErrHotsearchNotExsit: "热搜不存在", + ActivityDoesNotExist: "活动不存在", + UserWatchNumNotEnough: "免费观看次数不足", + MediaIsVip: "用户不是会员", + MediaIsNeedPay: "视频需要付费", + RepeatBuy: "重复购买", + MovieTicketsNotExits: "观影券不足", + MediaIsNotNeedPay: "视频不需要支付", + PostNoNeed2Pay: "帖子不需要支付", + SystemMsgNotExist: "消息不存在", + ForbidlogNotExist: "封禁消息不存在", + AttachmentDoesNotExist: "导出记录", + UserDownloadNotEnough: "下载次数不足", + UserDownloadNeedPay: "付费视频需要先购买", + ErrNoData: "无数据", + ModifyUserNameError: "昵称!每月只能修改一次", + RedeemCodeTimesUsedUp: "該兌換碼已使用", + RedeemCodeNotExists: "兌換碼已使用不存在", + RedeemCodeAlreadyUsed: "該兌換碼已使用", + InvalidToken: "框架错误", + ErrParamError: "参数错误", + NoToken: "无token", + RoleErr: "角色错误", + ReLogin: "重复登陆", + UploadToFileSrvFailed: "文件上传失败", + ErrCaptcha: "验证码为空", + ErrCaptchaInvalid: "验证码非法", + ErrCaptchaGet: "验证码获取失败", + ErrCaptchaResend: "验证码重复发送", + ErrCaptchaSendFail: "验证码发送失败", + InvalidateToken: "非法token", + NoUA: "没有UA信息", + UploadFile: "文件上传失败", + RedisSet: "设置redis错误", + RedisGet: "获取redis值错误", + RedisDel: "删除redis值错误", + EtcdGet: "获取etcd错误", + MongoDBQueryErr: "数据库查询失败", + ErrDbQueryError: "数据库查询错误", + ErrDbInsertError: "数据库插入错误", + ErrDbUpdateError: "数据库修改错误", + ErrDbDeleteError: "数据库删除错误", + ErrDbInputExist: "数据库输入值已经存在", + ErrRdsOperError: "redis操作错误", + ErrElsOperError: "redis操作错误", + ErrTransactionsError: "数据库事务操作错误", + ErrUserPermission: "用户权限", + ErrUserNotExsit: "用户不存在", + RedeemCodeRecordNotExists: "兑换记录不存在", + MediaIsNotNeedPreSale: "视频不支持预售", + MediaIsPreSale: "预售视频", + QRcodeError: "二维码错误", + ErrRecharge: "充值相关错误", + ErrMediaRecordNotExsit: "视频记录不存在", + UserPhoneBeenBound: "手机号码已被绑定", + DatingAlreadyComplaint: "已经举报过", + WtdrFrequencyLimits: "提现操作频率限制,请稍后再试", + WtdrErroTip1: "提现数额必须是100的整数倍", + WtdrErroTip2: "赠送6元体验金,满500可兑换钻石", + WtdrErroTip3: "请填写银行名字或升级App版本", + GameWtdrErroTip7: "银行卡必须与您以前的提现卡一致,如需更改请联系客服", + FileUploadDouble: "文件重复上传", + UserRepeatSubmitRchgOrder: "未支付订单太多,有问题请联系客服", + ErrIpForbid: "IP被禁止", + UserScoreNotEnough: "用户积分不足", + UserSocreLVLimit: "用户积分等级限制", + UploaderNeedBind: "后台用户没有up主绑定", + UploaderNeedActive: "Up主没有激活", + PostCollectionNotExists: "图文专辑不存在", + MediaCollectionNotExist: "该合集或选集不存在", + ContestNotExists: "该比赛不存在", + CannotJoinContest: "该比赛无法参加", + ContestIsVip: "该比赛只限VIP参加", + MediaNotVerified: "视频未上架", + PCCategoryUnmatch: "专辑的分类和作品的分类不一致", + ErrCareNotExist: "取消关注对象不存在", + ErrUpMasterTaskNotCompleted: "up主申请任务未完成", + HGameIsVip: "游戏需要会员", + HGameIsNeedPay: "游戏需要付费", + HGameNotExist: "游戏不存在", + ErrApplyUpIsVip: "UP主申请需要vip", + UserLevelCardNotExist: "用户等级卡不存在", + UserCheckinSetupNotExist: "签到配置不存在", + GiftWeekIsExist: "礼包已领取", + UserLevelPointTooLow: "用户等级积分太低", + UserGiftHaveReceive: "任务奖励已领取", + UserGiftExistNoReceive: "没有领取资格", + DatingUpMasterNotExist: "up主不存在", + UserIsUpMaster: "用户已经是up主", + UserUpCerting: "up主认证中", + MissionSetupNotExist: "任务配置不存在", + UserIsUpMasterBind: "用户已被up主绑定", + ErrCareIsExist: "不可重复关注", + ObjectDayStatDoesNotExist: "作品统计记录不存在", + ObjectDayViewIncStatDoesNotExist: "作品增量记录不存在", + UserNotEnoughFreeLotteryCount: "免费抽奖次数不足", + PrizeOutOfStore: "奖品库存不足", + MediaSubscribedNotNeedPay: "订阅的视频无需支付", + DatingComplaintNotExist: "举报帖子不存在", + DatingUserNotExist: "楼凤不存在", + WeekCommentNumNotEnough: "评论条数不足", + UserDauInfoNotExist: "搜寻日期没有资料", + PostCommentNotUse: "发送失败,服务器错误,请稍后再试", + AlreadyRefund: "已退款", + ErrUserPointsNotExist: "积分不足", + RedeemFail: "积分兑换失败", + CrowdfundNumberHaveBuy: "购买的号码已经出售", + CouponExpired: "卡券已过期", + CouponUsed: "卡券已使用", + CouponNotFound: "没有卡券信息", + VerifyOKPostCountLimit: "上架的帖子数量达到限制", + CouponCanNotUse: "卡券无法使用", + LotteryActivityNotEnable: "抽奖活动没有启用", + CurrTimeNotActivity: "当前时间没有活动", + AIOrderStripsCountLimit: "今天AI脱衣提交次数达到上限", + NotWtdrChannel: "没有匹配到提现通过", + ComicsNotJoinActivity: "这本漫画没有参与活动", + UserRepeatDrawDiscountCard: "重复翻牌", + NoUseDiscountCard: "不可使用折扣卡", + ErrUserAccExist: "账号已存在", + AIOrderNotDel: "未完成的订单不能删除", + AIWaitOrderTooMuch: "排队中的AI订单太多,请稍后再试", + ActorNotExist: "演员不存在", + VideoRepeat: "视频重复", + ErrReqForbidden: "请求被拒", +} \ No newline at end of file diff --git a/pub/common/srvversion/version.go b/pub/common/srvversion/version.go new file mode 100644 index 0000000..3cafe3a --- /dev/null +++ b/pub/common/srvversion/version.go @@ -0,0 +1,46 @@ +package srvversion + +import ( + "fmt" + "os" + //"os" +) + +var ( + //每次编译时需要手动修改本字段,否则每次编译得到的服务版本号都会是默认值 + SERVICE_VERSION = "V1.001.T" + + //以下几个字段编译期间自动替换,无需手动操作 + GIT_BRANCH = "GIT_BRANCH_FLAG" + GIT_VERSION = "GIT_VERSION_FLAG" + BUILD_DATE = "BUILD_DATE_FLAG" + BUILD_IP = "BUILD_IP_FLAG" +) + +func Info() { + fmt.Printf("version:%s\n", SERVICE_VERSION) + fmt.Printf("git branch:%s\n", GIT_BRANCH) + fmt.Printf("git version:%s\n", GIT_VERSION) + fmt.Printf("build date:%s\n", BUILD_DATE) + fmt.Printf("build ip:%s\n", BUILD_IP) + + os.Exit(0) //退出进程 +} + +func GetVersion() *Version { + return &Version{ + ServiceVer: SERVICE_VERSION, + GitVer: GIT_VERSION, + BuildDate: BUILD_DATE, + BuildIp: BUILD_IP, + Branch: GIT_BRANCH, + } +} + +type Version struct { + ServiceVer string `json:"serviceVer"` + GitVer string `json:"gitVer"` + BuildDate string `json:"buildDate"` + BuildIp string `json:"buildIp"` + Branch string `json:"branch"` +} diff --git a/pub/common/swaggerutil/swagger.go b/pub/common/swaggerutil/swagger.go new file mode 100644 index 0000000..741f3bd --- /dev/null +++ b/pub/common/swaggerutil/swagger.go @@ -0,0 +1,27 @@ +package swaggerutil + +import ( + "fmt" + "videoweb/pub/common/netutil" + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +func isDevHost(runMode string, svcListenIp string) bool { + localIp := netutil.GetLocalIP() + return (runMode == "dev" || runMode == "debug") && localIp != svcListenIp +} + +func SetupGinSwagger(runMode, svcListenIp, svcListenPort string) func(r *gin.Engine) { + return func(r *gin.Engine) { + if isDevHost(runMode, svcListenIp) { + url := ginSwagger.URL(fmt.Sprintf("http://%s:%s/swagger/doc.json", netutil.GetLocalIP(), svcListenPort)) + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) + } else { + url := ginSwagger.URL(fmt.Sprintf("http://%s:%s/swagger/doc.json", svcListenIp, svcListenPort)) + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) + } + } +} diff --git a/pub/common/synclock/lock.go b/pub/common/synclock/lock.go new file mode 100644 index 0000000..9245e70 --- /dev/null +++ b/pub/common/synclock/lock.go @@ -0,0 +1,102 @@ +package synclock + +import ( + "fmt" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/redis" +) + +const ( + UserExtSyncLockFmt = "userExtSyncLock:%d" // 用户账户扩展信息 + UserExtSyncLockExpire = 5 * time.Minute // 用户账户扩展信息 过期时间 + + UserAccountLockFmt = "UserAccountLockFmt:%d" // 用户账户 + UserAccountLockExpire = 5 * time.Minute // 用户账户 过期时间 + TraderAccountLockFmt = "TraderAccountLockFmt:%s" //商人账户 + SpinLockExpire = 3 * time.Second +) + +// UserExtSyncLock 通过redis按照用户ID加锁 +func UserExtcLock(userId int64) bool { + v, e := redis.Setnx_NewOK(fmt.Sprintf(UserExtSyncLockFmt, userId), true, UserExtSyncLockExpire) + if !v || e != nil { + return false + } + + return true +} + +// UserExtSpinLock 通过redis按照用户ID加锁 +func UserExtSpinLock(userId int64, lockTimeOut time.Duration) (bool, error) { + endTime := time.Now().Add(lockTimeOut) + for time.Now().Before(endTime) { + value, err := redis.Setnx_NewOK(fmt.Sprintf(UserExtSyncLockFmt, userId), true, UserExtSyncLockExpire) + if err != nil { + return false, errors.MakeWithMsg(errors.RedisError, "redis未启动") + } + if value { + return true, nil + } + time.Sleep(time.Millisecond) + } + + return false, errors.MakeWithMsg(errors.RedisError, "用户操作繁忙") +} + +// UserExtUnlock 按照用户ID释放锁 +func UserExtUnlock(userId int64) { + redis.Del(fmt.Sprintf(UserExtSyncLockFmt, userId)) +} + +// UserAccountLock 通过redis按照用户ID加锁 +func UserAccountLock(userId int64) bool { + v, e := redis.Setnx_NewOK(fmt.Sprintf(UserAccountLockFmt, userId), true, UserExtSyncLockExpire) + if !v || e != nil { + return false + } + + return true +} + +// UserAccountSpinLock 通过redis按照用户ID加锁 +func UserAccountSpinLock(userId int64, lockTimeOut time.Duration) (bool, error) { + endTime := time.Now().Add(lockTimeOut) + for time.Now().Before(endTime) { + value, err := redis.Setnx_NewOK(fmt.Sprintf(UserAccountLockFmt, userId), true, UserExtSyncLockExpire) + if err != nil { + return false, errors.MakeWithMsg(errors.RedisError, "redis未启动") + } + if value { + return true, nil + } + time.Sleep(time.Millisecond) + } + return false, errors.MakeWithMsg(errors.RedisError, "用户操作繁忙") +} + +// 通过redis按照商人ID加锁 +func TraderAccountSpinLock(userId string, lockTimeOut time.Duration) (bool, error) { + endTime := time.Now().Add(lockTimeOut) + for time.Now().Before(endTime) { + value, err := redis.Setnx_NewOK(fmt.Sprintf(TraderAccountLockFmt, userId), true, SpinLockExpire) + if err != nil { + return false, errors.MakeWithMsg(errors.RedisError, "redis未启动") + } + if value { + return true, nil + } + time.Sleep(time.Millisecond) + } + return false, errors.MakeWithMsg(errors.RedisError, "用户操作繁忙") +} + +// 按照商人ID释放锁 +func TraderAccountUnlock(userId string) { + redis.Del(fmt.Sprintf(TraderAccountLockFmt, userId)) +} + +// UserAccountUnlock 按照用户ID释放锁 +func UserAccountUnlock(userId int64) { + redis.Del(fmt.Sprintf(UserAccountLockFmt, userId)) +} diff --git a/pub/common/tgbot/helper.go b/pub/common/tgbot/helper.go new file mode 100644 index 0000000..58ce1de --- /dev/null +++ b/pub/common/tgbot/helper.go @@ -0,0 +1,94 @@ +package tgbot + +import ( + "videoweb/pub/common/srvdef" +) + +//获取群聊id +//https://api.telegram.org/bot1803190372:AAGmrrN6xUoh7IKiv4qCVJYdCs8wLt1ffCw/getUpdates + +var ( + //配置 + //zhihu app机器人 2048234434:AAF3XoW4iE6vAOmttTT_bofgan_PleKuaEk + //zhihu web机器人 1973289619:AAHjCZ8KQOewVfr20foHKlKVwJoulrf_OFc + tgToken = "2048234434:AAF3XoW4iE6vAOmttTT_bofgan_PleKuaEk" + //程序支付监控群tgids + programTgGroupIds = []int64{-778146822} + //天行监控组tgids + monitorTgGroupIds = []int64{-1001373963166} + //管理人员tgid(yl:1652126945) + adminTgIds = []int64{1652126945} + prefix = "[汁乎App]" + tgBot *TgBot = nil +) + +func InitTgBot(token string, prefixStr string) *srvdef.SrvErr { + if tgBot != nil { + return nil + } + if prefixStr != "" { + prefix = prefixStr + } + tgBot = NewTelegramBot(token) + if serr := tgBot.Init(); nil != serr { + return serr + } + + //SendToAll("支付监控测试", "Test") + return nil +} + +func ReleaseTgBot() { + if tgBot == nil { + return + } + tgBot.Release() +} + +// SendToAdmins 发到管理人员 +func SendToAdmins(title string, info string) { + sendTo(adminTgIds, title, info) +} + +// SendToProgram 发到程序 +func SendToProgram(title string, info string) { + sendTo(programTgGroupIds, title, info) +} + +// SendToMonitor 发到监控群组 +func SendToMonitor(title string, info string) { + sendTo(monitorTgGroupIds, title, info) +} + +// SendToProgram 发到所有 +func SendToAll(title string, info string) { + groupIds := programTgGroupIds + groupIds = append(groupIds, monitorTgGroupIds...) + //groupIds = append(groupIds, adminTgIds...) + sendTo(groupIds, title, info) +} + +func sendTo(ids []int64, title string, info string) { + if tgBot == nil { + return + } + //异步发送 + go func() { + msg := prefix + "\n" + title + "\n" + info + tgBot.Send(ids, msg) + }() +} + +func RegisterCommands(commands []*BotCmd) { + if tgBot == nil { + return + } + tgBot.RegisterCommands(commands) +} + +func Receive() { + if tgBot == nil { + return + } + tgBot.Receive() +} diff --git a/pub/common/tgbot/tgbot.go b/pub/common/tgbot/tgbot.go new file mode 100644 index 0000000..de5989c --- /dev/null +++ b/pub/common/tgbot/tgbot.go @@ -0,0 +1,169 @@ +package tgbot + +import ( + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" +) + +type RSP_TYPE int + +const ( + TextType RSP_TYPE = 0 + FileType RSP_TYPE = 1 +) + +type TgCmdRsp struct { + Buf []byte + BufType RSP_TYPE +} + +// TgBot Telegram机器人. +type TgBot struct { + // 机器人标示; + token string + // // 管理员分组; + // admins []int64 + // // 管理群分组; + // groups []int64 + // 机器人引擎; + engine *tgbotapi.BotAPI + // 命令存储集; + command map[string]func(string, int64) TgCmdRsp + help string + exitRcv chan interface{} +} + +// BotCmd 命令. +type BotCmd struct { + // 指令; + Key string + // 摘要; + Summary string + // 处理; + Handle func(string, int64) TgCmdRsp +} + +type BotCmdList []*BotCmd + +// NewBotCmd 新的命令. +func NewBotCmd(key, summary string, handle func(string, int64) TgCmdRsp) *BotCmd { + return &BotCmd{ + Key: key, + Summary: summary, + Handle: handle, + } +} + +// NewTelegramBot 新的Telegram机器人. +func NewTelegramBot(token string) *TgBot { + return &TgBot{ + token: token, + exitRcv: make(chan interface{}), + } +} + +// Init 机器人初始化. +func (tb *TgBot) Init() *srvdef.SrvErr { + var err error + tb.engine, err = tgbotapi.NewBotAPI(tb.token) + if err != nil { + log.Warn("(tgbot:Init) tgbotapi.NewBotAPI failed", log.E(err)) + return srvdef.New(srvdef.BusinessLevel, "", "") + } + tb.command = make(map[string]func(string, int64) TgCmdRsp) + tb.command["help"] = tb.menu + return nil +} + +// Release 机器人退出释放 +func (tb *TgBot) Release() *srvdef.SrvErr { + tb.engine.StopReceivingUpdates() + close(tb.exitRcv) + return nil +} + +func (tb *TgBot) menu(string, int64) TgCmdRsp { + return TgCmdRsp{Buf: []byte(tb.help), BufType: TextType} +} + +// RegisterCommands 注册命令并且自动生成help. +func (tb *TgBot) RegisterCommands(commands []*BotCmd) { + for _, cmd := range commands { + tb.command[cmd.Key] = cmd.Handle + tb.help += fmt.Sprintln("/" + cmd.Key + ": " + cmd.Summary) + } +} + +// Receive 接收消息(阻塞). +func (tb *TgBot) Receive() error { + log.Info("(tgbot:Receive) start") + // 设置更新频率; + cfg := tgbotapi.NewUpdate(0) + //cfg.Timeout = 1 + + // 得到更新管道; + updates, err := tb.engine.GetUpdatesChan(cfg) + if err != nil { + log.Warn("(tgbot:Receive) tb.engine.GetUpdatesChan failed", log.E(err)) + return srvdef.New(srvdef.BusinessLevel, "", "") + } + for { + select { + case update := <-updates: + tb.ProcessMessage(&update) + case <-tb.exitRcv: + log.Info("(tgbot:Receive) exist") + break + default: + } + time.Sleep(time.Second * 1) + } + return nil +} + +func (tb *TgBot) ProcessMessage(update *tgbotapi.Update) { + // 忽略非消息更新; + if update.Message == nil { + log.Info("(tgbot:ProcessMessage) message is nil") + return + } + info := fmt.Sprintf("[Name: %s%s][ID: %d][Text: %s]\n", + update.Message.From.FirstName, update.Message.From.LastName, + update.Message.Chat.ID, update.Message.Text) + log.Info(info) + if update.Message.IsCommand() { + message := tgbotapi.NewMessage(update.Message.Chat.ID, "") + handle, ok := tb.command[update.Message.Command()] + if !ok { + message.Text = "unknown command" + } else { + param := update.Message.CommandArguments() + rsp := handle(param, update.Message.Chat.ID) + switch rsp.BufType { + case FileType: + + default: + message.Text = string(rsp.Buf) + } + //message.Text = handle(param) + } + tb.engine.Send(message) + } +} + +func (tb *TgBot) StopReceive() { + tb.engine.StopReceivingUpdates() + close(tb.exitRcv) +} + +// Send 发送消息. +func (tb *TgBot) Send(ids []int64, text string) { + for _, id := range ids { + message := tgbotapi.NewMessage(id, text) + tb.engine.Send(message) + } +} diff --git a/pub/common/timehelper.go b/pub/common/timehelper.go new file mode 100644 index 0000000..2d6b21b --- /dev/null +++ b/pub/common/timehelper.go @@ -0,0 +1,55 @@ +package common + +import ( + "path/filepath" + "strings" + "time" +) + +func BindUrl(hostname string, path ...string) string { + if hostname == "" { + return strings.TrimLeft(filepath.Join(path...), "/") + } + if len(path) == 0 { + return hostname + } + su := strings.TrimLeft(filepath.Join(path...), "/") + pr := strings.TrimRight(hostname, "/") + return pr + "/" + su +} + +// 将time.Time转换成int64 +func Time2Stamp(t time.Time) int64 { + return t.UnixNano() / 1e9 +} + +/**时间对象->字符串*/ +func Time2Str() string { + const shortForm = "2006-01-01 15:04:05" + t := time.Now() + temp := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.Local) + str := temp.Format(shortForm) + return str +} + +/*时间戳->字符串*/ +func Stamp2Str(stamp int64) string { + timeLayout := "2006-01-02 15:04:05" + str := time.Unix(stamp/1000, 0).Format(timeLayout) + return str +} + +/*时间戳->时间对象*/ +func Stamp2Time(stamp int64) time.Time { + stampStr := Stamp2Str(stamp) + timer := Str2Time(stampStr) + return timer +} + +/**字符串->时间对象*/ +func Str2Time(formatTimeStr string) time.Time { + timeLayout := "2006-01-02 15:04:05" + loc, _ := time.LoadLocation("Local") + theTime, _ := time.ParseInLocation(timeLayout, formatTimeStr, loc) //使用模板在对应时区转化为time.time类型 + return theTime +} diff --git a/pub/common/topicmessage/topicMessage.go b/pub/common/topicmessage/topicMessage.go new file mode 100644 index 0000000..b4a4876 --- /dev/null +++ b/pub/common/topicmessage/topicMessage.go @@ -0,0 +1,79 @@ +package topicmessage + +import ( + "time" +) + +/* 通信协议,第一版*/ + +// 用户用户注册消息 +type UserRegisterMsg struct { + UserId int64 `json:"userId" bson:"userId"` // 用户ID + AppId int32 `json:"appId" bson:"appId"` // appID + SysType string `json:"sysType" bson:"sysType"` // 操作系统类型 安卓 IOS + PlatformId string `json:"platformId" bson:"platformId"` // 原始平台流水Id + DevType string `json:"devType" bson:"devType"` // 设备类型 + Name string `json:"name" bson:"name"` // 名称 + IsDirect bool `json:"isDirect" bson:"isDirect"` // true:是直推用户 + DeviceId string `json:"deviceId" bson:"deviceId"` // 设备Id + IP string `json:"ip,omitempty" bson:"ip"` // IP + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 + InviteCode string `json:"inviteCode" bson:"inviteCode"` // 被邀请码 + RegisterAt time.Time `json:"registerAt,omitempty" bson:"registerAt"` // 注册时间 + UserAgent string `json:"userAgent" bson:"userAgent"` // User-Agent识别 +} + +// 用户第一次访问 +type UserVisitMsg struct { + UserId int64 `bson:"userId" json:"userId"` // 用户Id + SumDate time.Time `bson:"sumDate" json:"sumDate"` // 记录时间 + AppId int32 `json:"appId" bson:"appId"` // appID + SysType string `json:"sysType" bson:"sysType"` // 操作系统类型 安卓 IOS + DevType string `json:"devType" bson:"devType"` // 设备类型 + IP string `json:"ip,omitempty" bson:"ip"` // IP + Version string `json:"version,omitempty" bson:"version"` // APP版本号 + VisitAt time.Time `json:"visitAt,omitempty" bson:"visitAt"` // 访问时间 + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 +} + +type UserOrderMsg struct { + UserId int64 `json:"userId" bson:"userId"` // 用户ID + AppId int32 `json:"appId" bson:"appId"` // appID + PlatformId string `json:"platformId" bson:"platformId"` // 原始平台流水Id + SysType string `json:"sysType" bson:"sysType"` // 操作系统类型 安卓 IOS + DevType string `json:"devType" bson:"devType"` // 设备类型 + ChannelName string `json:"channelName" bson:"channelName"` // 支付渠道名字 + CID string `json:"cid" bson:"cid"` // 渠道id + Type string `bson:"type" json:"type"` // 充值方式 + RchgUse int64 `json:"rchgUse" bson:"rchgUse"` // 充值用途 1:金币 2:VIP + Repeat bool `bson:"repeat" json:"repeat"` // 复冲: true 重复充值 false 第一次 + OrderId string `json:"orderId" bson:"orderId"` // 流水id + Money int64 `json:"money" bson:"money"` // 充值金额 订单金额 + PayMoney int64 `json:"payMoney" bson:"payMoney"` // 实际到账金额 用户实际支付金额 + Status int `json:"status" bson:"status"` // 2付款失败 3付款成功(目前只有成功才发送) + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 + Rate string `json:"rate" bson:"rate"` // 渠道费率 + SuccessAt time.Time `json:"successAt" bson:"successAt"` // 成功时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} + +type UserChannelEvent struct { + UserId int64 `bson:"userId" json:"userId"` // 用户Id + AppId int32 `json:"appId" bson:"appId"` // appID + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 + WatchCount int64 `json:"watchCount,omitempty" bson:"watchCount"` // 观看次数 + WatchTime int64 `json:"watchTime,omitempty" bson:"watchTime"` // 观看时长 + AdClick int64 `json:"adClick,omitempty" bson:"adClick"` // 广告点击 + AppClick int64 `json:"appClick,omitempty" bson:"appClick"` // APP点击 + RequestCount int64 `json:"requestCount,omitempty" bson:"requestCount"` // 请求次数 +} + +type MediaPlayEvent struct { + MediaId int64 `json:"mediaId" bson:"mediaId"` // 视频Id + Title string `json:"title" bson:"title"` // 标题 + FileId string `json:"fileId" bson:"fileId"` // 媒资库ID + ActionType int64 `json:"actionType" bson:"actionType"` // 类型:0:播放 1:点赞 2:购买 + UserId int64 `json:"userId" bson:"userId"` // 用户Id + AppId int32 `json:"appId" bson:"appId"` // appID + WatchAt time.Time `json:"watchAt" bson:"watchAt"` // 时间 +} diff --git a/pub/common/typeConv.go b/pub/common/typeConv.go new file mode 100644 index 0000000..7f51bb4 --- /dev/null +++ b/pub/common/typeConv.go @@ -0,0 +1,64 @@ +package common + +import ( + "reflect" + "unicode" +) + +// StructToMap 对象转map,去掉无效值 +func StructToMap(u interface{}) (m map[string]interface{}) { + t := reflect.TypeOf(u) + v := reflect.ValueOf(u) + p := make(map[string]interface{}) + for i := 0; i < t.NumField(); i++ { + fv := v.Field(i) + ft := t.Field(i) + jsonTag, _ := ft.Tag.Lookup("json") + switch fv.Kind() { + case reflect.String: + if fv.String() != "" { + p[jsonTag] = fv.Interface() + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if fv.Int() != 0 { + p[jsonTag] = fv.Interface() + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if fv.Uint() != 0 { + p[jsonTag] = fv.Interface() + } + case reflect.Float32, reflect.Float64: + if fv.Float() != 0 { + p[jsonTag] = fv.Interface() + } + default: + p[jsonTag] = fv.Interface() + + } + + } + return p +} + +// Ucfirst () 字符串首字母大写 +func Ucfirst(str string) string { + for i, v := range str { + return string(unicode.ToUpper(v)) + str[i+1:] + } + return "" +} + +// Lcfirst () 字符串首字母小写 +func Lcfirst(str string) string { + for i, v := range str { + return string(unicode.ToLower(v)) + str[i+1:] + } + return "" +} + +// MobileBlurry () +func MobileBlurry(str string) string { + + return str[0:3] + "****" + str[7:] + +} diff --git a/pub/common/useragent/build.go b/pub/common/useragent/build.go new file mode 100644 index 0000000..ebb474c --- /dev/null +++ b/pub/common/useragent/build.go @@ -0,0 +1,542 @@ +package useragent + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" + "videoweb/app/appg" + + "github.com/gin-gonic/gin" +) + +const ( + DefaultVer = "1.0.0" + SDKCommonHeader = "X-SDK-Common" +) + +// Info 与前端 X-User-Agent 字段对齐,供 SSR 组装上游请求头。 +type Info struct { + RawUA string + DevType string + SysType string + Ver string + BuildID string + DeviceBrand string + DeviceModel string + SystemName string + SystemVersion string + SID string +} + +// BuildOptions SSR / 代理侧可选覆盖项。 +type BuildOptions struct { + Ver string + SID string + // RawUA 为空时从 Request.UserAgent 读取 + RawUA string +} + +var ( + reIOSVer = regexp.MustCompile(`OS (\d+[_\d]*) like Mac OS X`) + reAndroidVer = regexp.MustCompile(`Android ([\d.]+)`) + reAndroidModel = regexp.MustCompile(`Android\s+[\d.]+;\s*([^;)]+?)(?:\s+Build\/|\))`) + reWinNT = regexp.MustCompile(`Windows NT ([\d.]+)`) + reMacVer = regexp.MustCompile(`Mac OS X ([\d_]+)`) + reEdge = regexp.MustCompile(`(?i)Edg/([\d.]+)`) + reOpera = regexp.MustCompile(`(?i)(?:OPR|Opera)/([\d.]+)`) + reChrome = regexp.MustCompile(`(?i)Chrome/([\d.]+)`) + reSafari = regexp.MustCompile(`(?i)Version/([\d.]+).*Safari`) + reFirefox = regexp.MustCompile(`(?i)Firefox/([\d.]+)`) + reIE = regexp.MustCompile(`(?i)(?:MSIE\s|Trident.*rv:)([\d.]+)`) + reTrackerRequestID = regexp.MustCompile(`^[a-fA-F0-9]{32}$`) + reTrackerCtx = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + reTrackerEvent = regexp.MustCompile(`^drama_(like|comment|collect|purchase)$`) + reTrackerAppID = regexp.MustCompile(`^[A-Z]{1,6}-[0-9]{1,6}$`) + reTrackerUID = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + reTrackerChannel = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) +) + +// ParseDeviceFromUA 从浏览器原生 User-Agent 解析设备信息(服务端无 navigator)。 +func ParseDeviceFromUA(ua string) (brand, model, deviceType, systemName, systemVersion string) { + ua = strings.TrimSpace(ua) + switch { + case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad") || strings.Contains(ua, "iPod"): + systemName = "iOS" + brand = "Apple" + if m := reIOSVer.FindStringSubmatch(ua); len(m) > 1 { + systemVersion = strings.ReplaceAll(m[1], "_", ".") + } + switch { + case strings.Contains(ua, "iPad"): + model, deviceType = "iPad", "tablet" + case strings.Contains(ua, "iPod"): + model, deviceType = "iPod", "mobile" + default: + model, deviceType = "iPhone", "mobile" + } + case strings.Contains(ua, "Android"): + systemName = "Android" + if m := reAndroidVer.FindStringSubmatch(ua); len(m) > 1 { + systemVersion = m[1] + } + if m := reAndroidModel.FindStringSubmatch(ua); len(m) > 1 { + model = strings.TrimSpace(m[1]) + } + brand = guessAndroidBrand(model, ua) + if strings.Contains(ua, "Mobile") { + deviceType = "mobile" + } else { + deviceType = "tablet" + } + case strings.Contains(ua, "Windows"): + systemName = "Windows" + deviceType = "desktop" + if m := reWinNT.FindStringSubmatch(ua); len(m) > 1 { + systemVersion = m[1] + } + case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"): + systemName = "macOS" + brand = "Apple" + model = "Mac" + deviceType = "desktop" + if m := reMacVer.FindStringSubmatch(ua); len(m) > 1 { + systemVersion = strings.ReplaceAll(m[1], "_", ".") + } + case strings.Contains(ua, "Linux"): + systemName = "Linux" + deviceType = "desktop" + default: + systemName = "Other" + deviceType = "desktop" + } + return +} + +func guessAndroidBrand(model, ua string) string { + s := model + " " + ua + rules := []struct { + re *regexp.Regexp + brand string + }{ + {regexp.MustCompile(`(?i)Build/HONOR|\bHONOR\b`), "Honor"}, + {regexp.MustCompile(`(?i)Build/HUAWEI|HUAWEI|HW-`), "Huawei"}, + {regexp.MustCompile(`(?i)SM-|samsung|Galaxy`), "Samsung"}, + {regexp.MustCompile(`(?i)Pixel`), "Google"}, + {regexp.MustCompile(`(?i)Redmi|POCO|XiaoMi|\bMi\b`), "Xiaomi"}, + {regexp.MustCompile(`(?i)OPPO|\bCPH\d{3,4}`), "OPPO"}, + {regexp.MustCompile(`(?i)vivo|\bV\d{4}`), "vivo"}, + {regexp.MustCompile(`(?i)OnePlus|ONEPLUS`), "OnePlus"}, + {regexp.MustCompile(`(?i)realme|\bRMX\d{3,4}`), "realme"}, + } + for _, r := range rules { + if r.re.MatchString(s) { + return r.brand + } + } + return "" +} + +func detectSysType(ua string) string { + uaLower := strings.ToLower(ua) + switch { + case strings.Contains(uaLower, "android"): + return "h5_android" + case strings.Contains(uaLower, "iphone"), strings.Contains(uaLower, "ipad"), strings.Contains(uaLower, "ipod"): + return "h5_ios" + default: + return "h5_pc" + } +} + +func parseBrowser(ua string) (name, version string) { + switch { + case reEdge.MatchString(ua): + m := reEdge.FindStringSubmatch(ua) + return "Edge", m[1] + case reOpera.MatchString(ua): + m := reOpera.FindStringSubmatch(ua) + return "Opera", m[1] + case reChrome.MatchString(ua) && !reEdge.MatchString(ua): + m := reChrome.FindStringSubmatch(ua) + return "Chrome", m[1] + case reSafari.MatchString(ua): + m := reSafari.FindStringSubmatch(ua) + return "Safari", m[1] + case reFirefox.MatchString(ua): + m := reFirefox.FindStringSubmatch(ua) + return "Firefox", m[1] + case reIE.MatchString(ua): + m := reIE.FindStringSubmatch(ua) + return "IE", m[1] + default: + return "", "" + } +} + +func buildDevType(brand, model, deviceType string) string { + parts := make([]string, 0, 3) + for _, v := range []string{brand, model, deviceType} { + v = strings.TrimSpace(v) + if v != "" { + parts = append(parts, v) + } + } + if len(parts) == 0 { + return "OTHER" + } + return strings.Join(parts, " ") +} + +// BuildInfo 根据原始 UA 组装结构化信息。 +func BuildInfo(opts BuildOptions) Info { + ua := strings.TrimSpace(opts.RawUA) + ver := strings.TrimSpace(opts.Ver) + if ver == "" { + ver = DefaultVer + } + brand, model, deviceType, systemName, systemVersion := ParseDeviceFromUA(ua) + browserName, browserVer := parseBrowser(ua) + buildID := strings.TrimSpace(strings.TrimSpace(browserName + " " + browserVer)) + + return Info{ + RawUA: ua, + DevType: buildDevType(brand, model, deviceType), + SysType: detectSysType(ua), + Ver: ver, + BuildID: buildID, + DeviceBrand: brand, + DeviceModel: model, + SystemName: systemName, + SystemVersion: systemVersion, + SID: strings.TrimSpace(opts.SID), + } +} + +// String 输出 X-User-Agent Header 值。 +func (info Info) String() string { + return fmt.Sprintf( + "DevType=%s;SysType=%s;Ver=%s;BuildID=%s;device_brand=%s;device_model=%s;system_name=%s;system_version=%s;sid=%s", + info.DevType, + info.SysType, + info.Ver, + info.BuildID, + info.DeviceBrand, + info.DeviceModel, + info.SystemName, + info.SystemVersion, + info.SID, + ) +} + +// BuildXUserAgent 直接返回 Header 字符串。 +func BuildXUserAgent(opts BuildOptions) string { + return BuildInfo(opts).String() +} + +// SIDFromRequest 从 cookie 读取 sid(与前端约定:sid / _s_token_)。 +func SIDFromRequest(r *http.Request) string { + if r == nil { + return "" + } + if c, err := r.Cookie("sid"); err == nil && c != nil { + if v := strings.TrimSpace(c.Value); v != "" { + return v + } + } + if c, err := r.Cookie("_s_token_"); err == nil && c != nil { + v := strings.TrimSpace(c.Value) + // 可能是 JSON:{"sid":"..."} 或纯字符串 + if strings.HasPrefix(v, "{") { + const key = `"sid"` + if i := strings.Index(v, key); i >= 0 { + rest := v[i+len(key):] + if j := strings.Index(rest, `"`); j >= 0 { + rest = rest[j+1:] + if k := strings.Index(rest, `"`); k >= 0 { + return rest[:k] + } + } + } + } + return v + } + return "" +} + +// BuildXUserAgentFromRequest 供 SSR 反向代理上游 API 使用。 +// 优先透传客户端已带的 X-User-Agent;否则用浏览器 UA 组装。 +func BuildXUserAgentFromRequest(c *gin.Context, ver string) string { + if c == nil || c.Request == nil { + return BuildXUserAgent(BuildOptions{Ver: ver}) + } + if xua := strings.TrimSpace(c.GetHeader("X-User-Agent")); xua != "" { + return xua + } + return BuildXUserAgent(BuildOptions{ + Ver: ver, + SID: SIDFromRequest(c.Request), + RawUA: c.Request.UserAgent(), + }) +} + +// TokenFromRequest 从 cookie 读取登录 token(与前端约定:token / _token_)。 +func TokenFromRequest(r *http.Request) string { + if r == nil { + return "" + } + for _, name := range []string{"token", "_token_"} { + if c, err := r.Cookie(name); err == nil && c != nil { + if v := cookieUnescape(c.Value); v != "" { + return v + } + } + } + return "" +} + +func cookieUnescape(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if decoded, err := url.QueryUnescape(raw); err == nil { + return strings.TrimSpace(decoded) + } + return raw +} + +// ClientIPFromRequest 优先透传已有 X-Real-IP / X-Forwarded-For,否则用远端地址。 +func ClientIPFromRequest(c *gin.Context) string { + if c == nil || c.Request == nil { + return "" + } + if ip := strings.TrimSpace(c.GetHeader("X-Real-IP")); ip != "" { + return ip + } + if xff := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); xff != "" { + if i := strings.Index(xff, ","); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return xff + } + return strings.TrimSpace(c.ClientIP()) +} + +// BuildUpstreamHeader 组装上游 API 请求头:X-Real-IP、User-Agent、X-User-Agent。 +// 登录 token / sid 已在 cookie 中时由调用方按需写入 Authorization 等字段。 +func BuildUpstreamHeader(c *gin.Context, ver string) map[string]string { + header := make(map[string]string, 40) + ApplyToHeader(header, c, ver) + return header +} + +var trackerEventRoutes = map[string]string{ + "/api/playlet/chapter/like/set": "drama_like", + "/api/playlet/like/set": "drama_like", + "/api/playlet/chapter/collect/set": "drama_collect", + "/api/playlet/collect/set": "drama_collect", + "/api/comment/send": "drama_comment", + "/api/playlet/chapter/unlock": "drama_purchase", +} + +var trackerCommonFieldKeys = map[string]struct{}{ + "app_id": {}, "uid": {}, "channel": {}, "sid": {}, "client_ts": {}, + "device": {}, "device_id": {}, "device_brand": {}, "device_model": {}, + "device_fingerprint": {}, "fp_version": {}, "user_agent": {}, + "sdk_version": {}, "app_version": {}, +} + +var trackerDramaFieldKeys = map[string]struct{}{ + "media_id": {}, "drama_id": {}, "episode": {}, "video_id": {}, "recommend_trace_id": {}, +} + +type trackerContextEnvelope struct { + Version int `json:"v"` + RequestID string `json:"request_id"` + EventName string `json:"event_name"` + Common map[string]json.RawMessage `json:"common"` + Drama map[string]json.RawMessage `json:"drama"` +} + +// ApplyToHeader 将来源请求的网络信息与 SDK 原始公共字段快照写入上游 header。 +// 埋点上下文只通过 canonical envelope 白名单透传,不在 BFF 重新生成或覆盖。 +func ApplyToHeader(header map[string]string, c *gin.Context, ver string) { + if header == nil { + return + } + if c != nil && c.Request != nil { + if ip := ClientIPFromRequest(c); ip != "" { + header["X-Real-IP"] = ip + } + if ua := strings.TrimSpace(c.Request.UserAgent()); ua != "" { + header["User-Agent"] = ua + } + } + header["X-User-Agent"] = BuildXUserAgentFromRequest(c, ver) + if c == nil || c.Request == nil { + return + } + if sdk := strings.TrimSpace(c.GetHeader(SDKCommonHeader)); sdk != "" { + header[SDKCommonHeader] = sdk + } + requestID := strings.TrimSpace(c.GetHeader("X-Tracker-Request-Id")) + eventName := strings.TrimSpace(c.GetHeader("X-Tracker-Event-Name")) + packedContext := strings.TrimSpace(c.GetHeader("X-DC-Request-Context")) + expectedEvent := trackerEventRoutes[c.FullPath()] + if expectedEvent == "" { + expectedEvent = trackerEventRoutes[c.Request.URL.Path] + } + if c.Request.Method != http.MethodPost || expectedEvent == "" || eventName != expectedEvent || + !reTrackerRequestID.MatchString(requestID) || !reTrackerEvent.MatchString(eventName) { + return + } + canonicalContext, ok := canonicalPackedTrackerContext(packedContext, requestID, eventName) + if !ok { + return + } + header["X-DC-Request-Context"] = canonicalContext + header["X-Tracker-Request-Id"] = requestID + header["X-Tracker-Event-Name"] = eventName +} + +func canonicalPackedTrackerContext(value, requestID, eventName string) (string, bool) { + if value == "" || len(value) > 8192 || !reTrackerCtx.MatchString(value) { + return "", false + } + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(raw) > 6144 { + return "", false + } + var envelope trackerContextEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return "", false + } + if envelope.Version != 1 || envelope.RequestID != requestID || + envelope.EventName != eventName || !reTrackerRequestID.MatchString(envelope.RequestID) || + !reTrackerEvent.MatchString(envelope.EventName) || + !hasOnlyTrackerFields(envelope.Common, trackerCommonFieldKeys) || + !hasOnlyTrackerFields(envelope.Drama, trackerDramaFieldKeys) || + !validTrackerCommonFields(envelope.Common) || !validTrackerDramaFields(envelope.Drama) { + return "", false + } + // 公共字段的值保持 SDK 原值;重新编码仅移除重复 JSON key,防止上下游解析差异。 + canonical, err := json.Marshal(envelope) + if err != nil || len(canonical) > 6144 { + return "", false + } + return base64.RawURLEncoding.EncodeToString(canonical), true +} + +func hasOnlyTrackerFields(values map[string]json.RawMessage, allowed map[string]struct{}) bool { + if len(values) != len(allowed) { + return false + } + for key := range values { + if _, ok := allowed[key]; !ok { + return false + } + } + return true +} + +func validTrackerCommonFields(values map[string]json.RawMessage) bool { + appID, ok := trackerStringField(values, "app_id", 50, false) + if !ok || !reTrackerAppID.MatchString(appID) || appID != appg.TrackerAppID() { + return false + } + uid, ok := trackerStringField(values, "uid", 32, true) + if !ok || (uid != "" && (utf8.RuneCountInString(uid) < 4 || !reTrackerUID.MatchString(uid) || + uid == "u_1001" || uid == "stress_user" || uid == "u_lab")) { + return false + } + channel, ok := trackerStringField(values, "channel", 50, true) + if !ok || (channel != "" && (!reTrackerChannel.MatchString(channel) || + channel == "{}" || channel == "0" || channel == "-" || strings.EqualFold(channel, "unknown"))) { + return false + } + if _, ok = trackerStringField(values, "sid", 50, true); !ok { + return false + } + clientTS, ok := trackerIntegerField(values, "client_ts") + if !ok || clientTS < 1767196800 || clientTS > 9999999999 { + return false + } + device, ok := trackerStringField(values, "device", 30, true) + if !ok || (device != "" && device != "Android" && device != "iOS" && device != "PC" && device != "OTHER") { + return false + } + if _, ok = trackerStringField(values, "device_id", 50, false); !ok { + return false + } + if _, ok = trackerStringField(values, "device_brand", 100, true); !ok { + return false + } + if _, ok = trackerStringField(values, "device_model", 100, true); !ok { + return false + } + if _, ok = trackerStringField(values, "device_fingerprint", 128, true); !ok { + return false + } + if _, ok = trackerStringField(values, "fp_version", 128, true); !ok { + return false + } + if _, ok = trackerStringField(values, "user_agent", 4096, true); !ok { + return false + } + if _, ok = trackerStringField(values, "sdk_version", 128, true); !ok { + return false + } + _, ok = trackerStringField(values, "app_version", 128, true) + return ok +} + +func validTrackerDramaFields(values map[string]json.RawMessage) bool { + if _, ok := trackerStringField(values, "media_id", 512, true); !ok { + return false + } + if _, ok := trackerStringField(values, "drama_id", 512, false); !ok { + return false + } + episode, ok := trackerIntegerField(values, "episode") + if !ok || episode <= 0 { + return false + } + if _, ok = trackerStringField(values, "video_id", 512, true); !ok { + return false + } + _, ok = trackerStringField(values, "recommend_trace_id", 2048, true) + return ok +} + +func trackerStringField(values map[string]json.RawMessage, key string, maxLen int, allowEmpty bool) (string, bool) { + raw, ok := values[key] + if !ok { + return "", false + } + var decoded *string + if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil { + return "", false + } + value := *decoded + if utf8.RuneCountInString(value) > maxLen || (!allowEmpty && value == "") { + return "", false + } + return value, true +} + +func trackerIntegerField(values map[string]json.RawMessage, key string) (int64, bool) { + raw, ok := values[key] + if !ok { + return 0, false + } + var decoded *int64 + if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil { + return 0, false + } + return *decoded, true +} diff --git a/pub/common/useragent/sdk_common.go b/pub/common/useragent/sdk_common.go new file mode 100644 index 0000000..fa53f3a --- /dev/null +++ b/pub/common/useragent/sdk_common.go @@ -0,0 +1,80 @@ +package useragent + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +// 限制解析开销;正常 SDK 公共参数远小于此大小。 +const maxSDKCommonHeaderSize = 16 * 1024 + +// SDKCommonFields 接收五项 SDK 公共字段及客户端 app_version,完整 fields 中的其他字段自动忽略。 +// 业务 DevID、用户 ID、渠道等继续走原有业务参数,不能被 SDK 对象覆盖。 +type SDKCommonFields struct { + SID string `json:"sid" bson:"sid"` + DeviceID string `json:"device_id" bson:"device_id"` + Device string `json:"device" bson:"device"` + DeviceFingerprint string `json:"device_fingerprint" bson:"device_fingerprint"` + FPVersion string `json:"fp_version" bson:"fp_version"` + AppVersion string `json:"app_version" bson:"app_version"` // 客户端版本随下单快照保存,支付回调不使用当前版本补值。 +} + +// ParseSDKCommonHeader 缺少请求头返回 nil;格式错误交给调用方记录,不阻断业务请求。 +// 不修剪、截断或推算字段值,SDK 指纹生成失败时也保留其空值。 +func ParseSDKCommonHeader(header string) (*SDKCommonFields, error) { + header = strings.TrimSpace(header) + if header == "" { + return nil, nil + } + if len(header) > maxSDKCommonHeaderSize { + return nil, fmt.Errorf("SDK 公共参数请求头超过 %d 字节", maxSDKCommonHeaderSize) + } + data, err := base64.StdEncoding.DecodeString(header) + if err != nil { + return nil, fmt.Errorf("SDK 公共参数 Base64 解码失败: %w", err) + } + if !utf8.Valid(data) { + return nil, fmt.Errorf("SDK 公共参数不是有效的 UTF-8") + } + var fields *SDKCommonFields + if err := json.Unmarshal(data, &fields); err != nil { + return nil, fmt.Errorf("SDK 公共参数 JSON 解析失败: %w", err) + } + if fields == nil { + return nil, fmt.Errorf("SDK 公共参数必须是 JSON 对象,不能为 null") + } + return fields, nil +} + +// Clone 下单时保存独立快照,避免请求对象后续变化影响支付事件。 +func (fields *SDKCommonFields) Clone() *SDKCommonFields { + if fields == nil { + return nil + } + snapshot := *fields + return &snapshot +} + +// MissingFields 仅用于缺失观测;合法空值仍原样上报,不作为业务失败。 +func (fields *SDKCommonFields) MissingFields() []string { + if fields == nil { + fields = &SDKCommonFields{} + } + var missing []string + for _, field := range []struct{ name, value string }{ + {"sid", fields.SID}, + {"device_id", fields.DeviceID}, + {"device", fields.Device}, + {"device_fingerprint", fields.DeviceFingerprint}, + {"fp_version", fields.FPVersion}, + {"app_version", fields.AppVersion}, + } { + if strings.TrimSpace(field.value) == "" { + missing = append(missing, field.name) + } + } + return missing +} diff --git a/pub/common/useragent/ua.go b/pub/common/useragent/ua.go new file mode 100644 index 0000000..349640a --- /dev/null +++ b/pub/common/useragent/ua.go @@ -0,0 +1,97 @@ +package useragent + +import ( + "net/url" + "strings" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + + "github.com/gin-gonic/gin" +) + +type UA struct { + DevID string + DevType string + SysType string + Ver string + BuildID string +} + +func IsH5(sysType string) bool { + if sysType == consts.H5IOS || sysType == consts.H5Android || sysType == consts.H5PC || sysType == consts.H5 || sysType == consts.Light { + return true + } else { + return false + } +} + +func (s UA) IsH5() bool { + return IsH5(s.SysType) +} + +func UserAgent(c *gin.Context) { + var uastr string + ua := UA{} + userAgent := c.GetHeader("X-User-Agent") //兼容H5 UserAgent + if userAgent == "" { + userAgent = c.Request.UserAgent() + } + if userAgent != "" { + // "BuildID=com.abc.Butterfly;SysType=h5;DevID=B9A00938-FE68-4686-9461-A365C7E5110D;Ver=0.0.1;DevType=iPhone;" + decodeUa, err := url.QueryUnescape(userAgent) + if err != nil { + log.Warn("URLDecode user-agent error", log.Any("ua", userAgent)) + uastr = userAgent + } else { + uastr = decodeUa + } + uas := strings.Split(uastr, ";") + for _, v := range uas { + vss := strings.Split(strings.TrimSpace(v), "=") + if len(vss) < 2 { + c.Set(consts.CtxUAH5, vss[0]) + // log.Warn("user-agent miss", log.Any("ua", userAgent), log.Any("v", v)) + continue + } + switch strings.TrimSpace(vss[0]) { + case "DevID": + ua.DevID = strings.TrimSpace(vss[1]) + c.Set(consts.CtxDevId, ua.DevID) + case "DevType": + ua.DevType = strings.TrimSpace(vss[1]) + c.Set(consts.CtxDevType, ua.DevType) + case "SysType": + IntputSysType := strings.TrimSpace(vss[1]) + if strings.Contains(IntputSysType, "h5_android") { + ua.SysType = consts.H5Android + } else if strings.Contains(IntputSysType, "h5_ios") { + ua.SysType = consts.H5IOS + } else if strings.Contains(IntputSysType, "h5_pc") { + ua.SysType = consts.H5PC + } else if strings.Contains(IntputSysType, "android") { + ua.SysType = consts.Android + } else if strings.Contains(IntputSysType, "ios") { + ua.SysType = consts.IOS + } else if strings.Contains(IntputSysType, "h5") { + ua.SysType = consts.H5 + } else if strings.Contains(IntputSysType, "pc") { + ua.SysType = consts.PC + } else if strings.Contains(IntputSysType, "light") { + ua.SysType = consts.Light + } + c.Set(consts.CtxSysType, ua.SysType) + case "Ver": + ua.Ver = strings.TrimSpace(vss[1]) + c.Set(consts.CtxVersion, ua.Ver) + case "BuildID": + ua.BuildID = strings.TrimSpace(vss[1]) + c.Set(consts.CtxBuildId, ua.BuildID) + default: + log.Warn("user-agent unknown", log.Any("ua", userAgent)) + } + } + } else { + log.Warn("user-agent empty") + } + c.Set(consts.CtxUA, ua) +} \ No newline at end of file diff --git a/pub/common/utils/array.go b/pub/common/utils/array.go new file mode 100644 index 0000000..e4b06d4 --- /dev/null +++ b/pub/common/utils/array.go @@ -0,0 +1,292 @@ +package utils + +import ( + "math/rand" + "reflect" +) + +//数组求交集,差集,并集 +/*求并集**/ +func Union(slice1, slice2 []string) []string { + m := make(map[string]int) + for _, v := range slice1 { + m[v]++ + } + + for _, v := range slice2 { + times, _ := m[v] + if times == 0 { + slice1 = append(slice1, v) + } + } + return slice1 +} + +// 求交集 +func Intersect(slice1, slice2 []string) []string { + m := make(map[string]int) + nn := make([]string, 0) + for _, v := range slice1 { + m[v]++ + } + + for _, v := range slice2 { + times, _ := m[v] + if times == 1 { + nn = append(nn, v) + } + } + return nn +} + +// 求交集 +func IntersectInt64(slice1, slice2 []int64) []int64 { + m := make(map[int64]int) + nn := make([]int64, 0) + for _, v := range slice1 { + m[v]++ + } + + for _, v := range slice2 { + times, _ := m[v] + if times == 1 { + nn = append(nn, v) + } + } + return nn +} + +// 求差集 slice1-并集 +func Difference(slice1, slice2 []string) []string { + m := make(map[string]int) + nn := make([]string, 0) + inter := Intersect(slice1, slice2) + for _, v := range inter { + m[v]++ + } + + for _, value := range slice1 { + times, _ := m[value] + if times == 0 { + nn = append(nn, value) + } + } + return nn +} + +// 求差集 slice1-并集 +func DifferenceInt64(slice1, slice2 []int64) []int64 { + m := make(map[int64]int) + nn := make([]int64, 0) + inter := IntersectInt64(slice1, slice2) + for _, v := range inter { + m[v]++ + } + + for _, value := range slice1 { + times, _ := m[value] + if times == 0 { + nn = append(nn, value) + } + } + return nn +} + +// 去重 +func RemoveRepeat(slc []int64) []int64 { + result := []int64{} // 存放结果 + for i := range slc { + flag := true + for j := range result { + if slc[i] == result[j] { + flag = false // 存在重复元素,标识为false + break + } + } + if flag { // 标识为false,不添加进结果 + result = append(result, slc[i]) + } + } + return result +} + +func IncludeInt32(slc []int32, n int32) bool { + for _, v := range slc { + if v == n { + return true + } + } + return false +} + +func IncludeInt64(slc []int64, n int64) bool { + for _, v := range slc { + if v == n { + return true + } + } + return false +} + +func RemoveInt64(slc []int64, n int64) []int64 { + for i := 0; i < len(slc); { + if slc[i] == n { + slc = append(slc[:i], slc[i+1:]...) + } else { + i++ + } + } + return slc +} + +func RemoveString(slc []string, n string) []string { + for i := 0; i < len(slc); { + if slc[i] == n { + slc = append(slc[:i], slc[i+1:]...) + } else { + i++ + } + } + return slc +} + +// RemoveRepeatString 去重string +func RemoveRepeatString(slc []string) []string { + result := []string{} // 存放结果 + for i := range slc { + flag := true + for j := range result { + if slc[i] == result[j] { + flag = false // 存在重复元素,标识为false + break + } + } + if flag { // 标识为false,不添加进结果 + result = append(result, slc[i]) + } + } + return result +} + +func IncludeString(slc []string, s string) bool { + for _, v := range slc { + if v == s { + return true + } + } + return false +} + +func EqualInt64s(slice1, slice2 []int64) bool { + if len(slice1) != len(slice2) { + return false + } + for _, v1 := range slice1 { + find := false + for _, v2 := range slice2 { + if v1 == v2 { + find = true + break + } + } + if !find { + return false + } + } + return true +} + +func EqualInt64WithOrder(slice1, slice2 []int64) bool { + if len(slice1) != len(slice2) { + return false + } + for k, v1 := range slice1 { + if slice2[k] != v1 { + return false + } + } + return true +} + +func EqualString(slice1, slice2 []string) bool { + if len(slice1) != len(slice2) { + return false + } + for _, v1 := range slice1 { + find := false + for _, v2 := range slice2 { + if v1 == v2 { + find = true + break + } + } + if find == false { + return false + } + } + return true +} + +// 切片乱序 +func ShuffleSlice(slice interface{}) { + rv := reflect.ValueOf(slice) + if rv.Type().Kind() != reflect.Slice { + return + } + + length := rv.Len() + if length < 2 { + return + } + swap := reflect.Swapper(slice) + //rand.Seed(time.Now().Unix()) + for i := length - 1; i >= 0; i-- { + j := rand.Intn(length) + swap(i, j) + } + return +} + +func RemoveOneInt64(slc []int64, n int64) []int64 { + for i := 0; i < len(slc); { + if slc[i] == n { + slc = append(slc[:i], slc[i+1:]...) + break + } else { + i++ + } + } + return slc +} + +// 求最大值 +func SliceMaxInt64(slc []int64) int64 { + maxVal := slc[0] + for i := 1; i < len(slc); i++ { + //从第二个 元素开始循环比较,如果发现有更大的,则交换 + if maxVal < slc[i] { + maxVal = slc[i] + } + } + return maxVal +} + +// 求最大值 +func SliceMinInt64(slc []int64) int64 { + minVal := slc[0] + for i := 1; i < len(slc); i++ { + //从第二个 元素开始循环比较,如果发现有更小的,则交换 + if minVal > slc[i] { + minVal = slc[i] + } + } + return minVal +} + +func ReverseAny(s interface{}) { + n := reflect.ValueOf(s).Len() + swap := reflect.Swapper(s) + for i, j := 0, n-1; i < j; i, j = i+1, j-1 { + swap(i, j) + } +} diff --git a/pub/common/utils/convUtil.go b/pub/common/utils/convUtil.go new file mode 100644 index 0000000..dda3ea9 --- /dev/null +++ b/pub/common/utils/convUtil.go @@ -0,0 +1,184 @@ +package utils + +import ( + "encoding/json" + "path/filepath" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ID = primitive.ObjectID + +// ToJsonM 将struct转换为 Json Map +func ToJsonM(obj interface{}) (map[string]interface{}, error) { + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + m := make(map[string]interface{}) + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +// ToBsonM 将struct转换为 Bson Map +func ToBsonM(s interface{}) (bson.M, error) { + data, err := bson.Marshal(s) + if err != nil { + return nil, err + } + m := make(bson.M) + if err := bson.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func MapToJsonString(m map[string]string) (string, error) { + if data, err := json.Marshal(m); err != nil { + return "", err + } else { + return string(data), nil + } +} + +func StructToJsonString(s interface{}) (string, error) { + j, err := json.Marshal(s) + if err != nil { + return "", err + } + m := make(map[string]interface{}) + if err := json.Unmarshal(j, &m); err != nil { + return "", err + } + if data, err := json.Marshal(m); err != nil { + return "", err + } else { + return string(data), nil + } +} + +// BMapToStruct s is struct ptr +func BMapToStruct(s interface{}, m map[string]interface{}) error { + bsonBytes, err := bson.Marshal(m) + if err != nil { + return err + } + return bson.Unmarshal(bsonBytes, s) +} + +// JMapToStruct s is struct ptr +func JMapToStruct(s interface{}, m map[string]interface{}) error { + bsonBytes, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(bsonBytes, s) +} + +// JSONStruct2Map 将struct转换为Map +func JSONStruct2Map(obj interface{}) (map[string]interface{}, error) { + m := make(map[string]interface{}) + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +// Map2JSONStruct 将map转换为Json struct +func Map2JSONStruct(v interface{}, m map[string]interface{}) error { + j, err := json.Marshal(m) + if err != nil { + return err + } + if err := json.Unmarshal(j, v); err != nil { + return err + } + return err +} + +func IDArray(ids []string) ([]ID, error) { + var idArray []ID + for _, id := range ids { + _id, err := primitive.ObjectIDFromHex(id) + if err != nil { + return nil, err + } + idArray = append(idArray, _id) + } + + return idArray, nil +} + +// 拼接https:// +func BindHttpSch(path string) string { + if strings.HasPrefix(path, "http") { + return path + } else { + return "https://" + path + } +} + +func BindUrl(hostname string, path ...string) string { + if hostname == "" { + return strings.TrimLeft(filepath.Join(path...), "/") + } + if len(path) == 0 { + return hostname + } + su := strings.TrimLeft(filepath.Join(path...), "/") + pr := strings.TrimRight(hostname, "/") + return pr + "/" + su +} + +func ObjectIDs2String(obj []primitive.ObjectID) []string { + arr := []string{} + if len(obj) == 0 { + return arr + } + for _, o := range obj { + arr = append(arr, o.Hex()) + } + return arr +} + +func String2ObjectID(obj []string) []primitive.ObjectID { + arr := []primitive.ObjectID{} + for _, o := range obj { + oid, err := primitive.ObjectIDFromHex(o) + if err != nil { + continue + } + arr = append(arr, oid) + } + return arr +} + +// PercentOf - calculate what percent [number1] is of [number2]. +// ex. 300 is 12.5% of 2400 +func PercentOf(part int, total int) float64 { + return (float64(part) * float64(100)) / float64(total) +} + +func MergeMap(src, dest map[string]interface{}) map[string]interface{} { + if len(src) == 0 { + return dest + } + if len(dest) == 0 { + return src + } + if len(src) == 0 && len(dest) == 0 { + return make(map[string]interface{}) + } + for k, v := range dest { + src[k] = v + } + return src +} diff --git a/pub/common/utils/copy.go b/pub/common/utils/copy.go new file mode 100644 index 0000000..51e0b2c --- /dev/null +++ b/pub/common/utils/copy.go @@ -0,0 +1,130 @@ +package utils + +import ( + "bytes" + "encoding/gob" + "reflect" + "time" +) + +// DeepCopy 深copy +func DeepCopy(dst, src interface{}) error { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(src); err != nil { + return err + } + return gob.NewDecoder(bytes.NewBuffer(buf.Bytes())).Decode(dst) +} + +// Interface for delegating copy process to type +type Interface interface { + DeepCopy() interface{} +} + +// Iface is an alias to Copy; this exists for backwards compatibility reasons. +func Iface(iface interface{}) interface{} { + return Copy(iface) +} + +// Copy creates a deep copy of whatever is passed to it and returns the copy +// in an interface{}. The returned value will need to be asserted to the +// correct type. +func Copy(src interface{}) interface{} { + if src == nil { + return nil + } + + // Make the interface a reflect.Value + original := reflect.ValueOf(src) + + // Make a copy of the same type as the original. + cpy := reflect.New(original.Type()).Elem() + + // Recursively copy the original. + copyRecursive(original, cpy) + + // Return the copy as an interface. + return cpy.Interface() +} + +// copyRecursive does the actual copying of the interface. It currently has +// limited support for what it can handle. Add as needed. +func copyRecursive(original, cpy reflect.Value) { + // check for implement deepcopy.Interface + if original.CanInterface() { + if copier, ok := original.Interface().(Interface); ok { + cpy.Set(reflect.ValueOf(copier.DeepCopy())) + return + } + } + + // handle according to original's Kind + switch original.Kind() { + case reflect.Ptr: + // Get the actual value being pointed to. + originalValue := original.Elem() + + // if it isn't valid, return. + if !originalValue.IsValid() { + return + } + cpy.Set(reflect.New(originalValue.Type())) + copyRecursive(originalValue, cpy.Elem()) + + case reflect.Interface: + // If this is a nil, don't do anything + if original.IsNil() { + return + } + // Get the value for the interface, not the pointer. + originalValue := original.Elem() + + // Get the value by calling Elem(). + copyValue := reflect.New(originalValue.Type()).Elem() + copyRecursive(originalValue, copyValue) + cpy.Set(copyValue) + + case reflect.Struct: + t, ok := original.Interface().(time.Time) + if ok { + cpy.Set(reflect.ValueOf(t)) + return + } + // Go through each field of the struct and copy it. + for i := 0; i < original.NumField(); i++ { + // The Type's StructField for a given field is checked to see if StructField.PkgPath + // is set to determine if the field is exported or not because CanSet() returns false + // for settable fields. I'm not sure why. -mohae + if original.Type().Field(i).PkgPath != "" { + continue + } + copyRecursive(original.Field(i), cpy.Field(i)) + } + + case reflect.Slice: + if original.IsNil() { + return + } + // Make a new slice and copy each element. + cpy.Set(reflect.MakeSlice(original.Type(), original.Len(), original.Cap())) + for i := 0; i < original.Len(); i++ { + copyRecursive(original.Index(i), cpy.Index(i)) + } + + case reflect.Map: + if original.IsNil() { + return + } + cpy.Set(reflect.MakeMap(original.Type())) + for _, key := range original.MapKeys() { + originalValue := original.MapIndex(key) + copyValue := reflect.New(originalValue.Type()).Elem() + copyRecursive(originalValue, copyValue) + copyKey := Copy(key.Interface()) + cpy.SetMapIndex(reflect.ValueOf(copyKey), copyValue) + } + + default: + cpy.Set(original) + } +} diff --git a/pub/common/utils/file.go b/pub/common/utils/file.go new file mode 100644 index 0000000..c595e6f --- /dev/null +++ b/pub/common/utils/file.go @@ -0,0 +1,56 @@ +package utils + +import "os" + +// CreateEmptyFile 创建一个固定size大小的空文件 +// 如果seek文件失败,则删除文件,并返回错误信息 +func CreateEmptyFile(name string, size int64) (f *os.File, err error) { + defer func() { + if err != nil && f != nil { + f.Close() + os.Remove(name) + } + }() + f, err = os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return + } + if _, err = f.Seek(size-1, 0); err != nil { + return + } + _, err = f.Write([]byte{0}) + return +} + +func WriteFile(data []byte, filename string) (err error) { + defer func() { + if err != nil { + os.Remove(filename) + } + }() + + pi := PathForInfo(filename) + if pi.Dir != "" { + if err = os.MkdirAll(pi.Dir, 0755); err != nil { + return + } + } + + f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return + } + _, err = f.Write(data) + f.Close() + return +} +func PathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/pub/common/utils/hash.go b/pub/common/utils/hash.go new file mode 100644 index 0000000..568035c --- /dev/null +++ b/pub/common/utils/hash.go @@ -0,0 +1,12 @@ +package utils + +// // MakeCrc +// func MakeCrc(s string) uint32 { +// return crc32.ChecksumIEEE([]byte(s)) +// } + +// func Md5V(str string) string { +// h := md5.New() +// h.Write([]byte(str)) +// return hex.EncodeToString(h.Sum(nil)) +// } \ No newline at end of file diff --git a/pub/common/utils/http.go b/pub/common/utils/http.go new file mode 100644 index 0000000..1467d0e --- /dev/null +++ b/pub/common/utils/http.go @@ -0,0 +1,283 @@ +package utils + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +var once sync.Once +var _client *http.Client + +func GetHttpClient() *http.Client { + once.Do(func() { + _client = &http.Client{ + Timeout: time.Minute * 1, //设置超时时间 + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, //限制 TLS握手的时间 + ResponseHeaderTimeout: 60 * time.Second, //限制读取response header的时间,默认 timeout + 5*time.Second + ExpectContinueTimeout: 1 * time.Second, //限制client在发送包含 Expect: 100-continue的header到收到继续发送body的response之间的时间等待。 + MaxIdleConns: 100, //所有host的连接池最大连接数量,默认无穷大 + MaxIdleConnsPerHost: 300, //每个host的连接池最大空闲连接数,默认2 + MaxConnsPerHost: 100, //每个host的最大连接数量 + IdleConnTimeout: 3 * time.Minute, //how long an idle connection is kept in the connection pool. + }, + } + }) + return _client +} + +func getBytes(request interface{}) ([]byte, error) { + var body []byte + switch t := request.(type) { + case []byte: + body = t + case string: + body = []byte(t) + default: + var err error + body, err = json.Marshal(request) + if err != nil { + return nil, err + } + } + return body, nil +} + +type OnHttpResponse func(res *http.Response) error + +func HttpRequest(req *http.Request, onResponse OnHttpResponse) error { + client := GetHttpClient() + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if onResponse != nil { + err = onResponse(res) + } + return err +} + +func HttpMethod(url, method string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + req, err := http.NewRequest(method, url, body) + if err != nil { + return err + } + if header != nil { + req.Header = header + } + return HttpRequest(req, onResponse) +} + +func HttpGet(url string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + return HttpMethod(url, http.MethodGet, header, body, onResponse) +} + +func HttpPost(url string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + return HttpMethod(url, http.MethodPost, header, body, onResponse) +} + +func HttpDelete(url string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + return HttpMethod(url, http.MethodDelete, header, body, onResponse) +} + +func HttpPostRequestJson(url string, request interface{}, resp interface{}) error { + header := make(http.Header) + header.Set("Content-Type", "application/json") + body, err := getBytes(request) + if err != nil { + return err + } + return HttpPost(url, header, bytes.NewReader(body), func(res *http.Response) error { + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, resp) + }) +} + +func POSTJson(url string, headers map[string]string, data interface{}) (*http.Response, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + body, err := getBytes(data) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header = header + client := GetHttpClient() + return client.Do(req) +} + +func GetWithParam(url string, headers map[string]string, params ...interface{}) (*http.Response, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + for _, p := range params { + url = addParams(url, toUrlValues(p)) + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header = header + client := GetHttpClient() + return client.Do(req) +} + +func GetWithJResp(bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + for _, p := range params { + url = addParams(url, toUrlValues(p)) + } + code := http.StatusBadRequest + err := HttpGet(url, header, nil, func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +func POSTWithJResp(bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + body, err := getBytes(params) + if err != nil { + return http.StatusBadRequest, err + } + code := http.StatusBadRequest + err = HttpPost(url, header, bytes.NewReader(body), func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +func POSTJsonWithJResp(bind interface{}, url string, headers map[string]string, data interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + header.Set("Content-Type", "application/json") + body, err := getBytes(data) + if err != nil { + return http.StatusBadRequest, err + } + code := http.StatusBadRequest + err = HttpPost(url, header, bytes.NewReader(body), func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +func DeleteWithJResp(bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + body, err := getBytes(params) + if err != nil { + return http.StatusBadRequest, err + } + code := http.StatusBadRequest + err = HttpDelete(url, header, bytes.NewReader(body), func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +// Add params to a url string. +func addParams(url_ string, params url.Values) string { + if len(params) == 0 { + return url_ + } + + if !strings.Contains(url_, "?") { + url_ += "?" + } + + if strings.HasSuffix(url_, "?") || strings.HasSuffix(url_, "&") { + url_ += params.Encode() + } else { + url_ += "&" + params.Encode() + } + + return url_ +} + +func toUrlValues(v interface{}) url.Values { + switch t := v.(type) { + case url.Values: + return t + case map[string][]string: + return url.Values(t) + case map[string]string: + rst := make(url.Values) + for k, v := range t { + rst.Add(k, v) + } + return rst + case nil: + return make(url.Values) + default: + panic("Invalid value") + } +} diff --git a/pub/common/utils/jwt.go b/pub/common/utils/jwt.go new file mode 100644 index 0000000..5810a0b --- /dev/null +++ b/pub/common/utils/jwt.go @@ -0,0 +1,127 @@ +package utils + +import ( + "errors" + "strings" + "time" + + "github.com/dgrijalva/jwt-go" +) + +const ( + userIDKey = "userId" + ipKey = "ip" + uaKey = "ua" +) + +type IPClaims struct { + Ip string + Expire time.Duration + UA string +} + +func GenerateIpToken(claims IPClaims, secret string) (string, error) { + return generateToken(jwt.MapClaims{ + ipKey: claims.Ip, + uaKey: claims.UA, + "exp": Now().Add(claims.Expire).Unix(), + "issuer": "com.butterfly", + "sub": "asign", + "aud": "public", + }, []byte(secret)) +} + +func ParseIpToken(tokenString string, secret string) (pass bool, ip, ua string, err error) { + t, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + var isExpire *bool + if vErr, ok := err.(*jwt.ValidationError); ok { + e := vErr.Errors&jwt.ValidationErrorExpired > 0 + isExpire = &e + } + if err != nil || isExpire == nil { + return + } + var ok bool + var jc jwt.MapClaims + jc, ok = t.Claims.(jwt.MapClaims) + if !ok { + err = errors.New("parse token fail") + return + } + ip, ok = jc[ipKey].(string) + if !ok { + err = errors.New("parse token fail") + return + } + ua, ok = jc[uaKey].(string) + if !ok { + err = errors.New("parse token fail") + return + } + pass = true + return +} + +type UserClaims struct { + UserID int64 + Expire time.Duration +} + +func generateToken(claims jwt.MapClaims, secret []byte) (string, error) { + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) +} + +func GenerateUserToken(claims UserClaims, secret string) (string, error) { + return generateToken(jwt.MapClaims{ + userIDKey: claims.UserID, + "exp": Now().Add(claims.Expire).Unix(), + "issuer": "com.butterfly", + "sub": "asign", + "aud": "public", + }, []byte(secret)) +} + +// ParseToken 解析Token +func ParseUserToken(token string, secret string) (userId int64, newToken string, err error) { + t, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + var isExpire *bool + if vErr, ok := err.(jwt.ValidationError); ok { + e := vErr.Errors&jwt.ValidationErrorExpired > 0 + isExpire = &e + } + if err != nil && isExpire == nil { + return + } + + jc, ok := t.Claims.(jwt.MapClaims) + if !ok { + err = errors.New("unknown token error1") + return + } + + id, ok := jc[userIDKey].(float64) + if !ok { + err = errors.New("unknown token error1") + return + } + userId = int64(id) + if isExpire != nil && *isExpire { + newToken, err = GenerateUserToken(UserClaims{UserID: userId, Expire: 30 * 24 * time.Hour}, secret) + return + } + err = nil + return +} + +// Strips 'Bearer ' prefix from bearer token string +func StripBearerPrefixFromTokenString(tok string) string { + if len(tok) > 6 && strings.ToUpper(tok[0:7]) == "BEARER " { + return tok[7:] + } else { + return tok + } +} \ No newline at end of file diff --git a/pub/common/utils/num.go b/pub/common/utils/num.go new file mode 100644 index 0000000..8d0c572 --- /dev/null +++ b/pub/common/utils/num.go @@ -0,0 +1,20 @@ +package utils + +import ( + "math/rand" + "time" +) + +// CountDigits 有几位数字 +func CountDigits(i int) (count int) { + for i != 0 { + i /= 10 + count++ + } + return +} + +func RandInt(min int, max int) int { + rand.Seed(time.Now().UnixNano()) + return min + rand.Intn(max-min) +} diff --git a/pub/common/utils/page.go b/pub/common/utils/page.go new file mode 100644 index 0000000..796db6e --- /dev/null +++ b/pub/common/utils/page.go @@ -0,0 +1,19 @@ +package utils + +func GetPageRange(totalLen, pageNum, pageSize int) (start, end int) { + if pageNum < 1 { + pageNum = 1 + } + if pageSize < 1 { + pageSize = 10 + } + start = (pageNum - 1) * pageSize + if start > totalLen { + start = totalLen + } + end = start + pageSize + if end > totalLen { + end = totalLen + } + return +} diff --git a/pub/common/utils/path.go b/pub/common/utils/path.go new file mode 100644 index 0000000..1b39d4c --- /dev/null +++ b/pub/common/utils/path.go @@ -0,0 +1,72 @@ +package utils + +import ( + "path/filepath" + "strings" + + "github.com/PuerkitoBio/purell" +) + +// PathInfo 路径信息 +type PathInfo struct { + Dir string // 目录 + Ext string // 扩展名 + Filename string // 没有扩展名的文件名名称 + FullFileName string // 文件全名 +} + +// PathForInfo 解析路径 +// 返回文件,路径,文件名等信息 +func PathForInfo(path string) (pi PathInfo) { + if path == "" { + return + } + ext := filepath.Ext(path) + dir, name := filepath.Split(path) + + pi.Dir = dir + pi.Ext = ext + pi.Filename = strings.TrimRight(name, ext) + pi.FullFileName = name + return +} + +// JoinUrlPath 域名与uri拼接(走 purell 标准化,会剥掉尾部斜杠)。 +// 适用于 CDN 文件链接(m3u8 分段、vtt、静态图等)这类"不需要尾部斜杠"的场景。 +// 需要保留尾部斜杠的页面 URL(sitemap/rss/indexnow)请使用 JoinUrlPathKeepSlash。 +func JoinUrlPath(host string, uri string) (urlpath string) { + urlpath = joinRawUrlPath(host, uri) + if urlpath == "" { + return + } + // 标准化 URL + normalized, err := purell.NormalizeURLString(urlpath, purell.FlagsUsuallySafeGreedy) + if err != nil { + return urlpath + } + return normalized +} + +// JoinUrlPathKeepSlash 域名与uri拼接,保留 uri 原始的尾部斜杠,不做任何标准化。 +// sitemap/rss/indexnow 的页面 URL 必须与路由注册的 /archives/:id/ 等完全一致, +// 否则爬虫每次访问都会被 gin 的 RedirectTrailingSlash 301 到带 / 的地址, +// 浪费抓取预算并造成重定向链。 +func JoinUrlPathKeepSlash(host string, uri string) string { + return joinRawUrlPath(host, uri) +} + +// joinRawUrlPath 纯拼接,不做任何标准化。 +func joinRawUrlPath(host string, uri string) (urlpath string) { + if uri == "" { + return + } + switch { + case strings.HasSuffix(host, "/") && strings.HasPrefix(uri, "/"): + urlpath = host[0:len(host)-1] + uri + case !strings.HasSuffix(host, "/") && !strings.HasPrefix(uri, "/"): + urlpath = host + "/" + uri + default: + urlpath = host + uri + } + return +} diff --git a/pub/common/utils/redis.go b/pub/common/utils/redis.go new file mode 100644 index 0000000..1064e73 --- /dev/null +++ b/pub/common/utils/redis.go @@ -0,0 +1,56 @@ +package utils + +import ( + "bytes" + "time" + "videoweb/pub/common/enum/redisconst" + sErr "videoweb/pub/common/errors" + "videoweb/pub/redis" +) + +const ( + expireTime = 30 * 24 * time.Hour +) + +type TokenRedis struct { +} + +func NewTokenRedis() *TokenRedis { + return &TokenRedis{} +} + +func (tr *TokenRedis) Save(userId int64, token string) (err error) { + if err = tr.Delete(userId); err != nil { + return + } + err = redis.Set(redisconst.AppUserTokenKey(userId), token, expireTime) + if err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +func (tr *TokenRedis) Exists(userId int64, token string) (has bool, err error) { + tokenbytes, err := tr.Get(userId) + if err != nil { + return + } + has = bytes.Compare(tokenbytes, []byte(token)) == 0 + return +} + +func (tr *TokenRedis) Get(userId int64) (token []byte, err error) { + token, err = redis.GetBytes(redisconst.AppUserTokenKey(userId)) + if err != nil { + err = sErr.MakeWithErr(sErr.RedisGet, err) + } + return +} + +func (tr *TokenRedis) Delete(userId int64) (err error) { + err = redis.Del(redisconst.AppUserTokenKey(userId)) + if err != nil { + err = sErr.MakeWithErr(sErr.RedisDel, err) + } + return +} diff --git a/pub/common/utils/shellcmd.go b/pub/common/utils/shellcmd.go new file mode 100644 index 0000000..7e51287 --- /dev/null +++ b/pub/common/utils/shellcmd.go @@ -0,0 +1,15 @@ +package utils + +import ( + "bytes" + "os/exec" +) + +func ShellCmd(cmdStr string) (string, error) { + cmd := exec.Command("/bin/bash", "-c", cmdStr) + var out bytes.Buffer + cmd.Stdout = &out + ec := cmd.Run() + + return out.String(), ec +} diff --git a/pub/common/utils/slices.go b/pub/common/utils/slices.go new file mode 100644 index 0000000..aa5d237 --- /dev/null +++ b/pub/common/utils/slices.go @@ -0,0 +1,116 @@ +package utils + +import ( + "golang.org/x/exp/constraints" + "sort" +) + +func SliceConstruct[T any](fn func() T) T { + return fn() +} + +type SliceWalkCallback func(index int) bool // true 继续 +func SliceWalk(length int, cb SliceWalkCallback) { + for i := 0; i < length; i++ { + if ok := cb(i); ok { + continue + } else { + break + } + } +} + +type SliceIncludeFilter func(index int) bool // 返回 true 继续, false 中断 +func SliceInclude(length int, filter SliceIncludeFilter) (int, bool) { + for i := 0; i < length; i++ { + if ok := filter(i); ok { + return i, true + } + } + return -1, false +} + +type SliceConstructNFilter[T any] func(index int) (T, bool) // 返回 是否保留,项值 +func SliceConstructN[T any](length int, filter SliceConstructNFilter[T]) []T { + ret := make([]T, 0) + for i := 0; i < length; i++ { + if item, keep := filter(i); keep { + ret = append(ret, item) + } + } + return ret +} + +// SliceUnique 使用泛型实现切片去重 +func SliceUnique[T comparable](s []T) []T { + seen := make(map[T]bool) + j := 0 + for _, v := range s { + if _, ok := seen[v]; !ok { + seen[v] = true + s[j] = v + j++ + } + } + return s[:j] +} + +// SlicesDifference 求两个集合的差集 +func SlicesDifference[T comparable](a, b []T) []T { + // 使用 map 存储集合 b 的元素,方便快速判断元素是否存在 + seen := make(map[T]struct{}, len(b)) + for _, v := range b { + seen[v] = struct{}{} + } + + // 遍历集合 a,将不在集合 b 中的元素放入新的切片 + diff := make([]T, 0) + for _, v := range a { + if _, ok := seen[v]; !ok { + diff = append(diff, v) + } + } + + return diff +} + +func RemoveSlicesElement[T comparable](slice []T, element T) []T { + // 使用两个指针,一个遍历,一个用来记录有效元素的位置 + j := 0 + for i := range slice { + if slice[i] != element { + slice[j] = slice[i] + j++ + } + } + // 返回截取后的切片 + return slice[:j] +} + +func SlicesIntersect[T constraints.Ordered | ~string](slice1, slice2 []T) []T { + dict := make(map[T]int) + ret := make([]T, 0) + for _, v1 := range slice1 { + dict[v1]++ + } + for _, v2 := range slice2 { + if _, exists := dict[v2]; exists { + ret = append(ret, v2) + } + } + return ret +} + +func IsSliceEqual[T constraints.Ordered](src []T, dst []T) bool { + sort.Slice(src, func(i, j int) bool { return src[i] < src[j] }) + sort.Slice(dst, func(i, j int) bool { return dst[i] < dst[j] }) + if len(src) != len(dst) { + return false + } + for i := range src { + if src[i] != dst[i] { + return false + } + } + return true +} diff --git a/pub/common/utils/strings.go b/pub/common/utils/strings.go new file mode 100644 index 0000000..768b726 --- /dev/null +++ b/pub/common/utils/strings.go @@ -0,0 +1,27 @@ +package utils + +// BinarySearch BinarySearch +func BinarySearch(s []string, t string, n int) (idx int) { + idx = -1 + if n == 0 { + return + } + start, end := 0, n-1 + for start <= end { + mid := (start + end) >> 1 + if s[mid] < t { + start = mid + 1 + } else if s[mid] > t { + end = mid - 1 + } else { + idx = mid + break + } + } + return +} + +var YesOrNo = map[bool]string{ + true: "是", + false: "否", +} diff --git a/pub/common/utils/structToMap.go b/pub/common/utils/structToMap.go new file mode 100644 index 0000000..f16d5ce --- /dev/null +++ b/pub/common/utils/structToMap.go @@ -0,0 +1,52 @@ +package utils + +import ( + "fmt" + "github.com/mitchellh/mapstructure" + "reflect" +) + +func Struct2Map(obj interface{}) map[string]interface{} { + t := reflect.TypeOf(obj) + v := reflect.ValueOf(obj) + if t.Kind() == reflect.Ptr { + t = t.Elem() + v = reflect.Indirect(v) + } + var data = make(map[string]interface{}) + //func (v Value) NumField() int, 返回v持有的结构体类型值的字段数,如果v的Kind不是Struct会panic + for i := 0; i < t.NumField(); i++ { + n := t.Field(i).Name + vv := v.Field(i).Interface() + data[Capitalize(n)] = vv + } + return data +} + +func Map2Struct(obj interface{}, mapInfo map[string]interface{}) interface{} { + err := mapstructure.Decode(mapInfo, obj) + if err != nil { + fmt.Println(err) + } + return obj +} + +// Capitalize 字符首字母大写 +func Capitalize(str string) string { + var upperStr string + vv := []rune(str) // 后文有介绍 + for i := 0; i < len(vv); i++ { + if i == 0 { + if vv[i] >= 65 && vv[i] <= 96 { // 后文有介绍 + vv[i] += 32 // string的码表相差32位 + upperStr += string(vv[i]) + } else { + fmt.Println("Not begins with uppercase letter,") + return str + } + } else { + upperStr += string(vv[i]) + } + } + return upperStr +} diff --git a/pub/common/utils/time.go b/pub/common/utils/time.go new file mode 100644 index 0000000..94d2f32 --- /dev/null +++ b/pub/common/utils/time.go @@ -0,0 +1,276 @@ +package utils + +import ( + "fmt" + "time" + "videoweb/pub/pubdef" +) + +var ( + LocBJ, _ = time.LoadLocation("Asia/Shanghai") +) + +func init() { + time.Local = LocBJ +} + +func Now() time.Time { + return time.Now().Local() +} + +func NowMillisecond() int64 { + return Now().UnixNano() / int64(time.Millisecond) +} + +func BeginOfToday() time.Time { + t := Now() + return BeginOfTime(t) +} + +func BeginOfTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +func GetNextMonthTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month+1, day, 0, 0, 0, 0, t.Location()) +} +func GetNextQuarterTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month+3, day, 0, 0, 0, 0, t.Location()) +} + +func GetNextYearTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year+1, month, day, 0, 0, 0, 0, t.Location()) +} + +func GetForeverTime() time.Time { + t := time.Now() + year, month, day := t.Date() + return time.Date(year+999, month, day, 0, 0, 0, 0, t.Location()) +} + +func IsoWeek() string { + year, week := time.Now().ISOWeek() + return fmt.Sprintf("%d%02d", year, week) +} + +// Deprecated +func EndOfToday() time.Time { + t := Now() + return EndOfTime(t) +} + +func EndOfTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 23, 59, 59, 0, t.Location()) +} + +func GetDateString(t time.Time) string { + return t.In(LocBJ).Format(pubdef.DateISO) +} + +func GetTodayDate(now time.Time) string { + return GetDateString(now.In(time.Local)) +} + +func GetYesterdayDate(now time.Time) string { + return GetDateString(now.Local().AddDate(0, 0, -1)) +} + +func GetDayBeforeYesterdayDate(now time.Time) string { + return GetDateString(now.Local().AddDate(0, 0, -2)) +} + +func GetDayRange(t time.Time) (dayStart time.Time, dayEnd time.Time) { + dayStart = BeginOfTime(t) + dayEnd = EndOfTime(t) + return +} + +// GetDayRangeByDate 返回一天的开始和结束时间, 00:00:00 - 23:59:59 +func GetDayRangeByDate(date string) (dayStart time.Time, dayEnd time.Time, err error) { + var d time.Time + d, err = time.ParseInLocation(pubdef.DateISO, date, LocBJ) + if err != nil { + return + } + dayStart, dayEnd = GetDayRange(d) + return +} + +func GetDayEnd(date string) time.Time { + d, _ := time.ParseInLocation(pubdef.DateISO, date, LocBJ) + return EndOfTime(d) +} + +func GetDayBegin(date string) time.Time { + d, _ := time.ParseInLocation(pubdef.DateISO, date, LocBJ) + return BeginOfTime(d) +} + +func BeginWithThisMonth() time.Time { + t := Now() + year, month, _ := t.Date() + return time.Date(year, month, 1, 0, 0, 0, 0, t.Location()) +} + +func BeginOfMonth(t time.Time) time.Time { + year, month, _ := t.In(time.Local).Date() + return time.Date(year, month, 1, 0, 0, 0, 0, t.Location()) +} + +func EndOfMonth(t time.Time) time.Time { + return BeginOfMonth(t).AddDate(0, 1, 0).Add(-time.Nanosecond) +} + +func EndOfLastMonth(t time.Time) time.Time { + return BeginOfMonth(t).Add(-time.Nanosecond) +} + +func EndOfLastMonthDate(t time.Time) string { + return GetDateString(EndOfLastMonth(t)) +} + +func EndOfLastWeek(t time.Time) time.Time { + return BeginOfWeekByTime(t).Add(-time.Nanosecond) +} + +func EndOfLastWeekDate(t time.Time) string { + return GetDateString(EndOfLastWeek(t)) +} + +// Deprecated +func BeginOfWeek() time.Time { + return BeginOfWeekByTime(Now()) +} + +func WeekDateRange(t time.Time) (string, string) { + b := BeginOfWeekByTime(t) + e := EndOfWeekByTime(t) + return GetDateString(b), GetDateString(e) +} + +func MonthDateRange(t time.Time) (string, string) { + b := BeginOfMonth(t) + e := EndOfMonth(t) + return GetDateString(b), GetDateString(e) +} + +func BeginOfWeekByTime(t time.Time) time.Time { + bt := BeginOfTime(t) + weekday := int(t.Weekday()) + weekStartDayInt := int(time.Monday) + if weekday < weekStartDayInt { + weekday = weekday + 7 - weekStartDayInt + } else { + weekday = weekday - weekStartDayInt + } + return bt.AddDate(0, 0, -weekday) +} + +func EndOfWeekByTime(t time.Time) time.Time { + return BeginOfWeekByTime(t).AddDate(0, 0, 7).Add(-time.Nanosecond) +} + +func BeginTodayMillsecond() int64 { + return BeginOfToday().UnixNano() / int64(time.Millisecond) +} + +// ServerCurrMillisecond 获得时间毫秒数 13位 +func ServerCurrMillisecond(t time.Time) int64 { + return t.UnixNano() / 1000000 +} + +func BeginWithTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +func EndWithTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 23, 59, 59, 0, t.Location()) +} + +func IsToday(t time.Time) bool { + if t.IsZero() { + return false + } + today := Now() + + return t.Year() == today.Year() && + t.Month() == today.Month() && + t.Day() == today.Day() +} + +func IsSameYearAndMonth(t1, t2 time.Time) bool { + return t1.Year() == t2.Year() && + t1.Month() == t2.Month() +} + +// 获取月的第一天 +func GetFirstDateOfMonth(d time.Time) time.Time { + d = d.AddDate(0, 0, -d.Day()+1) + return GetZeroTime(d) +} + +// 获取月的最后一天 +func GetLastDateOfMonth(d time.Time) time.Time { + return GetFirstDateOfMonth(d).AddDate(0, 1, -1) +} + +// 获取周的星期一(星期一~星期日) +func GetMondayOfWeek(d time.Time) time.Time { + offset := (int(d.Weekday()) + 6) % 7 + d = d.AddDate(0, 0, -offset) + return GetZeroTime(d) +} + +// 获取周的星期日(星期一~星期日) +func GetSundayOfWeek(d time.Time) time.Time { + offset := 6 - (int(d.Weekday())+6)%7 + d = d.AddDate(0, 0, offset) + return GetZeroTime(d) +} + +// 获取某一天的0点时间 +func GetZeroTime(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) +} + +// 获取当前整点时间 +func GetWholeTime(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), d.Hour(), 0, 0, 0, d.Location()) +} + +func MustParseTime(s string) time.Time { + layouts := []string{ + time.RFC3339, + pubdef.DateISO, + pubdef.TimeISO, + } + for _, layout := range layouts { + t, err := time.ParseInLocation(layout, s, time.Local) + if err != nil { + continue + } + return t + } + panic(fmt.Errorf("cannot parse %s as time.Time", s)) +} + +// 查看两个时间差了多少天,差一秒都算一天 +func TimeSub(t1, t2 time.Time) int64 { + t1 = time.Date(t1.Year(), t1.Month(), t1.Day(), 0, 0, 0, 0, time.Local) + t2 = time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, time.Local) + + return int64(t1.Sub(t2).Hours() / 24) +} +func RecentMinute(tim time.Time, scaleMinute int64) time.Time { + minute := int64(tim.Minute()) + lave := minute % scaleMinute + formatMinute := minute - lave + return time.Date(tim.Year(), tim.Month(), tim.Day(), tim.Hour(), int(formatMinute), 0, 0, tim.Location()) +} diff --git a/pub/common/utils/uasplit.go b/pub/common/utils/uasplit.go new file mode 100644 index 0000000..3d033eb --- /dev/null +++ b/pub/common/utils/uasplit.go @@ -0,0 +1,30 @@ +package utils + +import ( + "strings" + "videoweb/pub/common/enum/consts" +) + +func UASplit(useragent string) (phoneModel string, phoneSys string, appVer string) { + if useragent == "" { + return + } + infos := strings.Split(useragent, ";") + if len(infos) != 3 { + return + } + phoneinfos := strings.Split(strings.Trim(infos[1], " "), " ") + if len(phoneinfos) == 2 { + phoneModel = strings.Trim(phoneinfos[0], " ") + phoneSys = strings.Trim(phoneinfos[1], " ") + } + appVer = strings.Trim(infos[2], " ") + return +} + +func UADeviceType(ua string) string { + if strings.Contains(ua, "iPhone") { + return consts.IOS + } + return consts.Android +} diff --git a/pub/common/utils/utils.go b/pub/common/utils/utils.go new file mode 100644 index 0000000..8c64d51 --- /dev/null +++ b/pub/common/utils/utils.go @@ -0,0 +1,63 @@ +package utils + +import ( + "fmt" + "math/rand" + "runtime/debug" + "strings" + "time" + "videoweb/pub/common/log" + + "golang.org/x/exp/constraints" +) + +func RecoverError(onError func(err error)) { + if r := recover(); r != nil { + err := fmt.Errorf("internal error: %v", r) + if onError != nil { + onError(err) + } + log.Error(fmt.Sprintf("[Panic] RecoverError err: %+v\n", r) + string(debug.Stack())) + } +} + +func GetRandom(min, max int) int64 { + rand.Seed(time.Now().UnixNano()) + return int64(rand.Intn(max-min+1) + min) +} +func EnsureRange[T constraints.Ordered](value, min, max T) T { + if value < min { + value = min + } + if value > max { + value = max + } + return value +} + +func InRange[T constraints.Ordered](value, min, max T) bool { + if value < min { + return false + } + if value > max { + return false + } + return true +} + +// Shuffle 使用 Fisher-Yates 算法对切片进行随机排序 +func Shuffle[T any](slice []T) { + rand.Seed(time.Now().UnixNano()) + n := len(slice) + for i := n - 1; i > 0; i-- { + j := rand.Intn(i + 1) + slice[i], slice[j] = slice[j], slice[i] + } +} +func GetRandomMacAddress() string { + buf := make([]byte, 6) + rand.Read(buf) + buf[0] |= 2 + mac := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]) + return strings.ToUpper(mac) +} diff --git a/pub/common/utils/verify.go b/pub/common/utils/verify.go new file mode 100644 index 0000000..fb20ae5 --- /dev/null +++ b/pub/common/utils/verify.go @@ -0,0 +1,10 @@ +package utils + +import "regexp" + +// mobile verify +func VerifyMobileFormat(mobileNum string) bool { + regular := "^((13[0-9])|(14[5,7])|(15[0-3,5-9])|(17[0,3,5-8])|(18[0-9])|166|198|199|(147))\\d{8}$" + reg := regexp.MustCompile(regular) + return reg.MatchString(mobileNum) +} diff --git a/pub/common/utils/version.go b/pub/common/utils/version.go new file mode 100644 index 0000000..e2769d8 --- /dev/null +++ b/pub/common/utils/version.go @@ -0,0 +1,43 @@ +package utils + +import ( + "strconv" + "strings" +) + +func getSubVersions(ver string) []int { + var vers [4]int + vv := strings.Split(ver, ".") + for i, v := range vv { + vers[i], _ = strconv.Atoi(v) + } + return vers[:] +} + +// VersionCompare +func VersionCompare(a, b string) int { + + aa := getSubVersions(a) + bb := getSubVersions(b) + + for i := 0; i < len(aa) && i < len(bb); i++ { + + if aa[i] == bb[i] { + continue + } + + if aa[i] < bb[i] { + return -1 + } + + return 1 + } + + if len(aa) == len(bb) { + return 0 + } + if len(aa) < len(bb) { + return -1 + } + return 1 +} diff --git a/pub/common/v10/upgradev10.go b/pub/common/v10/upgradev10.go new file mode 100644 index 0000000..8908c46 --- /dev/null +++ b/pub/common/v10/upgradev10.go @@ -0,0 +1,55 @@ +package v10 + +import ( + "github.com/go-playground/validator/v10" + "reflect" + "sync" + + "github.com/gin-gonic/gin/binding" +) + +type DefaultValidator struct { + once sync.Once + validate *validator.Validate +} + +var _ binding.StructValidator = &DefaultValidator{} + +func (v *DefaultValidator) ValidateStruct(obj interface{}) error { + + if kindOfData(obj) == reflect.Struct { + + v.lazyinit() + + if err := v.validate.Struct(obj); err != nil { + return error(err) + } + } + + return nil +} + +func (v *DefaultValidator) Engine() interface{} { + v.lazyinit() + return v.validate +} + +func (v *DefaultValidator) lazyinit() { + v.once.Do(func() { + v.validate = validator.New() + v.validate.SetTagName("binding") + + // add any custom validations etc. here + }) +} + +func kindOfData(data interface{}) reflect.Kind { + + value := reflect.ValueOf(data) + valueType := value.Kind() + + if valueType == reflect.Ptr { + valueType = value.Elem().Kind() + } + return valueType +} diff --git a/pub/common/v10/validate.go b/pub/common/v10/validate.go new file mode 100644 index 0000000..ef5ca53 --- /dev/null +++ b/pub/common/v10/validate.go @@ -0,0 +1,171 @@ +package v10 + +import ( + "github.com/nyaruka/phonenumbers" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/go-playground/validator/v10" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// IsPureChar 匹配中文、字母、数字 +func IsPureChar(src string) bool { + return regexp.MustCompile("^[a-z0-9A-Z\u4e00-\u9fa5]+$").MatchString(src) +} + +// IsPureChineseChar 匹配中文 +func IsPureChineseChar(src string) bool { + return regexp.MustCompile("[\u4e00-\u9fa5]").MatchString(src) +} + +// 提取文字 包括数字 英文 不包含符号 +func ExtractPureEngChar(src string) string { + pureArray := regexp.MustCompile("[a-z0-9A-Z\u005f]").FindAllString(src, -1) + return strings.Join(pureArray, "") +} + +// 提取文字 包括中文 数字 英文 不包含符号 +func ExtractPureChar(src string) string { + src = strings.TrimSpace(src) + pureArray := regexp.MustCompile("[a-z0-9A-Z\u4e00-\u9fa5]+").FindAllString(src, -1) + return strings.Join(pureArray, "") +} + +// IsPhoneNumber 匹配所有国际化的手机号码(手机卡 + 上网卡) +func IsGlobalizationPhoneNumber(phoneNumber string) bool { + num, _ := phonenumbers.Parse(phoneNumber, "CN") + return phonenumbers.IsValidNumber(num) +} + +// IsEn 是否是英文的正则表达式验证 +func IsEn(data string) bool { + return regexp.MustCompile(`^[a-zA-Z]+$`).MatchString(data) +} + +// IsEnZh 是否是中英文的正则表达式验证 +func IsEnZh(data string) bool { + return regexp.MustCompile("^[a-zA-Z|\u4e00-\u9fa5]+$").MatchString(data) +} + +// IsEnNum 是否是英文数字的正则表达式验证 +func IsEnNum(data string) bool { + return regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString(data) +} + +// IsEnNumSpe 是否是英文数字特殊符号的验证 +func IsEnNumSpe(data string) bool { + return regexp.MustCompile(`^[a-zA-Z0-9|~!@#$%^&*()/\|,.<>?"'();:_+-=\[\]{}]+$`).MatchString(data) +} + +// IsObjectID 是否objectID的验证 +func IsObjectID(data string) bool { + _, err := primitive.ObjectIDFromHex(data) + if err != nil { + return false + } + return true +} + +// IsLengthBetween 判断data字符串长度是否在范围内 +func IsLengthBetween(data string, min int, max int) bool { + return len(data) >= min && len(data) <= max +} + +// IsNil nil判断 +func IsNil(i interface{}) bool { + vi := reflect.ValueOf(i) + if vi.Kind() == reflect.Ptr { + return vi.IsNil() + } + return false +} + +// Phone 验证手机号码 +func Phone( + v *validator.Validate, topStruct reflect.Value, currentStructOrField reflect.Value, + field reflect.Value, fieldType reflect.Type, fieldKind reflect.Kind, param string, +) bool { + if data, ok := field.Interface().(string); ok { + if len(data) <= 10 || len(data) >= 15 { + return false + } + if !IsGlobalizationPhoneNumber(data) { + return false + } + return true + } + return false +} + +// Time_Utc 转换成UTC时间 +func Time_Utc(fl validator.FieldLevel) bool { + timeParse, err := time.Parse(time.RFC3339, fl.Field().String()) + if err != nil { + return false + } + timevalue := reflect.ValueOf(timeParse) + fl.Field().Set(timevalue) + return true +} + +// StrLength 验证长度 +func StrLength( + v *validator.Validate, topStruct reflect.Value, currentStructOrField reflect.Value, + field reflect.Value, fieldType reflect.Type, fieldKind reflect.Kind, param string, +) bool { + num, err := strconv.Atoi(param) + if err != nil { + return false + } + if data, ok := field.Interface().(string); ok { + if len(data) == num { + return true + } + } + return false +} + +// NeedType 需要类型 +func NeedType( + v *validator.Validate, topStruct reflect.Value, currentStructOrField reflect.Value, + field reflect.Value, fieldType reflect.Type, fieldKind reflect.Kind, param string, +) bool { + if data, ok := field.Interface().(string); ok { + switch param { + case "en": // 英文 + return IsEn(data) + case "enZh": // 中英文 + return IsEnZh(data) + case "enNum": // 英文数字 + return IsEnNum(data) + case "enNumSpe": // 英文数字特殊符号 + return IsEnNumSpe(data) + case "objectID": // 是否为mongo的id + return IsObjectID(data) + } + } + return false +} + +// RegisterValidator 注解自定义参数验证 +func RegisterValidator() gin.HandlerFunc { + return func(c *gin.Context) { + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + //v.RegisterValidation("phone", Phone) + // v.RegisterValidation("len", StrLength) + // v.RegisterValidation("min", validator.IsGte) + // v.RegisterValidation("max", validator.IsLte) + // v.RegisterValidation("eq", validator.IsEq) + // v.RegisterValidation("type", NeedType) + v.RegisterValidation("time_utc", Time_Utc) + } + c.Next() + } +} diff --git a/pub/common/wordsfilter/filter.go b/pub/common/wordsfilter/filter.go new file mode 100644 index 0000000..fa03ce9 --- /dev/null +++ b/pub/common/wordsfilter/filter.go @@ -0,0 +1,113 @@ +package wordsfilter + +import ( + "bufio" + "io" + "net/http" + "os" + "regexp" + "time" +) + +// Filter 敏感词过滤器 +type Filter struct { + trie *Trie + noise *regexp.Regexp +} + +// New 返回一个敏感词过滤器 +func New() *Filter { + return &Filter{ + trie: NewTrie(), + noise: regexp.MustCompile(`[\|\s&%$@*]+`), + } +} + +// UpdateNoisePattern 更新去噪模式 +func (filter *Filter) UpdateNoisePattern(pattern string) { + filter.noise = regexp.MustCompile(pattern) +} + +// LoadWordDict 加载敏感词字典 +func (filter *Filter) LoadWordDict(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + return filter.Load(f) +} + +// LoadNetWordDict 加载网络敏感词字典 +func (filter *Filter) LoadNetWordDict(url string) error { + c := http.Client{ + Timeout: 5 * time.Second, + } + rsp, err := c.Get(url) + if err != nil { + return err + } + defer rsp.Body.Close() + + return filter.Load(rsp.Body) +} + +// Load common method to add words +func (filter *Filter) Load(rd io.Reader) error { + buf := bufio.NewReader(rd) + for { + line, _, err := buf.ReadLine() + if err != nil { + if err != io.EOF { + return err + } + break + } + filter.trie.Add(string(line)) + } + + return nil +} + +// AddWord 添加敏感词 +func (filter *Filter) AddWord(words ...string) { + filter.trie.Add(words...) +} + +// DelWord 删除敏感词 +func (filter *Filter) DelWord(words ...string) { + filter.trie.Del(words...) +} + +// Filter 过滤敏感词 +func (filter *Filter) Filter(text string) string { + return filter.trie.Filter(text) +} + +// Replace 和谐敏感词 +func (filter *Filter) Replace(text string, repl rune) string { + return filter.trie.Replace(text, repl) +} + +// FindIn 检测敏感词 +func (filter *Filter) FindIn(text string) (bool, string) { + text = filter.RemoveNoise(text) + return filter.trie.FindIn(text) +} + +// FindAll 找到所有匹配词 +func (filter *Filter) FindAll(text string) []string { + return filter.trie.FindAll(text) +} + +// Validate 检测字符串是否合法 +func (filter *Filter) Validate(text string) (bool, string) { + text = filter.RemoveNoise(text) + return filter.trie.Validate(text) +} + +// RemoveNoise 去除空格等噪音 +func (filter *Filter) RemoveNoise(text string) string { + return filter.noise.ReplaceAllString(text, "") +} diff --git a/pub/common/wordsfilter/trie_tree.go b/pub/common/wordsfilter/trie_tree.go new file mode 100644 index 0000000..d06bcc4 --- /dev/null +++ b/pub/common/wordsfilter/trie_tree.go @@ -0,0 +1,272 @@ +package wordsfilter + +// Trie 短语组成的Trie树. +type Trie struct { + Root *Node +} + +// Node Trie树上的一个节点. +type Node struct { + isRootNode bool + isPathEnd bool + Character rune + Children map[rune]*Node +} + +// NewTrie 新建一棵Trie +func NewTrie() *Trie { + return &Trie{ + Root: NewRootNode(0), + } +} + +// Add 添加若干个词 +func (tree *Trie) Add(words ...string) { + for _, word := range words { + tree.add(word) + } +} + +func (tree *Trie) add(word string) { + var current = tree.Root + var runes = []rune(word) + for position := 0; position < len(runes); position++ { + r := runes[position] + if next, ok := current.Children[r]; ok { + current = next + } else { + newNode := NewNode(r) + current.Children[r] = newNode + current = newNode + } + if position == len(runes)-1 { + current.isPathEnd = true + } + } +} + +func (tree *Trie) Del(words ...string) { + for _, word := range words { + tree.del(word) + } +} + +func (tree *Trie) del(word string) { + var current = tree.Root + var runes = []rune(word) + for position := 0; position < len(runes); position++ { + r := runes[position] + if next, ok := current.Children[r]; !ok { + return + } else { + current = next + } + + if position == len(runes)-1 { + current.SoftDel() + } + } +} + +// Replace 词语替换 +func (tree *Trie) Replace(text string, character rune) string { + var ( + parent = tree.Root + current *Node + runes = []rune(text) + length = len(runes) + left = 0 + found bool + ) + + for position := 0; position < len(runes); position++ { + current, found = parent.Children[runes[position]] + + if !found || (!current.IsPathEnd() && position == length-1) { + parent = tree.Root + position = left + left++ + continue + } + + // println(string(current.Character), current.IsPathEnd(), left) + if current.IsPathEnd() && left <= position { + for i := left; i <= position; i++ { + runes[i] = character + } + } + + parent = current + } + + return string(runes) +} + +// Filter 直接过滤掉字符串中的敏感词 +func (tree *Trie) Filter(text string) string { + var ( + parent = tree.Root + current *Node + left = 0 + found bool + runes = []rune(text) + length = len(runes) + resultRunes = make([]rune, 0, length) + ) + + for position := 0; position < length; position++ { + current, found = parent.Children[runes[position]] + + if !found || (!current.IsPathEnd() && position == length-1) { + resultRunes = append(resultRunes, runes[left]) + parent = tree.Root + position = left + left++ + continue + } + + if current.IsPathEnd() { + left = position + 1 + parent = tree.Root + } else { + parent = current + } + + } + + resultRunes = append(resultRunes, runes[left:]...) + return string(resultRunes) +} + +// Validate 验证字符串是否合法,如不合法则返回false和检测到 +// 的第一个敏感词 +func (tree *Trie) Validate(text string) (bool, string) { + const ( + Empty = "" + ) + var ( + parent = tree.Root + current *Node + runes = []rune(text) + length = len(runes) + left = 0 + found bool + ) + + for position := 0; position < len(runes); position++ { + current, found = parent.Children[runes[position]] + + if !found || (!current.IsPathEnd() && position == length-1) { + parent = tree.Root + position = left + left++ + continue + } + + if current.IsPathEnd() && left <= position { + return false, string(runes[left : position+1]) + } + + parent = current + } + + return true, Empty +} + +// FindIn 判断text中是否含有词库中的词 +func (tree *Trie) FindIn(text string) (bool, string) { + validated, first := tree.Validate(text) + return !validated, first +} + +// FindAll 找有所有包含在词库中的词 +func (tree *Trie) FindAll(text string) []string { + var matches []string + var ( + parent = tree.Root + current *Node + runes = []rune(text) + length = len(runes) + left = 0 + found bool + ) + + for position := 0; position < length; position++ { + current, found = parent.Children[runes[position]] + + if !found { + parent = tree.Root + position = left + left++ + continue + } + + if current.IsPathEnd() && left <= position { + matches = append(matches, string(runes[left:position+1])) + } + + if position == length-1 { + parent = tree.Root + position = left + left++ + continue + } + + parent = current + } + + var i = 0 + if count := len(matches); count > 0 { + set := make(map[string]struct{}) + for i < count { + _, ok := set[matches[i]] + if !ok { + set[matches[i]] = struct{}{} + i++ + continue + } + count-- + copy(matches[i:], matches[i+1:]) + } + return matches[:count] + } + + return nil +} + +// NewNode 新建子节点 +func NewNode(character rune) *Node { + return &Node{ + Character: character, + Children: make(map[rune]*Node, 0), + } +} + +// NewRootNode 新建根节点 +func NewRootNode(character rune) *Node { + return &Node{ + isRootNode: true, + Character: character, + Children: make(map[rune]*Node, 0), + } +} + +// IsLeafNode 判断是否叶子节点 +func (node *Node) IsLeafNode() bool { + return len(node.Children) == 0 +} + +// IsRootNode 判断是否为根节点 +func (node *Node) IsRootNode() bool { + return node.isRootNode +} + +// IsPathEnd 判断是否为某个路径的结束 +func (node *Node) IsPathEnd() bool { + return node.isPathEnd +} + +// SoftDel 置软删除状态 +func (node *Node) SoftDel() { + node.isPathEnd = false +} diff --git a/pub/esmanager/client.go b/pub/esmanager/client.go new file mode 100644 index 0000000..6dc05e4 --- /dev/null +++ b/pub/esmanager/client.go @@ -0,0 +1,165 @@ +package esmanager + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/elastic/go-elasticsearch/v7/esapi" +) + +const ( + AnalyzerIkSmart = "ik_smart" + AnalyzerIkMaxWord = "ik_max_word" + NumberOfShards = 1 + NumberOfReplicas = 1 +) + +func (m *Manager) InitIndices(indexName string, setting M) error { + res, err := m.client.Indices.Create(indexName, func(request *esapi.IndicesCreateRequest) { + request.Body = marshalSearchM(setting) + }) + defer res.Body.Close() + return err +} + +// GetTokens 获取分词 +func (m *Manager) Tokens(index string, bind interface{}, query M, o ...func(*esapi.IndicesAnalyzeRequest)) error { + o = append(o, + m.client.Indices.Analyze.WithContext(context.Background()), + m.client.Indices.Analyze.WithIndex(index), + m.client.Indices.Analyze.WithBody(marshalSearchM(query)), + m.client.Indices.Analyze.WithPretty(), + ) + + res, err := m.client.Indices.Analyze(o...) + if err != nil { + return err + } + defer res.Body.Close() + err = unmarshalAnalyzeBody(bind, res) + return err +} + +// search 搜索 +func (m *Manager) Search(index string, bind interface{}, query M, o ...func(*esapi.SearchRequest)) error { + marshalSearchM(query) + o = append(o, + m.client.Search.WithContext(context.Background()), + m.client.Search.WithIndex(index), + m.client.Search.WithBody(marshalSearchM(query)), + m.client.Search.WithTrackTotalHits(true), + m.client.Search.WithPretty(), + ) + res, err := m.client.Search(o...) + if err != nil { + return err + } + defer res.Body.Close() + err = unmarshalBody(bind, res) + return err +} + +// 批量插入 +func (m *Manager) Bulk(index string, source M, o ...func(*esapi.BulkRequest)) error { + o = append(o, + m.client.Bulk.WithIndex(index), + m.client.Bulk.WithContext(context.Background()), + m.client.Bulk.WithRefresh("true"), + ) + res, err := m.client.Bulk(marshalBulkM(source), o...) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("%+v", res)) + } + return nil +} + +// Count 获取数量 +func (m *Manager) Count(index string, query M, o ...func(*esapi.CountRequest)) (int64, error) { + o = append(o, + m.client.Count.WithIndex(index), + m.client.Count.WithBody(marshalSearchM(query)), + m.client.Count.WithContext(context.Background()), + ) + res, err := m.client.Count(o...) + if err != nil { + return 0, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return 0, errors.New(fmt.Sprintf("%+v", res)) + } + type Bind struct { + Count int64 `json:"count"` + } + var bind = Bind{} + err = unmarshalStruct(&bind, res) + if err != nil { + return 0, err + } + return bind.Count, err +} + +func (m *Manager) Get(index string, id string, bind interface{}, o ...func(*esapi.GetRequest)) error { + o = append(o, + m.client.Get.WithContext(context.Background()), + m.client.Get.WithPretty(), + ) + res, err := m.client.Get(index, id, o...) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("%+v", res)) + } + return unmarshalBody(bind, res) +} + +func (m *Manager) Add(index string, id string, doc interface{}, o ...func(*esapi.IndexRequest)) error { + o = append(o, + m.client.Index.WithDocumentID(id), + m.client.Index.WithContext(context.Background()), + m.client.Index.WithPretty(), + m.client.Index.WithRefresh("true"), + ) + res, err := m.client.Index(index, marshalStruct(doc), o...) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} + +func (m *Manager) Del(index string, id string, o ...func(*esapi.DeleteRequest)) error { + o = append(o, + m.client.Delete.WithContext(context.Background()), + m.client.Delete.WithPretty(), + m.client.Delete.WithRefresh("true"), + ) + res, err := m.client.Delete(index, id, o...) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} + +func (m *Manager) DelMany(indexs []string, query M, o ...func(*esapi.DeleteByQueryRequest)) error { + o = append(o, + m.client.DeleteByQuery.WithContext(context.Background()), + m.client.DeleteByQuery.WithPretty(), + m.client.DeleteByQuery.WithRefresh(true), + ) + res, err := m.client.DeleteByQuery(indexs, marshalSearchM(query), o...) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} \ No newline at end of file diff --git a/pub/esmanager/elastic.go b/pub/esmanager/elastic.go new file mode 100644 index 0000000..ba00bbd --- /dev/null +++ b/pub/esmanager/elastic.go @@ -0,0 +1,143 @@ +package esmanager + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/elastic/go-elasticsearch/v7/esapi" +) + +type M map[string]interface{} + +type A []map[string]interface{} + +type MGetBody struct { + ID string `json:"_id"` +} + +// ElasticResp 响应 +type Response = esapi.Response + +type Options struct { + Address []string + MaxIdleConnPerHost int + IdleConnTimeout time.Duration +} + +func unmarshalAggregateBody(bind interface{}, resp *Response) error { + var v map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&v) + if err != nil { + return err + } + if v != nil && len(v) > 0 { + if v, ok := v["aggregations"].(map[string]interface{}); ok { + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &bind) + } + } + return nil +} + +func unmarshalAnalyzeBody(bind interface{}, resp *Response) error { + var v map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&v) + if err != nil { + return err + } + if v != nil && len(v) > 0 { + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &bind) + } + return nil +} + +func unmarshalBody(bind interface{}, resp *Response) error { + var data map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + return err + } + if data != nil && len(data) > 0 { + if hits1, ok := data["hits"].(map[string]interface{}); ok { + b, _ := json.Marshal(hits1) + _ = json.Unmarshal(b, &bind) + } else if _, ok := data["docs"].([]interface{}); ok { + b, _ := json.Marshal(data["docs"]) + _ = json.Unmarshal(b, &bind) + } else if _, ok := data["_source"].(map[string]interface{}); ok { + b, _ := json.Marshal(data) + _ = json.Unmarshal(b, &bind) + } + } + return nil +} + +func marshalSearchM(query M) io.Reader { + var buf bytes.Buffer + if query == nil || len(query) == 0 { + return nil + } + err := json.NewEncoder(&buf).Encode(query) + if err != nil { + return nil + } + return bytes.NewReader(buf.Bytes()) +} + +func marshalBulkM(source M) io.Reader { + var buf bytes.Buffer + for k, v := range source { + var meta = []byte(fmt.Sprintf(`{ "index" : { "_id" : "%s" } }%s`, k, "\n")) + var data, _ = json.Marshal(v) + data = append(data, "\n"...) + buf.Grow(len(meta) + len(data)) + buf.Write(meta) + buf.Write(data) + } + return bytes.NewReader(buf.Bytes()) +} + +func marshalStruct(v interface{}) io.Reader { + var buf bytes.Buffer + if v == nil { + return nil + } + err := json.NewEncoder(&buf).Encode(v) + if err != nil { + return nil + } + return bytes.NewReader(buf.Bytes()) +} + +func unmarshalStruct(bind interface{}, resp *Response) error { + var v map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&v) + if err != nil { + return err + } + if v != nil && len(v) > 0 { + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &bind) + } + return nil +} \ No newline at end of file diff --git a/pub/esmanager/manager.go b/pub/esmanager/manager.go new file mode 100644 index 0000000..0f58f4e --- /dev/null +++ b/pub/esmanager/manager.go @@ -0,0 +1,53 @@ +package esmanager + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/elastic/go-elasticsearch/v7" +) + +var Instance *Manager + +type Manager struct { + client *elasticsearch.Client + conf *elasticsearch.Config +} + +func NewManager(url string, user string, pw string) (*Manager, error) { + if url == "" { + return nil, errors.New(fmt.Sprintf("Elastic NewManager url is empty!")) + } + cfg := elasticsearch.Config{ + Addresses: []string{url}, + Transport: &http.Transport{ + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 30, + }, + Username: user, + Password: pw, + } + return &Manager{conf: &cfg}, nil +} + +func (m *Manager) Client() *elasticsearch.Client { + return m.client +} + +func (m *Manager) ClientConnect() error { + c, err := elasticsearch.NewClient(*m.conf) + if err != nil { + return err + } + resp, err := c.Ping(c.Ping.WithContext(context.Background())) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("%+v", resp)) + } + m.client = c + return nil +} \ No newline at end of file diff --git a/pub/google/googleAuth.go b/pub/google/googleAuth.go new file mode 100644 index 0000000..1ba566d --- /dev/null +++ b/pub/google/googleAuth.go @@ -0,0 +1,92 @@ +package google + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "strings" + "time" +) + +type GoogleAuth struct { +} + +func NewGoogleAuth() *GoogleAuth { + return &GoogleAuth{} +} + +func (ga *GoogleAuth) un() int64 { + return time.Now().UnixNano() / 1000 / 30 +} + +func (ga *GoogleAuth) hmacSha1(key, data []byte) []byte { + h := hmac.New(sha1.New, key) + if total := len(data); total > 0 { + h.Write(data) + } + return h.Sum(nil) +} + +func (ga *GoogleAuth) base32encode(src []byte) string { + return base32.StdEncoding.EncodeToString(src) +} + +func (ga *GoogleAuth) base32decode(s string) ([]byte, error) { + return base32.StdEncoding.DecodeString(s) +} + +func (ga *GoogleAuth) toBytes(value int64) []byte { + var result []byte + mask := int64(0xFF) + shifts := [8]uint16{56, 48, 40, 32, 24, 16, 8, 0} + for _, shift := range shifts { + result = append(result, byte((value>>shift)&mask)) + } + return result +} + +func (ga *GoogleAuth) toUint32(bts []byte) uint32 { + return (uint32(bts[0]) << 24) + (uint32(bts[1]) << 16) + + (uint32(bts[2]) << 8) + uint32(bts[3]) +} + +func (ga *GoogleAuth) oneTimePassword(key []byte, data []byte) uint32 { + hash := ga.hmacSha1(key, data) + offset := hash[len(hash)-1] & 0x0F + hashParts := hash[offset : offset+4] + hashParts[0] = hashParts[0] & 0x7F + number := ga.toUint32(hashParts) + return number % 1000000 +} + +func (ga *GoogleAuth) GetSecret() string { + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, ga.un()) + return strings.ToUpper(ga.base32encode(ga.hmacSha1(buf.Bytes(), nil))) +} + +func (ga *GoogleAuth) GetCode(secret string) (string, error) { + secretUpper := strings.ToUpper(secret) + secretKey, err := ga.base32decode(secretUpper) + if err != nil { + return "", err + } + number := ga.oneTimePassword(secretKey, ga.toBytes(time.Now().Unix()/30)) + return fmt.Sprintf("%06d", number), nil +} + +func (ga *GoogleAuth) GetQrcode(user, secret string) string { + return fmt.Sprintf("otpauth://totp/%s?secret=%s", user, secret) +} + +func (ga *GoogleAuth) VerifyCode(secret, code string) (bool, error) { + _code, err := ga.GetCode(secret) + fmt.Println(_code, code, err) + if err != nil { + return false, err + } + return _code == code, nil +} diff --git a/pub/models/analyticsdailystat/analytics.go b/pub/models/analyticsdailystat/analytics.go new file mode 100644 index 0000000..75d247b --- /dev/null +++ b/pub/models/analyticsdailystat/analytics.go @@ -0,0 +1,182 @@ +package analyticsdailystat + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.AnalyticsDailyInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.AnalyticsDailyInfo.AppId, Value: 1}, + {Key: FN.AnalyticsDailyInfo.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsDailyInfo.SumDate, Value: 1}, + {Key: FN.AnalyticsDailyInfo.AppId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsDailyInfo.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(AnalyticsDailyInfo:initAnalyticsDailyInfoIndex) Create AnalyticsDailyInfo Index failed! err:%+v:", err)) + } +} + +// InsertOneAnalyticsDailyInfo 插入一个评论 +func InsertOneAnalyticsDailyInfo(ctx context.Context, AnalyticsDailyInfo *AnalyticsDailyInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, AnalyticsDailyInfo); err != nil { + log.Warn("AnalyticsDailyInfo:InsertOneAnalyticsDailyInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneAnalyticsDailyInfo 删除一个评论 +func DelOneAnalyticsDailyInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("AnalyticsDailyInfo:DelOneAnalyticsDailyInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyAnalyticsDailyInfo 删除一个评论评论 +func DelManyAnalyticsDailyInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("AnalyticsDailyInfo:DelOneAnalyticsDailyInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneAnalyticsDailyInfo 查找一个评论 +func FindOneAnalyticsDailyInfo(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *AnalyticsDailyInfo, *srvdef.SrvErr) { + var AnalyticsDailyInfo AnalyticsDailyInfo + if err := db.FindOneEx( + ctx, &AnalyticsDailyInfo, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("AnalyticsDailyInfo:FindOneAnalyticsDailyInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &AnalyticsDailyInfo, nil +} + +// CountAnalyticsDailyInfos 统计评论视频评论数量 +func CountAnalyticsDailyInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("AnalyticsDailyInfo:CountAnalyticsDailyInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneAnalyticsDailyInfo 更新一个评论 +func UpdateOneAnalyticsDailyInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsDailyInfo:UpdateOneAnalyticsDailyInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("AnalyticsDailyInfo:UpdateOneAnalyticsDailyInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyAnalyticsDailyInfo 更新一批评论 +func UpdateManyAnalyticsDailyInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsDailyInfo:UpdateManyAnalyticsDailyInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("AnalyticsDailyInfo:UpdateManyAnalyticsDailyInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrAnalyticsDailyInfoNotExsit, "", "") + } + return nil +} + +// FindManyAnalyticsDailyInfo 查找一批评论 +func FindManyAnalyticsDailyInfo(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (AnalyticsDailyInfoList, *srvdef.SrvErr) { + data := make(AnalyticsDailyInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("AnalyticsDailyInfo:FindManyAnalyticsDailyInfo db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// InsertManyAnalyticsDailyInfo 插入一批用户 +func InsertManyAnalyticsDailyInfo(ctx context.Context, histrory *AnalyticsDailyInfoList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, histrory); err != nil { + log.Warn("AnalyticsDailyInfo:InsertManyAnalyticsDailyInfo db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateAnalyticsDailyInfo 聚合查找一批数据 +func AggregateAnalyticsDailyInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsDailyInfo:AggregateAnalyticsDailyInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateAnalyticsDailyInfo 聚合查找一批数据 +func ObjectAggregateAnalyticsDailyInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsDailyInfo:AggregateAnalyticsDailyInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/analyticsdailystat/tag.go b/pub/models/analyticsdailystat/tag.go new file mode 100755 index 0000000..d98856c --- /dev/null +++ b/pub/models/analyticsdailystat/tag.go @@ -0,0 +1,42 @@ +package analyticsdailystat + +type TagAnalyticsDailyInfo struct { + AppId string + AppName string + CreatedAt string + Id string + LeadAmount string + LeadNewCount string + SumDate string + UpdatedAt string + YestodayActiveUsers string + YestodayNewUsers string + YestodayPV string + YestodaySessions string + YestodayUV string +} + +var FN struct { + AnalyticsDailyInfo TagAnalyticsDailyInfo +} + +var TN struct { + AnalyticsDailyInfo string +} + +func init() { + FN.AnalyticsDailyInfo.AppId = "appId" + FN.AnalyticsDailyInfo.AppName = "appName" + FN.AnalyticsDailyInfo.CreatedAt = "createdAt" + FN.AnalyticsDailyInfo.Id = "_id" + FN.AnalyticsDailyInfo.LeadAmount = "leadAmount" + FN.AnalyticsDailyInfo.LeadNewCount = "leadNewCount" + FN.AnalyticsDailyInfo.SumDate = "sumDate" + FN.AnalyticsDailyInfo.UpdatedAt = "updatedAt" + FN.AnalyticsDailyInfo.YestodayActiveUsers = "yestodayActiveUsers" + FN.AnalyticsDailyInfo.YestodayNewUsers = "yestodayNewUsers" + FN.AnalyticsDailyInfo.YestodayPV = "yestodayPV" + FN.AnalyticsDailyInfo.YestodaySessions = "yestodaySessions" + FN.AnalyticsDailyInfo.YestodayUV = "yestodayUV" + TN.AnalyticsDailyInfo = "analytics_daily_info" +} diff --git a/pub/models/analyticsdailystat/type.go b/pub/models/analyticsdailystat/type.go new file mode 100644 index 0000000..943e3ba --- /dev/null +++ b/pub/models/analyticsdailystat/type.go @@ -0,0 +1,27 @@ +package analyticsdailystat + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 数据统计 +// @table:analytics_daily_info +type AnalyticsDailyInfo struct { + Id primitive.ObjectID `bson:"_id,omitempty"` + SumDate time.Time `json:"sumDate" bson:"sumDate"` // 时间半小时 + AppId int64 `json:"appId" bson:"appId"` // appId + AppName string `json:"appName" bson:"appName"` // APP名称 + YestodayPV int64 `json:"yestodayPV" bson:"yestodayPV"` // 页面浏览量 (PV) + YestodayUV int64 `json:"yestodayUV" bson:"yestodayUV"` // 独立用户数 (UV) + YestodayActiveUsers int64 `json:"yestodayActiveUsers" bson:"yestodayActiveUsers"` // 活跃用户数 + YestodaySessions int64 `json:"yestodaySessions" bson:"yestodaySessions"` // 会话数 + YestodayNewUsers int64 `json:"yestodayNewUsers" bson:"yestodayNewUsers"` // 新增数量 + LeadNewCount float64 `json:"leadNewCount" bson:"leadNewCount"` // 日导量新增 + LeadAmount float64 `json:"leadAmount" bson:"leadAmount"` // 日导量充值 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} + +type AnalyticsDailyInfoList []*AnalyticsDailyInfo diff --git a/pub/models/analyticsstat/analytics.go b/pub/models/analyticsstat/analytics.go new file mode 100644 index 0000000..d4c55ce --- /dev/null +++ b/pub/models/analyticsstat/analytics.go @@ -0,0 +1,182 @@ +package analyticsstat + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.AnalyticsInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.AnalyticsInfo.AppId, Value: 1}, + {Key: FN.AnalyticsInfo.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsInfo.SumDate, Value: 1}, + {Key: FN.AnalyticsInfo.AppId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsInfo.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(AnalyticsInfo:initAnalyticsInfoIndex) Create AnalyticsInfo Index failed! err:%+v:", err)) + } +} + +// InsertOneAnalyticsInfo 插入一个评论 +func InsertOneAnalyticsInfo(ctx context.Context, AnalyticsInfo *AnalyticsInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, AnalyticsInfo); err != nil { + log.Warn("AnalyticsInfo:InsertOneAnalyticsInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneAnalyticsInfo 删除一个评论 +func DelOneAnalyticsInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("AnalyticsInfo:DelOneAnalyticsInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyAnalyticsInfo 删除一个评论评论 +func DelManyAnalyticsInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("AnalyticsInfo:DelOneAnalyticsInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneAnalyticsInfo 查找一个评论 +func FindOneAnalyticsInfo(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *AnalyticsInfo, *srvdef.SrvErr) { + var AnalyticsInfo AnalyticsInfo + if err := db.FindOneEx( + ctx, &AnalyticsInfo, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("AnalyticsInfo:FindOneAnalyticsInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &AnalyticsInfo, nil +} + +// CountAnalyticsInfos 统计评论视频评论数量 +func CountAnalyticsInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("AnalyticsInfo:CountAnalyticsInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneAnalyticsInfo 更新一个评论 +func UpdateOneAnalyticsInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsInfo:UpdateOneAnalyticsInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("AnalyticsInfo:UpdateOneAnalyticsInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyAnalyticsInfo 更新一批评论 +func UpdateManyAnalyticsInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsInfo:UpdateManyAnalyticsInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("AnalyticsInfo:UpdateManyAnalyticsInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrAnalyticsInfoNotExsit, "", "") + } + return nil +} + +// FindManyAnalyticsInfo 查找一批评论 +func FindManyAnalyticsInfo(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (AnalyticsInfoList, *srvdef.SrvErr) { + data := make(AnalyticsInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("AnalyticsInfo:FindManyAnalyticsInfo db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// InsertManyAnalyticsInfo 插入一批用户 +func InsertManyAnalyticsInfo(ctx context.Context, histrory *AnalyticsInfoList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, histrory); err != nil { + log.Warn("AnalyticsInfo:InsertManyAnalyticsInfo db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateAnalyticsInfo 聚合查找一批数据 +func AggregateAnalyticsInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsInfo:AggregateAnalyticsInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateAnalyticsInfo 聚合查找一批数据 +func ObjectAggregateAnalyticsInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsInfo:AggregateAnalyticsInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/analyticsstat/tag.go b/pub/models/analyticsstat/tag.go new file mode 100755 index 0000000..b7418b6 --- /dev/null +++ b/pub/models/analyticsstat/tag.go @@ -0,0 +1,42 @@ +package analyticsstat + +type TagAnalyticsInfo struct { + ActiveUsers string + AppId string + AppName string + CreatedAt string + DaySumDate string + Id string + SumDate string + UpdatedAt string + YestodayActiveUsers string + YestodayNewUsers string + YestodayPV string + YestodaySessions string + YestodayUV string +} + +var FN struct { + AnalyticsInfo TagAnalyticsInfo +} + +var TN struct { + AnalyticsInfo string +} + +func init() { + FN.AnalyticsInfo.ActiveUsers = "activeUsers" + FN.AnalyticsInfo.AppId = "appId" + FN.AnalyticsInfo.AppName = "appName" + FN.AnalyticsInfo.CreatedAt = "createdAt" + FN.AnalyticsInfo.DaySumDate = "daySumDate" + FN.AnalyticsInfo.Id = "_id" + FN.AnalyticsInfo.SumDate = "sumDate" + FN.AnalyticsInfo.UpdatedAt = "updatedAt" + FN.AnalyticsInfo.YestodayActiveUsers = "yestodayActiveUsers" + FN.AnalyticsInfo.YestodayNewUsers = "yestodayNewUsers" + FN.AnalyticsInfo.YestodayPV = "yestodayPV" + FN.AnalyticsInfo.YestodaySessions = "yestodaySessions" + FN.AnalyticsInfo.YestodayUV = "yestodayUV" + TN.AnalyticsInfo = "analytics_info" +} diff --git a/pub/models/analyticsstat/type.go b/pub/models/analyticsstat/type.go new file mode 100644 index 0000000..e455267 --- /dev/null +++ b/pub/models/analyticsstat/type.go @@ -0,0 +1,27 @@ +package analyticsstat + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 数据统计 +// @table:analytics_info +type AnalyticsInfo struct { + Id primitive.ObjectID `bson:"_id,omitempty"` + SumDate time.Time `json:"sumDate" bson:"sumDate"` // 时间半小时 + DaySumDate time.Time `json:"daySumDate" bson:"daySumDate"` // 当天 + AppId int64 `json:"appId" bson:"appId"` // appId + AppName string `json:"appName" bson:"appName"` // APP名称 + ActiveUsers int64 `json:"activeUsers" bson:"activeUsers"` // 最近半小时活跃数 + YestodayPV int64 `json:"yestodayPV" bson:"yestodayPV"` // 页面浏览量 (PV) + YestodayUV int64 `json:"yestodayUV" bson:"yestodayUV"` // 独立用户数 (UV) + YestodayActiveUsers int64 `json:"yestodayActiveUsers" bson:"yestodayActiveUsers"` // 活跃用户数 + YestodaySessions int64 `json:"yestodaySessions" bson:"yestodaySessions"` // 会话数 + YestodayNewUsers int64 `json:"yestodayNewUsers" bson:"yestodayNewUsers"` // 新增数量 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} + +type AnalyticsInfoList []*AnalyticsInfo diff --git a/pub/models/cfg/dbhelper.go b/pub/models/cfg/dbhelper.go new file mode 100644 index 0000000..6e8c6d5 --- /dev/null +++ b/pub/models/cfg/dbhelper.go @@ -0,0 +1,9 @@ +package cfg + +import ( + "videoweb/pub/mongos" +) + +var ( + sysCfgColl *mongos.Collection +) diff --git a/pub/models/cfg/dbop.go b/pub/models/cfg/dbop.go new file mode 100644 index 0000000..04ab8b5 --- /dev/null +++ b/pub/models/cfg/dbop.go @@ -0,0 +1,55 @@ +package cfg + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + sysCfgColl = mongos.Shop.Coll(TN.SysCfg) +} + +// FindOneSysCfg 查找一个系统配置 +func FindOneSysCfg(ctx context.Context, filter bson.M) ( + *SysCfg, *srvdef.SrvErr) { + var sysCfg SysCfg + if err := sysCfgColl.FindOneEx( + ctx, &sysCfg, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("cfg:FindOneSysCfg db.FindOneEx failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &sysCfg, nil +} + +// UpdateOneSysCfg 更新一个系统配置 +func UpdateOneSysCfg(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := sysCfgColl.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("cfg:UpdateOneSysCfg db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +func DeleteOneSysCfg(ctx context.Context, filter bson.M) *srvdef.SrvErr { + _, err := sysCfgColl.DeleteOne(ctx, filter) + if err != nil { + log.Warn("cfg:DeleteOneSysCfg db.DeleteOne failed", log.Any("filter", filter), log.E(err)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/cfg/tag.go b/pub/models/cfg/tag.go new file mode 100755 index 0000000..b6efffc --- /dev/null +++ b/pub/models/cfg/tag.go @@ -0,0 +1,68 @@ +package cfg + +type TagSysCfg struct { + AppAddr string + ForeverUrlAddr string + GitAddr string + GmailAddr string + Id string + ImgAppCdn string + ImgWebCdn string + MediaAppCdn string + MediaWebCdn string + NewUrlAddr string + NewUrlAddr1 string + NewUrlAddr2 string + QQ string + Telegram string + TelegramAdd string + TgGroup string + TranslationAddr string + TwitterAddr string + WatermarkImage string + WatermarkMedia string + WatermarkMediaTop string + WeiChart string + DHNewUrlAddr1 string + DHNewUrlAddr2 string + DHNewUrlAddr3 string + DHForeverUrlAddr string +} + +var FN struct { + SysCfg TagSysCfg +} + +var TN struct { + SysCfg string +} + +func init() { + FN.SysCfg.AppAddr = "appAddr" + FN.SysCfg.ForeverUrlAddr = "foreverUrlAddr" + FN.SysCfg.GitAddr = "gitAddr" + FN.SysCfg.GmailAddr = "gmailAddr" + FN.SysCfg.Id = "_id" + FN.SysCfg.ImgAppCdn = "imgAppCdn" + FN.SysCfg.ImgWebCdn = "imgWebCdn" + FN.SysCfg.MediaAppCdn = "mediaAppCdn" + FN.SysCfg.MediaWebCdn = "mediaWebCdn" + FN.SysCfg.NewUrlAddr = "newUrlAddr" + FN.SysCfg.NewUrlAddr1 = "newUrlAddr1" + FN.SysCfg.NewUrlAddr2 = "newUrlAddr2" + FN.SysCfg.QQ = "qq" + FN.SysCfg.Telegram = "telegram" + FN.SysCfg.TelegramAdd = "telegramAdd" + FN.SysCfg.TgGroup = "tgGroup" + FN.SysCfg.TranslationAddr = "translationAddr" + FN.SysCfg.TwitterAddr = "twitterAddr" + FN.SysCfg.WatermarkImage = "watermarkImage" + FN.SysCfg.WatermarkMedia = "watermarkMedia" + FN.SysCfg.WatermarkMediaTop = "watermarkMediaTop" + FN.SysCfg.WeiChart = "weiChart" + FN.SysCfg.DHNewUrlAddr1 = "dHNewUrlAddr1" + FN.SysCfg.DHNewUrlAddr2 = "dHNewUrlAddr2" + FN.SysCfg.DHNewUrlAddr3 = "dHNewUrlAddr3" + FN.SysCfg.DHForeverUrlAddr = "dHForeverUrlAddr" + TN.SysCfg = "sys_cfg" +} \ No newline at end of file diff --git a/pub/models/cfg/type.go b/pub/models/cfg/type.go new file mode 100644 index 0000000..bda8f9d --- /dev/null +++ b/pub/models/cfg/type.go @@ -0,0 +1,42 @@ +package cfg + +// @table:sys_cfg +type SysCfg struct { + Id int64 `json:"id" bson:"_id"` + UnencryptedCdn string `json:"unencryptedCdn" bson:"unencryptedCdn"` // 不加密资源使用的cdn + SitemapDefaultImages []string `json:"sitemapDefaultImages" bson:"sitemapDefaultImages"` // Sitemap里面默认封面图 + ImgAppCdn string `json:"imgAppCdn" bson:"imgAppCdn"` // 图片CDN(app使用) + ImgWebCdn string `json:"imgWebCdn" bson:"imgWebCdn"` // 图片CDN(web使用) + MediaWebCdn string `json:"mediaWebCdn" bson:"mediaWebCdn"` // 视频CDN(web使用) + MediaAppCdn string `json:"mediaAppCdn" bson:"mediaAppCdn"` // 视频CDN(app使用) + WatermarkImage string `json:"watermarkImage" bson:"watermarkImage"` // 图片水印文件 + WatermarkMedia string `json:"watermarkMedia" bson:"watermarkMedia"` // 视频水印文件(底部) + WatermarkMediaTop string `json:"watermarkMediaTop" bson:"watermarkMediaTop"` // 视频水印文件(顶部) + Mp4Addr string `json:"mp4Addr" bson:"mp4Addr"` // mp4地址(web使用) + PdfAddr string `json:"pdfAddr" bson:"pdfAddr"` // 回家的路PDF + //网站管理 + NewUrlAddr string `json:"newUrlAddr" bson:"newUrlAddr"` //最新地址 + NewUrlAddr1 string `json:"newUrlAddr1" bson:"newUrlAddr1"` //最新地址(备用地址1) + NewUrlAddr2 string `json:"newUrlAddr2" bson:"newUrlAddr2"` //最新地址(备用地址2) + ForeverUrlAddr string `json:"foreverUrlAddr" bson:"foreverUrlAddr"` //永久地址 + GmailAddr string `json:"gmailAddr" bson:"gmailAddr"` //邮箱地址 + AppAddr string `json:"appAddr" bson:"appAddr"` //APP下载地址(落地页) + WeiChart string `json:"weiChart" bson:"weiChart"` //微信群 + QQ string `json:"qq" bson:"qq"` //qq群 + TgGroup string `json:"tgGroup" bson:"tgGroup"` //tg群 + PotaoGroup string `json:"potaoGroup" bson:"potaoGroup"` //土豆群 + GitAddr string `json:"gitAddr" bson:"gitAddr"` //git地址 + TwitterAddr string `json:"twitterAddr" bson:"twitterAddr"` //推特地址 + Telegram string `json:"telegram" bson:"telegram"` //商务tg + TelegramAdd string `json:"telegramAdd" bson:"telegramAdd"` //加入我们tg + TranslationAddr string `json:"translationAddr" bson:"translationAddr"` //应用导航 + //SEO + WebTitle string `json:"webTitle" bson:"webTitle"` // 网站SEO标题 + WebKeyworld []string `json:"webKeyworld" bson:"webKeyworld"` // 网站SEO关键字 + WebDescription string `json:"webDescription" bson:"webDescription"` // 网站SEO描述 + WebSiteName string `json:"webSiteName" bson:"webSiteName"` // 网站SEO网站名称 + WebAddr string `json:"webAddr" bson:"webAddr"` // 网站SEO网站地址 + DHNewUrlAddr1 string `json:"dhNewUrlAddr1" bson:"dhNewUrlAddr1"` // 导航页-线路1 + DHNewUrlAddr2 string `json:"dhNewUrlAddr2" bson:"dhNewUrlAddr2"` // 导航页-线路2 + DHNewUrlAddr3 string `json:"dhNewUrlAddr3" bson:"dhNewUrlAddr3"` // 导航页-线路3 +} diff --git a/pub/models/commentmod/comment.go b/pub/models/commentmod/comment.go new file mode 100644 index 0000000..c64c6c3 --- /dev/null +++ b/pub/models/commentmod/comment.go @@ -0,0 +1,206 @@ +package commentmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.Comment) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Comment.ObjectId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.Id, Value: 1}, + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ObjectId, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + {Key: FN.Comment.Level, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ObjectId, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + {Key: FN.Comment.Level, Value: 1}, + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ParentsId, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.TopRank, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(comment:initCommentIndex) Create Comment Index failed! err:%+v:", err)) + } +} + +// InsertOneComment 插入一个评论 +func InsertOneComment(ctx context.Context, Comment *CommentModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, Comment); err != nil { + log.Warn("comment:InsertOneComment db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneComment 删除一个评论 +func DelOneComment(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("comment:DelOneComment db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyComment 删除一个评论评论 +func DelManyComment(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("comment:DelOneComment db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneComment 查找一个评论 +func FindOneComment(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *CommentModel, *srvdef.SrvErr) { + var Comment CommentModel + if err := db.FindOneEx( + ctx, &Comment, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("comment:FindOneComment db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &Comment, nil +} + +// CountComments 统计评论视频评论数量 +func CountComments(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("comment:CountComments db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneComment 更新一个评论 +func UpdateOneComment(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("comment:UpdateOneComment db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("comment:UpdateOneComment db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyComment 更新一批评论 +func UpdateManyComment(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("comment:UpdateManyComment db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("comment:UpdateManyComment db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrCommentNotExsit, "", "") + } + return nil +} + +// FindManyComment 查找一批评论 +func FindManyComment(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (CommentModelList, *srvdef.SrvErr) { + data := make(CommentModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("comment:FindManyComment db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// InsertManyComment 插入一批用户 +func InsertManyComment(ctx context.Context, histrory *CommentModelList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, histrory); err != nil { + log.Warn("comment:InsertManyComment db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateComment 聚合查找一批数据 +func AggregateComment(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("comment:AggregateComment db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateComment 聚合查找一批数据 +func ObjectAggregateComment(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("comment:AggregateComment db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/commentmod/tag.go b/pub/models/commentmod/tag.go new file mode 100755 index 0000000..6554013 --- /dev/null +++ b/pub/models/commentmod/tag.go @@ -0,0 +1,44 @@ +package commentmod + +type TagComment struct { + CreatedAt string + Id string + Level string + ObjectId string + ParentsId string + ReplyUserName string + Text string + TopRank string + UpdatedAt string + UserName string + ReviewStatus string + CommentCount string + Ip string + ObjectName string +} + +var FN struct { + Comment TagComment +} + +var TN struct { + Comment string +} + +func init() { + FN.Comment.CreatedAt = "createdAt" + FN.Comment.Id = "_id" + FN.Comment.Level = "level" + FN.Comment.ObjectId = "objectId" + FN.Comment.ParentsId = "parentsId" + FN.Comment.ReplyUserName = "replyUserName" + FN.Comment.Text = "text" + FN.Comment.TopRank = "topRank" + FN.Comment.UpdatedAt = "updatedAt" + FN.Comment.UserName = "userName" + FN.Comment.ReviewStatus = "reviewStatus" + FN.Comment.Ip = "ip" + FN.Comment.CommentCount = "commentCount" + FN.Comment.ObjectName = "objectName" + TN.Comment = "_comment" +} \ No newline at end of file diff --git a/pub/models/commentmod/type.go b/pub/models/commentmod/type.go new file mode 100644 index 0000000..cac1c55 --- /dev/null +++ b/pub/models/commentmod/type.go @@ -0,0 +1,26 @@ +package commentmod + +import ( + "time" +) + +// 评论管理 +// @table:comment +type CommentModel struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID + Ip string `json:"ip" bson:"ip"` // 评论IP + ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID + ObjectName string `json:"objectName" bson:"objectName"` // 对象名 + UserName string `json:"userName" bson:"userName"` // 用户名称 + Level int64 `json:"level" bson:"level"` // 评论级别 + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID + ReplyUserName string `json:"replyUserName" bson:"replyUserName"` // 回复名称 + Text string `json:"text" bson:"text"` // 评论内容 + CommentCount int64 `json:"commentCount" bson:"commentCount"` // 该评论回复数量 + ReviewStatus int `json:"reviewStatus" bson:"reviewStatus"` // 审核状态 0 未审核 1审核成功 2 审核失败 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` // 创建时间 + TopRank int64 `json:"topRank" bson:"topRank"` // 置顶排序 +} + +type CommentModelList []*CommentModel \ No newline at end of file diff --git a/pub/models/esmod/eshelper.go b/pub/models/esmod/eshelper.go new file mode 100644 index 0000000..bd9885c --- /dev/null +++ b/pub/models/esmod/eshelper.go @@ -0,0 +1,54 @@ +package esmod + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const ( + FiveMinuteScale = 5 //对齐时间(分钟) +) + +var ( + videoIndex = "webmdgw_video_index_v3" +) + +func GetVideoIndex() string { + return videoIndex +} + +func GetRecordByESIndex(ctx context.Context, esIndex string) ( + *ESTimestamp, *srvdef.SrvErr) { + + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + esRecord, serr := FindOneESRecord(ctx, filter) + if serr != nil { + if serr.Code != srvdef.ErrNoData { + return nil, serr + } + return nil, nil + } + return &esRecord.Timestamp, nil +} + +func SetOneEsRecord(ctx context.Context, esIndex string, + timestamp *ESTimestamp) *srvdef.SrvErr { + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + upsertDoc := bson.M{ + "$set": bson.M{ + FN.ESRecord.Timestamp.T: timestamp.T, + FN.ESRecord.Timestamp.I: timestamp.I, + FN.ESRecord.UpdatedAt: time.Now(), + }, + "$setOnInsert": bson.M{ + FN.ESRecord.Id: primitive.NewObjectID(), + FN.ESRecord.CreatedAt: time.Now(), + }, + } + serr := UpsertOneESRecord(ctx, filter, upsertDoc) + return serr +} \ No newline at end of file diff --git a/pub/models/esmod/esop.go b/pub/models/esmod/esop.go new file mode 100644 index 0000000..cfe0385 --- /dev/null +++ b/pub/models/esmod/esop.go @@ -0,0 +1,124 @@ +package esmod + +import ( + "context" + "fmt" + "videoweb/pub/esmanager" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +// Init 初始化数据 +func Init(ctx context.Context) { + initDBIndex(ctx) + initIndex() + return +} + +// initIndex 初始化索引 +func initIndex() { + if esmanager.Instance == nil { + return + } + initVideoIndex(videoIndex) +} + +// initDBIndex 初始化数据库索引 +func initDBIndex(ctx context.Context) { + //获取数据库表 + db = mongos.Shop.Coll(TN.ESRecord) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.ESRecord.EsIndex, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(esmod:initDBIndex) Create es_record Index failed! err:%+v:", err)) + } +} + +func initVideoIndex(esIndex string) { + var setting = esmanager.M{ + "settings": esmanager.M{ + "number_of_shards": esmanager.NumberOfShards, + "number_of_replicas": esmanager.NumberOfReplicas, + "analysis": esmanager.M{ + "analyzer": esmanager.M{ + "ik": esmanager.M{ + "tokenizer": esmanager.AnalyzerIkSmart, + }, + }, + }, + }, + "mappings": esmanager.M{ + "properties": esmanager.M{ + FN.ESVideo.Title: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkMaxWord, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.MediaType: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.Remark: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.Desc: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.Keyworld: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.CreatorName: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.Status: esmanager.M{ + "type": "integer", + }, + //FN.ESVideo.IsHot: esmanager.M{ + // "type": "boolean", + //}, + //FN.ESVideo.Sort: esmanager.M{ + // "type": "integer", + //}, + FN.ESVideo.WatchTimes: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.ShowTimes: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.Comments: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.AddedTime: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + //FN.ESVideo.CreatedAt: esmanager.M{ + // "type": "date", + // "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + //}, + }, + }, + } + if err := esmanager.Instance.InitIndices(esIndex, setting); err != nil { + panic(fmt.Sprintf("ElasticSearch Index %s initMediaIndex error", esIndex)) + } +} diff --git a/pub/models/esmod/recordop.go b/pub/models/esmod/recordop.go new file mode 100644 index 0000000..bb01d84 --- /dev/null +++ b/pub/models/esmod/recordop.go @@ -0,0 +1,144 @@ +package esmod + +import ( + "context" + "go.mongodb.org/mongo-driver/bson/primitive" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// InsertOneESRecord 插入ES操作记录 +func InsertOneESRecord(ctx context.Context, esRecord *ESRecord) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, esRecord); err != nil { + log.Warn("esmod:InsertOneESRecord db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneESRecord 删除一个ES操作记录 +func DelOneESRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("esmod:DelOnAppTag db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneESRecord 查找一个ES操作记录 +func FindOneESRecord(ctx context.Context, filter bson.M) ( + *ESRecord, *srvdef.SrvErr) { + var esRecord ESRecord + if err := db.FindOneEx( + ctx, &esRecord, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("esmod:FindOnESRecord db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &esRecord, nil +} + +// CountESRecords 统计ES操作记录 +func CountESRecords(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("esmod:CountMediaCats db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneESRecord 更新ES操作记录 +func UpdateOneESRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("esmod:UpdateOnESRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("esmod:UpdateOnESRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrMediaRecordNotExsit, "", "") + } + return nil +} + +// UpsertOneESRecord 插入或更新一条ES操作记录 +func UpsertOneESRecord(ctx context.Context, + filter bson.M, update bson.M) *srvdef.SrvErr { + if _, err := db.UpsertOne(ctx, filter, update); err != nil { + log.Info("esmod:UpsertOneESRecord db.InsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// UpdateManyESRecord 更新一批ES操作记录 +func UpdateManyESRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("esmod:UpdateManyMediaCat db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("esmod:UpdateManyMediaCat db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrMediaTagNotExsit, "", "") + } + return nil +} + +// FindManyESRecord 查找一批ES操作记录 +func FindManyESRecord(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (ESRecordList, *srvdef.SrvErr) { + data := make(ESRecordList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("esmod:FindManyESRecord db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +func GetRecordTimeByESIndex(ctx context.Context, esIndex string) (*ESRecord, *srvdef.SrvErr) { + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + esRecord, serr := FindOneESRecord(ctx, filter) + if serr != nil { + if serr.Code != srvdef.ErrNoData { + return nil, serr + } + return nil, nil + } + return esRecord, nil +} +func SetOneEsRecordTime(ctx context.Context, esIndex string, recordTime time.Time) *srvdef.SrvErr { + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + upsertDoc := bson.M{ + "$set": bson.M{ + FN.ESRecord.RecordTime: recordTime, + FN.ESRecord.UpdatedAt: time.Now(), + }, + "$setOnInsert": bson.M{ + FN.ESRecord.Id: primitive.NewObjectID(), + FN.ESRecord.CreatedAt: time.Now(), + }, + } + serr := UpsertOneESRecord(ctx, filter, upsertDoc) + return serr +} \ No newline at end of file diff --git a/pub/models/esmod/tag.go b/pub/models/esmod/tag.go new file mode 100755 index 0000000..b85923a --- /dev/null +++ b/pub/models/esmod/tag.go @@ -0,0 +1,90 @@ +package esmod + +type TagESRecord struct { + CreatedAt string + EsIndex string + Id string + RecordTime string + Timestamp struct { + I string + T string + } + UpdatedAt string +} +type TagESVideo struct { + Id string + Title string + MediaType string + AddedTime string + CreatorName string + Keyworlds string + Desc string + Remark string + Status string + IsHot string + Sort string + WatchTimes string + ShowTimes string + Comments string + UpdatedAt string + CreatedAt string + + Keyworld string + CreatorId string + FirstTag string + SecondTags string + CoverImg string + Nodes string + Video string + CategoryIds string + Tags string + Real string +} + +var FN struct { + ESRecord TagESRecord + ESVideo TagESVideo +} + +var TN struct { + ESRecord string + ESVideo string +} + +func init() { + FN.ESRecord = TagESRecord{ + CreatedAt: "createdAt", + EsIndex: "esIndex", + Id: "_id", + RecordTime: "recordTime", + Timestamp: struct { + I string + T string + }{ + I: "i", + T: "t", + }, + UpdatedAt: "updatedAt", + } + FN.ESVideo = TagESVideo{ + Id: "_id", + Title: "title", + Desc: "desc", + Status: "status", + AddedTime: "addedTime", + MediaType: "mediaType", + CreatorName: "creatorName", + Keyworlds: "keyworlds", + Remark: "remark", + IsHot: "isHot", + Sort: "sort", + UpdatedAt: "updatedAt", + CreatedAt: "createdAt", + WatchTimes: "watchTimes", + ShowTimes: "showTimes", + Comments: "comments", + } + + TN.ESRecord = "es_record" + TN.ESVideo = "es_video" +} \ No newline at end of file diff --git a/pub/models/esmod/type.go b/pub/models/esmod/type.go new file mode 100644 index 0000000..be5e13f --- /dev/null +++ b/pub/models/esmod/type.go @@ -0,0 +1,85 @@ +//go:generate gen_bson_tag -m esmod +//esmod essearch数据结构定义 + +package esmod + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ESTimestamp struct { + T uint32 `json:"t" bson:"t"` + I uint32 `json:"i" bson:"i"` +} + +func (v *ESTimestamp) IsZero() bool { + if v == nil { + return true + } + return v.T == 0 && v.I == 0 +} + +// ESOperator es操作记录 +// @table:es_record +type ESRecord struct { + Id primitive.ObjectID `json:"id" bson:"_id"` + EsIndex string `json:"esIndex" bson:"esIndex"` //es索引名称 + Timestamp ESTimestamp `json:"timestamp" bson:"timestamp"` //操作时间 + RecordTime time.Time `json:"recordTime" bson:"recordTime"` // 操作记录时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` //更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` //创建时间 +} + +type ESRecordList []*ESRecord + +// ESVideo es中存储的media数据 +// @table:es_video +type ESVideo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + MediaType videoinformationmod.MediaType `json:"mediaType" bson:"mediaType"` // 资源类型 + AddedTime time.Time `json:"addedTime" bson:"addedTime"` // 帖子上架时间 + CreatorName string `json:"creatorName" bson:"creatorName"` // 作者名称 + Keyworlds string `bson:"keyworlds,omitempty" json:"keyworlds"` // 关键字 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Status videoinformationmod.InformationSatus `json:"status" bson:"status"` // 帖子上架状态 + IsHot bool `json:"isHot" bson:"isHot"` // 是否热门 + Sort int64 `json:"sort" bson:"sort"` // 排序 + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 观看次数 + ShowTimes int64 `json:"showTimes" bson:"showTimes"` // 展示次数 + Comments int64 `json:"comments" bson:"comments"` // 评论条数 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字 + CreatorId int64 `json:"creatorId" bson:"creatorId"` // 作者Id + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + Nodes []videoinformationmod.Node `json:"nodes" bson:"nodes"` // 节点 + Video videoinformationmod.VideoInfo `json:"video" bson:"video"` // 视频 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类Id + Tags []int64 `json:"tags" bson:"tags"` // 标签 + Real videoinformationmod.Flow `json:"real" bson:"real"` // 真实流量 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量 +} + +type ESVideoList []*ESVideo +type ESTotal struct { + Value int64 `json:"value"` +} + +type ESVideoSource struct { + ID string `json:"_id"` + Score float32 `json:"_score"` + Source *ESVideo `json:"_source"` +} + +// ESVideoHits search media hits +type ESVideoHits struct { + Total ESTotal `json:"total"` + Hits []*ESVideoSource `json:"hits"` +} \ No newline at end of file diff --git a/pub/models/esmod/video.go b/pub/models/esmod/video.go new file mode 100644 index 0000000..53b19bc --- /dev/null +++ b/pub/models/esmod/video.go @@ -0,0 +1,135 @@ +package esmod + +import ( + "fmt" + "videoweb/app/api/proto" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" + "videoweb/pub/models/videoinformationmod" +) + +// SearchVideo 根据关键字搜索媒体 +func SearchVideo(content string, from int64, size int64, sort string) (int64, []*videoinformationmod.VideoInformation, *srvdef.SrvErr) { + fields := []string{FN.ESVideo.Title, FN.ESVideo.CreatorName, FN.ESVideo.Keyworld, FN.ESVideo.Desc, FN.ESVideo.Remark} + must := esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields}, + }, + {"term": esmanager.M{FN.ESVideo.Status: videoinformationmod.InformationSatusVerifyOK}}, + }, + }, + } + switch sort { + case string(proto.SearchSortTypeHot): + must = esmanager.M{ + "function_score": esmanager.M{ + "query": esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields, + }}, + {"term": esmanager.M{FN.ESVideo.Status: videoinformationmod.InformationSatusVerifyOK}}, + }, + }, + }, + "functions": esmanager.A{ + esmanager.M{ + "field_value_factor": esmanager.M{ + "field": FN.ESVideo.WatchTimes, + "factor": 1.0, + "modifier": "none", + }, + }, + }, + "score_mode": "sum", + "boost_mode": "multiply", + }, + } + case string(proto.SearchSortTypeNew): + must = esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields}, + }, + {"term": esmanager.M{FN.ESVideo.Status: videoinformationmod.InformationSatusVerifyOK}}, + }, + "should": esmanager.A{ + {"range": esmanager.M{FN.ESVideo.AddedTime: esmanager.M{"gte": "now-30d/d", "boost": 2.0}}}, //7天内提升4倍权重 + {"range": esmanager.M{FN.ESVideo.AddedTime: esmanager.M{"gte": "now-180d/d", "boost": 1.5}}}, // 30天内提升3倍权重 + {"range": esmanager.M{FN.ESVideo.AddedTime: esmanager.M{"gte": "now-365d/d", "boost": 1.1}}}, // 90天内提升1.5倍权重 + }, + "minimum_should_match": 0, // 允许不匹配任何should条件 + }, + } + } + + query := esmanager.M{ + "query": must, + "sort": esmanager.A{ + esmanager.M{"_score": esmanager.M{"order": "desc"}}, + }, + "from": from, + "size": size, + } + + videos := &ESVideoHits{} + err := esmanager.Instance.Search(videoIndex, videos, query) + if err != nil { + info := fmt.Sprintf( + "(esmod:SearchVideo) Search failed! err:%+v:", err) + log.Warn(info, + log.Any("keywords", content), + log.Any("from", from), + log.Any("size", size)) + return 0, nil, srvdef.New(srvdef.ErrElsOperError, "", "") + } + total := int64(0) + rslData := make([]*videoinformationmod.VideoInformation, 0) + if videos != nil && videos.Hits != nil && len(videos.Hits) > 0 { + total = videos.Total.Value + for _, video := range videos.Hits { + rslData = append(rslData, EsVideoInfoToData(video.Source)) + } + } + return total, rslData, nil +} + +func EsVideoInfoToData(esInfo *ESVideo) *videoinformationmod.VideoInformation { + info := &videoinformationmod.VideoInformation{ + Id: esInfo.Id, + Title: esInfo.Title, + MediaType: esInfo.MediaType, + AddedTime: esInfo.AddedTime, + CreatorId: esInfo.CreatorId, + CreatorName: esInfo.CreatorName, + Keyworld: esInfo.Keyworld, + FirstTag: esInfo.FirstTag, + SecondTags: esInfo.SecondTags, + CoverImg: esInfo.CoverImg, + Desc: esInfo.Desc, + Remark: esInfo.Remark, + Nodes: esInfo.Nodes, + Status: esInfo.Status, + Video: esInfo.Video, + CategoryIds: esInfo.CategoryIds, + Tags: esInfo.Tags, + IsHot: esInfo.IsHot, + Sort: esInfo.Sort, + Real: esInfo.Real, + VideoCount: esInfo.VideoCount, + UpdatedAt: esInfo.UpdatedAt, + CreatedAt: esInfo.CreatedAt, + } + return info +} \ No newline at end of file diff --git a/pub/models/ipblackmod/ipblack.go b/pub/models/ipblackmod/ipblack.go new file mode 100644 index 0000000..c60960d --- /dev/null +++ b/pub/models/ipblackmod/ipblack.go @@ -0,0 +1,109 @@ +package ipblackmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.IPBlackModel) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.IPBlackModel.Ip, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(IPBlackModel:InitIPBlackModel) Create IPBlackModel Index failed! err:%+v:", err)) + } +} + +// CountIPBlackModel 统计活动数量 +func CountIPBlackModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("IPBlackModelmod:CountIPBlackModel db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// InsertOneIPBlackModel 插入一条活动 +func InsertOneIPBlackModel(ctx context.Context, IPBlackModel *IPBlackModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, IPBlackModel); err != nil { + log.Info("IPBlackModelmod:InsertOneIPBlackModel db.InsertOne failed", + log.E(err), log.Any("IPBlackModel", IPBlackModel)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneIPBlackModel 删除一个活动 +func DelOneIPBlackModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("IPBlackModelmod:DelOneIPBlackModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneIPBlackModel 更新一个IPBlackModel +func UpdateOneIPBlackModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("IPBlackModelmod:UpdateOneIPBlackModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("IPBlackModelmod:UpdateOneIPBlackModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ObjectNotExist, "", "") + } + return nil +} + +// FindManyIPBlackModel 查找一批活动数据 +func FindManyIPBlackModel(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (IPBlackModelList, *srvdef.SrvErr) { + data := make(IPBlackModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("IPBlackModelmod:FindManyAcivities db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindOneIPBlackModel 查找一个媒体 +func FindOneIPBlackModel(ctx context.Context, filter bson.M) ( + *IPBlackModel, *srvdef.SrvErr) { + var IPBlackModel IPBlackModel + if err := db.FindOneEx(ctx, &IPBlackModel, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("IPBlackModel:FindOneIPBlackModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ObjectDayStatDoesNotExist, "", "") + } + return &IPBlackModel, nil +} \ No newline at end of file diff --git a/pub/models/ipblackmod/tag.go b/pub/models/ipblackmod/tag.go new file mode 100755 index 0000000..ff235ed --- /dev/null +++ b/pub/models/ipblackmod/tag.go @@ -0,0 +1,26 @@ +package ipblackmod + +type TagIPBlackModel struct { + CreatedAt string + ID string + Ip string + Desc string + UpdatedAt string +} + +var FN struct { + IPBlackModel TagIPBlackModel +} + +var TN struct { + IPBlackModel string +} + +func init() { + FN.IPBlackModel.CreatedAt = "createdAt" + FN.IPBlackModel.ID = "_id" + FN.IPBlackModel.Ip = "ip" + FN.IPBlackModel.UpdatedAt = "updatedAt" + FN.IPBlackModel.Desc = "desc" + TN.IPBlackModel = "ip_black" +} \ No newline at end of file diff --git a/pub/models/ipblackmod/type.go b/pub/models/ipblackmod/type.go new file mode 100644 index 0000000..be9f72f --- /dev/null +++ b/pub/models/ipblackmod/type.go @@ -0,0 +1,18 @@ +package ipblackmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 资源同步 +// @table:ip_black +type IPBlackModel struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Ip string `json:"ip" bson:"ip"` + Desc string `json:"desc" bson:"desc"` // 备注 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} +type IPBlackModelList []*IPBlackModel \ No newline at end of file diff --git a/pub/models/models.go b/pub/models/models.go new file mode 100644 index 0000000..7f88cb3 --- /dev/null +++ b/pub/models/models.go @@ -0,0 +1,53 @@ +package models + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/models/analyticsdailystat" + "videoweb/pub/models/analyticsstat" + "videoweb/pub/models/cfg" + "videoweb/pub/models/commentmod" + "videoweb/pub/models/esmod" + "videoweb/pub/models/ipblackmod" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/oplog" + "videoweb/pub/models/richtextcontentmod" + "videoweb/pub/models/syncfilemod" + "videoweb/pub/models/translationcategorymod" + "videoweb/pub/models/translationmod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videocollectionmediamod" + "videoweb/pub/models/videocreatermod" + + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videotagmod" +) + +func Init() { + log.Info("models init start..") + ctx := context.Background() + cfg.Init(ctx) + mwacc.Init(ctx) + oplog.Init(ctx) + + videocategorymod.Init(ctx) + videoinformationmod.Init(ctx) + videotagmod.Init(ctx) + + translationcategorymod.Init(ctx) + translationmod.Init(ctx) + videocategorymod.Init(ctx) + videocreatermod.Init(ctx) + videotagmod.Init(ctx) + videoinformationmod.Init(ctx) + videocreatermod.Init(ctx) + syncfilemod.Init(ctx) + richtextcontentmod.Init(ctx) + commentmod.Init(ctx) + videocollectionmediamod.Init(ctx) + ipblackmod.Init(ctx) + esmod.Init(ctx) + analyticsstat.Init(ctx) + analyticsdailystat.Init(ctx) + log.Info("models init end") +} \ No newline at end of file diff --git a/pub/models/mwacc/acc.go b/pub/models/mwacc/acc.go new file mode 100644 index 0000000..0832450 --- /dev/null +++ b/pub/models/mwacc/acc.go @@ -0,0 +1,170 @@ +package mwacc + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/errors" + sErr "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 过滤查询数量 +func AccCountByFilter(ctx context.Context, filters primitive.M, opts ...*options.CountOptions) (c int64, err error) { + c, err = mwAccColl.Count(ctx, filters) + return +} + +// 过滤查询账号 +func FindOneAccByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOneOptions) (*MwAcc, error) { + var res MwAcc + err := mwAccColl.FindOne(ctx, &res, filters, opts...) + if err != nil { + log.Error("mwacc FindOneAccByFilter err", log.Any("filters", filters), log.E(err)) + } + return &res, err +} + +// 过滤更新 +func UpOneAccByFilter(ctx context.Context, query primitive.M, update primitive.M) (err error) { + _, err = mwAccColl.UpdateOne(ctx, query, primitive.M{"$set": update}) + return +} + +// 过滤删除角色 +func DelAccByFilter(ctx context.Context, filters primitive.M) (err error) { + _, err = mwAccColl.DeleteOne(ctx, filters) + if err != nil { + log.Error("mwacc DelAccByFilter err", log.Any("filters", filters), log.E(err)) + } + return +} + +// // 更新最后登录时间 +// func UpAccLastTimeById(ctx context.Context, id int64) (*mongo.UpdateResult, error) { +// ret, err := mwAccColl.UpdateOne(ctx, primitive.M{FN.MwAcc.Id: id}, primitive.M{"$set": primitive.M{FN.MwAcc.LastTime: time.Now()}}) +// if err != nil { +// log.Error("mvacc UpLastTimeById err", log.Any(FN.MwAcc.Id, id), log.E(err)) +// } +// return ret, err +// } + +// 绑定二维码 +func BindAccQurl(ctx context.Context, acc string) (ok bool, err error) { + + res, dataErr := FindOneAccByFilter(ctx, primitive.M{ + FN.MwAcc.Acc: acc, + }) + + if dataErr != nil || res.Id == 0 { + err = sErr.MakeWithMsg(sErr.Logo, "用户不存在!") + return false, err + } + + if res.IsEnb == false { + err = sErr.MakeWithMsg(sErr.UserPermission, "用户被禁用!") + return false, err + } + err = UpOneAccByFilter(ctx, primitive.M{ + FN.MwAcc.Acc: acc, + }, primitive.M{ + FN.MwAcc.IsBase32: true, + }) + + if err != nil { + return false, err + } + return true, err +} + +// 插入角色 +func AccAdd(ctx context.Context, insertReq *AccInsertDoc) (id int64, ok bool, err error) { + + insertReq.Id = int64(mongos.Lid.MwAcc.Get()) + insertReq.CreatedAt = time.Now() + + _, err = mwAccColl.InsertOne(ctx, insertReq) + if err != nil { + log.ZapLog.Error(fmt.Sprintf("InsertRole mongo error %v", err)) + err = errors.MakeWithErr(errors.DBInsert, err) + return 0, false, err + } + return insertReq.Id, true, nil +} + +// 获取角色列表 +func GetAccList(ctx context.Context, search primitive.M, sort []primitive.M) ( + total int64, data []*AccGetListRec, err error) { + + var countPipe []primitive.M + countPipe = append(countPipe, search, bson.M{"$count": "total"}) + var aggCount AggStatCount + err = mwAccColl.ObjectAggregate(ctx, &aggCount, countPipe) + if err != nil { + return 0, nil, err + } + total = aggCount.Total + + var pip []primitive.M + pip = append(pip, search) + pip = append(pip, sort...) + pip = append(pip, + primitive.M{ + "$lookup": primitive.M{ + "localField": FN.MwAcc.RoleId, + "from": TN.MwRole, + "foreignField": FN.MwRole.Id, + "as": "roles", + }, + }, + primitive.M{"$unwind": primitive.M{ + "path": "$roles", + "preserveNullAndEmptyArrays": true, + }, + }, + primitive.M{ + "$project": primitive.M{ + FN.MwAcc.Id: 1, + FN.MwAcc.UserName: 1, + FN.MwAcc.Acc: 1, + FN.MwAcc.Pwd: 1, + FN.MwAcc.Email: 1, + FN.MwAcc.RoleId: 1, + FN.MwAcc.RoleName: "$roles.name", + FN.MwAcc.Base32: 1, + FN.MwAcc.IsBase32: 1, + // mwacc.FN.MwAcc.LastTime: 1, + FN.MwAcc.IsEnb: 1, + FN.MwAcc.Feedback: 1, + FN.MwAcc.CreatedAt: 1, + FN.MwAcc.UpdatedAt: 1, + FN.MwAcc.LimitIP: 1, + }, + }) + + err = mwAccColl.Aggregate(ctx, &data, pip) + if err != nil { + err = errors.MakeWithErr(errors.DBQuery, err) + return + } + return +} + +// 过滤查询账号 +func FindManyAccByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOptions) ([]*MwAcc, *srvdef.SrvErr) { + var res []*MwAcc + err := mwAccColl.Find(ctx, &res, filters, opts...) + if err != nil { + log.Error("mwacc FindOneAccByFilter err", log.Any("filters", filters), log.E(err)) + return res, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + log.Info("success") + + return res, nil +} diff --git a/pub/models/mwacc/dbhelper.go b/pub/models/mwacc/dbhelper.go new file mode 100644 index 0000000..48d7d4a --- /dev/null +++ b/pub/models/mwacc/dbhelper.go @@ -0,0 +1,13 @@ +package mwacc + +import "videoweb/pub/mongos" + +var ( + mwAccColl *mongos.Collection = nil + mwRoleColl *mongos.Collection = nil + mwAccTokenColl *mongos.Collection = nil +) + +type AggStatCount struct { + Total int64 `bson:"total"` +} diff --git a/pub/models/mwacc/drop.go b/pub/models/mwacc/drop.go new file mode 100644 index 0000000..352a218 --- /dev/null +++ b/pub/models/mwacc/drop.go @@ -0,0 +1,66 @@ +package mwacc + +import ( + "context" + "fmt" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + mwAccColl = mongos.Shop.Coll(TN.MwAcc) + mwRoleColl = mongos.Shop.Coll(TN.MwRole) + mwAccTokenColl = mongos.Shop.Coll(TN.MwAccToken) + initIndex(ctx) + return +} + +// initIndex 初始化索引 +func initIndex(ctx context.Context) { + initAccIndex(ctx) + initRoleIndex(ctx) + initAccTokenIndex(ctx) +} + +func initAccIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.MwAcc.Id, 1}, + }, + }, + } + if _, err := mwAccColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(mwacc:initAccIndex) Create mwacc Index failed! err:%+v:", err)) + } +} + +func initRoleIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.MwRole.Id, 1}, + }, + // Options: options.Index().SetUnique(true), + }, + } + if _, err := mwRoleColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(mwacc:initRoleIndex) Create mwacc Index failed! err:%+v:", err)) + } +} +func initAccTokenIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.MwAccToken.Id, 1}, + }, + // Options: options.Index().SetUnique(true), + }, + } + if _, err := mwAccTokenColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(mwacc:initAccTokenIndex) Create mwacc Index failed! err:%+v:", err)) + } +} diff --git a/pub/models/mwacc/fun.go b/pub/models/mwacc/fun.go new file mode 100644 index 0000000..5b415da --- /dev/null +++ b/pub/models/mwacc/fun.go @@ -0,0 +1,61 @@ +package mwacc + +import ( + "strconv" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" +) + +func MakeSearch(keyWords string, status int64, page pubdef.Page) (searchs bson.M) { + var search []bson.M + if len(keyWords) > 0 { + userId, _ := strconv.ParseInt(keyWords, 10, 64) + search = append(search, bson.M{FN.MwAcc.Id: userId}) + search = append(search, bson.M{FN.MwAcc.UserName: bson.M{"$regex": keyWords}}) + search = append(search, bson.M{FN.MwAcc.Acc: bson.M{"$regex": keyWords}}) + search = append(search, bson.M{FN.MwAcc.Email: bson.M{"$regex": keyWords}}) + } + + match := bson.M{} + if len(search) > 0 { + match["$or"] = search + } + + if status > 0 { + if status == 1 { + match[FN.MwAcc.IsEnb] = true + } else { + match[FN.MwAcc.IsEnb] = false + } + + } + + searchs = bson.M{} + searchs["$match"] = match + return +} + +func MakeSort(pageNum int64, pageSize int64) (sort []bson.M) { + sort = append(sort, bson.M{ + "$sort": bson.M{ + FN.MwAcc.CreatedAt: -1, + }, + }) + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + skip := (pageNum - 1) * pageSize + sort = append(sort, bson.M{ + "$skip": skip, + }) + + sort = append(sort, bson.M{ + "$limit": pageSize, + }) + + return +} diff --git a/pub/models/mwacc/mwacc_gen.go b/pub/models/mwacc/mwacc_gen.go new file mode 100644 index 0000000..1c4987b --- /dev/null +++ b/pub/models/mwacc/mwacc_gen.go @@ -0,0 +1,130 @@ +// Code generated by gen_bson_tag. DO NOT EDIT. +package mwacc + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// CreateOneMwAcc 创建一个MwAcc +func CreateOneMwAcc(ctx context.Context, data *MwAcc) *srvdef.SrvErr { + _, err := mwAccColl.InsertOne(ctx, data) + if err != nil { + log.Error("mwacc:CreateOneMwAcc db.InsertOne failed", + log.Any("doc", data), + log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneMwAcc 查找一个MwAcc +func FindOneMwAcc(ctx context.Context, filter bson.M) ( + *MwAcc, *srvdef.SrvErr) { + var data *MwAcc + if err := mwAccColl.FindOneEx(ctx, &data, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("mwacc:FindOneMwAcc mwAccColl.FindOneEx failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return data, nil +} + +// DelOneOneMwAcc 删除一个MwAcc +func DelOneOneMwAcc(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := mwAccColl.DeleteOne(ctx, filter) + if err != nil { + log.Warn("mwacc:DelOneOneMwAcc mwAccColl.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneMwAcc 更新一个MwAcc +func UpdateOneMwAcc(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := mwAccColl.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("mwacc:UpdateOneMwAcc mwAccColl.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// CountMwAccs 统计MwAcc数量 +func CountMwAccs(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := mwAccColl.Count(ctx, filter) + if err != nil { + log.Warn("mwacc:CountMwAccs mwAccColl.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// FindManyMwAccs 查找一批MwAcc +func FindManyMwAccs(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (MwAccList, *srvdef.SrvErr) { + data := make(MwAccList, 0) + if err := mwAccColl.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("mwacc:FindManyMwAccs mwAccColl.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManyMwAccsWithBind 用自己定义结构查找一批MwAcc数据 +func FindManyMwAccsWithBind(ctx context.Context, bind interface{}, filter bson.M, + opts ...*options.FindOptions) *srvdef.SrvErr { + if err := mwAccColl.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("mwacc:FindManyMwAccsWithBind mwAccColl.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// AggregateMwAccs 聚合查找一批MwAcc +func AggregateMwAccs(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := mwAccColl.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("mwacc:AggregateMwAccs mwAccColl.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateMwAccs 聚合查找一批MwAcc数据 +func ObjectAggregateMwAccs(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := mwAccColl.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("mwacc:AggregateMwAccs mwAccColl.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateMwAccs 批量写入或者更新MwAcc数据 +func BulkWriteMwAccs(ctx context.Context, + docs []mongo.WriteModel, opts ...*options.BulkWriteOptions) *srvdef.SrvErr { + _, err := mwAccColl.BulkWrite(ctx, docs, opts...) + if err != nil { + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} diff --git a/pub/models/mwacc/role.go b/pub/models/mwacc/role.go new file mode 100644 index 0000000..92cd8a7 --- /dev/null +++ b/pub/models/mwacc/role.go @@ -0,0 +1,158 @@ +package mwacc + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/pageopt" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 角色聚集处理 +func RoleAggregate(ctx context.Context, filters []primitive.M, opts ...*options.AggregateOptions) (roles []*MwRole, err error) { + roles = make([]*MwRole, 0) + err = mwRoleColl.Aggregate(ctx, &roles, filters) + return +} + +// 过滤查询数量 +func RoleCountByFilter(ctx context.Context, filters primitive.M, opts ...*options.CountOptions) (c int64, err error) { + c, err = mwRoleColl.Count(ctx, filters) + return +} + +// 过滤查询角色 +func FindOneRoleByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOneOptions) (*MwRole, *srvdef.SrvErr) { + var res MwRole + err := mwRoleColl.FindOne(ctx, &res, filters, opts...) + if err != nil { + log.Error("mwacc FindOneRoleByFilter err", log.Any("filters", filters), log.E(err)) + return &res, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &res, nil +} + +// 过滤更新 +func UpOneRoleByFilter(ctx context.Context, query primitive.M, update primitive.M) *srvdef.SrvErr { + _, err := mwRoleColl.UpdateOne(ctx, query, update) + if err != nil { + log.Error("mwacc UpOneRoleByFilter: db.UpdateOne err", log.Any("update", update), log.E(err)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// 过滤删除角色 +func DelRoleByFilter(ctx context.Context, filters primitive.M) *srvdef.SrvErr { + _, err := mwRoleColl.DeleteOne(ctx, filters) + if err != nil { + log.Error("mwacc DelRoleByFilter err", log.Any("filters", filters), log.E(err)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return nil +} + +// 更新角色权限 +func UpRoleAuth(ctx context.Context, id int64, keys []string, dataRouter []*Router) (ok bool, err error) { + + _, err = mwRoleColl.UpdateOne(ctx, primitive.M{ + FN.MwRole.Id: id, + }, primitive.M{ + "$set": primitive.M{ + FN.MwRole.ApiMenus: keys, + FN.MwRole.Router: dataRouter, + }, + }) + + if err != nil { + log.Error("mvacc UpRoleAuth err", log.Any(FN.MwRole.Id, id), log.E(err)) + return false, err + } + return true, nil +} + +// 获取角色列表 +func GetRoleList(ctx context.Context, skip, limit int64, matchs ...pageopt.Matcher) (data []*RoleGetListRec, err error) { + data = make([]*RoleGetListRec, 0) + + err = mwRoleColl.Aggregate(ctx, &data, + []primitive.M{ + {"$match": pageopt.MergeM(matchs)}, + {"$sort": primitive.M{FN.MwRole.Id: -1}}, + {"$skip": skip}, + {"$limit": limit}, + {"$lookup": primitive.M{ + "localField": FN.MwRole.Id, + "from": TN.MwAcc, + "foreignField": FN.MwAcc.RoleId, + "as": "onlyRole", + }}, + {"$project": primitive.M{ + FN.MwRole.Id: 1, + FN.MwRole.Name: 1, + FN.MwRole.Desc: 1, + FN.MwRole.IsEnb: 1, + FN.MwRole.ApiMenus: 1, + FN.MwRole.CreatedAt: 1, + FN.MwRole.UpdatedAt: 1, + "account": primitive.M{ + "$size": "$onlyRole", + }, + }}, + }) + if err != nil && err.Error() != errors.NotFind { + log.ZapLog.Error(fmt.Sprintf("mwacc GetRoleList error %v", err)) + err = errors.MakeWithErr(errors.DBQuery, err) + return + } + return +} + +// 下拉列表 +func RoleDrop(ctx context.Context) (data []*RoleDropDoc, err error) { + + query := primitive.M{FN.MwRole.IsEnb: true} + + opt := options.Find().SetProjection(primitive.M{ + FN.MwRole.Id: 1, + FN.MwRole.Name: 1, + }) + + err = mwRoleColl.Find(ctx, &data, query, opt) + if err != nil { + err = errors.MakeWithErr(errors.DBQuery, err) + return + } + return +} + +// 插入角色 +func RoleAdd(ctx context.Context, name string, desc string, isEnb bool) (ok bool, err error) { + c, err := mwRoleColl.Count(ctx, primitive.M{FN.MwRole.Name: name}) + if c > 0 { + err = errors.MakeWithMsg(errors.DBInsert, "角色名称") + return false, err + } + + _, err = mwRoleColl.InsertOne(ctx, primitive.M{ + FN.MwRole.Id: mongos.Lid.MwRole.Get(), + FN.MwRole.Name: name, + FN.MwRole.Desc: desc, + FN.MwRole.IsEnb: isEnb, + FN.MwRole.CreatedAt: time.Now(), + }) + + if err != nil { + log.ZapLog.Error(fmt.Sprintf("InsertRole mongo error %v", err)) + err = errors.MakeWithErr(errors.DBInsert, err) + return false, err + } + + return true, nil +} diff --git a/pub/models/mwacc/tag.go b/pub/models/mwacc/tag.go new file mode 100755 index 0000000..28b8406 --- /dev/null +++ b/pub/models/mwacc/tag.go @@ -0,0 +1,88 @@ +package mwacc + +type TagMwRole struct { + ApiMenus string + CreatedAt string + Desc string + Id string + IsEnb string + Name string + Router string + UpdatedAt string +} +type TagMwAcc struct { + Acc string + Base32 string + Code string + CreatedAt string + Email string + Feedback string + Id string + IsBase32 string + IsEnb string + LastTime string + LimitIP string + Pwd string + Qurl string + RoleId string + RoleName string + Token string + UpdatedAt string + UserName string +} +type TagMwAccToken struct { + AccId string + CreatedAt string + Id string + Token string + UpdatedAt string +} + +var FN struct { + MwRole TagMwRole + MwAcc TagMwAcc + MwAccToken TagMwAccToken +} + +var TN struct { + MwRole string + MwAcc string + MwAccToken string +} + +func init() { + FN.MwRole.ApiMenus = "apiMenus" + FN.MwRole.CreatedAt = "createdAt" + FN.MwRole.Desc = "desc" + FN.MwRole.Id = "_id" + FN.MwRole.IsEnb = "isEnb" + FN.MwRole.Name = "name" + FN.MwRole.Router = "router" + FN.MwRole.UpdatedAt = "updatedAt" + FN.MwAcc.Acc = "acc" + FN.MwAcc.Base32 = "base32" + FN.MwAcc.Code = "code" + FN.MwAcc.CreatedAt = "createdAt" + FN.MwAcc.Email = "email" + FN.MwAcc.Feedback = "feedback" + FN.MwAcc.Id = "_id" + FN.MwAcc.IsBase32 = "isBase32" + FN.MwAcc.IsEnb = "isEnb" + FN.MwAcc.LastTime = "lastTime" + FN.MwAcc.LimitIP = "limitIP" + FN.MwAcc.Pwd = "pwd" + FN.MwAcc.Qurl = "qurl" + FN.MwAcc.RoleId = "roleId" + FN.MwAcc.RoleName = "roleName" + FN.MwAcc.Token = "token" + FN.MwAcc.UpdatedAt = "updatedAt" + FN.MwAcc.UserName = "userName" + FN.MwAccToken.AccId = "accId" + FN.MwAccToken.CreatedAt = "createdAt" + FN.MwAccToken.Id = "_id" + FN.MwAccToken.Token = "token" + FN.MwAccToken.UpdatedAt = "updatedAt" + TN.MwRole = "mw_role" + TN.MwAcc = "mw_acc" + TN.MwAccToken = "mw_acc_token" +} diff --git a/pub/models/mwacc/token.go b/pub/models/mwacc/token.go new file mode 100644 index 0000000..4bdd821 --- /dev/null +++ b/pub/models/mwacc/token.go @@ -0,0 +1,84 @@ +package mwacc + +import ( + "context" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 过滤更新 +func UpOneTokenByFilter(ctx context.Context, query primitive.M, update primitive.M) (err error) { + _, err = mwAccTokenColl.UpdateOne(ctx, query, primitive.M{"$set": update}) + return +} + +func SaveToken(ctx context.Context, accId int64, token string) (ok bool, err error) { + var booToken MwAccToken + + // 查找 + err = mwAccTokenColl.FindOne(ctx, &booToken, primitive.M{FN.MwAccToken.AccId: accId}) + + if booToken.AccId > 0 { + // 更新 + err = UpOneTokenByFilter(ctx, primitive.M{ + FN.MwAccToken.AccId: accId, + }, primitive.M{ + FN.MwAccToken.Token: token, + FN.MwAccToken.UpdatedAt: time.Now(), + }) + if err != nil { + return false, err + } + return true, nil + } + + if err != nil || booToken.AccId == 0 { + if _, err := mwAccTokenColl.InsertOne(ctx, + primitive.M{ + FN.MwAccToken.Id: mongos.Lid.MwAccToken.Get(), + FN.MwAccToken.AccId: accId, + FN.MwAccToken.Token: token, + FN.MwAccToken.CreatedAt: time.Now(), + }); err != nil { + return false, err + } + } + + return true, nil +} + +func CheckToken(ctx context.Context, token string) (ok bool, err error) { + var booToken MwAccToken + Terr := mwAccTokenColl.FindOne(ctx, &booToken, primitive.M{FN.MwAccToken.Token: token}) + if Terr != nil || booToken.AccId == 0 { + err = errors.MakeWithMsg(errors.NoToken, "token 不存在或已过期") + return false, err + } + return true, nil +} + +// // 检测Token +// func CheckToken(ctx context.Context, token string) (ok bool, err error) { +// var booToken WebToken +// Terr := tokenColl.FindOne(ctx, &booToken, primitive.M{"token": token}) +// if Terr != nil || booToken.Id == 0 { +// err = errors.MakeWithMsg(errors.NoToken, "token 不存在或已过期") +// return +// } +// return true, nil +// } + +// 删除token +func DeleteOneToken(ctx context.Context, filter bson.M) *srvdef.SrvErr { + if _, err := mwAccTokenColl.DeleteOne(ctx, filter); err != nil { + log.Warn("mwacc:DeleteOneToken db.DeleteOne failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return nil +} diff --git a/pub/models/mwacc/type.go b/pub/models/mwacc/type.go new file mode 100644 index 0000000..1f5e471 --- /dev/null +++ b/pub/models/mwacc/type.go @@ -0,0 +1,78 @@ +package mwacc + +import ( + "time" +) + +type Meta struct { + Title string `bson:"title"` + Icon string `bson:"icon"` + NoCache bool `bson:"noCache"` +} + +type Apiurl struct { + Url []string `bson:"url"` + Desc string `bson:"desc"` + Status bool `bson:"status"` + Method []string `bson:"method"` +} + +// 编辑返回去的数据 +type Router struct { + Path string `bson:"path"` + Component string `bson:"component"` + Redirect string `bson:"redirect"` + Name string `bson:"name"` + IsShow bool `bson:"isShow"` + Hidden bool `bson:"hidden"` + Meta Meta `bson:"meta"` + Children []Router `bson:"children"` + ApiUrl []Apiurl `bson:"apiUrl"` +} + +// 角色 +// @table:mw_role +type MwRole struct { + Id int64 `bson:"_id"` + Name string `bson:"name"` // 角色名称 + Desc string `bson:"desc"` // 描述 + IsEnb bool `bson:"isEnb"` // 是否启用 false:禁用 true:启用 + ApiMenus []string `bson:"apiMenus"` // 已有权限的api + Router []Router `bson:"router"` + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 +} + +// 账号 +// @table:mw_acc +type MwAcc struct { + Id int64 `bson:"_id"` // id + UserName string `bson:"userName"` // 用户名 + Acc string `bson:"acc"` // 账号 + Pwd string `bson:"pwd"` // 密码 + Email string `bson:"email"` // 邮箱 + RoleId int64 `bson:"roleId"` // 角色Id + RoleName string `bson:"roleName"` // 角色名 + Base32 string `bson:"base32"` // 二步签名 + IsBase32 bool `bson:"isBase32"` // 是否已签名 + Code string `bson:"code"` // 验证码 + Qurl []byte `bson:"qurl"` // 二维码链接 + Token string `bson:"token"` // token + LastTime time.Time `bson:"lastTime"` // 登录时间 + IsEnb bool `bson:"isEnb"` // 使能 + Feedback string `bson:"feedback"` // 禁用原因 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 + LimitIP []string `bson:"limitIP"` // 限制IP +} +type MwAccList []*MwAcc + +// 账号Token +// @table:mw_acc_token +type MwAccToken struct { + Id int64 `bson:"_id"` // id + AccId int64 `bson:"accId"` // 账号id + Token string `bson:"token"` // token + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 +} diff --git a/pub/models/mwacc/web.go b/pub/models/mwacc/web.go new file mode 100644 index 0000000..ec5f3fb --- /dev/null +++ b/pub/models/mwacc/web.go @@ -0,0 +1,53 @@ +package mwacc + +import ( + "time" +) + +type AccGetListRec struct { + Id int64 `bson:"_id"` // id + Username string `bson:"username"` // 用户名 + Acc string `bson:"acc"` // 用户名 + Pwd string `bson:"pwd"` // 密码 + Email string `bson:"email"` // 邮箱 + RoleId int64 `bson:"roleId"` // 角色Id + RoleName string `bson:"roleName"` // 角色名 + // Base32 string `bson:"base32"` // 二步签名 + IsBase32 bool `bson:"isBase32"` // 是否已签名 + IsEnb bool `bson:"isEnb"` // 使能 + Feedback string `bson:"feedback"` // 禁用原因 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 + LimitIP []string `bson:"limitIP"` // 允许访问的IP +} + +type RoleGetListRec struct { + Id int64 `bson:"_id"` // 角色Id + Name string `bson:"name"` // 角色名称 + Desc string `bson:"desc"` // 描述 + IsEnb bool `bson:"isEnb"` // 是否启用 false:禁用 true:启用 + ApiMenus []string `bson:"apiMenus"` // 已有权限的api + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 + AccCount int64 `bson:"accCount"` // 账户数量 +} + +type AccInsertDoc struct { + Id int64 `bson:"_id"` // id + UserName string `bson:"userName"` // 用户名 + Pwd string `bson:"pwd"` // 密码 + Acc string `bson:"acc"` // 用户名 + Email string `bson:"email"` // 邮箱 + RoleId int64 `bson:"roleId"` // 角色Id + Base32 string `bson:"base32"` // 二步签名 + IsBase32 bool `bson:"isBase32"` // 是否已签名 + IsEnb bool `bson:"isEnb"` // 使能 + LastTime time.Time `bson:"lastTime"` // 登录时间 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + LimitIP []string `bson:"limitIP"` // 允许访问的IP +} + +type RoleDropDoc struct { + Id int64 `bson:"_id"` // 角色Id + Name string `bson:"name"` // 角色名称 +} diff --git a/pub/models/oplog/dbhelper.go b/pub/models/oplog/dbhelper.go new file mode 100644 index 0000000..d19a38e --- /dev/null +++ b/pub/models/oplog/dbhelper.go @@ -0,0 +1,9 @@ +package oplog + +import ( + "videoweb/pub/mongos" +) + +var ( + opLogColl *mongos.Collection +) diff --git a/pub/models/oplog/oplog.go b/pub/models/oplog/oplog.go new file mode 100644 index 0000000..ecb33ec --- /dev/null +++ b/pub/models/oplog/oplog.go @@ -0,0 +1,175 @@ +package oplog + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + opLogColl = mongos.ShopLog.Coll(TN.OpLog) + initIndex(ctx) + return +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.OpLog.ActId, 1}, + {FN.OpLog.Action, 1}, + {FN.OpLog.Path, 1}, + }, + }, + { + Keys: bson.D{ + {FN.OpLog.CreatedAt, -1}, + }, + }, + } + if _, err := opLogColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(oplog:initIndex) Create adLog Index failed! err:%+v:", err)) + } +} + +// 添加日志 +func AddLog(ctx context.Context, path string, desc string, actId int64, rawData, ip string) (err error) { + _, err = opLogColl.InsertOne(ctx, primitive.M{ + FN.OpLog.Id: mongos.Lid.MwOpLog.Get(), + FN.OpLog.Action: desc, + FN.OpLog.Path: path, + FN.OpLog.ActId: actId, + FN.OpLog.RawData: rawData, + FN.OpLog.CreatedAt: utils.Now(), + FN.OpLog.IP: ip, + }) + return +} + +type LogReq struct { + PageNum int64 `json:"pageNum"` // 页码 + PageSize int64 `json:"pageSize"` // + Path *string `json:"path"` // 请求接口 + Action *string `json:"action"` // 操作目的 + ActId *int64 `json:"actId"` // 操作人Id + StartTime *time.Time `json:"startTime"` // 开始时间 + EndTime *time.Time `json:"endTime"` // 开始时间 +} + +// 操作日志信息 +type OpLogInfo struct { + Path string `json:"path" bson:"path"` // 请求接口 + ActId int64 `json:"actId" bson:"actId"` // 操作人Id + Action string `json:"action" bson:"action"` // 操作目的 + Param string `json:"param" bson:"rawData"` // 操作内容 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 操作时间 + ActName string `json:"actName" bson:"actName"` // 操作人 +} + +type OpPageRes struct { + Total int64 `json:"total" bson:"total"` + List []*OpLogInfo `json:"list" bson:"list"` +} + +// 获取日志列表 +func PageLogList(ctx context.Context, req *LogReq) (res *OpPageRes, err error) { + filter := bson.M{} + if req.Path != nil && *req.Path != "" { + filter[FN.OpLog.Path] = bson.M{"$regex": req.Path} + } + if req.Action != nil && *req.Action != "" { + filter[FN.OpLog.Action] = req.Action + } + if req.ActId != nil && *req.ActId > 0 { + filter[FN.OpLog.ActId] = req.ActId + } + if req.StartTime != nil && req.EndTime != nil { + filter[FN.OpLog.CreatedAt] = bson.M{"$gte": req.StartTime, "$lt": req.EndTime} + } + res = new(OpPageRes) + res.Total, err = mongos.ShopLog.Coll(TN.OpLog).Count(ctx, filter) + if err != nil { + return + } + + sort := bson.M{FN.OpLog.CreatedAt: -1} + opt := &options.FindOptions{} + opt.SetSort(sort) + skip := (req.PageNum - 1) * req.PageSize + opt.SetSkip(skip) + opt.SetLimit(req.PageSize) + list := make(OpLogList, 0) + err = mongos.ShopLog.Coll(TN.OpLog).Find(ctx, &list, filter, opt) + if err != nil { + return + } + actIds := make([]int64, 0) + for _, v := range list { + actIds = append(actIds, v.ActId) + } + // err = opLogColl.Aggregate(ctx, &res.List, []bson.M{ + // {"$match": conf}, + // {"$sort": bson.M{FN.OpLog.CreatedAt: -1}}, + // {"$skip": (req.PageNum - 1) * req.PageSize}, + // {"$limit": req.PageSize}, + // {"$lookup": bson.M{ + // "localField": FN.OpLog.ActId, + // "from": mwacc.TN.MwAcc, + // "foreignField": mwacc.FN.MwAcc.Id, + // "as": "admins", + // }}, + // {"$unwind": "$admins"}, + // {"$project": bson.M{ + // FN.OpLog.Path: 1, + // FN.OpLog.Action: 1, + // FN.OpLog.ActId: 1, + // FN.OpLog.RawData: 1, + // FN.OpLog.CreatedAt: 1, + // "actName": "$admins.userName", + // }}, + // }) + return +} + +// CountOpLogs 计算某个查询的数量 +func CountOpLogs(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + total, err := mongos.ShopLog.Coll(TN.OpLog).Count(ctx, filter) + if err != nil { + log.Info("oplog:CountOpLogs db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return total, nil +} + +// InsertOneUser 插入一个用户 +func InsertOneUser(ctx context.Context, opLog *OpLog) *srvdef.SrvErr { + if _, err := mongos.ShopLog.Coll(TN.OpLog).InsertOne(ctx, opLog); err != nil { + log.Warn("oplog:InsertOneUser db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindManyOpLogs 查找一批数据 +func FindManyOpLogs(ctx context.Context, + filter bson.M, opts ...*options.FindOptions) ( + OpLogList, *srvdef.SrvErr) { + data := make(OpLogList, 0) + if err := mongos.ShopLog.Coll(TN.OpLog).Find(ctx, &data, filter, opts...); err != nil { + log.Warn("oplog:FindManyOpLogs db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} diff --git a/pub/models/oplog/tag.go b/pub/models/oplog/tag.go new file mode 100755 index 0000000..e2bc190 --- /dev/null +++ b/pub/models/oplog/tag.go @@ -0,0 +1,34 @@ +package oplog + +type TagOpLog struct { + ActId string + Action string + CreatedAt string + IP string + Id string + IsWhite string + OpType string + Path string + RawData string +} + +var FN struct { + OpLog TagOpLog +} + +var TN struct { + OpLog string +} + +func init() { + FN.OpLog.ActId = "actId" + FN.OpLog.Action = "action" + FN.OpLog.CreatedAt = "createdAt" + FN.OpLog.IP = "ip" + FN.OpLog.Id = "_id" + FN.OpLog.IsWhite = "isWhite" + FN.OpLog.OpType = "opType" + FN.OpLog.Path = "path" + FN.OpLog.RawData = "rawData" + TN.OpLog = "mw_op_log" +} diff --git a/pub/models/oplog/type.go b/pub/models/oplog/type.go new file mode 100644 index 0000000..c3348ef --- /dev/null +++ b/pub/models/oplog/type.go @@ -0,0 +1,22 @@ +package oplog + +import ( + "time" + "videoweb/pub/common" +) + +// 操作日志 +// @table:mw_op_log +type OpLog struct { + Id int64 `bson:"_id"` // id + Path string `bson:"path"` // 请求接口 + OpType common.ApiOpType `bson:"opType"` // 操作类型 + IsWhite bool `bson:"isWhite"` // 是否白名单 + ActId int64 `bson:"actId"` // 操作人Id + Action string `bson:"action"` // 操作目的 + RawData string `bson:"rawData"` // 原始数据 + CreatedAt time.Time `bson:"createdAt"` // 操作时间 + IP string `bson:"ip"` // 操作人IP +} + +type OpLogList []*OpLog diff --git a/pub/models/richtextcontentmod/richtextcontent.go b/pub/models/richtextcontentmod/richtextcontent.go new file mode 100644 index 0000000..eceb86e --- /dev/null +++ b/pub/models/richtextcontentmod/richtextcontent.go @@ -0,0 +1,153 @@ +package richtextcontentmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.RichTextContentModel) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.RichTextContentModel.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.RichTextContentModel.Status, Value: 1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(RichTextContentModelModel:initRichTextContentModelModelIndex) Create RichTextContentModelModel Index failed! err:%+v:", err)) + } +} + +// InsertOneRichTextContentModelModel 插入定时任务 +func InsertOneRichTextContentModelModel(ctx context.Context, RichTextContentModelModel *RichTextContentModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, RichTextContentModelModel); err != nil { + log.Warn("RichTextContentModelModel:InsertOneRichTextContentModelModel db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneRichTextContentModelModel 插入定时任务 +func FindOneRichTextContentModelModel(ctx context.Context, filter bson.M) ( + *RichTextContentModel, *srvdef.SrvErr) { + var RichTextContentModelModel RichTextContentModel + if err := db.FindOneEx(ctx, &RichTextContentModelModel, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("RichTextContentModelModel:FindOneRichTextContentModelModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &RichTextContentModelModel, nil +} + +// DelOneRichTextContentModelModel 插入定时任务 +func DelOneRichTextContentModelModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("RichTextContentModelModel:DelOneRichTextContentModelModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneRichTextContentModelModel 插入定时任务 +func UpdateOneRichTextContentModelModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("RichTextContentModelModel:UpdateOneRichTextContentModelModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpsertOneRichTextContentModelModel 更新插入一个记录 +func UpsertOneRichTextContentModelModel(ctx context.Context, filter bson.M, upsertDoc bson.M) *srvdef.SrvErr { + _, err := db.UpsertOne(ctx, filter, upsertDoc) + if err != nil { + log.Info("RichTextContentModelModel:UpsertOneRichTextContentModelModel db.UpsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// CountRichTextContentModelModels 统计用户数量 +func CountRichTextContentModelModels(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("RichTextContentModelModel:CountRichTextContentModelModels db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateManyRichTextContentModelModel 更新满足条件的用户 +func UpdateManyRichTextContentModelModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("RichTextContentModelModel:UpdateManyRichTextContentModelModel db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManyRichTextContentModelModel 查找一批用户数据 +func FindManyRichTextContentModelModel(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (RichTextContentModelList, *srvdef.SrvErr) { + data := make(RichTextContentModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("RichTextContentModelModel:FindManyRichTextContentModelModel db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManyRichTextContentModelModelWithBind 用自己定义结构查找一批用户数据 +func FindManyRichTextContentModelModelWithBind(ctx context.Context, bind interface{}, filter bson.M, + opts ...*options.FindOptions) *srvdef.SrvErr { + if err := db.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("RichTextContentModelModel:FindManyRichTextContentModelModelWithBind db.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// AggregateRichTextContentModelModels 聚合查找一批用户信息 +func AggregateRichTextContentModelModels(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("feedback:AggregateRichTextContentModelModels db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/richtextcontentmod/tag.go b/pub/models/richtextcontentmod/tag.go new file mode 100755 index 0000000..8598aa1 --- /dev/null +++ b/pub/models/richtextcontentmod/tag.go @@ -0,0 +1,40 @@ +package richtextcontentmod + +type TagRichTextContentModel struct { + Content string + CreatedAt string + Desc string + Id string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Sort string + Status string + Title string + UpdatedAt string +} + +var FN struct { + RichTextContentModel TagRichTextContentModel +} + +var TN struct { + RichTextContentModel string +} + +func init() { + FN.RichTextContentModel.Content = "content" + FN.RichTextContentModel.CreatedAt = "createdAt" + FN.RichTextContentModel.Desc = "desc" + FN.RichTextContentModel.Id = "_id" + FN.RichTextContentModel.SeoInfo.Description = "seoInfo.webDescription" + FN.RichTextContentModel.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.RichTextContentModel.SeoInfo.Title = "seoInfo.webTitle" + FN.RichTextContentModel.Sort = "sort" + FN.RichTextContentModel.Status = "status" + FN.RichTextContentModel.Title = "title" + FN.RichTextContentModel.UpdatedAt = "updatedAt" + TN.RichTextContentModel = "rich_text_content" +} diff --git a/pub/models/richtextcontentmod/type.go b/pub/models/richtextcontentmod/type.go new file mode 100644 index 0000000..bdc8223 --- /dev/null +++ b/pub/models/richtextcontentmod/type.go @@ -0,0 +1,22 @@ +package richtextcontentmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// RichTextContentModel 富文本内容 +// @table:rich_text_content +type RichTextContentModel struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 标题(内部) + Content string `json:"content" bson:"content"` // 内容 + Status bool `json:"status" bson:"status"` // 是否启用 + Sort int64 `json:"sort" bson:"sort"` // 排序值 + Desc string `json:"desc" bson:"desc"` // 标题(app) + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `bson:"createdAt" json:"createdAt" ` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt" ` // 更新时间 +} + +type RichTextContentModelList []*RichTextContentModel diff --git a/pub/models/skdrecord/record.go b/pub/models/skdrecord/record.go new file mode 100644 index 0000000..b93294a --- /dev/null +++ b/pub/models/skdrecord/record.go @@ -0,0 +1,153 @@ +package skdrecord + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.SkdRecord) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.SkdRecord.Job, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.SkdRecord.CreatedAt, Value: 1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(SkdRecord:initSkdRecordIndex) Create SkdRecord Index failed! err:%+v:", err)) + } +} + +// InsertOneSkdRecord 插入定时任务 +func InsertOneSkdRecord(ctx context.Context, SkdRecord *SkdRecord) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, SkdRecord); err != nil { + log.Warn("SkdRecord:InsertOneSkdRecord db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneSkdRecord 插入定时任务 +func FindOneSkdRecord(ctx context.Context, filter bson.M) ( + *SkdRecord, *srvdef.SrvErr) { + var SkdRecord SkdRecord + if err := db.FindOneEx(ctx, &SkdRecord, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("SkdRecord:FindOneSkdRecord db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrSkdRecordNotExsit, "", "") + } + return &SkdRecord, nil +} + +// DelOneSkdRecord 插入定时任务 +func DelOneSkdRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("SkdRecord:DelOneSkdRecord db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneSkdRecord 插入定时任务 +func UpdateOneSkdRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("SkdRecord:UpdateOneSkdRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrSkdRecordNotExsit, "", "") + } + return nil +} + +// UpsertOneSkdRecord 更新插入一个记录 +func UpsertOneSkdRecord(ctx context.Context, filter bson.M, upsertDoc bson.M) *srvdef.SrvErr { + _, err := db.UpsertOne(ctx, filter, upsertDoc) + if err != nil { + log.Info("SkdRecord:UpsertOneSkdRecord db.UpsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// CountSkdRecords 统计用户数量 +func CountSkdRecords(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("SkdRecord:CountSkdRecords db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateManySkdRecord 更新满足条件的用户 +func UpdateManySkdRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("SkdRecord:UpdateManySkdRecord db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManySkdRecord 查找一批用户数据 +func FindManySkdRecord(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (SkdRecordList, *srvdef.SrvErr) { + data := make(SkdRecordList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("SkdRecord:FindManySkdRecord db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManySkdRecordWithBind 用自己定义结构查找一批用户数据 +func FindManySkdRecordWithBind(ctx context.Context, bind interface{}, filter bson.M, + opts ...*options.FindOptions) *srvdef.SrvErr { + if err := db.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("SkdRecord:FindManySkdRecordWithBind db.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// AggregateSkdRecords 聚合查找一批用户信息 +func AggregateSkdRecords(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("feedback:AggregateSkdRecords db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/skdrecord/tag.go b/pub/models/skdrecord/tag.go new file mode 100755 index 0000000..0344cef --- /dev/null +++ b/pub/models/skdrecord/tag.go @@ -0,0 +1,30 @@ +package skdrecord + +type TagSkdRecord struct { + CreatedAt string + FirstRecordTime string + ID string + Item string + Job string + RecordTime string + UpdatedAt string +} + +var FN struct { + SkdRecord TagSkdRecord +} + +var TN struct { + SkdRecord string +} + +func init() { + FN.SkdRecord.CreatedAt = "createdAt" + FN.SkdRecord.FirstRecordTime = "firstRecordTime" + FN.SkdRecord.ID = "_id" + FN.SkdRecord.Item = "item" + FN.SkdRecord.Job = "job" + FN.SkdRecord.RecordTime = "recordTime" + FN.SkdRecord.UpdatedAt = "updatedAt" + TN.SkdRecord = "skd_record" +} diff --git a/pub/models/skdrecord/type.go b/pub/models/skdrecord/type.go new file mode 100644 index 0000000..bbd641c --- /dev/null +++ b/pub/models/skdrecord/type.go @@ -0,0 +1,27 @@ +package skdrecord + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 表名 +// SkdRecord 获取充值记录 +// @table:skd_record +type SkdRecord struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Job string `bson:"job"` // 工作 + Item string `bson:"item"` // 统计项 + RecordTime time.Time `bson:"recordTime"` // 提交时间 + FirstRecordTime time.Time `bson:"firstRecordTime"` // 第一次提交时间 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 +} + +const ( + GameRecordJob = "GameRecordJob" // 按天统计访问信息 + LoufengUpdate = "LoufengUpdate" // 楼风更新信息 +) + +type SkdRecordList []*SkdRecord diff --git a/pub/models/syncfilemod/sync.go b/pub/models/syncfilemod/sync.go new file mode 100644 index 0000000..0feb28d --- /dev/null +++ b/pub/models/syncfilemod/sync.go @@ -0,0 +1,108 @@ +package syncfilemod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.SyncRecord) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.SyncRecord.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(SyncRecord:InitSyncRecord) Create SyncRecord Index failed! err:%+v:", err)) + } +} + +// CountSyncRecord 统计活动数量 +func CountSyncRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("SyncRecordmod:CountSyncRecord db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// InsertOneSyncRecord 插入一条活动 +func InsertOneSyncRecord(ctx context.Context, SyncRecord *SyncRecord) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, SyncRecord); err != nil { + log.Info("SyncRecordmod:InsertOneSyncRecord db.InsertOne failed", + log.E(err), log.Any("SyncRecord", SyncRecord)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneSyncRecord 删除一个活动 +func DelOneSyncRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("SyncRecordmod:DelOneSyncRecord db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneSyncRecord 更新一个SyncRecord +func UpdateOneSyncRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("SyncRecordmod:UpdateOneSyncRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("SyncRecordmod:UpdateOneSyncRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ObjectNotExist, "", "") + } + return nil +} + +// FindManySyncRecord 查找一批活动数据 +func FindManySyncRecord(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (SyncRecordList, *srvdef.SrvErr) { + data := make(SyncRecordList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("SyncRecordmod:FindManyAcivities db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindOneSyncRecord 查找一个媒体 +func FindOneSyncRecord(ctx context.Context, filter bson.M) ( + *SyncRecord, *srvdef.SrvErr) { + var SyncRecord SyncRecord + if err := db.FindOneEx(ctx, &SyncRecord, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("SyncRecord:FindOneSyncRecord db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ObjectDayStatDoesNotExist, "", "") + } + return &SyncRecord, nil +} \ No newline at end of file diff --git a/pub/models/syncfilemod/tag.go b/pub/models/syncfilemod/tag.go new file mode 100755 index 0000000..b9da6f4 --- /dev/null +++ b/pub/models/syncfilemod/tag.go @@ -0,0 +1,30 @@ +package syncfilemod + +type TagSyncRecord struct { + CreatedAt string + ID string + Job string + RecordId string + SuccessAt string + UpdatedAt string + RecordTime string +} + +var FN struct { + SyncRecord TagSyncRecord +} + +var TN struct { + SyncRecord string +} + +func init() { + FN.SyncRecord.CreatedAt = "createdAt" + FN.SyncRecord.ID = "_id" + FN.SyncRecord.Job = "job" + FN.SyncRecord.RecordId = "recordId" + FN.SyncRecord.SuccessAt = "successAt" + FN.SyncRecord.UpdatedAt = "updatedAt" + FN.SyncRecord.RecordTime = "recordTime" + TN.SyncRecord = "sync_record" +} \ No newline at end of file diff --git a/pub/models/syncfilemod/type.go b/pub/models/syncfilemod/type.go new file mode 100644 index 0000000..75f1141 --- /dev/null +++ b/pub/models/syncfilemod/type.go @@ -0,0 +1,32 @@ +package syncfilemod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 资源同步 +// @table:sync_record +type SyncRecord struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Job string `json:"job" bson:"job"` // 工作 + RecordId int64 `json:"recordId" bson:"recordId"` // 用户ID + RecordTime time.Time `json:"recordTime" bson:"recordTime"` // 记录时间 + SuccessAt time.Time `json:"successAt" bson:"successAt"` // 成功时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} +type SyncRecordList []*SyncRecord + +const ( + NovelJob = "novelJob" // 小说记录同步 + ComicsJob = "comicsJob" // 漫画记录同步 + PostJob = "postJob" // 帖子记录同步 + VideoJob = "videoJob" // 视频同步 +) + +var SyncJobDescribe = map[string]string{ + NovelJob: "小说记录同步", + ComicsJob: "漫画记录同步", +} \ No newline at end of file diff --git a/pub/models/translationcategorymod/category.go b/pub/models/translationcategorymod/category.go new file mode 100644 index 0000000..3e6895c --- /dev/null +++ b/pub/models/translationcategorymod/category.go @@ -0,0 +1,112 @@ +package translationcategorymod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.TranslationCategory) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.TranslationCategory.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.TranslationCategory.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initTranslationCategoryIndex) Create TranslationCategory Index failed! err:%+v:", err)) + } + +} + +// InsertOneTranslationCategory 插入一个导航分类 +func InsertOneTranslationCategory(ctx context.Context, qa *TranslationCategory) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("TranslationCategorymod:InsertOneTranslationCategory db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneTranslationCategory 删除一个导航分类 +func DelOneTranslationCategory(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("TranslationCategorymod:DelOneTranslationCategory db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneTranslationCategory 查找一个导航分类 +func FindOneTranslationCategory(ctx context.Context, filter bson.M) ( + *TranslationCategory, *srvdef.SrvErr) { + var TranslationCategory TranslationCategory + if err := db.FindOneEx(ctx, &TranslationCategory, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("TranslationCategorymod:FindOneTranslationCategory db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &TranslationCategory, nil +} + +func FindManyTranslationCategorys(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (TranslationCategoryList, *srvdef.SrvErr) { + data := make(TranslationCategoryList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyTranslationCategorys", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountTranslationCategorys 统计导航分类数量 +func CountTranslationCategorys(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountTranslationCategorys db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneTranslationCategory 更新一个导航分类 +func UpdateOneTranslationCategory(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneTranslationCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneTranslationCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/translationcategorymod/tag.go b/pub/models/translationcategorymod/tag.go new file mode 100755 index 0000000..7ef84a1 --- /dev/null +++ b/pub/models/translationcategorymod/tag.go @@ -0,0 +1,30 @@ +package translationcategorymod + +type TagTranslationCategory struct { + CreatedAt string + Id string + Name string + Rank string + Status string + UpdatedAt string + UrlValue string +} + +var FN struct { + TranslationCategory TagTranslationCategory +} + +var TN struct { + TranslationCategory string +} + +func init() { + FN.TranslationCategory.CreatedAt = "createdAt" + FN.TranslationCategory.Id = "_id" + FN.TranslationCategory.Name = "name" + FN.TranslationCategory.Rank = "rank" + FN.TranslationCategory.Status = "status" + FN.TranslationCategory.UpdatedAt = "updatedAt" + FN.TranslationCategory.UrlValue = "urlValue" + TN.TranslationCategory = "translation_category" +} diff --git a/pub/models/translationcategorymod/type.go b/pub/models/translationcategorymod/type.go new file mode 100644 index 0000000..9e88045 --- /dev/null +++ b/pub/models/translationcategorymod/type.go @@ -0,0 +1,17 @@ +package translationcategorymod + +import ( + "time" +) + +// @table:translation_category +type TranslationCategory struct { + Id int64 `json:"id" bson:"_id,omitempty"` // Id + Name string `json:"name" bson:"name"` // 分类名称 + Status bool `json:"status" bson:"status"` // 上架状态 + VideoCategoryId int64 `json:"videoCategoryId" bson:"videoCategoryId"` // 视频分类 + Rank int64 `json:"rank" bson:"rank"` // 排序 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type TranslationCategoryList []*TranslationCategory diff --git a/pub/models/translationmod/tag.go b/pub/models/translationmod/tag.go new file mode 100755 index 0000000..7aefb73 --- /dev/null +++ b/pub/models/translationmod/tag.go @@ -0,0 +1,36 @@ +package translationmod + +type TagTranslationInfo struct { + CreatedAt string + HtmlId string + Id string + Link string + Name string + Rank string + Status string + TransCategoryId string + UpdatedAt string + VideoCategoryId string +} + +var FN struct { + TranslationInfo TagTranslationInfo +} + +var TN struct { + TranslationInfo string +} + +func init() { + FN.TranslationInfo.CreatedAt = "createdAt" + FN.TranslationInfo.HtmlId = "htmlId" + FN.TranslationInfo.Id = "_id" + FN.TranslationInfo.Link = "link" + FN.TranslationInfo.Name = "name" + FN.TranslationInfo.Rank = "rank" + FN.TranslationInfo.Status = "status" + FN.TranslationInfo.TransCategoryId = "transCategoryId" + FN.TranslationInfo.UpdatedAt = "updatedAt" + FN.TranslationInfo.VideoCategoryId = "videoCategoryId" + TN.TranslationInfo = "translation_info" +} diff --git a/pub/models/translationmod/translation.go b/pub/models/translationmod/translation.go new file mode 100644 index 0000000..807c0c7 --- /dev/null +++ b/pub/models/translationmod/translation.go @@ -0,0 +1,112 @@ +package translationmod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.TranslationInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.TranslationInfo.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.TranslationInfo.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initTranslationInfoIndex) Create TranslationInfo Index failed! err:%+v:", err)) + } + +} + +// InsertOneTranslationInfo 插入一个渠道统计 +func InsertOneTranslationInfo(ctx context.Context, qa *TranslationInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("TranslationInfomod:InsertOneTranslationInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneTranslationInfo 删除一个渠道统计 +func DelOneTranslationInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("TranslationInfomod:DelOneTranslationInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneTranslationInfo 查找一个渠道统计 +func FindOneTranslationInfo(ctx context.Context, filter bson.M) ( + *TranslationInfo, *srvdef.SrvErr) { + var TranslationInfo TranslationInfo + if err := db.FindOneEx(ctx, &TranslationInfo, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("TranslationInfomod:FindOneTranslationInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &TranslationInfo, nil +} + +func FindManyTranslationInfos(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (TranslationInfoList, *srvdef.SrvErr) { + data := make(TranslationInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyTranslationInfos", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountTranslationInfos 统计渠道统计数量 +func CountTranslationInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountTranslationInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneTranslationInfo 更新一个渠道统计 +func UpdateOneTranslationInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneTranslationInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneTranslationInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/translationmod/type.go b/pub/models/translationmod/type.go new file mode 100644 index 0000000..34c50ef --- /dev/null +++ b/pub/models/translationmod/type.go @@ -0,0 +1,20 @@ +package translationmod + +import ( + "time" +) + +// @table:translation_info +type TranslationInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` // Id + Name string `json:"name" bson:"name"` // 分类名称 + TransCategoryId int64 `json:"transCategoryId" bson:"transCategoryId"` // 导航分类 + VideoCategoryId int64 `json:"videoCategoryId" bson:"videoCategoryId"` // 视频分类 + HtmlId int64 `json:"htmlId" bson:"htmlId"` // 富文本ID + Link string `json:"link" bson:"link"` // 外部跳转 + Rank int64 `json:"rank" bson:"rank"` // 排序 + Status bool `json:"status" bson:"status"` // 上架状态 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type TranslationInfoList []*TranslationInfo diff --git a/pub/models/utils/dbhelper.go b/pub/models/utils/dbhelper.go new file mode 100644 index 0000000..d4b585b --- /dev/null +++ b/pub/models/utils/dbhelper.go @@ -0,0 +1 @@ +package utils diff --git a/pub/models/utils/dbop.go b/pub/models/utils/dbop.go new file mode 100644 index 0000000..05c75d3 --- /dev/null +++ b/pub/models/utils/dbop.go @@ -0,0 +1,11 @@ +package utils + +import ( + "context" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + //initIndex(ctx) + return +} diff --git a/pub/models/utils/tag.go b/pub/models/utils/tag.go new file mode 100755 index 0000000..bee5413 --- /dev/null +++ b/pub/models/utils/tag.go @@ -0,0 +1,30 @@ +package utils + +type TagGenerateId struct { + BusinessId string + CreateAt string + Desc string + Id string + MaxId string + Step string + UpdateAt string +} + +var FN struct { + GenerateId TagGenerateId +} + +var TN struct { + GenerateId string +} + +func init() { + FN.GenerateId.BusinessId = "businessId" + FN.GenerateId.CreateAt = "createAt" + FN.GenerateId.Desc = "desc" + FN.GenerateId.Id = "_id" + FN.GenerateId.MaxId = "maxId" + FN.GenerateId.Step = "step" + FN.GenerateId.UpdateAt = "updateAt" + TN.GenerateId = "generate_id" +} diff --git a/pub/models/utils/type.go b/pub/models/utils/type.go new file mode 100644 index 0000000..394afbc --- /dev/null +++ b/pub/models/utils/type.go @@ -0,0 +1,18 @@ +package utils + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// @table:generate_id +type GenerateId struct { + Id primitive.ObjectID `bson:"_id"` + UpdateAt time.Time `bson:"updateAt"` + BusinessId string `bson:"businessId"` + MaxId int64 `bson:"maxId"` + Step int64 `bson:"step"` + Desc string `bson:"desc"` + CreateAt time.Time `bson:"createAt"` +} diff --git a/pub/models/videocategorymod/category.go b/pub/models/videocategorymod/category.go new file mode 100644 index 0000000..a35d2cb --- /dev/null +++ b/pub/models/videocategorymod/category.go @@ -0,0 +1,132 @@ +package videocategorymod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoCategory) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoCategory.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCategory.ContentType, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCategory.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoCategoryIndex) Create VideoCategory Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoCategory 插入一个渠道统计 +func InsertOneVideoCategory(ctx context.Context, qa *VideoCategory) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoCategorymod:InsertOneVideoCategory db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoCategory 删除一个渠道统计 +func DelOneVideoCategory(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoCategorymod:DelOneVideoCategory db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoCategory 查找一个渠道统计 +func FindOneVideoCategory(ctx context.Context, filter bson.M) ( + *VideoCategory, *srvdef.SrvErr) { + var VideoCategory VideoCategory + if err := db.FindOneEx(ctx, &VideoCategory, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoCategorymod:FindOneVideoCategory db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoCategory, nil +} + +func FindManyVideoCategorys(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoCategoryList, *srvdef.SrvErr) { + data := make(VideoCategoryList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoCategorys", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoCategorys 统计渠道统计数量 +func CountVideoCategorys(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoCategorys db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoCategory 更新一个渠道统计 +func UpdateOneVideoCategory(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func UpdateManyVideoCategory(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/videocategorymod/tag.go b/pub/models/videocategorymod/tag.go new file mode 100755 index 0000000..cb37ff8 --- /dev/null +++ b/pub/models/videocategorymod/tag.go @@ -0,0 +1,46 @@ +package videocategorymod + +type TagVideoCategory struct { + ContentType string + CreatedAt string + Desc string + Id string + IndexNow string + Name string + Rank string + SeoInfo struct { + Description string + Keyworld string + Title string + } + SortType string + Status string + Title string + UpdatedAt string +} + +var FN struct { + VideoCategory TagVideoCategory +} + +var TN struct { + VideoCategory string +} + +func init() { + FN.VideoCategory.ContentType = "contentType" + FN.VideoCategory.CreatedAt = "createdAt" + FN.VideoCategory.Desc = "desc" + FN.VideoCategory.Id = "_id" + FN.VideoCategory.IndexNow = "indexNow" + FN.VideoCategory.Name = "name" + FN.VideoCategory.Rank = "rank" + FN.VideoCategory.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoCategory.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoCategory.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoCategory.SortType = "sortType" + FN.VideoCategory.Status = "status" + FN.VideoCategory.Title = "title" + FN.VideoCategory.UpdatedAt = "updatedAt" + TN.VideoCategory = "video_category" +} \ No newline at end of file diff --git a/pub/models/videocategorymod/type.go b/pub/models/videocategorymod/type.go new file mode 100644 index 0000000..4e9042b --- /dev/null +++ b/pub/models/videocategorymod/type.go @@ -0,0 +1,30 @@ +package videocategorymod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// @table:video_category +type VideoCategory struct { + Id int64 `json:"id" bson:"_id,omitempty"` // Id + Name string `json:"name" bson:"name"` // 分类名称 + Title string `json:"title" bson:"title"` // APP显示 + Desc string `json:"desc" bson:"desc"` // 描述 + Status bool `json:"status" bson:"status"` // 上架状态 + ContentType int64 `json:"contentType" bson:"contentType"` // 内容:0: 默认 1:首页 2:今日 3:合集 + SortType int64 `json:"sortType" bson:"sortType"` // 排序方式 0:上线时间 1:观看次数 2排序 + Rank int64 `json:"rank" bson:"rank"` // 排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoCategoryList []*VideoCategory +type VideoSortType int32 + +const ( + VideoSortTypeAddTime VideoSortType = 0 // 上线时间 + VideoSortTypeWatchs VideoSortType = 1 // 观看次数 + VideoSortTypeRank VideoSortType = 2 // 视频排序 +) \ No newline at end of file diff --git a/pub/models/videocollectionmediamod/tag.go b/pub/models/videocollectionmediamod/tag.go new file mode 100755 index 0000000..4c3f916 --- /dev/null +++ b/pub/models/videocollectionmediamod/tag.go @@ -0,0 +1,30 @@ +package videocollectionmediamod + +type TagVideoCollectionMediaModel struct { + ID string + CollectionID string + VideoId string + Status string + Rank string + CreatedAt string + UpdatedAt string +} + +var FN struct { + VideoCollectionMediaModel TagVideoCollectionMediaModel +} + +var TN struct { + VideoCollectionMediaModel string +} + +func init() { + FN.VideoCollectionMediaModel.ID = "_id" + FN.VideoCollectionMediaModel.CollectionID = "collectionId" + FN.VideoCollectionMediaModel.VideoId = "videoId" + FN.VideoCollectionMediaModel.Status = "status" + FN.VideoCollectionMediaModel.Rank = "rank" + FN.VideoCollectionMediaModel.CreatedAt = "createdAt" + FN.VideoCollectionMediaModel.UpdatedAt = "updatedAt" + TN.VideoCollectionMediaModel = "video_collection_media" +} \ No newline at end of file diff --git a/pub/models/videocollectionmediamod/type.go b/pub/models/videocollectionmediamod/type.go new file mode 100644 index 0000000..d966f1b --- /dev/null +++ b/pub/models/videocollectionmediamod/type.go @@ -0,0 +1,21 @@ +package videocollectionmediamod + +import ( + "go.mongodb.org/mongo-driver/bson/primitive" + "time" + "videoweb/pub/models/videoinformationmod" +) + +// VideoCollectionMediaModel 视频合集资源 +// @table:video_collection_media +type VideoCollectionMediaModel struct { + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` // 唯一ID + CollectionID int64 `json:"collectionId" bson:"collectionId"` // 合集ID + VideoId int64 `json:"videoId" bson:"videoId"` // 视频ID + Status videoinformationmod.InformationSatus `json:"status" bson:"status"` // 状态 + Rank int64 `json:"rank" bson:"rank"` // 排序 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 刷新时间 + +} +type VideoCollectionMediaModelList []*VideoCollectionMediaModel \ No newline at end of file diff --git a/pub/models/videocollectionmediamod/videocollection.go b/pub/models/videocollectionmediamod/videocollection.go new file mode 100644 index 0000000..dcbfcb3 --- /dev/null +++ b/pub/models/videocollectionmediamod/videocollection.go @@ -0,0 +1,154 @@ +package videocollectionmediamod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoCollectionMediaModel) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CollectionID, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CollectionID, Value: 1}, + {Key: FN.VideoCollectionMediaModel.Rank, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CollectionID, Value: 1}, + {Key: FN.VideoCollectionMediaModel.VideoId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.VideoId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoCollectionMediaModelIndex) Create VideoCollectionMediaModel Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoCollectionMediaModel 插入一个渠道统计 +func InsertOneVideoCollectionMediaModel(ctx context.Context, qa *VideoCollectionMediaModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoCollectionMediaModelmod:InsertOneVideoCollectionMediaModel db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoCollectionMediaModel 删除一个渠道统计 +func DelOneVideoCollectionMediaModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoCollectionMediaModelmod:DelOneVideoCollectionMediaModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} +func DelManyVideoCollectionMediaModel(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("VideoCollectionMediaModelmod:DelManyVideoCollectionMediaModel db.DeleteMany failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoCollectionMediaModel 查找一个渠道统计 +func FindOneVideoCollectionMediaModel(ctx context.Context, filter bson.M) ( + *VideoCollectionMediaModel, *srvdef.SrvErr) { + var VideoCollectionMediaModel VideoCollectionMediaModel + if err := db.FindOneEx(ctx, &VideoCollectionMediaModel, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoCollectionMediaModelmod:FindOneVideoCollectionMediaModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoCollectionMediaModel, nil +} + +func FindManyVideoCollectionMediaModels(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoCollectionMediaModelList, *srvdef.SrvErr) { + data := make(VideoCollectionMediaModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoCollectionMediaModels", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoCollectionMediaModels 统计渠道统计数量 +func CountVideoCollectionMediaModels(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoCollectionMediaModels db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoCollectionMediaModel 更新一个渠道统计 +func UpdateOneVideoCollectionMediaModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoCollectionMediaModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoCollectionMediaModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func InsertManyVideoCollectionMedia(ctx context.Context, list []*VideoCollectionMediaModel) *srvdef.SrvErr { + _, err := db.InsertMany(ctx, &list) + if err != nil { + log.Warn("post:InsertManyVideoCollectionMediaModel db.InsertMany failed", + log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/videocreatermod/creater.go b/pub/models/videocreatermod/creater.go new file mode 100644 index 0000000..922fe93 --- /dev/null +++ b/pub/models/videocreatermod/creater.go @@ -0,0 +1,127 @@ +package videocreatermod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoCreaterInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoCreaterInfo.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCreaterInfo.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoCreaterInfoIndex) Create VideoCreaterInfo Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoCreaterInfo 插入一个渠道统计 +func InsertOneVideoCreaterInfo(ctx context.Context, qa *VideoCreaterInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + log.Warn("VideoCreaterInfomod:InsertOneVideoCreaterInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoCreaterInfo 删除一个渠道统计 +func DelOneVideoCreaterInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoCreaterInfomod:DelOneVideoCreaterInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoCreaterInfo 查找一个渠道统计 +func FindOneVideoCreaterInfo(ctx context.Context, filter bson.M) ( + *VideoCreaterInfo, *srvdef.SrvErr) { + var VideoCreaterInfo VideoCreaterInfo + if err := db.FindOneEx(ctx, &VideoCreaterInfo, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoCreaterInfomod:FindOneVideoCreaterInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoCreaterInfo, nil +} + +func FindManyVideoCreaterInfos(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoCreaterInfoList, *srvdef.SrvErr) { + data := make(VideoCreaterInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoCreaterInfos", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoCreaterInfos 统计渠道统计数量 +func CountVideoCreaterInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoCreaterInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoCreaterInfo 更新一个渠道统计 +func UpdateOneVideoCreaterInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoCreaterInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoCreaterInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func UpdateManyVideoCreaterInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoCreaterInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/videocreatermod/tag.go b/pub/models/videocreatermod/tag.go new file mode 100755 index 0000000..21762f3 --- /dev/null +++ b/pub/models/videocreatermod/tag.go @@ -0,0 +1,38 @@ +package videocreatermod + +type TagVideoCreaterInfo struct { + CreatedAt string + Id string + IndexNow string + Name string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Sort string + Status string + UpdatedAt string +} + +var FN struct { + VideoCreaterInfo TagVideoCreaterInfo +} + +var TN struct { + VideoCreaterInfo string +} + +func init() { + FN.VideoCreaterInfo.CreatedAt = "createdAt" + FN.VideoCreaterInfo.Id = "_id" + FN.VideoCreaterInfo.IndexNow = "indexNow" + FN.VideoCreaterInfo.Name = "name" + FN.VideoCreaterInfo.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoCreaterInfo.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoCreaterInfo.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoCreaterInfo.Sort = "sort" + FN.VideoCreaterInfo.Status = "status" + FN.VideoCreaterInfo.UpdatedAt = "updatedAt" + TN.VideoCreaterInfo = "video_creater" +} \ No newline at end of file diff --git a/pub/models/videocreatermod/type.go b/pub/models/videocreatermod/type.go new file mode 100644 index 0000000..951bbed --- /dev/null +++ b/pub/models/videocreatermod/type.go @@ -0,0 +1,19 @@ +package videocreatermod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// @table:video_creater +type VideoCreaterInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Name string `json:"name" bson:"name"` // 帖子标题 + Status bool `json:"status" bson:"status"` // 上架状态 + Sort int64 `json:"sort" bson:"sort"` // 排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoCreaterInfoList []*VideoCreaterInfo \ No newline at end of file diff --git a/pub/models/videoinformationmod/information.go b/pub/models/videoinformationmod/information.go new file mode 100644 index 0000000..2d40e81 --- /dev/null +++ b/pub/models/videoinformationmod/information.go @@ -0,0 +1,248 @@ +package videoinformationmod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoInformation) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CategoryIds, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Tags, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatorId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.CategoryIds, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Video.FileId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.CategoryIds, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatorId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "nodes.nodeId", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatorId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Tags, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.IsTop, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.IsTop, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoInformationIndex) Create VideoInformation Index failed! err:%+v:", err)) + } +} + +// InsertOneVideoInformation 插入一个渠道统计 +func InsertOneVideoInformation(ctx context.Context, qa *VideoInformation) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoInformationmod:InsertOneVideoInformation db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} +func InsertManyVideoInformation(ctx context.Context, qa []*VideoInformation) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, &qa); err != nil { + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoInformation 删除一个渠道统计 +func DelOneVideoInformation(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoInformationmod:DelOneVideoInformation db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoInformation 查找一个渠道统计 +func FindOneVideoInformation(ctx context.Context, filter bson.M) ( + *VideoInformation, *srvdef.SrvErr) { + var VideoInformation VideoInformation + if err := db.FindOneEx(ctx, &VideoInformation, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoInformationmod:FindOneVideoInformation db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoInformation, nil +} + +func FindManyVideoInformations(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoInformationList, *srvdef.SrvErr) { + data := make(VideoInformationList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoInformations", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoInformations 统计渠道统计数量 +func CountVideoInformations(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoInformations db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoInformation 更新一个渠道统计 +func UpdateOneVideoInformation(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoInformation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoInformation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func UpdateManyVideoInformation(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoInformation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/videoinformationmod/tag.go b/pub/models/videoinformationmod/tag.go new file mode 100755 index 0000000..839afa2 --- /dev/null +++ b/pub/models/videoinformationmod/tag.go @@ -0,0 +1,100 @@ +package videoinformationmod + +type TagVideoInformation struct { + AddedTime string + CategoryIds string + CoverImg string + CreatedAt string + CreatorId string + CreatorName string + Desc string + FirstTag string + Id string + IndexNow string + IsHot string + IsTop string + Keyworld string + MediaType string + Nodes string + Real struct { + Comments string + MonthWatchs string + ShowTimes string + TodayWatchs string + WatchTimes string + WeekWatchs string + } + Remark string + SecondTags string + Sort string + Status string + Tags string + Title string + UpdatedAt string + PageUpdatedAt string + Video struct { + Cover string + FileId string + Height string + PreMp4FileNameFs string + TaskId string + Url string + VideoTime string + Width string + UnencryptedPreMp4 string + } + VideoCount string + UnencryptedImage string +} + +var FN struct { + VideoInformation TagVideoInformation +} + +var TN struct { + VideoInformation string +} + +func init() { + FN.VideoInformation.AddedTime = "addedTime" + FN.VideoInformation.CategoryIds = "categoryIds" + FN.VideoInformation.CoverImg = "coverImg" + FN.VideoInformation.CreatedAt = "createdAt" + FN.VideoInformation.CreatorId = "creatorId" + FN.VideoInformation.CreatorName = "creatorName" + FN.VideoInformation.Desc = "desc" + FN.VideoInformation.FirstTag = "firstTag" + FN.VideoInformation.Id = "_id" + FN.VideoInformation.IndexNow = "indexNow" + FN.VideoInformation.IsHot = "isHot" + FN.VideoInformation.IsTop = "isTop" + FN.VideoInformation.Keyworld = "keyworld" + FN.VideoInformation.MediaType = "mediaType" + FN.VideoInformation.Nodes = "nodes" + FN.VideoInformation.Real.Comments = "real.comments" + FN.VideoInformation.Real.MonthWatchs = "real.monthWatchs" + FN.VideoInformation.Real.ShowTimes = "real.showTimes" + FN.VideoInformation.Real.TodayWatchs = "real.todayWatchs" + FN.VideoInformation.Real.WatchTimes = "real.watchTimes" + FN.VideoInformation.Real.WeekWatchs = "real.weekWatchs" + FN.VideoInformation.Remark = "remark" + FN.VideoInformation.SecondTags = "secondTags" + FN.VideoInformation.Sort = "sort" + FN.VideoInformation.Status = "status" + FN.VideoInformation.Tags = "tags" + FN.VideoInformation.Title = "title" + FN.VideoInformation.UpdatedAt = "updatedAt" + FN.VideoInformation.PageUpdatedAt = "pageUpdatedAt" + FN.VideoInformation.Video.Cover = "video.cover" + FN.VideoInformation.Video.FileId = "video.fileId" + FN.VideoInformation.Video.Height = "video.height" + FN.VideoInformation.Video.PreMp4FileNameFs = "video.preMp4FileNameFs" + FN.VideoInformation.Video.TaskId = "video.taskId" + FN.VideoInformation.Video.Url = "video.url" + FN.VideoInformation.Video.VideoTime = "video.videoTime" + FN.VideoInformation.Video.Width = "video.width" + FN.VideoInformation.Video.UnencryptedPreMp4 = "video.unencryptedPreMp4" + FN.VideoInformation.VideoCount = "videoCount" + FN.VideoInformation.UnencryptedImage = "unencryptedImage" + TN.VideoInformation = "video_information" +} diff --git a/pub/models/videoinformationmod/type.go b/pub/models/videoinformationmod/type.go new file mode 100644 index 0000000..b340df8 --- /dev/null +++ b/pub/models/videoinformationmod/type.go @@ -0,0 +1,85 @@ +package videoinformationmod + +import ( + "time" +) + +// @table:video_information +type VideoInformation struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + MediaType MediaType `json:"mediaType" bson:"mediaType"` // 资源类型 + AddedTime time.Time `json:"addedTime" bson:"addedTime"` // 帖子上架时间 + CreatorId int64 `json:"creatorId" bson:"creatorId"` // 作者Id + CreatorName string `json:"creatorName" bson:"creatorName"` // 作者名称 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字 + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + UnencryptedImage string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Nodes []Node `json:"nodes" bson:"nodes"` // 节点 + Status InformationSatus `json:"status" bson:"status"` // 帖子上架状态 + Video VideoInfo `json:"video" bson:"video"` // 视频 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类Id + Tags []int64 `json:"tags" bson:"tags"` // 标签 + IsHot bool `json:"isHot" bson:"isHot"` // 是否热门 + Sort int64 `json:"sort" bson:"sort"` // 排序 + Real Flow `json:"real" bson:"real"` // 真实流量 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + PageUpdatedAt time.Time `json:"pageUpdatedAt" bson:"pageUpdatedAt"` // 页面展示更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + IsTop bool `json:"isTop" bson:"isTop"` // 是否置顶 +} +type VideoInformationList []*VideoInformation + +type Node struct { + NodeId string `json:"nodeId" bson:"nodeId"` // 节点ID + Title string `bson:"title" json:"title"` // 节点标题 + Videos []VideoInfo `json:"videos" bson:"videos"` // 视频信息列表 + Images []string `json:"images" bson:"images"` // 图片 + Url string `json:"url" bson:"url"` // 链接 + Text string `json:"text" bson:"text"` // 文本 +} + +type VideoInfo struct { + TaskId string `json:"taskId" bson:"taskId"` // 视频ID(视频才会存在) + FileId string `json:"fileId" bson:"fileId"` // 媒资库ID + Url string `json:"url" bson:"url"` // 视频URl + Cover string `json:"cover" bson:"cover"` // 视频封面 + Height int64 `json:"height" bson:"height"` // 分辨率高(视频才会存在) + Width int64 `json:"width" bson:"width"` // 分辨率宽(视频才会存在) + VideoTime int32 `bson:"videoTime" json:"videoTime"` // 影片长度 + PreMp4FileNameFs string `bson:"preMp4FileNameFs,omitempty" json:"preMp4FileNameFs"` // 预览MP4文件 + UnencryptedPreMp4 string `bson:"unencryptedPreMp4,omitempty" json:"unencryptedPreMp4"` // 不加密的预览MP4 + VttFile string `bson:"vttFile" json:"vttFile"` // 雪碧图 + TotalFrames int64 `bson:"totalFrames,omitempty" json:"totalFrames"` // 总帧数 +} + +type InformationSatus int32 + +const ( + InformationSatusWaiting InformationSatus = 0 // 初始化 + InformationSatusVerifyOK InformationSatus = 1 // 上架 + InformationSatusVerifyCancel InformationSatus = 2 // 下架 +) + +type Flow struct { + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 观看次数 + ShowTimes int64 `json:"showTimes" bson:"showTimes"` // 展示次数 + Comments int64 `json:"comments" bson:"comments"` // 评论条数 + WeekWatchs int64 `json:"weekWatchs" bson:"weekWatchs"` // 本周观看次数 + MonthWatchs int64 `json:"monthWatchs" bson:"monthWatchs"` // 本月观看次数 + TodayWatchs int64 `json:"todayWatchs" bson:"todayWatchs"` // 当日观看次数 +} + +type MediaType int64 + +const ( + MediaType_Video MediaType = 0 // 视频 + MediaType_Collection MediaType = 1 // 合集 + MediaType_Advitise MediaType = 2 // 广告 +) diff --git a/pub/models/videotagmod/tag.go b/pub/models/videotagmod/tag.go new file mode 100755 index 0000000..58f2adf --- /dev/null +++ b/pub/models/videotagmod/tag.go @@ -0,0 +1,38 @@ +package videotagmod + +type TagVideoTagInfo struct { + CreatedAt string + Id string + IndexNow string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Sort string + Status string + Title string + UpdatedAt string +} + +var FN struct { + VideoTagInfo TagVideoTagInfo +} + +var TN struct { + VideoTagInfo string +} + +func init() { + FN.VideoTagInfo.CreatedAt = "createdAt" + FN.VideoTagInfo.Id = "_id" + FN.VideoTagInfo.IndexNow = "indexNow" + FN.VideoTagInfo.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoTagInfo.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoTagInfo.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoTagInfo.Sort = "sort" + FN.VideoTagInfo.Status = "status" + FN.VideoTagInfo.Title = "title" + FN.VideoTagInfo.UpdatedAt = "updatedAt" + TN.VideoTagInfo = "video_taginfo" +} \ No newline at end of file diff --git a/pub/models/videotagmod/taginfo.go b/pub/models/videotagmod/taginfo.go new file mode 100644 index 0000000..910622d --- /dev/null +++ b/pub/models/videotagmod/taginfo.go @@ -0,0 +1,132 @@ +package videotagmod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoTagInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Status, Value: 1}, + {Key: FN.VideoTagInfo.Sort, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoTagInfoIndex) Create VideoTagInfo Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoTagInfo 插入一个渠道统计 +func InsertOneVideoTagInfo(ctx context.Context, qa *VideoTagInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoTagInfomod:InsertOneVideoTagInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoTagInfo 删除一个渠道统计 +func DelOneVideoTagInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoTagInfomod:DelOneVideoTagInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoTagInfo 查找一个渠道统计 +func FindOneVideoTagInfo(ctx context.Context, filter bson.M) ( + *VideoTagInfo, *srvdef.SrvErr) { + var VideoTagInfo VideoTagInfo + if err := db.FindOneEx(ctx, &VideoTagInfo, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoTagInfomod:FindOneVideoTagInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoTagInfo, nil +} + +func FindManyVideoTagInfos(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoTagInfoList, *srvdef.SrvErr) { + data := make(VideoTagInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoTagInfos", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoTagInfos 统计渠道统计数量 +func CountVideoTagInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoTagInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoTagInfo 更新一个渠道统计 +func UpdateOneVideoTagInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoTagInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoTagInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} +func UpdateManyVideoTagInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoTagInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/videotagmod/type.go b/pub/models/videotagmod/type.go new file mode 100644 index 0000000..5685931 --- /dev/null +++ b/pub/models/videotagmod/type.go @@ -0,0 +1,19 @@ +package videotagmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// @table:video_taginfo +type VideoTagInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + Status bool `json:"status" bson:"status"` // 上架状态 + Sort int64 `json:"sort" bson:"sort"` // 排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoTagInfoList []*VideoTagInfo \ No newline at end of file diff --git a/pub/mongos/QueryBuilder.go b/pub/mongos/QueryBuilder.go new file mode 100644 index 0000000..9cdb5df --- /dev/null +++ b/pub/mongos/QueryBuilder.go @@ -0,0 +1,150 @@ +package mongos + +import ( + "go.mongodb.org/mongo-driver/bson" +) + +type QueryBuilder struct { + query bson.M +} + +func NewQueryBuilder() *QueryBuilder { + return &QueryBuilder{query: bson.M{}} +} + +func (q *QueryBuilder) Build() bson.M { + return q.query +} +func (q *QueryBuilder) addCondition(condition bool, field string, value interface{}) { + if condition { + q.query[field] = value + } +} + +func (q *QueryBuilder) Eq(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = value + } + return q +} + +func (q *QueryBuilder) Ne(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$ne": value} + } + return q +} + +func (q *QueryBuilder) Or(conditions ...bson.M) *QueryBuilder { + if len(conditions) > 0 { + q.query["$or"] = conditions + } + return q +} + +func (q *QueryBuilder) Set(value bson.M) *QueryBuilder { + q.query["$set"] = value + return q +} + +func (q *QueryBuilder) Gt(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$gt": value} + } + return q +} + +func (q *QueryBuilder) Gte(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$gte": value} + } + return q +} + +func (q *QueryBuilder) Lt(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$lt": value} + } + return q +} + +func (q *QueryBuilder) Lte(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$lte": value} + } + return q +} + +func (q *QueryBuilder) In(field string, values *[]int64, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$in": *values} + } + return q +} + +func (q *QueryBuilder) Nin(field string, values []interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$nin": values} + } + return q +} + +func (q *QueryBuilder) Regex(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"regex": value.(string)} + } + return q +} + +func (q *QueryBuilder) All(field string, values []interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$all": values} + } + return q +} + +func (q *QueryBuilder) ElemMatch(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$elemMatch": value} + } + return q +} + +func (q *QueryBuilder) Size(field string, value int, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$size": value} + } + return q +} + +func (q *QueryBuilder) Exists(field string, value bool, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$exists": value} + } + return q +} + +func (q *QueryBuilder) Mod(field string, divisor, remainder int, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$mod": []int{divisor, remainder}} + } + return q +} + +func (q *QueryBuilder) GeoWithin(field string, geometry interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$geoWithin": bson.M{ + "$geometry": geometry, + }} + } + return q +} + +func (q *QueryBuilder) GeoIntersects(field string, geometry interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$geoIntersects": bson.M{ + "$geometry": geometry, + }} + } + return q +} diff --git a/pub/mongos/base_mod.go b/pub/mongos/base_mod.go new file mode 100644 index 0000000..5b402c7 --- /dev/null +++ b/pub/mongos/base_mod.go @@ -0,0 +1,582 @@ +package mongos + +import ( + "context" + "errors" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + + "reflect" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type Collection struct { + name string + coll *mongo.Collection +} + +func (b *Collection) FindOne(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOneOptions) error { + err := b.coll.FindOne(ctx, query, opts...).Decode(bind) + if err != nil && err == mongo.ErrNoDocuments { + return nil + } + return err +} + +func (b *Collection) FindOneEx(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOneOptions) error { + err := b.coll.FindOne(ctx, query, opts...).Decode(bind) + return err +} + +func (b *Collection) FindMOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update interface{}, opts ...*options.FindOneAndUpdateOptions) error { + setReturn := false + for _, opt := range opts { + if opt != nil && opt.ReturnDocument != nil { + setReturn = true + break + } + } + if !setReturn { + after := options.After + opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) + } + um := utils.Struct2Map(update) + // um["update_at"] = time.Now() + // set := bson.M{ + // "$set": um, + // } + result := b.coll.FindOneAndUpdate(ctx, query, um, opts...) + err := result.Decode(bind) + return err +} + +func (b *Collection) FindMOneAndUpdateInc(ctx context.Context, bind interface{}, query bson.M, update interface{}, inc interface{}, opts ...*options.FindOneAndUpdateOptions) error { + um := utils.Struct2Map(update) + um["update_at"] = time.Now() + im := utils.Struct2Map(inc) + set := bson.M{ + "$set": um, + } + if im != nil { + set["$inc"] = inc + } + + setReturn := false + for _, opt := range opts { + if opt != nil && opt.ReturnDocument != nil { + setReturn = true + break + } + } + if !setReturn { + after := options.After + opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) + } + + result := b.coll.FindOneAndUpdate(ctx, query, set, opts...) + err := result.Decode(bind) + return err +} + +// func (b *Collection) FindOneAndUpdateTiny( +// ctx context.Context, +// query interface{}, +// update bson.M, +// opts ...*options.FindOneAndUpdateOptions) error { +// result := b.collection().FindOneAndUpdate(ctx, query, update, opts...) +// return result.Err() +// } + +// func (b *MongoTool) FindOneAndUpdateReturnTiny(ctx context.Context, +// bind interface{}, +// query bson.M, +// update bson.M, +// afterDoc bool, +// opts ...*options.FindOneAndUpdateOptions) error { + +// if afterDoc { +// opts = append(opts, options.FindOneAndUpdate().SetReturnDocument(options.After)) +// } else { +// opts = append(opts, options.FindOneAndUpdate().SetReturnDocument(options.Before)) +// } + +// result := b.collection().FindOneAndUpdate(ctx, query, update, opts...).Decode(bind) +// return result +// } + +// func (b *Collection) FindOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) error { +// setReturn := false +// for _, opt := range opts { +// if opt != nil && opt.ReturnDocument != nil { +// setReturn = true +// break +// } +// } +// if !setReturn { +// after := options.After +// opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) +// } +// b.setUpdatedAt(update, time.Now()) +// result := b.collection().FindOneAndUpdate(ctx, query, update, opts...) +// err := result.Decode(bind) +// return err +// } + +func (b *Collection) Find(ctx context.Context, bind interface{}, query bson.M, opt ...*options.FindOptions) error { + err := b.validSlice(bind) + cur, err := b.coll.Find(ctx, query, opt...) + if err != nil && err == mongo.ErrNoDocuments { + return nil + } else if err != nil { + return err + } + err = cur.All(ctx, bind) + return err +} + +// func (b *Collection) FindByPage(ctx context.Context, bind interface{}, query bson.M, pageAndSort *PageAndSort, opts ...*options.FindOptions) error { +// err := b.validSlice(bind) + +// if err != nil { +// return err +// } + +// opt := &options.FindOptions{} +// if pageAndSort == nil { +// return errors.New("page must not nil") +// } + +// if pageAndSort.Page < 0 || pageAndSort.PageSize == 0 { +// return errors.New("page and PageSize must not nil") +// } + +// skip := pageAndSort.Page * pageAndSort.PageSize +// opt.Skip = &skip +// opt.Limit = &pageAndSort.PageSize +// if pageAndSort.Sort != nil { +// opt.Sort = pageAndSort.Sort +// } +// opts = append(opts, opt) +// cur, err := b.collection().Find(ctx, query, opts...) +// if err != nil && err == mongo.ErrNoDocuments { +// return nil +// } else if err != nil { +// return err +// } +// err = cur.All(ctx, bind) +// return err + +// } + +func (b *Collection) FindOneAndDelete(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOneAndDeleteOptions) error { + result := b.coll.FindOneAndDelete(ctx, query, opts...) + if result.Err() != nil { + return result.Err() + } + return result.Decode(bind) +} + +func (b *Collection) InsertOneTiny(ctx context.Context, doc interface{}) (err error) { + _, err = b.coll.InsertOne(ctx, doc) + return +} + +func (b *Collection) InsertOne(ctx context.Context, doc interface{}) (int, error) { + result, err := b.coll.InsertOne(ctx, doc) + if err != nil { + // log.Info("Collection:InsertOne failed", log.Any("colName", b.name), log.E(err)) + return 0, err + } + if _, ok := result.InsertedID.(primitive.ObjectID); ok { + return 1, err + } + return 0, err +} +func (b *Collection) IDInsertOne(ctx context.Context, doc interface{}) (string, error) { + result, err := b.coll.InsertOne(ctx, doc) + if err != nil { + log.Info("Collection:InsertOne failed", log.Any("colName", b.name), log.E(err)) + return "", err + } + if id, ok := result.InsertedID.(primitive.ObjectID); ok { + return id.Hex(), err + } + return "", err +} + +func (b *Collection) validSlice(bind interface{}) error { + if reflect.TypeOf(bind).Kind() != reflect.Ptr { + return errors.New("interface must be a Ptr") + } + if reflect.TypeOf(bind).Elem().Kind() != reflect.Slice { + return errors.New("interface must be a slice") + } + return nil +} + +func (b *Collection) InsertMany(ctx context.Context, docs interface{}) ([]interface{}, error) { + err := b.validSlice(docs) + if err != nil { + return nil, err + } + res := b.setTimeForSlice(docs) + result, err := b.coll.InsertMany(ctx, res) + if err != nil { + return nil, err + } + return result.InsertedIDs, err +} + +// struct方式 +func (b *Collection) UpdateMOne(ctx context.Context, query bson.M, update interface{}) (*mongo.UpdateResult, error) { + um := utils.Struct2Map(update) + um["update_at"] = time.Now() + set := bson.M{ + "$set": um, + } + return b.coll.UpdateOne(ctx, query, set) +} + +func (b *Collection) UpdateMOneInc(ctx context.Context, query bson.M, update interface{}, inc interface{}) (*mongo.UpdateResult, error) { + um := utils.Struct2Map(update) + um["update_at"] = time.Now() + set := bson.M{ + "$set": um, + } + im := utils.Struct2Map(inc) + if im != nil { + set["$inc"] = im + } + return b.coll.UpdateOne(ctx, query, set) +} + +// 原生bson.M 方式 +func (b *Collection) UpdateOne(ctx context.Context, query bson.M, update bson.M, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error) { + //b.setUpdatedAt(update, time.Now()) + return b.coll.UpdateOne(ctx, query, update, opts...) +} + +func (b *Collection) UpsertOne(ctx context.Context, query bson.M, update bson.M) (*mongo.UpdateResult, error) { + opt := &options.UpdateOptions{} + opt.SetUpsert(true) + //b.setDeaultTime(update) + result, err := b.coll.UpdateOne(ctx, query, update, opt) + return result, err + +} + +func (b *Collection) UpdateMMany(ctx context.Context, query bson.M, update interface{}) (*mongo.UpdateResult, error) { + // b.setUpdatedAt(update, time.Now()) + return b.coll.UpdateMany(ctx, query, update) +} + +func (b *Collection) UpdateMany(ctx context.Context, query bson.M, update bson.M) (*mongo.UpdateResult, error) { + //b.setUpdatedAt(update, time.Now()) + return b.coll.UpdateMany(ctx, query, update) +} + +// func (b *Collection) DeleteById(ctx context.Context, id interface{}) (int64, error) { + +// param := bson.M{"_id": id} +// result, err := b.collection().DeleteOne(ctx, param) +// if err != nil { +// return 0, err +// } +// return result.DeletedCount, nil + +// } + +func (b *Collection) DeleteOne(ctx context.Context, query bson.M, opts ...*options.DeleteOptions) (int64, error) { + result, err := b.coll.DeleteOne(ctx, query) + if err != nil { + return 0, err + } + return result.DeletedCount, nil +} + +func (b *Collection) DeleteMany(ctx context.Context, query bson.M, opts ...*options.DeleteOptions) (int64, error) { + result, err := b.coll.DeleteMany(ctx, query, opts...) + if err != nil { + return 0, err + } + deletedCount := result.DeletedCount + return deletedCount, nil + +} + +func (b *Collection) Distince(ctx context.Context, filter bson.M, fieldName string) ([]interface{}, error) { + return b.coll.Distinct(ctx, fieldName, filter) +} + +func (b *Collection) Aggregate(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) error { + + err := b.validSlice(bind) + if err != nil { + return err + } + //bs, _ := json.Marshal(pipeline) + //log.Debug(string(bs)) + cur, err := b.coll.Aggregate(ctx, pipeline, opts...) + if err != nil { + return err + } + return cur.All(ctx, bind) +} + +func (b *Collection) ObjectAggregate(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) error { + cur, err := b.coll.Aggregate(ctx, pipeline, opts...) + if err != nil { + return err + } + if cur.Next(ctx) { + return cur.Decode(bind) + } + return nil +} + +func (b *Collection) QueryOne(ctx context.Context, bind interface{}, query interface{}, opts ...*options.FindOneOptions) error { + err := b.coll.FindOne(ctx, query, opts...).Decode(bind) + if err != nil && err == mongo.ErrNoDocuments { + return nil + } + return err +} + +func (b *Collection) Count(ctx context.Context, query interface{}, opts ...*options.CountOptions) (int64, error) { + return b.coll.CountDocuments(ctx, query, opts...) +} + +func (b *Collection) EstimatedCount(ctx context.Context, opts ...*options.EstimatedDocumentCountOptions) (int64, error) { + return b.coll.EstimatedDocumentCount(ctx, opts...) +} + +func (b *Collection) BulkWrite(ctx context.Context, models []mongo.WriteModel, opts ...*options.BulkWriteOptions) (*mongo.BulkWriteResult, error) { + return b.coll.BulkWrite(ctx, models, opts...) +} + +// CreateIndex 创建数据索引.options 在index里面创建 +func (b *Collection) CreateIndex(ctx context.Context, models []mongo.IndexModel) ([]string, error) { + data, err := b.coll.Indexes().CreateMany(ctx, models) + if err != nil { + if strings.Contains(err.Error(), "IndexKeySpecsConflict") { + return data, nil + } + if strings.Contains(err.Error(), "IndexOptionsConflict") { + return data, nil + } + return data, err + } + return data, nil +} + +// DropIndex 删除数据索引.options 在index里面创建 +// func (b *Collection) DropIndex(ctx context.Context, indexName string) error { +// _, err := b.coll.Indexes().DropOne(ctx, indexName) +// if err != nil { +// return err +// } +// return nil +// } + +// func (b *MongoTool) addCreatedAt(val interface{}) { +// if _, ok := val.(bson.M); ok { +// return +// } +// if _, ok := val.(map[string]interface{}); ok { +// return +// } +// v := reflect.ValueOf(val).Elem().FieldByName("CreatedAt") +// if v.IsValid() { +// v.Set(reflect.ValueOf(time.Now())) +// } + +// } +// func (b *MongoTool) addUpdatedAt(val interface{}) { +// if _, ok := val.(bson.M); ok { +// return +// } +// if _, ok := val.(map[string]interface{}); ok { +// return +// } +// v := reflect.ValueOf(val).Elem().FieldByName("UpdatedAt") +// if v.IsValid() { +// v.Set(reflect.ValueOf(time.Now())) +// } +// } + +func (b *Collection) setTimeForSlice(docs interface{}) []interface{} { + arr := reflect.ValueOf(docs).Elem() + result := make([]interface{}, 0) + for i := 0; i < arr.Len(); i++ { + ele := arr.Index(i).Elem() + now := time.Now() + if ma := ele.FieldByName("update_at"); ma.IsValid() { + ma.Set(reflect.ValueOf(now)) + } + if ca := ele.FieldByName("create_at"); ca.IsValid() { + ca.Set(reflect.ValueOf(now)) + } + result = append(result, ele.Interface()) + } + return result +} + +// func (b *Collection) validSlice(bind interface{}) error { + +// if reflect.TypeOf(bind).Kind() != reflect.Ptr { +// return errors.New("interface must be a Ptr") +// } +// if reflect.TypeOf(bind).Elem().Kind() != reflect.Slice { +// return errors.New("interface must be a slice") +// } +// return nil +// } + +// func (b *Collection) setUpdatedAt(doc bson.M, now time.Time) { +// set, exists := doc["$set"] +// if !exists { +// doc["$set"] = bson.M{"updatedAt": now} +// return +// } + +// if res, ok := set.(bson.M); ok { +// res["updatedAt"] = now +// } +// } + +// func (b *Collection) setCreatedAt(doc bson.M, now time.Time) { +// set, exists := doc["$set"] +// if !exists { +// doc["$set"] = bson.M{"createdAt": now} +// return +// } +// if res, ok := set.(bson.M); ok { +// res["createdAt"] = now +// } +// } + +// func (b *Collection) setDeaultTime(doc bson.M) { +// now := time.Now() +// set, exists := doc["$set"] +// if exists { +// if res, ok := set.(bson.M); ok { +// delete(res, "createdAt") +// res["updatedAt"] = now +// if d, ok := doc["$setOnInsert"]; ok { +// if ss, ok := d.(bson.M); ok { +// if _, ok := ss["createdAt"]; !ok { +// ss["createdAt"] = now +// } +// } +// } else { +// doc["$setOnInsert"] = bson.M{"createdAt": now} +// } +// return +// } +// if res, ok := set.(interface{}); ok { +// struct2Map := utils.Struct2Map(res) +// delete(struct2Map, "createdAt") +// struct2Map["updatedAt"] = now +// doc["$set"] = struct2Map +// doc["$setOnInsert"] = bson.M{"createdAt": now} +// return +// } +// } +// } + +// //////////////////////////////////////////// +func (b *Collection) GetStringIDInsertOne(ctx context.Context, doc interface{}) (string, error) { + result, err := b.coll.InsertOne(ctx, doc) + if err != nil { + return "", err + } + if id, ok := result.InsertedID.(primitive.ObjectID); ok { + return id.Hex(), nil + } + return "", err +} + +// InsertOne 插入单条信息 +func (b *Collection) GetObjectIDInsertOne(ctx context.Context, document interface{}, opts ...*options.InsertOneOptions) (*mongo.InsertOneResult, error) { + return b.coll.InsertOne(ctx, document, opts...) +} + +// UpdateOneNew 或者修改或者插入一条数据 +func (b *Collection) UpdateOneNew(ctx context.Context, query bson.M, update bson.M) (*mongo.UpdateResult, error) { + return b.coll.UpdateOne(ctx, query, update, options.Update().SetUpsert(true)) +} + +func (b *Collection) Max(ctx context.Context, fieldName string, filter bson.M) (int64, error) { + //b.coll.Aggregate(ctx, ) + type Temp struct { + Max int64 `bson:"max"` + } + var temp []Temp + query := []bson.M{ + {"$group": bson.M{ + "_id": nil, + "max": bson.M{"$max": "$" + fieldName}, + }, + }, + } + if err := b.Aggregate(ctx, &temp, query); err != nil || len(temp) <= 0 { + return 0, err + } + + return temp[0].Max, nil +} + +// func (b *Collection) setUpdatedAt(doc bson.M, now time.Time) { +// set, exists := doc["$set"] +// if !exists { +// doc["$set"] = bson.M{"updatedAt": now} +// return +// } + +// if res, ok := set.(bson.M); ok { +// res["updatedAt"] = now +// } +// } + +func (b *Collection) FindOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) error { + setReturn := false + for _, opt := range opts { + if opt != nil && opt.ReturnDocument != nil { + setReturn = true + break + } + } + if !setReturn { + after := options.After + opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) + } + // b.coll.setUpdatedAt(update, time.Now()) + result := b.coll.FindOneAndUpdate(ctx, query, update, opts...) + err := result.Decode(bind) + return err +} + +// // +// func (b *Collection) IfNotExistThenInsertAndUpdate(ctx context.Context, query bson.M, insert bson.M, update bson.M) error { +// // func (b *MongoTool) FindOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) error { +// err := b.FindOneAndUpdate(ctx, nil, query, update) +// if err != nil { +// _, err = b.InsertOne(ctx, insert) +// } +// return err +// } + +func (b *Collection) WatchOpLog(ctx context.Context, pipeline []bson.M, + opt ...*options.ChangeStreamOptions) (*mongo.ChangeStream, error) { + changeStream, err := b.coll.Watch(ctx, pipeline, opt...) + if err != nil { + return nil, err + } + return changeStream, nil +} \ No newline at end of file diff --git a/pub/mongos/codec.go b/pub/mongos/codec.go new file mode 100644 index 0000000..9d9fb30 --- /dev/null +++ b/pub/mongos/codec.go @@ -0,0 +1,119 @@ +package mongos + +import ( + "fmt" + "github.com/shopspring/decimal" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/bsoncodec" + "go.mongodb.org/mongo-driver/bson/bsonrw" + "go.mongodb.org/mongo-driver/bson/bsontype" + "go.mongodb.org/mongo-driver/bson/primitive" + "reflect" + "time" +) + +var Registry = func() *bsoncodec.Registry { + builder := bson.NewRegistryBuilder() + builder.RegisterDecoder(reflect.TypeOf(time.Time{}), &localTimeDecoder{}) + builder.RegisterDecoder(reflect.TypeOf(decimal.Decimal{}), &decimalDecoder{}) + builder.RegisterEncoder(reflect.TypeOf(decimal.Decimal{}), &decimalEncoder{}) + builder.RegisterDefaultDecoder(reflect.Float32, &floatDecoder{}) + builder.RegisterDefaultDecoder(reflect.Float64, &floatDecoder{}) + return builder.Build() +}() + +type floatDecoder struct { +} + +func (dvd *floatDecoder) DecodeValue(dc bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error { + var f float64 + var err error + switch vr.Type() { + case bsontype.Int32: + i32, err := vr.ReadInt32() + if err != nil { + return err + } + f = float64(i32) + case bsontype.Int64: + i64, err := vr.ReadInt64() + if err != nil { + return err + } + f = float64(i64) + case bsontype.Double: + f, err = vr.ReadDouble() + if err != nil { + return err + } + default: + return fmt.Errorf("cannot decode %v into a float32 or float64 type", vr.Type()) + } + val.SetFloat(f) + return nil +} + +type localTimeDecoder struct{} + +func (*localTimeDecoder) DecodeValue(dc bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error { + dvd := bsoncodec.DefaultValueDecoders{} + err := dvd.TimeDecodeValue(dc, vr, val) + if err != nil { + return err + } + t := val.Interface().(time.Time) + val.Set(reflect.ValueOf(t.Local())) + return nil +} + +type decimalDecoder struct{} + +func (*decimalDecoder) DecodeValue(dc bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error { + if !val.IsValid() || val.Type() != reflect.TypeOf(decimal.Decimal{}) { + return bsoncodec.ValueDecoderError{Name: "DecimalDecodeValue", Types: []reflect.Type{reflect.TypeOf(decimal.Decimal{})}, Received: val} + } + var ( + num bool + ) + if vr.Type() == bson.TypeInt32 || vr.Type() == bson.TypeInt64 { + vr.ReadInt32() + vr.ReadInt64() + num = true + } + if num { + d := decimal.NewFromFloat(0.0) + val.Set(reflect.ValueOf(d)) + return nil + } + + mongodecimal, err := vr.ReadDecimal128() + if err != nil { + return err + } + d, err := decimal.NewFromString(mongodecimal.String()) + if err != nil { + return err + } + val.Set(reflect.ValueOf(d)) + return nil +} + +// + +type decimalEncoder struct{} + +func (*decimalEncoder) EncodeValue(ctx bsoncodec.EncodeContext, vw bsonrw.ValueWriter, val reflect.Value) error { + if !val.IsValid() || val.Type() != reflect.TypeOf(decimal.Decimal{}) { + return bsoncodec.ValueDecoderError{Name: "DecimalEncodeValue", Types: []reflect.Type{reflect.TypeOf(decimal.Decimal{})}, Received: val} + } + + if d, ok := val.Interface().(decimal.Decimal); ok { + mongodecimal, err := primitive.ParseDecimal128(d.StringFixed(2)) + if err != nil { + return err + } + val = reflect.ValueOf(mongodecimal) + } + dve := bsoncodec.DefaultValueEncoders{} + return dve.Decimal128EncodeValue(ctx, vw, val) +} diff --git a/pub/mongos/generate_id.go b/pub/mongos/generate_id.go new file mode 100644 index 0000000..19123f9 --- /dev/null +++ b/pub/mongos/generate_id.go @@ -0,0 +1,80 @@ +package mongos + +import ( + "context" + "fmt" + "videoweb/pub/models/utils" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var Lid struct { + User *lid + MwAcc *lid + MwRole *lid + MwOpLog *lid + MwAccToken *lid + SystemMsg *lid + AdvitiseInfo *lid + AdvitiseBind *lid + AdvitiseLocation *lid + App *lid + VideoTag *lid + VideoCategory *lid + VideoInfo *lid + TransCategory *lid + Translation *lid + VideoCreator *lid + Comment *lid + HtmlText *lid +} + +func StartLid() { + + coll := Shop.Coll(utils.TN.GenerateId) + initIndex(coll) + + Lid.User = createLid(coll, "user", 10) + Lid.MwAcc = createLid(coll, "MwAcc", 1) + Lid.MwRole = createLid(coll, "MwRole", 1) + Lid.MwOpLog = createLid(coll, "MwOpLog", 10) + Lid.MwAccToken = createLid(coll, "MwAccToken", 10) + Lid.SystemMsg = createLid(coll, "SystemMsg", 10) + Lid.AdvitiseInfo = createLid(coll, "AdvitiseInfo", 10) + Lid.AdvitiseBind = createLid(coll, "AdvitiseBind", 10) + Lid.AdvitiseLocation = createLid(coll, "AdvitiseLocation", 10) + Lid.App = createLid(coll, "App", 10) + Lid.VideoTag = createLid(coll, "VideoTag", 10) + Lid.VideoCategory = createLid(coll, "VideoCategory", 10) + Lid.VideoInfo = createLid(coll, "VideoInfo", 10) + Lid.TransCategory = createLid(coll, "TransCategory", 10) + Lid.Translation = createLid(coll, "Translation", 10) + Lid.VideoCreator = createLid(coll, "VideoCreator", 10) + Lid.Comment = createLid(coll, "Comment", 10) + Lid.HtmlText = createLid(coll, "HtmlText", 10) +} + +// initIndex 初始化索引 +func initIndex(coll *Collection) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: utils.FN.GenerateId.BusinessId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + } + if _, err := coll.CreateIndex(context.Background(), indexes); err != nil { + panic(fmt.Sprintf("(user:initIndex) Create Index user failed! err:%+v:", err)) + } +} + +func createLid(db *Collection, businessId string, len int) *lid { + lid, err := newLid(db, businessId, len) + if err != nil { + panic(err) + } + return lid +} diff --git a/pub/mongos/lid.go b/pub/mongos/lid.go new file mode 100644 index 0000000..f2dd3da --- /dev/null +++ b/pub/mongos/lid.go @@ -0,0 +1,102 @@ +package mongos + +import ( + "context" + "time" + "videoweb/pub/models/utils" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +const notFindMsg = "mongo: no documents in result" + +type lid struct { + db *Collection // 数据库连接 + businessId string // 业务id + ch chan int64 // id缓冲池 + min, max int64 // id段最小值,最大值 +} + +// newLid 创建一个lid,db:数据库连接;businessId:业务id;len:缓冲池大小 +func newLid(db *Collection, businessId string, len int) (*lid, error) { + lid := lid{ + db: db, + businessId: businessId, + ch: make(chan int64, len), + } + go lid.productId() + return &lid, nil +} + +// Get 获取自增id +func (l *lid) Get() int64 { + return <-l.ch +} + +// productId 生产id +func (l *lid) productId() { + l.reset() + for { + if l.min >= l.max { + l.reset() + + } + + l.min++ + l.ch <- l.min + } +} + +// reset 不断尝试从数据库获取,直到成功 +func (l *lid) reset() { + for { + err := l.getFromDB() + if err == nil { + return + } + time.Sleep(time.Second) + continue + } +} + +func (l *lid) getFromDB() error { + var ( + max_id int64 + step int64 + //idModel IdModel + idModel utils.GenerateId + ) + businessIdfilter := bson.M{utils.FN.GenerateId.BusinessId: l.businessId} + txErr := Shop.Transaction(context.Background(), func(sctx mongo.SessionContext) (err error) { + err = l.db.FindOneEx(sctx, &idModel, businessIdfilter) + if err != nil { + if err == mongo.ErrNoDocuments { + idModel.MaxId = 10000 + _, err = l.db.InsertOne(sctx, + bson.M{ + utils.FN.GenerateId.BusinessId: l.businessId, + utils.FN.GenerateId.MaxId: 0, + utils.FN.GenerateId.Step: 10, + utils.FN.GenerateId.Desc: "id自增器", + utils.FN.GenerateId.CreateAt: time.Now(), + utils.FN.GenerateId.UpdateAt: time.Now()}) + if err != nil { + return + } + } else { + return + } + } + max_id = idModel.MaxId + step = idModel.Step + _, err = l.db.UpdateOne(sctx, businessIdfilter, bson.M{"$set": bson.M{utils.FN.GenerateId.MaxId: max_id + step}}) + if err != nil { + return + } + l.min = max_id + l.max = max_id + step + return + }) + return txErr +} diff --git a/pub/mongos/manager.go b/pub/mongos/manager.go new file mode 100644 index 0000000..ddec16e --- /dev/null +++ b/pub/mongos/manager.go @@ -0,0 +1,168 @@ +package mongos + +import ( + "context" + "errors" + "time" + "videoweb/pub/common/log" + + "go.mongodb.org/mongo-driver/mongo/readconcern" + "go.mongodb.org/mongo-driver/mongo/readpref" + "go.mongodb.org/mongo-driver/mongo/writeconcern" + "go.mongodb.org/mongo-driver/x/mongo/driver/connstring" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// var ( +// connPools *mongo.Client +// dataBase *mongo.Database +// ) + +var ( + //业务库 + Shop *Manager + //日志库 + ShopLog *Manager +) + +type Manager struct { + client *mongo.Client + //dataBase *mongo.Database + url string + db string +} + +func NewManager(url string) *Manager { + return &Manager{url: url} +} + +func (m *Manager) Database() *mongo.Database { + return m.client.Database(m.db) +} + +func (m *Manager) Coll(name string) *Collection { + coll := m.Database().Collection(name) + return &Collection{name: name, coll: coll} +} + +func (m *Manager) Init() error { + cs, err := connstring.Parse(m.url) + if err != nil { + log.Error("db connect parse err", log.E(err)) + return err + } + if cs.Database == "" { + log.Error("db connect database MUST contains") + return errors.New("database MUST contains") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(m.url)) + if err != nil { + log.Error("[warn] connect mongo db err", log.E(err)) + return err + } + if err = client.Ping(ctx, nil); err != nil { + log.Error("[warn] ping mongo db err", log.E(err)) + return err + } + + //connPools = client + m.client = client + m.db = cs.Database + + log.Info("mongos:Init ok!", log.Any("dbName", m.db)) + return nil +} + +func (m *Manager) DisConnect() { + if m.client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := m.client.Disconnect(ctx); err != nil { + log.Error("db disconnect err", log.E(err)) + } + + m.client = nil + } +} + +// Transaction 开启事务处理包裹处理,里面处理的全是利用的事务的ctx 切记里面的context必须要使用同一个,因为里面有重试机制,循环调用该函数 +func (m *Manager) Transaction(ctx context.Context, fn func(sctx mongo.SessionContext) error) error { + opts := options.Session().SetDefaultReadPreference(readpref.Primary()) + handler := func(sctx mongo.SessionContext) error { + lHandler := func(ctx mongo.SessionContext) (err error) { + tOpts := options.Transaction() + tOpts.SetReadConcern(readconcern.Snapshot()) + tOpts.SetWriteConcern(writeconcern.New(writeconcern.WMajority())) + err = sctx.StartTransaction(tOpts) + if err != nil { + return + } + defer func() { + if rErr := recover(); rErr != nil { + log.Info("caught panic during transaction, aborting...", log.Any("", rErr)) + aErr := sctx.AbortTransaction(sctx) + if aErr != nil { + log.Info("中止事务错误", log.E(aErr)) + } + } + }() + err = fn(sctx) + if err != nil { + aErr := sctx.AbortTransaction(sctx) + if aErr != nil { + log.Info("中止事务错误", log.E(aErr)) + } + log.Info("事务内错误", log.E(err)) + return + } + return commitWithRetry(sctx) + } + return runTransactionWithRetry(sctx, lHandler) + } + return m.client.UseSessionWithOptions(ctx, opts, handler) +} + +func runTransactionWithRetry(sctx mongo.SessionContext, txnFn func(mongo.SessionContext) error) error { + var err error + for i := int64(0); i < 100; i++ { + err = txnFn(sctx) // Performs transaction. + if err == nil { + return nil + } + // If transient error, retry the whole transaction + if cmdErr, ok := err.(mongo.CommandError); ok && cmdErr.HasErrorLabel("TransientTransactionError") { + continue + } + return err + } + return err +} + +// commitWithRetry is an example function demonstrating transaction retry logic. +func commitWithRetry(sctx mongo.SessionContext) error { + for { + err := sctx.CommitTransaction(sctx) + switch e := err.(type) { + case nil: + return nil + case mongo.CommandError: + // Can retry commit + if e.HasErrorLabel("UnknownTransactionCommitResult") { + continue + } + return e + default: + return e + } + } +} + +// func Transaction(ctx context.Context, fn func(sctx mongo.SessionContext) error) error { +// return connMgr.Transaction(ctx, fn) +// } diff --git a/pub/pubdef/pubdef.go b/pub/pubdef/pubdef.go new file mode 100644 index 0000000..d50d18c --- /dev/null +++ b/pub/pubdef/pubdef.go @@ -0,0 +1,25 @@ +package pubdef + +type Page struct { + PageNum int64 `form:"pageNum" json:"pageNum,omitempty"` + PageSize int64 `form:"pageSize" json:"pageSize,omitempty"` +} + +const ( + DateISO = "2006-01-02" + TimeISO = "2006-01-02 15:04:05" + AppId = 1045 +) + +// TrackerAppId 数据上报 Web SDK 的业务 appId。 +func TrackerAppId() string { + return "JHA-1045" +} + +const PageSize int64 = 40 + +type SeoValue struct { + Title string `json:"title" bson:"webTitle"` // 网站SEO标题 + Keyworld []string `json:"keyworld" bson:"webKeyworld"` // 网站SEO关键字 + Description string `json:"description" bson:"webDescription"` // 网站SEO描述 +} diff --git a/pub/pubdef/user.go b/pub/pubdef/user.go new file mode 100644 index 0000000..8e6b910 --- /dev/null +++ b/pub/pubdef/user.go @@ -0,0 +1,24 @@ +package pubdef + +type UserStatus int32 + +const ( + UserStatusNormal UserStatus = 0 // 正常 + UserStatusDisable UserStatus = 1 // 封禁 + UserStatusSlient UserStatus = 2 // 禁言 +) + +var UserStatusMap = map[UserStatus]string{ + UserStatusNormal: "正常", + UserStatusDisable: "封禁", + UserStatusSlient: "禁止登陆", +} + +type LoginType int32 + +const ( + LoginTypeDevice LoginType = 0 + iota // 设备登陆 + LoginTypeMobile // 手机登陆 + LoginTypeQrCode // 二维码登陆 + LoginTypeAccount // 账号登陆 +) \ No newline at end of file diff --git a/pub/redis/List.go b/pub/redis/List.go new file mode 100644 index 0000000..db85e71 --- /dev/null +++ b/pub/redis/List.go @@ -0,0 +1,95 @@ +package redis + +import ( + "github.com/go-redis/redis" + "time" +) + +// List represents a redis list. +type List struct { + key string + client *redis.Client +} + +// NewList creates a new redis list instance. +func NewList(client *redis.Client, key string) *List { + return &List{ + key: key, + client: client, + } +} + +// Key returns the key of the list. +func (l *List) Key() string { + return l.key +} + +// Expire sets the expiration time of the list. +func (l *List) Expire(expiration time.Duration) (bool, error) { + return l.client.Expire(l.key, expiration).Result() +} + +// Delete deletes the list. +func (l *List) Delete() (int64, error) { + return l.client.Del(l.key).Result() +} + +// Len returns the length of the list. +func (l *List) Len() (int64, error) { + return l.client.LLen(l.key).Result() +} + +// Push adds a new element to the tail of the list. +func (l *List) Push(value interface{}) error { + return l.client.RPush(l.key, value).Err() +} + +// Pop removes and returns the first element of the list. +func (l *List) Pop() (string, error) { + return l.client.LPop(l.key).Result() +} + +// PushFront adds a new element to the head of the list. +func (l *List) PushFront(value interface{}) error { + return l.client.LPush(l.key, value).Err() +} + +// PopBack removes and returns the last element of the list. +func (l *List) PopBack() (string, error) { + return l.client.RPop(l.key).Result() +} + +// Index returns the element at the specified index. +func (l *List) Index(index int64) (string, error) { + return l.client.LIndex(l.key, index).Result() +} + +// Range returns a range of elements from the list. +func (l *List) Range(start, stop int64) ([]string, error) { + return l.client.LRange(l.key, start, stop).Result() +} + +// Trim removes elements from the list. +func (l *List) Trim(start, stop int64) error { + return l.client.LTrim(l.key, start, stop).Err() +} + +// Set sets the element at the specified index. +func (l *List) Set(index int64, value interface{}) error { + return l.client.LSet(l.key, index, value).Err() +} + +// InsertBefore inserts a new element before the specified element. +func (l *List) InsertBefore(pivot, value interface{}) error { + return l.client.LInsertBefore(l.key, pivot, value).Err() +} + +// InsertAfter inserts a new element after the specified element. +func (l *List) InsertAfter(pivot, value interface{}) error { + return l.client.LInsertAfter(l.key, pivot, value).Err() +} + +// Remove removes all occurrences of the specified element. +func (l *List) Remove(count int64, value interface{}) (int64, error) { + return l.client.LRem(l.key, count, value).Result() +} diff --git a/pub/redis/channeldef.go b/pub/redis/channeldef.go new file mode 100644 index 0000000..f76ed5d --- /dev/null +++ b/pub/redis/channeldef.go @@ -0,0 +1,75 @@ +package redis + +import "time" + +// RCType redis channel type +type RCType string + +const ( + // config + RCT_cfg_all RCType = "config.*" + // tabkind 配置改变 + RCT_cfg_tabkind_change RCType = "config.tabkindchange" + // mediatag 配置改变 + RCT_cfg_mediatag_change RCType = "config.mediatagchange" + // mediacat 配置改变 + RCT_cfg_mediacat_change RCType = "config.mediacatchange" + // mediatopic 配置改变 + RCT_cfg_mediatopic_change RCType = "config.mediatopicchange" + // mediacolumn 配置改变 + RCT_cfg_mediacolumn_change RCType = "config.mediacolumnchange" + // syscfg 系统配置改变 + RCT_cfg_syscfg_change RCType = "config.syscfgchange" + + // 广告 + RCT_ad_all RCType = "ad.*" + // 广告配置改变 + RCT_ad_cfgchange RCType = "ad.cfgchange" + + // 系统通知 + RCT_blt_all RCType = "blt.*" + // 系统通知 + RCT_blt_cfgchange RCType = "blt.cfgchange" + + //media + RCT_media_all RCType = "media.*" + //media 信息改变 + RCT_media_change RCType = "media.change" + //media 信息添加 + RCT_media_add RCType = "media.add" + //media 信息删除 + RCT_media_del RCType = "media.del" + //media 相关排行榜改变 + RCT_media_topchange RCType = "media.topchange" + + //user + RCT_user_all RCType = "user.*" + //user 信息改变 + RCT_user_change RCType = "user.change" + //user 信息添加 + RCT_user_add RCType = "user.add" + //user 信息删除 + RCT_user_del RCType = "user.del" + //user 相关排行榜改变 + RCT_user_topchange RCType = "user.topchange" + + //视频推荐 + RCT_recommend_all RCType = "recommend.*" + //博主推荐 + RCT_recommend_blogger_all RCType = "recommend.bloggerALL" + // 视频每日推荐 + RCT_recommend_media_all RCType = "recommend.mediaAll" + + //tg消息 + RCT_tgmsg_all RCType = "tgmsg.*" + RCT_tgmsg_send_msg RCType = "tgmsg.sendmsg" + + //vipcard + RCT_vipcard_all RCType = "vipcard.*" + RCT_vipcard_change RCType = "vipcard.change" + + NameSource = "name:source" //姓名池 + NameSourceExpire = 2 * 24 * time.Hour + CommunicateServerChannel = "comm:lld#server" + CommunicateClientChannel = "comm:lld#client" +) diff --git a/pub/redis/hash.go b/pub/redis/hash.go new file mode 100644 index 0000000..c0f5d0c --- /dev/null +++ b/pub/redis/hash.go @@ -0,0 +1,112 @@ +package redis + +import ( + "time" + + "github.com/go-redis/redis" +) + +//key: {name:value, name:value, name:value} + +type Hash struct { + key string + client *redis.Client +} + +func NewHash(client *redis.Client, key string) *Hash { + return &Hash{ + key: key, + client: client, + } +} + +func (s *Hash) Key() string { + return s.key +} + +// Expire 设置集合过期时间 +func (s *Hash) Expire(expiration time.Duration) (bool, error) { + return s.client.Expire(s.key, expiration).Result() +} + +// Delete 删除一个或多个 Name +func (s *Hash) Delete(names ...string) (int64, error) { + return s.client.HDel(s.key, names...).Result() +} + +// Exists 检测Name是否在哈希表中 +func (s *Hash) Exists(name string) (bool, error) { + return s.client.HExists(s.key, name).Result() +} + +// NameValues 获取所有哈希表数据 +func (s *Hash) NameValues() (map[string]string, error) { + return s.client.HGetAll(s.key).Result() +} + +// Names 获取所有哈希表的 Name +func (s *Hash) Names() ([]string, error) { + return s.client.HKeys(s.key).Result() +} + +// Values 获取所有哈希表的 Total +func (s *Hash) Values() ([]string, error) { + return s.client.HVals(s.key).Result() +} + +// SetNX 当name不存在时才向哈希表中设置一对Name:Total +func (s *Hash) SetNX(name string, value interface{}) (bool, error) { + return s.client.HSetNX(s.key, name, value).Result() +} + +// Set 向哈希表中设置一对Name:Total +func (s *Hash) Set(name string, value interface{}) (bool, error) { + return s.client.HSet(s.key, name, value).Result() +} + +// Get 获取哈希表中Name对应的Value +func (s *Hash) Get(name string) (string, error) { + return s.client.HGet(s.key, name).Result() +} + +// GetResult 获取哈希表中Name对应的Value +func (s *Hash) GetResult(name string) *redis.StringCmd { + return s.client.HGet(s.key, name) +} + +// IncInt 返回累加之后的新值 +func (s *Hash) IncInt(name string, value int64) (int64, error) { + return s.client.HIncrBy(s.key, name, value).Result() +} + +// IncFloat 返回累加之后的新值 +func (s *Hash) IncFloat(name string, value float64) (float64, error) { + return s.client.HIncrByFloat(s.key, name, value).Result() +} + +// Len 返回哈希表的大小 +func (s *Hash) Len() (int64, error) { + return s.client.HLen(s.key).Result() +} + +// Scan 扫描哈希表数据 +func (s *Hash) Scan(match string, piece int64, callback func(name, value string)) error { + proc := func(l []string) { + for i := 0; i+1 < len(l); i += 2 { + callback(l[i], l[i+1]) + } + } + lst, cur, err := s.client.HScan(s.key, 0, match, piece).Result() + if err != nil { + return err + } + proc(lst) + for cur > 0 { + lst, cur, err = s.client.HScan(s.key, cur, match, piece).Result() + if err != nil { + return err + } + proc(lst) + } + return nil +} diff --git a/pub/redis/manager.go b/pub/redis/manager.go new file mode 100644 index 0000000..79b1d3e --- /dev/null +++ b/pub/redis/manager.go @@ -0,0 +1,177 @@ +package redis + +import ( + "fmt" + "regexp" + "sync" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "github.com/go-redis/redis" +) + +type SubscribeCallback func(channel RCType, message string) + +var ( + redisCli *redis.Client + opts Options + + //订阅参数 + pubsub *redis.PubSub + subCBMap map[RCType]SubscribeCallback + cbMapLock sync.RWMutex +) + +func Client() *redis.Client { + return redisCli +} + +func Disconnect() { + if redisCli != nil { + if pubsub != nil { + pubsub.Close() + } + redisCli.Close() + redisCli = nil + } +} + +func Init(opts Options) error { + c := redis.NewClient( + &redis.Options{ + Addr: opts.Addr, + Password: opts.Psw, + DB: opts.Db, + }, + ) + + if err := c.Ping().Err(); err != nil { + log.Error(fmt.Sprintf("[error] ping redis err %+v\n", err)) + return err + } + redisCli = c + + pubsub = nil + if opts.Subscribe { + subCBMap = make(map[RCType]SubscribeCallback) + pubsub = redisCli.Subscribe() + //接受发布消息 + go ReceiveMessage() + } + + log.Info("redis:Init Ok!") + return nil +} + +// OnMessage 订阅消息回调 +func OnMessage(channel RCType, message string) { + cbMapLock.RLock() + defer cbMapLock.RUnlock() + flag := false + for k, v := range subCBMap { + reg := regexp.MustCompile(string(k)) + //log.Info("test", log.Any("k", k), log.Any("channelchannel", channel)) + if b := reg.MatchString(string(channel)); b { + v(channel, message) + flag = true + } + } + if !flag { + log.Warn("(redis:OnMessage) callback doesn't exist!", log.Any("channel", channel)) + } +} + +// ReceiveMessage 接受发布消息 +func ReceiveMessage() { + for { + msg, err := pubsub.Receive() + if err != nil { + fmt.Println("test1", err) + return + } + + switch msg := msg.(type) { + case *redis.Subscription: + // Ignore. + log.Info("(redis:ReceiveMessage) subscribe success!", log.Any("channel", msg.Channel)) + case *redis.Pong: + // Ignore. + fmt.Println("Pong") + case *redis.Message: + channel := msg.Channel + message := msg.Payload + OnMessage(RCType(channel), message) + default: + log.Error("(redis:ReceiveMessage) msg!", log.Any("msg", msg)) + return + } + } +} + +// Subscribe 订阅一个消息 +func Subscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:Subscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.Subscribe(string(channel)) + if err != nil { + log.Warn("(redis:Subscribe) Subscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + subCBMap[channel] = cb + return nil +} + +// UnSubscribe 取消订阅一个消息 +func UnSubscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:UnSubscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.Unsubscribe(string(channel)) + if err != nil { + log.Warn("(redis:UnSubscribe) UnSubscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + delete(subCBMap, channel) + return nil +} + +// PSubscribe 模式匹配消息 +func PSubscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:PSubscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.PSubscribe(string(channel)) + if err != nil { + log.Warn("(redis:PSubscribe) PSubscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + subCBMap[channel] = cb + return nil +} + +// PUnsubscribe 模式取消匹配消息 +func PUnsubscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:PUnsubscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.PUnsubscribe(string(channel)) + if err != nil { + log.Warn("(redis:PUnsubscribe) PUnsubscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + delete(subCBMap, channel) + return nil +} diff --git a/pub/redis/options.go b/pub/redis/options.go new file mode 100644 index 0000000..fed9708 --- /dev/null +++ b/pub/redis/options.go @@ -0,0 +1,9 @@ +package redis + +// Redis 配置项 +type Options struct { + Addr string `json:"addr"` // 地址 + Psw string `json:"psw"` // 密码 + Db int `json:"db"` // 库 + Subscribe bool `json:"subscribe"` // 是否有订阅功能 +} diff --git a/pub/redis/redis.go b/pub/redis/redis.go new file mode 100644 index 0000000..d504209 --- /dev/null +++ b/pub/redis/redis.go @@ -0,0 +1,275 @@ +package redis + +import ( + "errors" + "fmt" + "time" + sErr "videoweb/pub/common/errors" + "videoweb/pub/common/log" + + goredis "github.com/go-redis/redis" +) + +type ZMember = goredis.Z + +// Set 设置键值对 +func Set(key string, value interface{}, expireTime time.Duration) (err error) { + if err = redisCli.Set(key, value, expireTime).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// Incr 增加1 +func Incr(key string, expireTime time.Duration) (val int64, err error) { + val, err = redisCli.Incr(key).Result() + if err != nil { + return + } + duration := redisCli.TTL(key).Val() + //log.Info("Incr Test", log.Any("duration", duration)) + if duration.Seconds() == -1 { + redisCli.Expire(key, expireTime) + } + return +} + +func SAdd(key string, members ...interface{}) (err error) { + _, err = redisCli.SAdd(key, members...).Result() + return +} + +// 无序集合操作 随机删除集合中某一元素 +func SPop(key string, members ...interface{}) (string, error) { + val, err := redisCli.SPop(key).Result() + if err != nil { + log.Error(fmt.Sprintf("SPop failed %+v:", err)) + } + return val, err +} + +func SRem(key string, members ...interface{}) (err error) { + if err = redisCli.SRem(key, members...).Err(); err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// ScanSlice 返回集合成员 +func SScanSlice(key string, container interface{}) (err error) { + err = redisCli.SMembers(key).ScanSlice(container) + return err +} + +// SMembers +func SMembers(key string) (list []string, err error) { + result := redisCli.SMembers(key) + if result.Err() != nil { + err = sErr.MakeWithErr(sErr.RedisGet, result.Err()) + return + } + list = result.Val() + return +} + +// RPush +func RPush(key string, value string) error { + if key == "" { + return errors.New("key should not empty") + } + if err := redisCli.RPush(key, value).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisRPush, err) + } + return nil +} + +// RPush +func RPushValues(key string, value ...interface{}) error { + if key == "" { + return errors.New("key should not empty") + } + if err := redisCli.RPush(key, value...).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisRPush, err) + } + return nil +} + +// LPop +func LPop(key string) (*string, error) { + if key == "" { + return nil, errors.New("key should not empty") + } + + cmd := redisCli.LPop(key) + if cmd.Err() != nil { + return nil, sErr.MakeWithErr(sErr.RedisLPop, cmd.Err()) + } + + data, err := cmd.Result() + if err != nil { + if err == goredis.Nil { + return nil, nil + } + return nil, err + } + + return &data, nil +} + +// Get 根据键 获取值 +func Get(key string) (*string, error) { + if key == "" { + return nil, errors.New("key should not empty") + } + data, err := redisCli.Get(key).Result() + if err != nil { + if err == goredis.Nil { + return nil, nil + } + return nil, err + } else { + return &data, nil + } +} + +// Get 根据键 获取值 +func GetBytes(key string) (bytes []byte, err error) { + bytes, err = redisCli.Get(key).Bytes() + if err != nil { + return nil, sErr.MakeWithErr(sErr.RedisGet, err) + } + return +} + +// Del 删除 +func Del(keys ...string) (err error) { + if err = redisCli.Del(keys...).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisDel, err) + } + return +} + +// Exists keys exists? +func Exists(keys ...string) (has bool, err error) { + intcmd := redisCli.Exists(keys...) + if err = intcmd.Err(); err != nil { + return + } + has = intcmd.Val() > 0 + return +} + +func Keys(pattern string) (keys []string, err error) { + list := redisCli.Keys(pattern) + keys = list.Val() + err = list.Err() + return +} + +// Setnx 设置键值对 +func Setnx(key string, value interface{}, expirTime time.Duration) (err error) { + if err = redisCli.SetNX(key, value, expirTime).Err(); err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// Setnx 设置键值对 正确的封装 +func Setnx_NewOK(key string, value interface{}, expirTime time.Duration) (v bool, err error) { + cmd := redisCli.SetNX(key, value, expirTime) + return cmd.Val(), cmd.Err() +} + +func Expire(key string, expirTime time.Duration) (err error) { + if err = redisCli.Expire(key, expirTime).Err(); err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// Redis `SRANDMEMBER key count` command. +func SRandMemberN(key string, count int64) (list []string, err error) { + result := redisCli.SRandMemberN(key, count) + if result.Err() != nil { + return + } + list = result.Val() + return +} + +// 有序集合操作 添加多个有序集合 +func ZAdd(key string, z ...ZMember) (int64, error) { + count, err := redisCli.ZAdd(key, z...).Result() + if err != nil { + log.Error(fmt.Sprintf("ZAdd failed %+v:", err)) + } + return count, err +} + +// 有序集合 数量 +func ZCard(key string) (int64, error) { + count, err := redisCli.ZCard(key).Result() + if err != nil { + log.Error(fmt.Sprintf("ZCard failed %+v:", err)) + } + return count, err +} + +// 返回区间成员 +func ZRange(key string, start, end int64) ([]string, error) { + data, err := redisCli.ZRange(key, start, end).Result() + if err != nil { + log.Error(fmt.Sprintf("ZRange failed %+v:", err)) + } + return data, err +} + +// 按照元素排序 删除指定排名的元素 +func ZRemRangeByRank(key string, start, end int64) (int64, error) { + count, err := redisCli.ZRemRangeByRank(key, start, end).Result() + if err != nil { + log.Error(fmt.Sprintf("ZRemRangeByRank failed %+v:", err)) + } + return count, err +} + +// 按照元素排序 删除按指定的分数区间的的元素 +func ZRemRangeByScore(key, start, end string) (int64, error) { + count, err := redisCli.ZRemRangeByScore(key, start, end).Result() + if err != nil { + log.Error(fmt.Sprintf("ZRemRangeByScore failed %+v:", err)) + } + return count, err +} + +// Publish 发布消息 +func Publish(channel RCType, message string) error { + if err := redisCli.Publish(string(channel), message).Err(); err != nil { + log.Error(fmt.Sprintf("Publish failed %+v:", err)) + return err + } + return nil +} + +func SendMsgToTg(message string) error { + return Publish(RCT_tgmsg_send_msg, message) +} + +// SCard 发布消息 +func SCard(key string) (int64, error) { + count, err := redisCli.SCard(key).Result() + if err != nil { + log.Error(fmt.Sprintf("SCard failed %+v:", err)) + } + + return count, err +} + +// 获取key的剩余生存时间 +func TTL(key string) time.Duration { + durCmd := redisCli.TTL(key) + if durCmd != nil { + return durCmd.Val() + } + return 0 +} diff --git a/pub/redis/zset.go b/pub/redis/zset.go new file mode 100644 index 0000000..c3d1e7c --- /dev/null +++ b/pub/redis/zset.go @@ -0,0 +1,136 @@ +package redis + +import ( + "github.com/go-redis/redis" + "strconv" + "time" +) + +type Z = redis.Z + +type ZSet struct { + key string + client *redis.Client +} + +func NewZSet(client *redis.Client, key string) *ZSet { + return &ZSet{ + key: key, + client: client, + } +} + +func (s *ZSet) Key() string { + return s.key +} + +// Expire 设置集合过期时间 +func (s *ZSet) Expire(expiration time.Duration) (bool, error) { + return s.client.Expire(s.key, expiration).Result() +} + +// Add 添加一个或多个成员到有序集合 +func (s *ZSet) Add(members ...Z) (int64, error) { + return s.client.ZAdd(s.key, members...).Result() +} + +// Count 返回有序集合在指定分数范围内的成员数量 +func (s *ZSet) Count(min, max float64) (int64, error) { + return s.client.ZCount(s.key, strconv.FormatFloat(min, 'f', -1, 64), strconv.FormatFloat(max, 'f', -1, 64)).Result() +} + +// Score 获取成员的分数 +func (s *ZSet) Score(member string) (float64, error) { + return s.client.ZScore(s.key, member).Result() +} + +// Rank 获取成员的排名(从小到大,0开始) +func (s *ZSet) Rank(member string) (int64, error) { + return s.client.ZRank(s.key, member).Result() +} + +// RevRank 获取成员的排名(从大到小,0开始) +func (s *ZSet) RevRank(member string) (int64, error) { + return s.client.ZRevRank(s.key, member).Result() +} + +// Range 返回指定排名范围的成员 +func (s *ZSet) Range(start, stop int64) ([]string, error) { + return s.client.ZRange(s.key, start, stop).Result() +} + +// RangeWithScores 返回指定排名范围的成员和分数 +func (s *ZSet) RangeWithScores(start, stop int64) ([]Z, error) { + return s.client.ZRangeWithScores(s.key, start, stop).Result() +} + +// RangeByScore 返回指定分数范围的成员 +func (s *ZSet) RangeByScore(min, max float64) ([]string, error) { + opt := redis.ZRangeBy{ + Min: strconv.FormatFloat(min, 'f', -1, 64), + Max: strconv.FormatFloat(max, 'f', -1, 64), + } + return s.client.ZRangeByScore(s.key, opt).Result() +} + +// RangeByScoreWithScores 返回指定分数范围的成员和分数 +func (s *ZSet) RangeByScoreWithScores(min, max float64) ([]Z, error) { + opt := redis.ZRangeBy{ + Min: strconv.FormatFloat(min, 'f', -1, 64), + Max: strconv.FormatFloat(max, 'f', -1, 64), + } + return s.client.ZRangeByScoreWithScores(s.key, opt).Result() +} + +// Remove 删除一个或多个成员 +func (s *ZSet) Remove(members ...string) (int64, error) { + return s.client.ZRem(s.key, members).Result() +} + +// RemoveRangeByRank 删除指定排名范围的成员 +func (s *ZSet) RemoveRangeByRank(start, stop int64) (int64, error) { + return s.client.ZRemRangeByRank(s.key, start, stop).Result() +} + +// RemoveRangeByScore 删除指定分数范围的成员 +func (s *ZSet) RemoveRangeByScore(min, max float64) (int64, error) { + return s.client.ZRemRangeByScore(s.key, strconv.FormatFloat(min, 'f', -1, 64), strconv.FormatFloat(max, 'f', -1, 64)).Result() +} + +// Len 返回有序集合的成员数 +func (s *ZSet) Len() (int64, error) { + return s.client.ZCard(s.key).Result() +} + +// IncScore 为成员的分数加上增量 +func (s *ZSet) IncScore(member string, increment float64) (float64, error) { + return s.client.ZIncrBy(s.key, increment, member).Result() +} + +// Scan 扫描有序集合 +func (s *ZSet) Scan(match string, count int64, callback func(member string, score float64) error) error { + cursor := uint64(0) + for { + keys, newCursor, err := s.client.ZScan(s.key, cursor, match, count).Result() + if err != nil { + return err + } + + for i := 0; i < len(keys); i += 2 { + score, err := strconv.ParseFloat(keys[i+1], 64) + if err != nil { + return err + } + err = callback(keys[i], score) + if err != nil { + return err + } + } + + if newCursor == 0 { + break + } + cursor = newCursor + } + return nil +} diff --git a/pub/storage/proxy/object.go b/pub/storage/proxy/object.go new file mode 100644 index 0000000..d2068f7 --- /dev/null +++ b/pub/storage/proxy/object.go @@ -0,0 +1,384 @@ +package proxy + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "videoweb/pub/common/http" + "videoweb/pub/common/log" +) + +const ( + apiDomain = "http://resourcets.bbaazz.me/file/upload" +) + +var ( + header = map[string]string{"Content-Type": "application/json"} +) + +const ( + Success = 200 +) + +func UploadImg(fileName string, data []byte, domain string) (pathStr string, err error) { + r := objreq{ + Name: fileName, + Data: base64.StdEncoding.EncodeToString(data), + } + jsonbytes, err := json.Marshal(r) + if err != nil { + return + } + url := ImageUploadAddressRepair(domain, BucketImageAddressV2) + + resp, err := http.Post(url, header, bytes.NewReader(jsonbytes)) + if err != nil { + return + } + + var or objresp + if err = json.Unmarshal(resp.Data, &or); err != nil { + return + } + if or.Code != Success { + err = fmt.Errorf("上传失败:%s", or.Msg) + return + } + pathStr = or.Object.Name + return +} + +func UploadBase64Img(fileName string, data string, domain string) (pathStr string, err error) { + r := objreq{ + Name: fileName, + Data: data, + } + jsonbytes, err := json.Marshal(r) + if err != nil { + return + } + if len(domain) == 0 { + domain = apiDomain + } + url := domain + if !strings.Contains(domain, "/file/upload") { + url = domain + "/file/upload" + } + resp, err := http.Post(url, header, bytes.NewReader(jsonbytes)) + if err != nil { + return + } + + var or objresp + if err = json.Unmarshal(resp.Data, &or); err != nil { + return + } + if or.Code != Success { + err = fmt.Errorf("上传失败:%s", or.Msg) + return + } + pathStr = or.Object.Name + if strings.Contains(domain, "resourceimg.bbaazz.me") && + !strings.HasPrefix(pathStr, "v2/") { + pathStr = "v2/" + pathStr + } + return +} + +func fileServer() (url string) { + return "http://resourcets.bbaazz.me" +} + +func PostOk(baseInfo *ShardOk, domain string) (result *VideoOkResult, err error) { + result = &VideoOkResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + url := VideoOkAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(baseInfo) + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + fmt.Println(string(fr.Data)) + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(result) + return nil, err + } + fmt.Println(result) + return +} +func PostOkV2(baseInfo *ShardOk, domain string) (result *VideoOkResult, err error) { + result = &VideoOkResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + url := VideoOkAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(paramJson) + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(result) + return nil, err + } + fmt.Println(result) + return +} +func QueryMedia(baseInfo *MediaQueryeReq, domain string) (result *VideoQueryResult, err error) { + result = &VideoQueryResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/query") { + url = domain + "/file/web/media/query" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(err) + return nil, err + } + + return +} +func QueryMedias(baseInfo *MediaQueryesReq, domain string) (result *VideoQuerysResult, err error) { + result = &VideoQuerysResult{ + Data: make([]MediaResp, 0), + } + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/task/querys") { + url = domain + "/media/web/task/querys" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(err) + return nil, err + } + return +} +func SyncMedia(baseInfo *MediaTaskSyncReq, domain string) (result *VideoQuerysResult, err error) { + result = &VideoQuerysResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/task/sync") { + url = domain + "/media/web/task/sync" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(err) + return nil, err + } + + return +} + +func UploadVideo(domain string, body *ShardBody) (result *Result, err error) { + if body == nil { + return nil, errors.New("no video to upload") + } + url := VideoBodyAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(url) + + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := http.Post(url, header, bytes.NewBuffer(buf)) + if err != nil { + return nil, err + } + fmt.Println("resp-->", string(resp.Data)) + result = new(Result) + if err = json.Unmarshal(resp.Data, result); err != nil { + return nil, err + } + return +} + +func UploadVideoV2(domain string, body *ShardBodyV2) (result *Result, err error) { + if body == nil { + return nil, errors.New("no video to upload") + } + url := VideoBodyAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(url) + + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := http.Post(url, header, bytes.NewBuffer(buf)) + if err != nil { + return nil, err + } + fmt.Println("resp-->", string(resp.Data)) + result = new(Result) + if err = json.Unmarshal(resp.Data, result); err != nil { + return nil, err + } + return +} + +func PostSearch(param *MediaSearchReq, domain string) (result *MediaSearchResult, err error) { + result = &MediaSearchResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/search") { + url = domain + "/file/web/media/search" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + //log.Info("proxy:test", log.Any("url", url), log.Any("result", result)) + return +} + +func PostUpdate(param *MediaUpdateReq, domain string) (result *MediaUpdateResult, err error) { + result = &MediaUpdateResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/update") { + url = domain + "/file/web/media/update" + } + fr, perr := http.Post(url, header, bytes.NewReader(paramJson)) + if perr != nil { + log.Error("PostUpdate Unmarshal", log.E(perr)) + err = perr + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + log.Error("PostUpdate Unmarshal", log.E(err), log.Any("data", string(fr.Data))) + return nil, err + } + return +} + +func PostDelete(param *MediaDelReq, domain string) (result *MediaDelResult, err error) { + result = &MediaDelResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/delete") { + url = domain + "/file/web/media/delete" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + return +} + +func PostUpdatePreTime(param *MediaUpdatePreTime, domain string) (result *MediaDelResult, err error) { + result = &MediaDelResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/update/prefile") { + url = domain + "/file/web/media/update/prefile" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + return +} + +func UploadVideoV2New(domain string, body *ShardBodyV2) (result *Result, err error) { + if body == nil { + return nil, errors.New("no video to upload") + } + if len(domain) == 0 { + domain = fileServer() + } + url := domain + if !strings.Contains(domain, "/file/web/proxy/videoBodyV2") { + url = domain + "/file/web/proxy/videoBodyV2" + } + fmt.Println(url) + + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := http.Post(url, header, bytes.NewBuffer(buf)) + if err != nil { + return nil, err + } + fmt.Println("resp-->", string(resp.Data)) + result = new(Result) + if err = json.Unmarshal(resp.Data, result); err != nil { + return nil, err + } + // if strings.Contains(domain, "resourcets.bbaazz.me") && !strings.HasPrefix(result.Data.VideoUri, "v2/") { + // result.Data.VideoUri = "v2/" + result.Data.VideoUri + // } + fmt.Println("result-->", result) + if result.Code != 200 { + err = fmt.Errorf("result code err") + } + return +} diff --git a/pub/storage/proxy/test.html b/pub/storage/proxy/test.html new file mode 100644 index 0000000..e0c8bb8 --- /dev/null +++ b/pub/storage/proxy/test.html @@ -0,0 +1,3 @@ + <div class="line" style="margin-top: 0"></div> + <blockquote><p>51吃瓜国内最新地址 <a href="https://51cga33.com">https://51cga33.com</a></p><p>51吃瓜海外永久地址 <a href="https://51cg1.com">https://51cg1.com</a></p><p>播放异常? 请刷新 或 使用<a href="/app.html">51吃瓜App</a> </p><p>苹果App白屏 请等待1分钟/切换网络/重新下载</p><p>发邮件获取 最新网址 👇长按复制保存👇<br> <a href="mailto:51cgfun@pm.me">51cgfun@pm.me</a></p></blockquote><p>曲阜师范大学学生【何鑫】的私密自拍视频流出,尺度炸裂,令人瞠目!这位清纯外表的学妹私下化身小母狗,为完成神秘“主人”的任务大秀骚浪本性!视频中,她身着宽松裸体背带裤,半遮半掩的布料勾勒出白嫩胴体,饱满小胸和粉嫩美穴若隐若现。</p><p><img loading="" lazy src="/usr/plugins/tbxw/zw.png" data-xuid="1" data-xkrkllgl="https://pic.arohkt.cn/upload_01/xiao/20250716/2025071618595633850.jpeg" alt="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 1" title="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 1"></p><p><img loading="" lazy src="/usr/plugins/tbxw/zw.png" data-xuid="2" data-xkrkllgl="https://pic.arohkt.cn/upload_01/xiao/20250716/2025071618595716822.jpeg" alt="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 2" title="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 2"></p><p><img loading="" lazy src="/usr/plugins/tbxw/zw.png" data-xuid="3" data-xkrkllgl="https://pic.arohkt.cn/upload_01/xiao/20250716/2025071618595888952.jpeg" alt="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 3" title="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 3"></p><p><img loading="" lazy src="/usr/plugins/tbxw/zw.png" data-xuid="4" data-xkrkllgl="https://pic.arohkt.cn/upload_01/xiao/20250716/2025071618595914322.jpeg" alt="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 4" title="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 4"></p><span id="menu_index_1" name="menu_index_1"></span><h2>何鑫裸体自拍玩穴</h2><p>她站在宿舍镜前,挑逗地拉下背带裤,露出湿漉漉的私处,手指大胆深入玩穴,湿滑水声混杂娇喘,镜头特写捕捉到她揉搓嫩穴的每一个细节,令人血脉喷张!</p><p><img loading="" lazy src="/usr/plugins/tbxw/zw.png" data-xuid="5" data-xkrkllgl="https://pic.arohkt.cn/upload_01/xiao/20250716/2025071619000598058.jpeg" alt="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 5" title="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 5"></p><p><img loading="" lazy src="/usr/plugins/tbxw/zw.png" data-xuid="6" data-xkrkllgl="https://pic.arohkt.cn/upload_01/xiao/20250716/2025071619000664755.jpeg" alt="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 6" title="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 6"></p><p><img loading="" lazy src="/usr/plugins/tbxw/zw.png" data-xuid="7" data-xkrkllgl="https://pic.arohkt.cn/upload_01/xiao/20250716/2025071619000740149.jpeg" alt="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 7" title="曲阜师范大学05年小母狗何鑫裸体自拍玩穴 7"></p><span id="menu_index_2" name="menu_index_2"></span><h2>何鑫足交伺候主人</h2><p>何鑫还完成足交任务,赤裸坐在床边,用白嫩玉足轻抚主人粗大肉棒,脚趾灵活挑逗,油亮光泽的脚掌摩擦到主人爽到低吼!</p><p><div class="dplayer" data-config='{"live":false,"autoplay":false,"theme":"#FADFA3","loop":false,"screenshot":false,"hotkey":true,"preload":"metadata","lang":"zh-cn","logo":null,"volume":0.69999999999999996,"mutex":true,"video_ads_url":"","ads_jump_url":"","ads_jump_time":-1,"video":{"url":"https:\/\/hls.qzkj.tech\/videos5\/8237add62367e0f0a4577a6e01a9ac41\/8237add62367e0f0a4577a6e01a9ac41.m3u8?auth_key=1752753843-6878e6b3b6eba-0-1dc27d84112bedd2d5e026d20954248a&v=3&time=0","type":"hls","thumbnails":null},"open_danmaku":"1"}'></div></p><p>关键词:#曲阜师范大学何鑫 #何鑫裸体自拍 #何鑫对镜玩穴自慰 #何鑫足交伺候主人 #何鑫不雅自拍视频 #曲阜师范大学 #何鑫 #自慰 #足交 #51吃瓜 #51吃瓜网 #黑料 #吃瓜</p><p>想看更多精彩完整版,下载51吃瓜APP一爽到底~</p><blockquote><p>51吃瓜APP下载地址:<br> 最新地址 <a href="https://url46.co/1222">https://url46.co/1222</a><br> 安卓下载备用地址一: <a href="https://url46.co/1237">https://url46.co/1237</a><br> 安卓下载备用地址二: <a href="https://url46.co/1238">https://url46.co/1238</a><br> iOS下载备用地址 <a href="https://url46.co/1239">https://url46.co/1239</a></p></blockquote> <div class="tags"> + <div itemprop="keywords" class="keywords YkziYC"><a href="/tag/%E8%87%AA%E6%85%B0/">自慰</a><a href="/tag/%E8%B6%B3%E4%BA%A4/">足交</a><a href="/tag/%E9%BB%91%E6%96%99/">黑料</a><a href="/tag/%E5%90%83%E7%93%9C/">吃瓜</a><a href="/tag/51%E5%90%83%E7%93%9C%E7%BD%91/">51吃瓜网</a><a href="/tag/%E6%9B%B2%E9%98%9C%E5%B8%88%E8%8C%83%E5%A4%A7%E5%AD%A6/">曲阜师范大学</a><a href="/tag/51%E5%90%83%E7%93%9C/">51吃瓜</a><a href="/tag/%E6%9B%B2%E9%98%9C%E5%B8%88%E8%8C%83%E5%A4%A7%E5%AD%A6%E4%BD%95%E9%91%AB/">曲阜师范大学何鑫</a><a href="/tag/%E4%BD%95%E9%91%AB%E8%A3%B8%E4%BD%93%E8%87%AA%E6%8B%8D/">何鑫裸体自拍</a><a href="/tag/%E4%BD%95%E9%91%AB%E5%AF%B9%E9%95%9C%E7%8E%A9%E7%A9%B4%E8%87%AA%E6%85%B0/">何鑫对镜玩穴自慰</a><a href="/tag/%E4%BD%95%E9%91%AB%E8%B6%B3%E4%BA%A4%E4%BC%BA%E5%80%99%E4%B8%BB%E4%BA%BA/">何鑫足交伺候主人</a><a href="/tag/%E4%BD%95%E9%91%AB%E4%B8%8D%E9%9B%85%E8%87%AA%E6%8B%8D%E8%A7%86%E9%A2%91/">何鑫不雅自拍视频</a><a href="/tag/%E4%BD%95%E9%91%AB/">何鑫</a></div> \ No newline at end of file diff --git a/pub/storage/proxy/types.go b/pub/storage/proxy/types.go new file mode 100644 index 0000000..e789571 --- /dev/null +++ b/pub/storage/proxy/types.go @@ -0,0 +1,404 @@ +package proxy + +import ( + "strings" + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type objreq struct { + Name string `json:"fileName"` + Data string `json:"fileData"` +} + +type Obj struct { + Domain string `json:"domain"` + Name string `json:"fileName"` +} + +type objresp struct { + Code int `json:"code"` + Object Obj `json:"data"` + Msg string `json:"msg"` +} + +type ShardBody struct { + TaskId string `json:"taskId,omitempty"` // 文档md5 + Pos int `json:"pos,omitempty"` // 位置 + FileData string `json:"fileData,omitempty"` // 数据 + Type string `json:"type,omitempty"` + TotalPos int `json:"totalPos,omitempty"` // 分片总数 +} + +type ShardBodyV2 struct { + TaskId string `json:"taskId,omitempty"` // 文档md5 + Pos int `json:"pos,omitempty"` // 位置 + FileData string `json:"fileData,omitempty"` // 数据 + Type string `json:"type,omitempty"` + TotalPos int `json:"totalPos,omitempty"` + FileName string `json:"fileName,omitempty"` + Publish string `json:"publish,omitempty"` + AppId int `json:"appId,omitempty"` + MP4Folder string `json:"mp4Folder"` // 保存mp4的文件夹名,为空表示不需要保存mp4 +} + +type Data struct { + Id string `json:"id,omitempty"` + VideoUri string `json:"videoUri,omitempty"` +} + +type Result struct { + Code int `json:"code,omitempty"` // + Data Data `json:"data,omitempty"` // + Msg string `json:"msg,omitempty"` // +} + +type VideoOkResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data string `json:"data,omitempty"` +} +type ShardOk struct { + TaskId string `json:"taskId,omitempty"` // 文档md5 + Id string `json:"id,omitempty"` // Id + Title *string `json:"title,omitempty"` // 标题 + CoverImg *string `json:"coverImg,omitempty"` // 封面 +} + +type MediaTaskSyncReq struct { + AppId int64 `bson:"appId" json:"appId"` // APPID + LastUpdateTime time.Time `bson:"lastUpdateTime" json:"lastUpdateTime"` // 最后更新时间 + PageSize int64 `bson:"pageSize" json:"pageSize"` +} + +type MediaQueryeReq struct { + Id string `json:"id"` + TaskId string `json:"taskId"` +} +type MediaQueryesReq struct { + Ids []string `json:"ids"` +} +type VideoQueryResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaResp `json:"data,omitempty"` +} +type VideoQuerysResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data []MediaResp `json:"data,omitempty"` +} +type MediaSyncReq struct { + PageNum int64 `json:"pageNum"` + PageSize int64 `json:"pageSize"` + Param *SyncParam `json:"param"` +} +type SyncParam struct { + Status string `bson:"status,omitempty" json:"status,omitempty"` // 当前状态 + FirstTag string `json:"firstTag,omitempty"` // 一级标签 + Publish string `json:"publish,omitempty"` // 视频发布者 +} +type VideoSyncResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaSearchResp `json:"data,omitempty"` +} + +// type MediaSearchResp struct { +// Total int64 `json:"total"` +// List []*MediaSync `json:"list"` +// } + +type MediaResp struct { + Id primitive.ObjectID `bson:"_id,omitempty" json:"id"` + MediaSourceId primitive.ObjectID `bson:"mediaSourceId,omitempty" json:"mediaSourceId"` // 原始Id + VideoTime int32 `bson:"videoTime,omitempty" json:"videoTime"` // 影片长度 + Size int64 `bson:"size,omitempty" json:"size"` // 文件size,单位byte + AppId int64 `bson:"appId,omitempty" json:"appId"` // 应用列表 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + UnencryptedImage string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + VideoCover string `bson:"videoCover,omitempty" json:"videoCover"` // 视频封面 + Title string `bson:"title,omitempty" json:"title"` // 标题 + Publish string `bson:"publish,omitempty" json:"publish"` // 视频发布者 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字 + + Nodes []videoinformationmod.Node `bson:"nodes" json:"nodes"` // 节点 + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + Status string `bson:"status,omitempty" json:"status"` // 当前状态 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + TaskStartTime time.Time `bson:"taskStartTime,omitempty" json:"taskStartTime"` // 任务开始时间 + SourceNameFs string `bson:"sourceNameFs,omitempty" json:"sourceNameFs"` // 远程源文件 + IsDelLocalDir bool `bson:"isDelLocalDir,omitempty" json:"isDelLocalDir"` // 是否删除本机文件 + WatermarkImage string `json:"watermarkImage" bson:"watermarkImage"` // 图片水印文件 + WatermarkMedia string `json:"watermarkMedia" bson:"watermarkMedia"` // 视频水印文件 + DownloadRetry int `bson:"downloadRetry,omitempty" json:"downloadRetry"` // 下载重试次数 + WatermarkRetry int `bson:"watermarkRetry" json:"watermarkRetry"` // 水印重试次数 + CompressRetry int `bson:"compressRetry" json:"compressRetry"` // 压缩重试次数 + SegmentRetry int `bson:"segmentRetry" json:"segmentRetry"` // 切片重试次数 + LinodeRetry int `bson:"linodeRetry" json:"linodeRetry"` // 上传重试次数 + + // 切片相关信息 + Filename string `bson:"filename,omitempty" json:"filename"` // 文件名称 + FieldNameFs string `bson:"fieldNameFs,omitempty" json:"fieldNameFs"` // 文件名称 + PreMp4FileNameFs string `bson:"preMp4FileNameFs,omitempty" json:"preMp4FileNameFs"` // 预览MP4文件 + UnencryptedPreMp4 string `bson:"unencryptedPreMp4,omitempty" json:"unencryptedPreMp4"` // 不加密的预览MP4 + TempFieldNameFs string `bson:"tempFieldNameFs,omitempty" json:"tempFieldNameFs"` // 临时存储 + Ext string `bson:"ext,omitempty" json:"ext"` // 视频后缀 + TaskID string `bson:"taskId,omitempty" json:"taskId"` // 项目组传来的唯一标示 + Width int64 `bson:"width,omitempty" json:"width"` // 分辨率宽 + Height int64 `bson:"height,omitempty" json:"height"` // 分辨率高 + FrameRate string `bson:"frameRate,omitempty" json:"frameRate"` // 视频贞数 + SyBitRate string `bson:"syBitRate,omitempty" json:"syBitRate"` // 音频bit率 + VidBitRate string `bson:"vidBitRate,omitempty" json:"vidBitRate"` // 视频bit率 + CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updatedAt"` // 更新时间 + ProxyUpload *int32 `bson:"proxyUpload,omitempty" json:"proxyUpload"` // 代理上传 + Retry int `bson:"retry" json:"retry"` // 重试次数 默认为零 最大重试3次 + NotifySignal bool `bson:"notifySignal" json:"notifySignal"` //中止通知的信号 false 可以发送通知,true 中止发送通知 + + InfoTsName string `bson:"infoTsName" json:"infoTsName"` // 从该ts中获取视频分辨率等信息 + SyncFlag int64 `bson:"syncFlag" json:"syncFlag"` // 同步状态 + SrcKey string `bson:"srcKey,omitempty" json:"srcKey"` // 原始Key +} + +func (info *MediaResp) ToVideoInfo() *videoinformationmod.VideoInformation { + rsl := &videoinformationmod.VideoInformation{ + Id: mongos.Lid.VideoInfo.Get(), + Title: info.Title, + AddedTime: time.Time{}, + Keyworld: info.Keyworld, + FirstTag: info.FirstTag, + SecondTags: info.SecondTags, + CoverImg: info.CoverImg, + UnencryptedImage: info.UnencryptedImage, + Desc: info.Desc, + Remark: info.Remark, + MediaType: videoinformationmod.MediaType_Video, + Status: videoinformationmod.InformationSatusWaiting, + Tags: make([]int64, 0), + Real: videoinformationmod.Flow{}, + UpdatedAt: time.Now(), + CreatedAt: time.Now(), + } + rsl.Nodes = make([]videoinformationmod.Node, 0) + if info.Nodes != nil && len(info.Nodes) > 0 { + for _, node := range info.Nodes { + node.Videos = make([]videoinformationmod.VideoInfo, 0) + rsl.Nodes = append(rsl.Nodes, node) + } + } + if len(info.FieldNameFs) > 0 { + rsl.Video = videoinformationmod.VideoInfo{ + TaskId: info.TaskID, + FileId: info.Id.Hex(), + Url: info.FieldNameFs, + Cover: info.VideoCover, + Height: info.Height, + Width: info.Width, + VideoTime: info.VideoTime, + PreMp4FileNameFs: info.PreMp4FileNameFs, + UnencryptedPreMp4: info.UnencryptedPreMp4, + } + if !strings.HasSuffix(rsl.Video.Url, ".m3u8") { + rsl.Video.Url += ".m3u8" + } + } + return rsl +} + +type MediaType int + +const ( + Preview MediaType = 1 + Normal MediaType = 2 +) + +type FailReason string + +const ( + FailReasonNone FailReason = "" + FailReasonUploadTimeoutError FailReason = "UploadTimeoutError" // 上传超时 + FailReasonMergeError FailReason = "MergeError" // 合并错误 + FailReasonCompressError FailReason = "CompressError" // 压缩错误 + FailReasonSegmentError FailReason = "SegmentError" // 分片错误 + FailReasonLinodeError FailReason = "LinodeError" // Linode错误 +) + +type BaseInfo struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + SrcId int64 `bson:"srcId,omitempty" json:"srcId"` // 原始ID + Title string `bson:"title,omitempty" json:"title"` // 标题 + CheckSum string `bson:"checkSum,omitempty" json:"checkSum"` // md5校验码 + VideoTime int32 `bson:"videoTime,omitempty" json:"videoTime"` // 影片长度 + Publish string `json:"publish,omitempty" bson:"publish"` // 视频发布者 + Type string `bson:"type,omitempty" json:"type"` // 长视频/短视频 + Preview MediaType `bson:"preview,omitempty" json:"preview"` // 预览普通视频:1:预览 2:正片 + Country string `bson:"country,omitempty" json:"country"` // 国家 (国产、日本、欧美.... 前端固定) + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + UnencryptedImage string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + PreFileName string `bson:"preFileName,omitempty" json:"preFileName"` // 预览文件 + Status string `bson:"status,omitempty" json:"status"` // 当前状态 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Size int64 `bson:"size,omitempty" json:"size"` // 文件size,单位byte + Bango string `bson:"bango,omitempty" json:"bango"` // 番号,唯一 + Directors []string `bson:"directors,omitempty" json:"directors"` // 导演 + Actors []string `bson:"actors,omitempty" json:"actors"` // 演员 + Studio string `bson:"studio,omitempty" json:"studio"` // 工作室/所有者 + AddedTime time.Time `bson:"addedTime,omitempty" json:"addedTime"` // 上架时间 + AppIds []int64 `bson:"appids,omitempty" json:"appids,omitempty"` // 应用列表 + + // 切片相关信息 + Filename string `bson:"filename,omitempty" json:"filename"` // 文件名称 + FieldNameFs string `bson:"fieldNameFs,omitempty" json:"fieldNameFs"` // 文件名称 + Ext string `bson:"ext,omitempty" json:"ext"` // 视频后缀 + TaskID string `bson:"taskId,omitempty" json:"taskId"` // 项目组传来的唯一标示 + Width int `bson:"width,omitempty" json:"width"` // 分辨率宽 + Height int `bson:"height,omitempty" json:"height"` // 分辨率高 + Ratio float64 `bson:"ratio,omitempty" json:"ratio"` // 宽高比 + FrameRate string `bson:"frameRate,omitempty" json:"frameRate"` // 视频贞数 + SyBitRate string `bson:"syBitRate,omitempty" json:"syBitRate"` // 音频bit率 + VidBitRate string `bson:"vidBitRate,omitempty" json:"vidBitRate"` // 视频bit率 + CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updatedAt"` // 更新时间 + ProxyUpload *int32 `bson:"proxyUpload,omitempty" json:"proxyUpload"` // 代理上传 + + IsAdd bool `bson:"isAdd,omitempty" json:"isAdd"` // 是否加入到本地 + Retry int `bson:"retry" json:"retry"` // 重试次数 默认为零 最大重试3次 + NotifySignal bool `bson:"notifySignal" json:"notifySignal"` //中止通知的信号 false 可以发送通知,true 中止发送通知 + ShowLocation int64 `bson:"showLocation" json:"showLocation"` // 显示位置 + Remark string `bson:"remark,omitempty" json:"remark"` + + MergeRetry int `bson:"mergeRetry" json:"mergeRetry"` //合并重试次数 + CompressRetry int `bson:"compressRetry" json:"compressRetry"` //压缩重试次数 + SegmentRetry int `bson:"segmentRetry" json:"segmentRetry"` //切片重试次数 + LinodeRetry int `bson:"linodeRetry" json:"linodeRetry"` //上传linode重试次数 + FailReason FailReason `bson:"failReason" json:"failReason"` //失败原因 + DeleteDisk bool `bson:"deleteDisk" json:"deleteDisk"` //是否删除了对于磁盘文件 + InfoTsName string `bson:"infoTsName" json:"infoTsName"` //从该ts中获取视频分辨率等信息 + UploadedPoses []int64 `bson:"uploadedPoses" json:"uploadedPoses"` //上传过的pos列表 +} + +type MediaUpdate struct { + ID string `json:"id,omitempty"` + Title *string `bson:"title,omitempty" json:"title,omitempty"` // 标题 + AddedTime *time.Time `bson:"addedTime,omitempty" json:"addedTime,omitempty"` // 上架时间 + Publish *string `json:"publish,omitempty" bson:"publish,omitempty"` // 视频发布者 + Country *string `bson:"country,omitempty" json:"country,omitempty"` // 国家 (国产、日本、欧美....) + Preview *MediaType `bson:"preview,omitempty" json:"preview,omitempty"` // 预览普通视频:1:预览 2:正片 + Type *string `bson:"type,omitempty" json:"type,omitempty"` // 长视频/短视频 + FirstTag *string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签 + SecondTags *[]string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签 + Bango *string `bson:"bango,omitempty" json:"bango,omitempty"` // 番号,唯一 + AppIds *[]int64 `bson:"appids,omitempty" json:"appids,omitempty"` // 应用列表 + Desc *string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述 + PreFileName *string `bson:"preFileName,omitempty" json:"preFileName,omitempty"` // 预览文件 + CoverImg *string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面 + UnencryptedImage *string `bson:"unencryptedImage,omitempty" json:"unencryptedImage,omitempty"` // 不加密封面 + CreatedAt *time.Time `bson:"createdAt,omitempty" json:"createdAt,omitempty"` // 创建时间 + UpdatedAt *time.Time `bson:"updatedAt,omitempty" json:"updatedAt,omitempty"` // 更新时间 + ShowLocation *int64 `bson:"showLocation,omitempty" json:"showLocation"` // 显示位置 + MoveInfo *MoveInfo `bson:"moveInfo,omitempty" json:"moveInfo,omitempty"` // 电影自动更新 +} +type MoveInfo struct { + Score float64 `json:"score" bson:"score"` // 评分 暂无评分=0 + SerialCount int64 `json:"serialCount" bson:"serialCount"` // 总集数 serialCount + SerialIndex int64 `json:"serialIndex" bson:"serialIndex"` // 集数 lastName + Likes int64 `json:"likes" bson:"likes"` // 点赞数量 favoriteCount + Watchs int64 `json:"watchs" bson:"watchs"` // 点赞数量 view + UpdateDate time.Time `json:"updateDate" bson:"updateDate"` // 更新时间 languagesplaylist 短剧和剧集有更新 电影 + MoveTag []string `json:"moveTag" bson:"moveTag"` // 标签:cidMapper + post_Year + regional +} +type VideoSearch struct { + ID string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` // 标题 + TaskID *string `json:"taskId,omitempty"` // 项目组传来的唯一标示 + Publish *string `bson:"publish,omitempty"` // 视频发布者 + Type *string `json:"type"` // 视频:长视频/短视频--时间阈值 + Preview *int32 `json:"preview"` // 预览:1:预览 2:普通视频 + Country *string `json:"country"` // 国家 (国产、日本、欧美.... 前端固定) + Appid *int64 `json:"appid"` // 应用ID + FirstTag *string `json:"firstTag,omitempty"` // 一级标签 + SecondTags *string `json:"secondTags,omitempty"` // 二级标签标签 + Status *string `json:"status,omitempty"` // 当前状态 + Directors *[]string `json:"director,omitempty"` // 导演 + Actors *[]string `json:"actors,omitempty"` // 演员 + Studio *string `json:"studio,omitempty"` // 工作室 + Bango *string `json:"bango,omitempty"` // 番号,唯一 + Via *string `json:"via,omitempty"` // 来源 + Remark *string `json:"remark,omitempty"` // 备注 + AddedTime *string `json:"addedTime,omitempty"` // 上架时间 + UploadTime *string `json:"uploadTime,omitempty"` // 上传时间 + Start *time.Time `json:"start"` // 开始时间 + End *time.Time `json:"end"` // 结束时间 + ShowLocation *int64 `json:"showLocation"` // 显示位置 +} + +type MediaSearchReq struct { + PageNum int64 `json:"pageNum"` + PageSize int64 `json:"pageSize"` + Param VideoSearch `json:"param"` +} + +// MediaSearchReq 媒体搜索请求返回 +type MediaSearchResp struct { + Total int64 `json:"total"` + List []*BaseInfo `json:"list"` +} + +type MediaSearchResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaSearchResp `json:"data,omitempty"` +} + +type MediaDelReq struct { + ID string `json:"id,omitempty"` +} + +// MediasDelResp 请求批量删除媒体回复 +type MediaDelResp struct { +} + +type MediaUpdatePreTime struct { + ID string `json:"id,omitempty"` + PreFileTime int64 `bson:"preFileTime,omitempty" json:"preFileTime"` // 预览文件(开始时间) +} + +type MediaDelResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + // Data MediaDelResp `json:"data,omitempty"` +} + +type MediaUpdateReq struct { + //Ids []string `json:"id"` + MediaUpdate +} + +// MediasUpdateResp 请求批量更新媒体回复 +type MediaUpdateResp struct { +} + +type MediaUpdateResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaUpdateResp `json:"data,omitempty"` +} + +type AppAdvitiseInfoReq struct { + Id int64 `json:"id" binding:"required"` +} +type AdvitiseInfo struct { + Id string `json:"id" bson:"_id"` // 广告id + Location int64 `json:"location" bson:"location"` // 位置id + Title string `json:"title" bson:"title"` // 广告标题 + Images string `json:"images" bson:"images"` // 视频banner + Link string `json:"link" bson:"link"` // 链接 +} diff --git a/pub/storage/proxy/utils.go b/pub/storage/proxy/utils.go new file mode 100644 index 0000000..7fc0a24 --- /dev/null +++ b/pub/storage/proxy/utils.go @@ -0,0 +1,139 @@ +package proxy + +import ( + "fmt" + "strings" +) + +const ( + // 视频桶地址 + BucketVideoAddressV1 = "http://ts.bbaazz.me" + // 图片桶地址 + BucketImageAddressV1 = "http://img.bbaazz.me" + BucketImageAddressV2 = "http://tong3yuanimg.bbaazz.me" + + // 上传路径 + ImageUploadPathV1 = "/file/upload" + ImageUploadPathV2 = "/file/upload" // "/file/web/media/uploadImg" + VideoBodyPathV1 = "/file/web/proxy/videoBody" + VideoBodyPathV2 = "/file/web/proxy/videoBodyV2" + VideoOkPathV1 = "/file/web/proxy/videoOk" + VideoOkPathV2 = "/file/web/proxy/videoOkv2" +) + +var ( + BucketVideoAddressV2 = "http://139.162.22.207:24996" //测试 +) + +func InitBucketVideoAddress(addressV2 string) { + BucketVideoAddressV2 = addressV2 +} + +func DomainAddressRepair(domain string, defaultBucketAddress ...string) string { + if domain == "" { + if len(defaultBucketAddress) > 0 && defaultBucketAddress[0] != "" { + domain = defaultBucketAddress[0] + } else { + domain = BucketVideoAddressV2 + } + } + return domain +} + +func ImageUploadAddressRepair(domain string, defaultBucketAddress ...string) string { + domain = DomainAddressRepair(domain, defaultBucketAddress...) + if !strings.Contains(domain, ImageUploadPathV1) && !strings.Contains(domain, ImageUploadPathV2) { + if strings.HasPrefix(domain, BucketImageAddressV1) { + return domain + ImageUploadPathV1 + } else if strings.HasPrefix(domain, BucketImageAddressV2) { + return domain + ImageUploadPathV2 + } + return domain + ImageUploadPathV2 + } + return domain +} + +func VideoBodyAddressRepair(domain string, defaultBucketAddress ...string) string { + domain = DomainAddressRepair(domain, defaultBucketAddress...) + if strings.HasPrefix(domain, BucketVideoAddressV1) { + if !strings.Contains(domain, VideoBodyPathV1) { + return domain + VideoBodyPathV1 + } + return domain + } else if strings.HasPrefix(domain, BucketVideoAddressV2) { + if !strings.Contains(domain, VideoBodyPathV2) { + return domain + VideoBodyPathV2 + } + return domain + } else { // 未知存储桶,默认v2 + if !strings.Contains(domain, VideoBodyPathV2) { + return domain + VideoBodyPathV2 + } + return domain + } +} + +func VideoOkAddressRepair(domain string, defaultBucketAddress ...string) string { + domain = DomainAddressRepair(domain, defaultBucketAddress...) + if strings.HasPrefix(domain, BucketVideoAddressV1) { + if !strings.Contains(domain, VideoOkPathV1) { + return domain + VideoOkPathV1 + } + return domain + } else if strings.HasPrefix(domain, BucketVideoAddressV2) { + if !strings.Contains(domain, VideoOkPathV2) { + return domain + VideoOkPathV2 + } + return domain + } else { // 未知存储桶,默认v2 + if !strings.Contains(domain, VideoOkPathV2) { + return domain + VideoOkPathV2 + } + return domain + } +} + +func GetExistsUrlPathBucketAddress(path string) string { + if strings.HasPrefix(path, "v1/") { + return BucketVideoAddressV1 + } else if strings.HasPrefix(path, "v2/") { + return BucketVideoAddressV2 + } else if strings.HasPrefix(path, "v3/") { + return BucketVideoAddressV2 + } else { + return BucketVideoAddressV1 // 欲漫涩、微博、快看禁漫 默认用桶2,其他的app,默认用桶1 + } +} + +func AppendV2PrefixToUrlPath(name string) string { + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + name = fmt.Sprintf("v2/%s", name) + } + return name +} + +func AppendV1PrefixToUrlPath(name string) string { + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + name = fmt.Sprintf("v1/%s", name) + } + return name +} + +func AppendsMediaS3Version(name string) string { + // 默认使用V1 + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + return "/v2" + } + return "" +} +func AppendsMediaS3VersionV1(name string) string { + // 默认使用V1 + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + return "/v1" + } + return "" +} \ No newline at end of file diff --git a/pub/storage/upload.go b/pub/storage/upload.go new file mode 100644 index 0000000..646c5f0 --- /dev/null +++ b/pub/storage/upload.go @@ -0,0 +1,276 @@ +package storage + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "mime/multipart" + "videoweb/pub/storage/proxy" + + "strconv" +) + +var ( +// buffer = bytes.Buffer{} +// mtx sync.Mutex +) + +// web后台上传使用, form-data方式上传 +func UploadImage(ctx context.Context, f *multipart.FileHeader, domain string) (path string, err error) { + if f == nil { + return "", errors.New("file is nil!") + } + var file multipart.File + if file, err = f.Open(); err != nil { + return + } + + defer file.Close() + + //mtx.Lock() + //defer mtx.Unlock() + buffer := bytes.Buffer{} + if _, err = buffer.ReadFrom(file); err != nil { + return + } + return proxy.UploadImg(f.Filename, buffer.Bytes(), domain) +} + +func UploadVideoBody(ctx context.Context, form *multipart.Form, domain string) (*proxy.Result, error) { + if form == nil { + return nil, errors.New("multipart from is nil") + } + sb, err := makeShardBodyByForm(form) + if err != nil { + return nil, err + } + return proxy.UploadVideo(domain, sb) +} + +func UploadVideoBodyV2(ctx context.Context, form *multipart.Form, + domain string, appIdIn int, publish string) (*proxy.Result, error) { + if form == nil { + return nil, errors.New("multipart from is nil") + } + sb, err := makeShardBodyByFormV2(form, appIdIn, publish) + if err != nil { + return nil, err + } + return proxy.UploadVideoV2(domain, sb) +} + +func VideoBody(ctx context.Context, taskId, typ, domain string, pos int, data []byte) (*proxy.Result, error) { + body := &proxy.ShardBody{ + TaskId: taskId, + Pos: pos, + FileData: base64.StdEncoding.EncodeToString(data), + Type: typ, + } + if typ == "1" { + body.Type = "AV" + } else if typ == "2" { + body.Type = "SP" + } + return proxy.UploadVideo(domain, body) +} + +func UploadVideoOK(ctx context.Context, in *proxy.ShardOk, domain string) (*proxy.VideoOkResult, error) { + return proxy.PostOk(in, domain) +} +func UploadVideoOKV2(ctx context.Context, in *proxy.ShardOk, domain string) (*proxy.VideoOkResult, error) { + return proxy.PostOkV2(in, domain) +} + +func QueryMedia(ctx context.Context, in *proxy.MediaQueryeReq, domain string) (*proxy.VideoQueryResult, error) { + return proxy.QueryMedia(in, domain) +} +func QueryMedias(ctx context.Context, in *proxy.MediaQueryesReq, domain string) (*proxy.VideoQuerysResult, error) { + return proxy.QueryMedias(in, domain) +} + +func QueryAdvitiseList(ctx context.Context, in *proxy.AppAdvitiseInfoReq, domain string) (*proxy.AdvitiseQuerysResult, error) { + return proxy.QueryAdvitise(in, domain) +} + +func SyncMedia(in *proxy.MediaTaskSyncReq, domain string) (result *proxy.VideoQuerysResult, err error) { + return proxy.SyncMedia(in, domain) +} +func makeShardBodyByForm(form *multipart.Form) (*proxy.ShardBody, error) { + poss := form.Value["pos"] + taskIds := form.Value["taskId"] + types := form.Value["type"] + fs := form.File["file"] + totalPoss := form.Value["totalChunks"] + + var ( + pos, total int + taskId string + typ string + f *multipart.FileHeader + err error + ) + + if len(fs) == 0 || fs[0] == nil { + return nil, errors.New("no file") + } + f = fs[0] + + if len(poss) == 0 || poss[0] == "" { + return nil, errors.New("no pos") + } + if pos, err = strconv.Atoi(poss[0]); err != nil { + return nil, fmt.Errorf("invalid pos") + } + if len(totalPoss) == 0 || totalPoss[0] == "" { + return nil, errors.New("no totalPos") + } + if total, err = strconv.Atoi(totalPoss[0]); err != nil { + return nil, fmt.Errorf("invalid totalPos") + } + + if len(taskIds) == 0 || taskIds[0] == "" { + return nil, errors.New("no taskId") + } + taskId = taskIds[0] + + if len(types) == 0 || types[0] == "" { + return nil, errors.New("no type") + } + if types[0] == "1" { + typ = "AV" + } else if types[0] == "2" { + typ = "SP" + } else if types[0] == "3" { + typ = "AI" + } else { + typ = types[0] + } + + file, ne := f.Open() + if ne != nil { + return nil, ne + } + defer file.Close() + //mtx.Lock() + //defer mtx.Unlock() + buffer := bytes.Buffer{} + if _, err = buffer.ReadFrom(file); err != nil { + return nil, err + } + + resp := &proxy.ShardBody{ + TaskId: taskId, + Pos: pos, + FileData: base64.StdEncoding.EncodeToString(buffer.Bytes()), + Type: typ, + TotalPos: total, + } + return resp, nil +} + +func makeShardBodyByFormV2(form *multipart.Form, appIdIn int, publishIn string) ( + *proxy.ShardBodyV2, error) { + taskIds := form.Value["taskId"] + types := form.Value["type"] + poss := form.Value["pos"] + fs := form.File["file"] + totalPoss := form.Value["totalChunks"] + fileNames := form.Value["filename"] + appIds := form.Value["appId"] + publishs := form.Value["publish"] + + var ( + pos, total int + taskId string + f *multipart.FileHeader + fileName string + typ string + publish string + appId int + err error + ) + + if len(fs) == 0 || fs[0] == nil { + return nil, errors.New("no file") + } + f = fs[0] + + if len(poss) == 0 || poss[0] == "" { + return nil, errors.New("no pos") + } + if pos, err = strconv.Atoi(poss[0]); err != nil { + return nil, fmt.Errorf("invalid pos") + } + if len(totalPoss) == 0 || totalPoss[0] == "" { + return nil, errors.New("no totalPos") + } + if total, err = strconv.Atoi(totalPoss[0]); err != nil { + return nil, fmt.Errorf("invalid totalPos") + } + + if len(taskIds) == 0 || taskIds[0] == "" { + return nil, errors.New("no taskId") + } + taskId = taskIds[0] + + if len(types) == 0 || types[0] == "" { + return nil, errors.New("no type") + } + if types[0] == "1" { + typ = "AV" + } else if types[0] == "2" { + typ = "SP" + } else if types[0] == "3" { + typ = "AI" + } else { + typ = types[0] + } + + if len(fileNames) == 0 || fileNames[0] == "" { + return nil, errors.New("no fileName") + } + fileName = fileNames[0] + + if len(publishs) == 0 || publishs[0] == "" { + return nil, errors.New("no publish") + } + publish = publishs[0] + if publish == "" { + publish = publishIn + } + + if len(appIds) == 0 || appIds[0] == "" { + return nil, errors.New("no appId") + } + if appId, err = strconv.Atoi(appIds[0]); err != nil { + return nil, fmt.Errorf("invalid appId") + } + + if appId == 0 { + appId = appIdIn + } + + file, ne := f.Open() + if ne != nil { + return nil, ne + } + defer file.Close() + buffer := bytes.Buffer{} + if _, err = buffer.ReadFrom(file); err != nil { + return nil, err + } + + resp := &proxy.ShardBodyV2{ + TaskId: taskId, + Pos: pos, + FileData: base64.StdEncoding.EncodeToString(buffer.Bytes()), + Type: typ, + TotalPos: total, + FileName: fileName, + AppId: appId, + Publish: publish, + } + return resp, nil +} diff --git a/read.md b/read.md new file mode 100644 index 0000000..0c22486 --- /dev/null +++ b/read.md @@ -0,0 +1,5 @@ +# 项目初始化状态 + +这是一个保留服务层与数据层的 Go/Gin 项目骨架。页面模板、Web/API 处理器、路由注册和静态资源已清除。当前 HTTP 服务没有注册业务路由,请求会返回 404。 + +在 `app` 目录执行 `go run .` 启动服务。监听地址由 `app/conf/app.yaml` 的 `listenAddr` 配置。